Skip to content

Layer 1 — Hardware Abstraction (HAL)

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

python/hal/src/openral_hal/protocol.py

Normative HAL protocol plus explicit optional lifecycle extensions.

  • class HAL(Protocol) (L89) — Structural protocol every HAL adapter must satisfy.
  • attr description: RobotDescription
  • connect() -> None (L106) — Open connection to robot/sim.
  • disconnect() -> None (L116) — Close connection (idempotent).
  • read_state() -> JointState (L124) — Latest joint state snapshot (hot path).
  • send_action(action: Action) -> None (L138) — Forward action chunk to controller (hot path).
  • estop() -> None (L154) — Trigger emergency stop, always raises ROSEStopRequested.
  • class LifecycleEStopHAL(Protocol) (L64) — Opt-in propagation of the generic lifecycle e-stop to downstream hardware or owned processes.
  • estop() -> None (L74) — Propagate the generic lifecycle e-stop to downstream hardware / owned processes.
  • class ResettableLifecycleEStopHAL(LifecycleEStopHAL, Protocol) (L80) — Opt-in in-process recovery contract.
  • reset_estop() -> None (L83) — In-process recovery from a resettable e-stop.
  • class EStopRecovery(StrEnum) (L35) — Declares whether recovery is resettable or requires a full lifecycle restart.
  • class HALHealthReport (L43) — Cached, I/O-free health payload (message + fields) for the lifecycle diagnostics heartbeat.
  • class HALHealthProvider(Protocol) (L55) — Opt-in health-reporting protocol.
  • health() -> HALHealthReport (L58) — 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 MujocoArmHAL.__init__ accepts. (L64) Lets _sim_kwargs_for build a value that unpacks under mypy --strict with no # type: ignore.
  • _resolve_mjcf_path(desc: RobotDescription) -> str [private] — Resolves desc.assets.mjcf to an absolute MJCF path via openral_core.assets.resolve_asset; raises ROSConfigError if unset/unresolvable. (L84)
  • _kinematic_group(joint_name, groups, *, robot) -> str [private] (L114) — Maps a joint name to its kinematic group (substring match against groups) for the G1/H1 humanoid velocity/effort/PD-gain lookup tables; raises ROSConfigError naming robot if none match.
  • build_hal(description, *, mode: Literal["sim","real"], transport=None, sim_env_yaml=None) -> HAL (resolver.py L39) — The single seam for building a robot's sim or real HAL from its manifest, used by deploy sim/deploy run. mode picks description.hal.sim/.real (or a scene YAML via sim_env_yaml), merged under hal.parameters.defaults, with safety.joint_state_staleness_limit_s (when declared) passed as the HAL's staleness_limit_s — sim-derived HALs included — and transport on top; raises ROSConfigError/ROSCapabilityMismatch for a bad or missing entry.
  • hal_joint_states_topic(description, *, mode, hal_node_name, override=None) -> str | None (resolver.py L141) — The JointState topic the deploy runtime's Python nodes (world_state ingest + the runner's ROSPublishingHAL cache) read. override (DeployRuntime.joint_states_topic) wins; else, on mode="real" with a hal.real class exposing every RosControlDrivable member (checked on the class, not constructed — same membership _attach_ros_control_transport uses), /<hal_node_name>/joint_states, the HAL's rate-limited republish instead of the broadcaster's 0.5-1 kHz /joint_states that starves an in-process VLA of the GIL; None (= /joint_states) for serial/interbotix/sim HALs. Called by deploy_e2e.launch.py, which forwards the result as the runtime node's joint_states_topic param.
  • urdf_joint_names(description, urdf_xml) -> list[str] (resolver.py L196) — The URDF joint each manifest joint's state drives (its name when the URDF declares it, else its sim_joint_name, else ""), parallel to description.joints; [] when every name already agrees. A sim HAL publishes joint states under the logical names, so a URDF that names its joints differently (the OpenArm's vendored one) left every moving link off /tf and every tf2 consumer (vision attachment leg TCP / attach link, octomap payload clearing) blind. deploy_e2e.launch.py passes the result to the sim HAL as urdf_joint_names and remaps robot_state_publisher onto the HAL's ~/urdf_joint_states. Raises ROSConfigError on unparseable XML.
  • _import_object(path: str) -> object [private] — Resolves a "module.path:Attribute" import string; raises ROSConfigError if malformed/missing. Reuse watch: the canonical entrypoint importer — don't hand-roll importlib in HAL callers.
  • joint_actuator_effort(data, dof) -> float (L142) — The one definition of sim /joint_states.effort: MuJoCo qfrc_actuator at the joint's DOF (gear/transmission applied, all actuators on the DOF summed, gravity load included). Used by MujocoArmHAL and SimAttachedHAL.
  • class MujocoArmHAL — Generic MuJoCo-backed HAL adapter for position-controlled arms (and, via the _per_step_update hook, torque-controlled humanoids like the H1). (L158)
  • read_images() -> dict[str, NDArray] (L515) — Renders the manifest's RGB SensorSpecs off the live MJCF, keyed by sensor name, each at that sensor's own intrinsics resolution (one cached mujoco.Renderer per resolution). Skips a missing camera / render error with a one-shot warning; returns {} when disconnected or no RGB sensors.
  • __init__(description, *, mjcf_path, joint_qpos_addr, actuator_index, joint_qvel_addr=None, grippers=(), keyframe_index=None, seed_ctrl_from_qpos=False, settle_steps=None, gravity_enabled=True, staleness_limit_s=0.5) — Init only; MJCF loads at connect(). joint_qvel_addr defaults to joint_qpos_addr, overridden by humanoid HALs (G1/H1) whose floating base shifts qvel by 1. grippers is a SimGripperDescription sequence — one for single-arm robots, two for bimanual (Aloha, OpenArm). (L215)
  • _per_step_update(targets) -> None — Hook run before every mj_step; default no-op, overridden by torque-mode subclasses (H1MujocoHAL) to recompute actuator torque from qpos/qvel each step.
  • connect() -> None (L302) — Loads the MJCF and prepares MjData; first splices in manifest RGB and depth cameras missing from the MJCF via _camera_rig.rig_cameras_into_mjcf (bare-arm twins like so100/so101). Idempotent.
  • disconnect() -> None (L451) — Release the MuJoCo model (idempotent); resets the slot-group stager, restarting tick numbering (also after an estop).
  • mujoco_handles() -> tuple[Any, Any] | None (L472) — Exposes the live MuJoCo (model, data) handles for the bare-twin arm, mirroring SimAttachedHAL.mujoco_handles so SimSensorBridge's offscreen cinecam can render a 3rd-person view; None until connected.
  • read_state() -> JointState (L608) — Joint state in description-joint order, read live from MjData — never raises ROSPerceptionStale (the data is always current); a stale gap logs a one-shot warning instead of raising. Staleness is instead policed by the subscription HALs (ros_control/aloha), not here. effort is joint_actuator_effort per joint (grippers at their leader DOF).
  • send_action(action: Action) -> None (L724) — Forward last waypoint to MuJoCo and step. Stamps _last_action_ns so the idle stepper yields to a recent command. ADR-0102 slot groups, every twin in the hierarchy (ALOHA, Franka, UR5e/UR10e, Rizon4, SO-100, G1, H1, OpenArm, Anvil OpenArm v2): a tick_group_size > 1 action is staged in a SlotGroupStager and, once the tick's last slot lands, composed by name (compose_slot_group_action) into one full-dof JOINT_POSITION step, so an arm/gripper never moves on a new chunk while the rest holds a stale one; a slot at or below last_committed_tick of the same runner session, or of a superseded session, is refused as a replay (ROSRuntimeError, TickWatermark). An ungrouped ticked action goes through SlotGroupStager.admit / commit_tick, so it shares the watermark. Before this lived in the base only the OpenArm twin did it and the others refused every gripper slot (audit 2026-09-24).
  • discard_staged_slots() -> int (L710) — SlotGroupStager.discard (watermark kept); the twin is latch-only (never receives estop()), so the HAL lifecycle node calls this on every /openral/estop latch and /openral/estop_cleared. Returns the slots dropped.
  • (property) last_committed_tick -> int — Tick of the last slot group applied to MuJoCo (0 = none); the HAL lifecycle node acknowledges a grouped tick on /openral/action_applied only once it equals the tick. (L697)
  • (property) last_committed_session -> int — runner_session_id of last_committed_tick (0 = none / session-less); the ack also requires it to match. (L706)
  • (property) last_applied_action -> Action | None (L686) — The command last applied to MuJoCo: the composed full-dof action for a slot group, the action itself otherwise; read from the SlotGroupStager (SlotGroupStager.last_applied_action), so it moves in step with last_committed_tick. Fed to the grasp trigger by the lifecycle node.
  • sim_time_ns() -> int | None (L484) — Bare-twin MuJoCo elapsed time in ns from live MjData.time; None before connect/after disconnect/e-stop. The /clock seam for OpenArm/SO-100/SO-101 deploy-sim graphs, matching SimAttachedHAL.sim_time_ns().
  • _resolve_time_base() -> None [private] — Called by connect (issue #358): (L379) with no pinned settle_steps and a declared description.control_rate_hz, derives the physics steps per action via openral_core.physics_steps_per_tick (shrinking opt.timestep so the period is a whole number of steps, logged as hal.time_base.timestep_shrunk), holds it with check_time_base, sets _step_while_active = False so the bridge's wall-time idle stepper yields to an active skill, and logs sim.time_base once. A pinned count is honoured and logged as hal.time_base.pinned; a manifest without a rate keeps the legacy single step (hal.time_base.unresolved) — neither is timing-faithful and both stay wall-paced.
  • (property) sim_dt_per_tick_s -> float | None — Sim seconds one send_action advances once held to the control rate; (L443) None before connect, with a pinned settle_steps, or without a declared rate.
  • clock_authority() -> ClockAuthority (L501) — Return ClockAuthority.simulation("mujoco", timestep_s=<sim_dt_per_tick_s, else model.opt.timestep>) while connected, otherwise ClockAuthority.host_wall().
  • idle_step(wall_dt_s=None) -> bool (L897) — Sim-only HOLD stepper that keeps cameras/joint-state live while idle; leaves ctrl at its last commanded pose and, with wall_dt_s, advances that wall-time slice (capped by _IDLE_STEP_CAP). Returns False after disconnect/e-stop, so it can never drive an e-stopped robot.
  • (property) last_action_ns -> int (L888) — time.monotonic_ns() of the last send_action (0 if never actuated). Read by SimSensorBridge.should_idle_step to yield the idle stepper to a recently-commanded skill; mirrors SimAttachedHAL.last_action_ns.
  • reset_to_pose(pose: list[float]) -> None — Maintenance/test snap of qpos with ctrl re-seeded; gripper values are mapped through SimGripperDescription.ctrl_range so they stay in the HAL's normalized units. Skill startup instead uses the runner's checked ramp or MoveIt. (L793)
  • estop() -> None (L866) — Discard any half-staged slot group (keeping the committed-tick watermark), zero ctrl and raise ROSEStopRequested.
  • (classmethod) from_description(description, *, settle_steps=None, gravity_enabled=True, staleness_limit_s=0.5, mjcf_path_override=None) -> MujocoArmHAL — Manifest-driven constructor: builds the HAL's MJCF path, qpos/qvel/actuator maps and gripper config from description.sim, removing the need for per-robot subclasses. (L1152)
  • (staticmethod) _sim_kwargs_for(description, *, settle_steps=None, gravity_enabled=True, staleness_limit_s=0.5, mjcf_path_override=None) -> _MujocoArmInitKwargs — Translates description.sim into the __init__ kwarg dict; default 1:1 joint→qpos/actuator mapping from description.joints, offset 7/6 when sim.floating_base=True. Used by from_description and _init_from_description. (L1076)
  • (instance method) _init_from_description(description, *, mjcf_path=None, settle_steps=None, gravity_enabled=True, staleness_limit_s=0.5) -> None — Shared seam every thin per-robot subclass (UR5e/UR10e, Franka, ALOHA, OpenArm, Rizon4, G1, H1, SO-100) forwards to instead of hand-rolling super().__init__(DESC, **_sim_kwargs_for(DESC, …)). (L1205)
  • private: _require_connected, _validate_action, _last_arm_targets, _apply_arm_targets, _apply_gripper_targets, _read_gripper_value, _gripper_command_to_raw, _reset_gripper_qpos, _effective_actuator_index_for
  • module const _IDLE_STEP_CAP = 200 (L139) — Upper bound (physics steps) on how much sim time one idle_step wall-time tick may advance, so an executor stall cannot fast-forward the world by seconds.

python/hal/src/openral_hal/_base.py

Shared HAL mixin — _connected flag, validation helpers, and a default disconnect.

  • class HALBase — Non-ABC mixin every adapter subclasses. (L53)
  • disconnect() -> None (L71) — Flag-and-log default (guard on _connected, log.info("hal.disconnect", ...), clear the flag). Idempotent. Adapters holding a real resource (SDK handle, MuJoCo buffers, USB port) override it; AlohaHAL and RosControlHAL use the default as-is.
  • private: _require_connected, _require_control_mode, _validate_action_dims
  • resolve_staleness_limit_s(description, override) -> float (L22) — A real HAL's joint-state freshness window: override when not None, else description.safety.joint_state_staleness_limit_s; ROSConfigError when neither exists. Replaces every real HAL's constructor default (audit C F3): used by RosControlHAL, AlohaHAL and SO100FollowerHAL.
  • _raw_floats(raw: dict[str, object], key: str, width: int) -> list[float] [private] — Shared read_state decode: raw[key] as floats if it's a list, else width zeros. Used by AlohaHAL.read_state and RosControlHAL.read_state.

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] (L112) — For each RGB or depth SensorSpec with a sim_placement missing from xml (a depth sensor's camera is what the sim bridge ray-casts and renders its registered colour from), splices a <camera> into its parent_body (wrist) or <worldbody> (overhead), plus minimal staging (visual-only floor + fill light) when the MJCF has none. Returns (xml, changed) — changed=False when no rigging is needed, so an already-composed MJCF passes through unchanged. Idempotent; raises ROSConfigError when a sensor's parent_body is missing. Called by MujocoArmHAL.connect.
  • module const _STAGING_FLOOR: str (L74) — Visual-only (contype=0 conaffinity=0) ground-plane <geom> XML fragment spliced in by _ensure_staging so a bare-arm deploy twin's cameras have a surface to see.

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}); the hal entrypoints (hal.sim / hal.real) are inherited from base. (L48)

python/hal/src/openral_hal/_sensor_wiring.py

Resolve sensor catalog ids into SensorSpec / SensorBundle and attach them to a RobotDescription copy — the shared helper behind every per-robot *_with_sensors(...) factory (franka_panda_with_sensors, ur5e_with_sensors, ur10e_with_sensors, so100_with_sensors).

  • SensorRequest = str | tuple[str, Mapping[str, Any]] (L23) — Either a bare catalog id ("intel/realsense_d435") or an (id, kwargs) pair forwarding kwargs to the catalog factory.
  • with_sensors(description, requests, *, default_parent_frame=None) -> RobotDescription (L28) — Deep-copies description and appends catalog-resolved sensors/bundles (via openral_sensors.CATALOG.build); each request's parent_frame defaults to default_parent_frame or description.base_frame. Raises KeyError for an unknown catalog id.

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 around MujocoArmHAL; __init__ forwards to self._init_from_description(FRANKA_PANDA_DESCRIPTION, …). (L286)
  • __init__(*, mjcf_path=None, settle_steps=None, gravity_enabled=True, staleness_limit_s=0.5) (L318)
  • _panda_joint_specs() -> list[JointSpec] (L123)
  • module const _PANDA_ARM_JOINT_NAMES: list[str] (L53) — 7 arm joint names, upstream panda_jointN naming.
  • module const _PANDA_GRIPPER_JOINT_NAME = "panda_gripper" (L63) — synthetic 1-DoF gripper channel, normalized like SO-100's.
  • module const _PANDA_JOINT_NAMES: list[str] (L65) — arm + gripper, full manifest joint order.
  • module const _PANDA_SIM_JOINT_NAMES: dict[str, str] (L75) — manifest joint name → native MJCF joint name, used by SimAttachedHAL.read_state's name resolution.
  • module const _PANDA_POSITION_LIMITS: dict[str, tuple[float, float]] (L88) — per-joint position limits (Franka FR3/Panda data sheet, rad).
  • module const _PANDA_VELOCITY_LIMITS: dict[str, float] (L99) — per-joint velocity limits (rad/s).
  • module const _PANDA_EFFORT_LIMITS: dict[str, float] (L110) — per-joint torque limits (Nm).
  • const FRANKA_PANDA_DESCRIPTION = RobotDescription(...) (L177) — sim baseline; sdk_kind="open", hal.sim=None (build_hal derives MujocoArmHAL from the manifest) + hal.real="openral_hal.franka_panda_real:FrankaPandaRealHAL"; an in-code mirror of robots/franka_panda/robot.yaml, which is the runtime source of truth. All MuJoCo wiring (MJCF URI, joint→qpos/actuator maps, gripper config) lives in FRANKA_PANDA_DESCRIPTION.sim. The real-HW companion FRANKA_PANDA_REAL_DESCRIPTION lives in franka_panda_real.py.
  • franka_panda_with_sensors(catalog_ids=None) -> RobotDescription — Copy of FRANKA_PANDA_DESCRIPTION with catalog sensors attached; None defaults to the wrist-mounted RealSense D435i reference loadout. (L257)

python/hal/src/openral_hal/franka_panda_real.py

Real-hardware HAL adapter for the Franka Emika Panda over the FCI.

  • module const _DEFAULT_FRANKA_CONTROLLER: str = "franka_arm_controller" (L58)
  • module const _DEFAULT_FRANKA_JOINT_STATE_TOPIC: str = "/joint_states" (L63)
  • module const _DEFAULT_FRANKA_ERROR_RECOVERY: str = "/error_recovery" (L70) — the franka_msgs/action/ErrorRecovery name recorded for the operator; never called by estop().
  • class FrankaPandaRealHAL(RosControlHAL) — Production adapter for a physical Panda over franka_ros2 / FCI. Takes description= (loaded manifest; None → FRANKA_PANDA_REAL_DESCRIPTION). Subclasses RosControlHAL (the UR shape) so it is structurally RosControlDrivable and the lifecycle node attaches the production RosControlTransport under hal_mode:=real; the earlier composed wrapper forwarded only the five HAL Protocol methods, exposed none of the drivable surface, and was therefore never wired — a real Panda deploy published every command into _default_publish. (L89)
  • __init__(*, fci_ip='172.16.0.2', controller_name='franka_arm_controller', joint_state_topic='/joint_states', command_topic=None, error_recovery_action='/error_recovery', publish_fn=None, state_fn=None, staleness_limit_s=None, description=None) (L165) — staleness_limit_s=None reads the manifest's safety.joint_state_staleness_limit_s.
  • estop_recovery = RESTART_REQUIRED — stopRobot() ends the FCI session; the reflex is cleared and the FCI re-armed by the operator.
  • fci_ip -> str [@property] (L199)
  • error_recovery_action -> str [@property] — franka_ros2's franka_msgs/action/ErrorRecovery the operator runs before re-arming; recorded, never called by estop() (it clears a reflex — a recovery, not a stop). (L204)
  • connect() -> None — Logs the FCI target, then the base connect. (L210)
  • estop() — Inherited, not overridden: deactivating franka_arm_controller through controller_manager is the stop — franka_hardware's on_deactivate calls libfranka's stopRobot(). The earlier override published a dict to /error_recovery/goal, an undeclared topic the transport refused, so it was a silent no-op; it is gone.
  • Inherits description (= FRANKA_PANDA_REAL_DESCRIPTION), controller_name, disconnect, read_state, send_action, command_bindings, attach_transport, attach_controller_stop, reset_estop (refuses: RESTART_REQUIRED) from RosControlHAL.
  • const FRANKA_PANDA_REAL_DESCRIPTION = make_real_description(FRANKA_PANDA_DESCRIPTION, sdk_kind="closed_with_api") (L83) — inherits the shared hal; what robots/franka_panda/robot.yaml mirrors.

python/hal/src/openral_hal/sawyer_real.py

Real-hardware HAL adapter for the Rethink Sawyer 7-DoF arm.

  • module const _SAWYER_JOINT_NAMES: tuple[str, ...] (L66) — 7 arm joint names.
  • module const _SAWYER_POSITION_LIMITS: dict[str, tuple[float, float]] (L79)
  • module const _SAWYER_VELOCITY_LIMITS: dict[str, float] (L89)
  • module const _SAWYER_EFFORT_LIMITS: dict[str, float] (L99)
  • module const _DEFAULT_SAWYER_CONTROLLER: str = "sawyer_arm_controller" (L223)
  • module const _DEFAULT_SAWYER_JOINT_STATE_TOPIC: str = "/robot/joint_states" (L228)
  • module const _DEFAULT_SAWYER_ESTOP_TOPIC: str = "/robot/set_super_stop" (L237)
  • class SawyerRealHAL(RosControlHAL) — Production adapter for a physical Sawyer over intera_sdk / sawyer_robot. Takes description= (loaded manifest; None → SAWYER_REAL_DESCRIPTION). Subclasses RosControlHAL for the same reason as FrankaPandaRealHAL: the composed wrapper it replaced was not RosControlDrivable, so the lifecycle node never attached the production transport to it. (L243)
  • __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=None, description=None) (L317) — staleness_limit_s=None reads the manifest's window.
  • hostname -> str [@property] (L351)
  • connect() -> None (L355)
  • estop_recovery = RESTART_REQUIRED — the super stop is cleared by /robot/set_super_reset + re-enable, an operator step.
  • (property) estop_topic -> str — /robot/set_super_stop, intera's super-stop topic. (L370)
  • vendor_stop_topics() -> list[str] — [estop_topic], declared so the transport creates its std_msgs/Empty publisher at wire-up. (L376)
  • _vendor_stop(seam) -> str — After the base deactivated sawyer_arm_controller, publishes intera's super stop (RobotEnable.stop()'s topic: "Simulate an e-stop button being pressed. Robot must be reset to clear the stopped state"). The topic carries no service ack; the acknowledged half is the controller deactivation, and the HIL gate reads /robot/state.stopped.
  • Inherits description (= SAWYER_REAL_DESCRIPTION), controller_name, disconnect, read_state, send_action, command_bindings, attach_transport, attach_controller_stop, estop, reset_estop (refuses: RESTART_REQUIRED) from RosControlHAL.
  • _sawyer_joint_specs() -> list[JointSpec] (L110)
  • const SAWYER_DESCRIPTION = RobotDescription(...) (L153) — 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") (L213) — inherits the shared hal; what robots/sawyer/robot.yaml mirrors.

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 and by tests; ROS node entrypoint is the generic packages/openral_hal_node/.

  • const PANDA_MOBILE_BASE_JOINT_NAMES: list[str] — Base joints [base_x, base_y, base_yaw], derived from PANDA_MOBILE_DESCRIPTION.base_joints (not hardcoded). (L84)
  • 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. (L100)
  • const PANDA_MOBILE_DESCRIPTION: RobotDescription — Canonical RobotDescription, loaded from robots/panda_mobile/robot.yaml at module import. Single source of truth for joint metadata + sim_joint_name overrides; the arm/base/gripper name constants above derive from it via JointSpec.role. (L77)
  • class PandaMobileHAL — In-process digital-twin HAL, PandaMobileHAL(*, initial_pose=None, dt_s=0.05, description=None). description is the loaded manifest (build_hal threads it, so panda_mobile_vslam reports its own sensors); None falls back to robots/panda_mobile/robot.yaml. Joint layout (base from base_joints, arm/gripper by role) is read per instance from it. Routes BODY_TWIST → planar Euler integration of (vx, vy, wz); routes JOINT_POSITION → 7-vec arm targets or 11-vec base+arm+gripper targets. (L121)
  • connect() -> None (L198) — Idempotent; flips the connected flag.
  • disconnect() -> None (L202) — Idempotent; clears the connected flag.
  • read_state() -> JointState (L206) — Fresh 10-DoF snapshot; raises ROSConfigError if called before connect().
  • send_action(action: Action) -> None (L219) — Applies the action to in-memory state; accepts JOINT_POSITION / BODY_TWIST / CARTESIAN_DELTA / GRIPPER_POSITION, each reading its matching Action field. No-ops while e-stop is latched. Raises ROSConfigError for an unsupported mode or a missing/mis-shaped payload.
  • estop() -> None (L276) — Latch the e-stop flag; subsequent send_action calls no-op until reset_estop.
  • reset_estop() -> None (L287) — Clear the e-stop latch; caller asserts the cause has been resolved.
  • (property) estop_latched -> bool (L292)
  • (property) base_pose -> tuple[float, float, float] (L297) — Current base (x, y, yaw) for tests / odom publishers.
  • (property) base_twist -> tuple[float, ...] (L302) — Last commanded base body twist (vx, vy, vz, wx, wy, wz); zeroed once a non-BODY_TWIST action is sent, and reads zero once no BODY_TWIST arrived for _TWIST_STALE_PERIODS (2) control periods — the base only moves on a command, so a stopped stream is a still base.
  • module const _PANDA_MOBILE_ARM_JOINT_NAMES (L88) — the 7 Franka arm joint names within the 11-DoF panda_mobile order.
  • module const _PANDA_MOBILE_GRIPPER_JOINT_NAME (L96) — the 1-DoF gripper joint name.
  • module const _PLANAR_TWIST_EPS (L113) — tolerance for detecting a non-planar BODY_TWIST component.
  • (removed: the base_sim_joint_names re-export wrapper — callers now import openral_core.extract_base_sim_joint_names directly.)

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. - module const _OPTICAL_IN_MJCAM (L37) — REP-103 optical frame (x right, y down, z forward) expressed in the MuJoCo camera frame, as a diagonal flip-y/z matrix. - module const _MIN_EYE_DISTANCE_M = 1e-6 (L40) — degenerate eye→lookat distance below which azimuth/elevation are undefined. - module const _VIEWER_LOOKAT_LIFT_M = 0.7 (L45) — deploy-viewer orbit-pivot lift off the floor onto the robot body. - module const _VIEWER_PULLBACK_M = 2.0 (L46) — deploy-viewer eye pullback so cluttered mobile-manip scenes show the whole robot. - module const _VIEWER_CAMERA_PREFS (L251) — default prefer substrings for preferred_viewer_camera_id ("agentview", "top", "frontview", "front"). - is_depth_sensor(spec) -> bool — True when spec.modality in ("depth", "point_cloud") and it carries pinhole intrinsics (required to back-project). (L52) - module const BODY_TO_OPTICAL_QUAT_XYZW = (-0.5, 0.5, -0.5, 0.5) (L64) — REP-103 body frame → its optical child, rpy (-π/2, 0, -π/2). - depth_optical_frame_id(spec) -> str (L67) — The optical frame a depth sensor's sim raster, cloud and registered colour are stamped in: spec.frame_id when it is already an *_optical_frame, else its REP-103 child <frame_id>_optical_frame (a body-convention camera mount like the ZED's zed_camera_link). Stamping optical data with the body frame rotated every consumer's geometry. - 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. (L89) - robot_self_body_ids(model, sim_joint_names) -> frozenset[int] — Every MJCF body sharing a first-_-token prefix with one of the robot's sim_joint_names (mobilebase0/robot0/gripper0). Passed as synthesize_depth_image(exclude_body_ids=…) so the depth raster self-filters the robot out of its own world map. (L140) - depth_synth_kwargs(spec, *, max_range_default, render_size=None) -> dict — Maps a depth SensorSpec to depth-synth kwargs (intrinsics + min/max_range_m, falling back to max_range_default); with render_size, intrinsics are rescaled via openral_core.scale_intrinsics_to to match the render resolution. (L102) - resolve_base_body_name(model, *, description=None) -> str | None — Resolves the MJCF body backing the robot's base_frame (first base joint's prefix + _base, else mobilebase0_base/base/robot0_base/base_link); None if none exist. Backs the depth self-filter anchor and viewer fallback — not the TF/extrinsic parent, which is resolve_base_frame_body_name. (L172) - resolve_base_frame_body_name(model, *, description=None) -> str | None — Resolves the MJCF body whose pose base_frame actually carries on /tf (ADR-0095): tries the base joint's _support body (robosuite's arm-mount plate) before delegating to resolve_base_body_name. They differ only on robosuite/RoboCasa mobile manipulators, whose chassis root sits well below the arm mount MobileBaseBridge publishes as base_link; fixed-base arms resolve identically either way. (L208) - preferred_viewer_camera_id(model, *, prefer=("agentview","top","frontview","front")) -> int — Picks the MJCF camera the viewer should open from: first camera name matching a prefer substring, else the first declared camera, else -1 if none. Consumed by initial_viewer_camera. (L254) - 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 (always mjCAMERA_FREE, so mouse orbit/zoom stay live). Eye placed at the preferred_viewer_camera_id camera with orbit pivot on the robot base when one exists, else delegates to base_aligned_free_camera. (L407) - apply_robosuite_visual_geomgroups(opt, model) -> bool — For a robosuite/RoboCasa model, hides collision shells (geomgroup 0) and shows textured visual geoms (group 1) so mujoco.viewer renders textures instead of collision boxes. Gated on a robosuite signature, not geom counts (dm_control/gym-aloha put visuals in group 0). Used by sim run --view. (L294) - 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 for camera-less models: centres on the robot base, offset by its world yaw, at distance_scale × model.stat.extent capped at max_distance_m. Falls back to model.stat.center with no yaw when base_body_name is absent. (L325) - 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, for broadcasting base_frame → <camera>_optical_frame. Raises ROSConfigError if camera/body absent. (L461) - pointcloud2_from_points_xyz(points, *, frame_id, stamp=None, rgb=None) -> PointCloud2 — Packs an (N, 3) float32 array into an unordered XYZ-float32 sensor_msgs/PointCloud2, the layout octomap_server's cloud_in expects; with rgb each point also carries the PCL rgb field. (L513) - colours_from_frame(points, rgb, k, optical_in_base) -> NDArray[np.uint8] — Colours base-frame points by projecting them into a registered RGB frame; behind the camera or outside the image is grey. (L564) - depth_image_from_grid(depth, *, frame_id, stamp=None) -> Image — Packs an (H, W) float32 metric-depth raster into a 32FC1 sensor_msgs/Image (0.0 = no measurement) for nvblox's projective depth integrator. (L595) - points_from_depth_grid(depth, *, fx, fy, cx, cy, clearing=None, max_range_m=None) -> NDArray[np.float32] — Back-projects an (H, W) metric-depth raster into an (N, 3) optical-frame cloud, dropping 0.0 pixels. clearing re-adds the self-filter's clearing rays at max_range_m so OctoMap can clear cells behind the robot's silhouette; without it those cells can never clear. Raises ROSConfigError on a shape mismatch or a marked mask with no max_range_m. (L632) - depth_grid_from_image(msg) -> NDArray[np.float64] — Inverse of depth_image_from_grid: decodes a driver's depth Image into an (H, W) metre raster, accepting 32FC1 (metres) and 16UC1 (millimetres, rescaled). Raises ROSConfigError on an unsupported encoding or a length mismatch. Used by VisionAttachmentBridge to read real wrist-camera depth. (L740) - 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. (L787) - intrinsics_from_camera_info(msg) -> IntrinsicsPinhole — Inverse of camera_info_from_intrinsics, duck-typed: fx=k[0], fy=k[4], cx=k[2], cy=k[5] at the message's width/height. How a consumer projects through the driver's live calibration instead of a manifest's nominal intrinsics (OpenArm ZED Mini: fx 1498.18 published vs 960 declared, Thor cell 2026-10-02). Raises ROSConfigError on a missing / non-9 k, a non-positive focal length or a zero resolution (the all-zero uncalibrated default). Used by segmenter_node.projection_geometry. (L836) - resample_mask_nearest(mask, shape) -> NDArray[np.bool_] — Nearest-neighbour resample of a boolean mask on pixel centres (floor((r + 0.5) * H_src / H_dst)), to bring an RGB-resolution mask onto the same camera's depth raster before backproject_masked_depth. Raises ROSConfigError on a non-2-D mask, non-positive shape, or aspect ratios differing by >1 % (a different crop, not a different resolution). (L885)

python/hal/src/openral_hal/aloha.py

HAL adapter for the Trossen ALOHA bimanual setup, plus the MuJoCo digital twin.

  • class AlohaHAL(HALBase) — Real-hardware adapter for the 14-DoF ALOHA over the Interbotix XS SDK. Takes description= (loaded manifest; None → ALOHA_REAL_DESCRIPTION). Stays on HALBase, not RosControlHAL: the driver owns the bus, like SO100FollowerHAL. Its command topics are ros2_control names a real ALOHA does not expose, so send_action reports success and moves nothing — issue #250 holds the xs_sdk wire contract and the on-rig checks needed before changing the defaults. Its e-stop, by contrast, targets what a real ALOHA does expose: estop_recovery = RESTART_REQUIRED, and estop() cuts torque on every arm namespace through an attached InterbotixStopSeam. (L411)
  • __init__(*, left_arm_controller=..., right_arm_controller=..., left_gripper_controller=..., right_gripper_controller=..., joint_state_topic='/joint_states', arm_namespaces=('follower_left', 'follower_right'), publish_fn=None, state_fn=None, staleness_limit_s=None, stop_timeout_s=5.0, description=None) — staleness_limit_s=None reads the manifest's window (0.2 s, formerly a code-only default). — The former estop_topic (/aloha/estop, a broadcast for a watchdog node that does not exist in this repo) is gone; arm_namespaces names the xs_sdk robots the stop torques off. (L484)
  • arm_namespaces() -> list[str] — The xs_sdk robot namespaces the stop torques off; the InterbotixStoppable surface the lifecycle node reflects on. (L522)
  • attach_torque_stop(seam) -> None — Bind the InterbotixStopSeam after construction (build_hal runs before any ROS node exists); rejects a non-seam with ROSConfigError. (L526)
  • (property) last_stop_report -> DownstreamStopReport | None — The DownstreamStopReporting surface the lifecycle heartbeat and FATAL log read. (L540)
  • connect() -> None (L546)
  • read_state() -> JointState (L563)
  • send_action(action) -> None — Splits the 14-D action 4-ways across per-arm + per-gripper controllers. (L589)
  • estop() -> None — Drop the connection flag first, then torque_enable(cmd_type='group', name='all', enable=false) on every arm namespace (each attempted even if an earlier one refused), record a DownstreamStopReport (stopped only when every arm acknowledged; controller_states per arm torque_off / torque_unknown), raise ROSEStopRequested. Torque off leaves the ViperX arms limp (no brakes) — the same outcome as the rig's hardware e-stop and the only stop xs_sdk offers. (L653)
  • private: _require_connected, _cut_torque
  • class InterbotixStopSeam(Protocol) — torque_enable(robot_name, *, group, enable, timeout_s) -> TriggerReport; production InterbotixXSTransport, unit lane SimTorqueSeam. (L383)
  • torque_enable(robot_name, *, group, enable, timeout_s) -> TriggerReport — /<robot_name>/torque_enable with cmd_type='group'; success is the call completing. (L391)
  • class InterbotixStoppable(Protocol) — arm_namespaces() + attach_torque_stop(seam); what the lifecycle node's _attach_interbotix_transport reflects on. (L399)
  • arm_namespaces() -> list[str] (L402)
  • attach_torque_stop(seam) -> None (L406)
  • class AlohaMujocoHAL(MujocoArmHAL) — MuJoCo digital twin for the 14-DoF bimanual ALOHA; thin manifest-driven wrapper around MujocoArmHAL (bimanual amendment). All wiring lives in ALOHA_DESCRIPTION.sim: gym_aloha:bimanual_viperx_transfer_cube URI, explicit joint_qpos_addr / actuator_index (left arm 0-5, left gripper 6, right arm 8-13, right gripper 14 — skipping the negative-finger slots), two grippers on a normalized [0, 1] channel (0 closed, 1 open; NORMALISED writes, AFFINE_LOW_HIGH reads, mapped onto the finger range [0.021, 0.057] m) with mirror_actuator_index (positive finger + negative finger), keyframe_index: 0 (seeds the fingers inside that range). (L732)
  • __init__(*, mjcf_path=None, settle_steps=None, gravity_enabled=True, staleness_limit_s=0.5) — Forwards to self._init_from_description(ALOHA_DESCRIPTION, …). (L770)
  • _aloha_joint_specs() -> list[JointSpec] (L171)
  • _default_publish(topic, msg) -> None (L720)
  • module const _ALOHA_LEFT_ARM_JOINTS: tuple[str, ...] (L116) — left-arm joint names, ViperX 300 order.
  • module const _ALOHA_LEFT_GRIPPER_JOINT: str (L124) — left gripper joint name.
  • module const _ALOHA_RIGHT_ARM_JOINTS: tuple[str, ...] (L125) — right-arm joint names.
  • module const _ALOHA_RIGHT_GRIPPER_JOINT: str (L133) — right gripper joint name.
  • module const _ALOHA_JOINT_NAMES: tuple[str, ...] (L135) — full 14-DoF joint order: left arm + left gripper + right arm + right gripper.
  • module const _PI: float = 3.14159 (L147) — truncated π matching the YAML manifest's string form, used by _ALOHA_ARM_POSITION_LIMITS so the manifest-vs-HAL drift guard stays byte-equal.
  • module const _ALOHA_ARM_POSITION_LIMITS: dict[str, tuple[float, float]] (L148) — per-joint-group position limits from the ViperX 300 data sheet.
  • module const _ALOHA_GRIPPER_POSITION_LIMITS: tuple[float, float] (L160)
  • module const _ALOHA_ARM_AXIS: dict[str, tuple[float, float, float]] (L161) — per-joint-group rotation axis.
  • module const _DEFAULT_LEFT_ARM_CONTROLLER: str (L360) — AlohaHAL's default left-arm xs_sdk controller name.
  • module const _DEFAULT_RIGHT_ARM_CONTROLLER: str (L361) — default right-arm controller name.
  • module const _DEFAULT_LEFT_GRIPPER_CONTROLLER: str (L362) — default left-gripper controller name.
  • module const _DEFAULT_RIGHT_GRIPPER_CONTROLLER: str (L363) — default right-gripper controller name, matching the udev-pinned CAN dev-id convention.
  • module const _DEFAULT_ALOHA_JOINT_STATE_TOPIC: str (L367) — default /joint_states topic.
  • module const _DEFAULT_ALOHA_ARM_NAMESPACES: tuple[str, str] (L375) — the xs_sdk robot namespaces estop torques off (follower_left, follower_right).
  • module const _TORQUE_GROUP_ALL = "all" (L376) — the TorqueEnable group name covering every joint of one arm.
  • const ALOHA_DESCRIPTION = RobotDescription(...) (L215) — 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") (L348) — inherits the shared hal; what robots/aloha_bimanual/robot.yaml mirrors.

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 to self._init_from_description(UR5e_DESCRIPTION, …). (L345)
  • __init__(*, mjcf_path=None, settle_steps=None, gravity_enabled=True, staleness_limit_s=0.5) (L371)
  • class UR10eHAL(MujocoArmHAL) — UR10e HAL (MuJoCo-backed). Same shape as UR5eHAL. (L389)
  • __init__(*, mjcf_path=None, settle_steps=None, gravity_enabled=True, staleness_limit_s=0.5) (L401)
  • ur5e_with_sensors(catalog_ids=None) -> RobotDescription (L289)
  • ur10e_with_sensors(catalog_ids=None) -> RobotDescription (L315)
  • _ur_joint_specs(velocity_limits, effort_limits) -> list[JointSpec] (L120)
  • module const _UR_JOINT_NAMES: list[str] (L59) — 6 arm joint names, shared by UR5e/UR10e.
  • module const _UR5E_POSITION_LIMITS: dict[str, tuple[float, float]] (L70) — shared by UR5e and UR10e (same joint range family).
  • module const _UR5E_VELOCITY_LIMITS: dict[str, float] (L79)
  • module const _UR5E_EFFORT_LIMITS: dict[str, float] (L89)
  • module const _UR10E_VELOCITY_LIMITS: dict[str, float] (L100)
  • module const _UR10E_EFFORT_LIMITS: dict[str, float] (L110)
  • const UR5e_DESCRIPTION = RobotDescription(...) (L158) — sim manifest; all MuJoCo wiring lives in UR5e_DESCRIPTION.sim.
  • const UR10e_DESCRIPTION = RobotDescription(...) (L223) — sim manifest; all MuJoCo wiring lives in UR10e_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 via ur_robot_driver. Takes description= (loaded manifest; None → UR5e_REAL_DESCRIPTION). (L197)
  • class UR10eRealHAL(_URRealHAL) — Real UR10e via ur_robot_driver. Takes description= (loaded manifest; None → UR10e_REAL_DESCRIPTION). (L254)
  • class _URRealHAL(RosControlHAL) — Shared real-HW base (controller / topic defaults + deadman_topic + dashboard_stop_service). estop_recovery = RESTART_REQUIRED. (L87)
  • vendor_stop_services() -> list[str] — [dashboard_stop_service] (default /dashboard_client/stop). (L171)
  • _vendor_stop(seam) -> str — After the base deactivated scaled_joint_trajectory_controller, calls the dashboard stop (std_srvs/Trigger), which stops the external_control program on the pendant so the robot performs a controlled stop and the driver's control connection ends; a refused Trigger raises ROSRuntimeError so the report reads unacknowledged. Recovery: operator restarts the program (/dashboard_client/play + resend_robot_program), relaunches the HAL node, re-aligns. (L175)
  • module const _UR_CONTROLLER_NAME: str (L60) — default ur_robot_driver JointTrajectoryController name.
  • module const _UR_JOINT_STATE_TOPIC: str (L61)
  • module const _UR_DEADMAN_TOPIC: str (L62)
  • module const _UR_DASHBOARD_STOP_SERVICE: str (L69) — default ur_robot_driver dashboard stop service (std_srvs/Trigger).
  • const UR5e_REAL_DESCRIPTION = make_real_description(UR5e_DESCRIPTION, sdk_kind="closed") (L76) — inherits the shared hal; what robots/ur5e/robot.yaml mirrors.
  • const UR10e_REAL_DESCRIPTION = make_real_description(UR10e_DESCRIPTION, sdk_kind="closed") (L81) — inherits the shared hal; what robots/ur10e/robot.yaml mirrors.

python/hal/src/openral_hal/so100_follower.py

SO100FollowerHAL — wraps lerobot's SO-100 follower arm USB driver.

  • module const _SO100_JOINT_NAMES: list[str] (L81) — canonical joint order, matching lerobot's bus motor dict order.
  • module const _RESET_MAX_RAD_S = 0.5 (L96) — reset_to_pose ramp speed cap.
  • module const _RESET_MIN_S = 1.0 (L97) — reset_to_pose ramp minimum duration.
  • module const _RESET_MAX_S = 6.0 (L98) — reset_to_pose ramp maximum duration.
  • module const _RESET_STEP_HZ = 30.0 (L99) — reset_to_pose ramp waypoint rate.
  • module const _RESET_DEADBAND_RAD (L102) — below this max joint delta the arm counts as already at the target and the ramp is skipped.
  • class SO100FollowerHAL — HAL adapter wrapping lerobot's SO-100/SO-101 follower. Takes description= (the loaded manifest, so an SO-101 deploy reports so101_follower); None falls back to SO100_DESCRIPTION. Declares estop_recovery = RESTART_REQUIRED: its vendor E-stop disconnects the motor bus, so command execution requires a fresh lifecycle start and alignment. (L313)
  • __init__(port='/dev/ttyUSB0', *, calibrate_on_connect=False, id=None, calibration_dir=None, max_relative_target=None, staleness_limit_s=None, robot=None, description=None) (L363) — staleness_limit_s=None reads the manifest's window.
  • connect() -> None — Open USB serial connection. (L408)
  • disconnect() -> None — Close USB, disable motor torque (idempotent). (L524)
  • read_state() -> JointState — Joint state in radians. (L537)
  • send_action(action: Action) -> None — Forward one step to the SO-100 motor bus. (L565)
  • reset_to_pose(pose: list[float]) -> None — Explicit maintenance/test ramp from current → target (speed-capped _RESET_MAX_RAD_S, duration clamped [_RESET_MIN_S, _RESET_MAX_S], _RESET_STEP_HZ waypoints). The lifecycle node exposes it at /openral/<robot>/reset_to_pose; skill startup uses the runner's safety-kernel path. (L588)
  • estop() -> None — Disconnect motors then raise. (L651)
  • _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_lerobot and the reset_to_pose ramp route through it so a calibration/range change can never apply to one actuation path and not the other. (L285)
  • _deg_to_rad(deg) -> float (L280)
  • _rad_to_deg(rad) -> float (L305)
  • const SO100_DESCRIPTION = RobotDescription(...) (L106)
  • so100_with_sensors(catalog_ids=None) -> RobotDescription — Copy of SO100_DESCRIPTION with catalog sensors attached; None defaults to the LeRobot reference loadout (["logitech/c920"]). (L249)

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) (L577) — Six-axis joint-position + normalized-gripper adapter. Takes description= (loaded manifest; None keeps GALAXEA_A1_DESCRIPTION). read_state/send_action use a cached snapshot/latest target so network I/O stays off the hot path. Commands fail closed on stale state/status, unaccepted motor bits, non-finite values, target misalignment, or an excessive feedback-relative step. estop asks the sidecar to stop its owned ROS 1 stack and always raises ROSEStopRequested.
  • connect() -> None (L699) — Connect to the sidecar (sends the hello handshake with joint names/limits/timeouts/masks); requires one complete fresh snapshot. Raises ROSRuntimeError if already connected.
  • disconnect() -> None (L733) — Close the sidecar transport idempotently.
  • read_state() -> JointState (L738) — Return the latest non-stale six-joint feedback snapshot.
  • send_action(action: Action) -> None (L750) — Queue one validated joint or normalized-gripper target; raises ROSConfigError for an unsupported control mode.
  • health() -> HALHealthReport (L805) — Cached hardware health for the lifecycle diagnostics heartbeat (sidecar address, feedback/status ages, motor status, relay state, staged/forwarded targets).
  • estop() -> None (L827) — Stop the sidecar-owned ROS 1 stack (bounded, SIGINT then SIGKILL) and always raise ROSEStopRequested.
  • module const _JOINT_NAMES (L63) — six arm_jointN names.
  • module const _JOINT_LIMITS (L64) — per-joint (min, max) position limits, official A1 URDF.
  • module const _JOINT_ORIGINS_XYZ (L72) — per-joint URDF origin translation, transcribed from the official A1 URDF.
  • module const _JOINT_ORIGINS_RPY (L80) — per-joint URDF origin rotation, transcribed from the official A1 URDF.
  • module const _PROTOCOL_VERSION = 1 (L88) — sidecar wire-protocol version sent in the hello handshake.
  • module const _GRIPPER_STATUS_INDEX (L89) — index of the gripper's status code within the sidecar's status array.
  • module const _MAX_TCP_PORT = 65536 (L90) — upper bound accepted for the sidecar's loopback TCP port.
  • module const _MAX_STATUS_MASK = 0xFFFFFFFF (L91) — bit-mask upper bound for a motor status/error mask.
  • const GALAXEA_A1_DESCRIPTION (L94) — Real-only RobotDescription, mirrored by robots/galaxea_a1/robot.yaml: official A1 URDF joint names/limits, sidecar deadlines, motor masks, 0..104 mm normalized gripper mapping, and calibrated D455 front / D405 wrist RGB observation contracts. Collision primitives are absent until their redistribution/lowering provenance is cleared.
  • tools/galaxea_a1_ros1_sidecar.py — Python-3.8 ROS 1 process owning roscore, signal_arm/single_arm_node.launch, and the official mobiman/jointTracker_demo_node binary. A sidecar-owned relay is the sole publisher to /arm_joint_command_host, gated LOCKED → ARMING → ACTIVE (aligned to target + feedback before forwarding); a gripper command while not ACTIVE is refused fail-closed. A lease timeout, malformed command, stale feedback, motor fault, disconnect, or e-stop stops the whole owned process group.
  • tools/run_galaxea_a1_sidecar.sh — Docker launcher requiring an explicit operator-provided image and SDK path; mounts both read-only, claims only the selected serial device. --check-only verifies image, SDK files, cache, serial ownership, loopback port, container name, and process lock without opening the serial device or starting a container.

Galaxea A1 hardware bring-up and the LingBot-VA rSkill deploy runbook moved to robots/galaxea_a1/README.md.

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, so gravity must stay disabled in closed-loop tests. Unlike G1/UR/Franka/SO-100, the H1 menagerie ships torque actuators, so this HAL runs a software PD position loop every physics step.

  • class H1MujocoHAL(MujocoArmHAL) — Takes description= (loaded manifest; None → H1_DESCRIPTION). 19-DoF humanoid HAL driving mujoco_menagerie/unitree_h1/h1.xml (legs + torso + arms, no wrists); thin manifest-driven wrapper around MujocoArmHAL. Overrides _apply_arm_targets to a no-op and _per_step_update to compute tau = kp*(target - q) - kv*dq clamped to ctrlrange, so the public action contract stays "position targets in radians" — mirroring how unitree_sdk2 wraps torque control in a position loop on real hardware. (L329)
  • __init__(*, mjcf_path=None, settle_steps=None, gravity_enabled=True, staleness_limit_s=0.5) (L370)
  • _per_step_update(targets) -> None — Recomputes PD torque every mj_step.
  • _apply_arm_targets(targets) -> None — No-op (PD loop runs per-step instead).
  • module const _H1_LEFT_LEG_JOINTS: tuple[str, ...] (L80) — left-leg joint names.
  • module const _H1_RIGHT_LEG_JOINTS: tuple[str, ...] (L87) — right-leg joint names.
  • module const _H1_TORSO_JOINTS: tuple[str, ...] (L94) — torso joint name(s).
  • module const _H1_LEFT_ARM_JOINTS: tuple[str, ...] (L95) — left-arm joint names.
  • module const _H1_RIGHT_ARM_JOINTS: tuple[str, ...] (L101) — right-arm joint names.
  • module const _H1_JOINT_NAMES: tuple[str, ...] (L107) — full 19-DoF joint order (legs + torso + arms).
  • module const _H1_POSITION_LIMITS: dict[str, tuple[float, float]] (L122) — per-joint position limits.
  • module const _H1_EFFORT_LIMITS: dict[str, float] (L149) — per-joint actuator effort ceiling used to clamp the software PD loop's torque.
  • module const _H1_VELOCITY_LIMITS_BY_GROUP: dict[str, float] (L176) — per-kinematic-group velocity limits.
  • module const _H1_KP_BY_GROUP: dict[str, float] (L309) — per-kinematic-group PD proportional gain.
  • module const _H1_KV_BY_GROUP: dict[str, float] (L317) — per-kinematic-group PD derivative gain.
  • _h1_group(joint_name) -> str — Return the kinematic group token (hip / knee / ankle / torso / shoulder / elbow) for joint_name. (L189)
  • _h1_parent_child(joint_name) -> tuple[str, str] — Return (parent_link, child_link) for an H1 joint. (L194)
  • _h1_joint_specs() -> list[JointSpec] — Build the 19 JointSpecs from the joint-name tuples + the per-joint limit tables. (L230)
  • _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). (L320)
  • const H1_DESCRIPTION = RobotDescription(...) (L253) — Sim baseline; sdk_kind="open", hal.sim="openral_hal.h1:H1MujocoHAL", hal.real=None (sim-only). In-code mirror of robots/h1/robot.yaml (the runtime source; build_hal passes it as description=); drift-guarded against robots/h1/robot.yaml.

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 driving mujoco_menagerie/flexiv_rizon4/flexiv_rizon4.xml via MujocoArmHAL. Thin manifest-driven wrapper; __init__ forwards to self._init_from_description(RIZON4_DESCRIPTION, …). (L180)
  • __init__(*, mjcf_path=None, settle_steps=None, gravity_enabled=True, staleness_limit_s=0.5) (L211)
  • _rizon4_joint_specs() -> list[JointSpec] — Build the 7 JointSpecs from the joint-name tuple + per-joint limit tables. (L111)
  • module const _RIZON4_JOINT_NAMES: tuple[str, ...] (L65) — 7 arm joint names.
  • module const _RIZON4_POSITION_LIMITS: dict[str, tuple[float, float]] (L80)
  • module const _RIZON4_VELOCITY_LIMIT: float (L94)
  • module const _RIZON4_EFFORT_LIMITS: dict[str, float] (L100)
  • const RIZON4_DESCRIPTION = RobotDescription(...) (L132) — Sim baseline; sdk_kind="open", hal.sim=None (derived MujocoArmHAL), hal.real=None (sim-only). In-code mirror of robots/rizon4/robot.yaml; drift-guarded against robots/rizon4/robot.yaml.

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) (L0) — 16-DoF (7 arm + 1 gripper per side) bimanual HAL driving enactic/openarm_mujoco/v2/openarm_v20_bimanual.xml; thin manifest-driven wrapper around MujocoArmHAL. All wiring (MJCF URI fetched via ensure_openarm_v2_mjcf, joint/actuator maps, gripper config) lives in OPENARM_DESCRIPTION.sim. (L474) ADR-0102 slot-group reassembly, the stale-tick guard and last_committed_tick are inherited from MujocoArmHAL.send_action (this class held its own copy until 2026-09-24).
  • __init__(*, mjcf_path=None, settle_steps=None, gravity_enabled=True, staleness_limit_s=0.5) — Forwards to self._init_from_description(OPENARM_DESCRIPTION, …). (L512)
  • _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)
  • module const _OPENARM_LEFT_ARM_JOINTS: tuple[str, ...] (L106) — left-arm joint names.
  • module const _OPENARM_RIGHT_ARM_JOINTS: tuple[str, ...] (L107) — right-arm joint names.
  • module const _OPENARM_LEFT_GRIPPER_JOINT: str (L108) — left gripper joint name.
  • module const _OPENARM_RIGHT_GRIPPER_JOINT: str (L109) — right gripper joint name.
  • module const _OPENARM_JOINT_NAMES: tuple[str, ...] (L111) — full 16-DoF joint order.
  • module const _OPENARM_LEFT_ARM_POSITION_LIMITS: dict[str, tuple[float, float]] (L122)
  • module const _OPENARM_RIGHT_ARM_POSITION_LIMITS: dict[str, tuple[float, float]] (L131)
  • module const _OPENARM_LEFT_GRIPPER_POSITION_LIMITS: tuple[float, float] (L143)
  • module const _OPENARM_RIGHT_GRIPPER_POSITION_LIMITS: tuple[float, float] (L144)
  • module const _OPENARM_ARM_EFFORT_LIMITS: dict[str, float] (L150)
  • module const _OPENARM_GRIPPER_EFFORT_LIMIT: float (L166)
  • module const _OPENARM_ARM_VELOCITY_LIMIT: float (L169)
  • module const _OPENARM_GRIPPER_VELOCITY_LIMIT: float (L170)
  • const OPENARM_DESCRIPTION = RobotDescription(...) (L250) — Shared baseline for sim and real (name="openarm_v2", 16 revolute joints). sdk_kind="open" (the whole real path — openarm_can + openarm_ros2 — is Apache-2.0, unlike UR/Franka's closed vendor runtime). hal.sim=None (derived MujocoArmHAL, so the manifest's head_zed sensor and collision_geometry reach the twin) + hal.real_bringup="openral_hal_openarm:real_bringup.launch.py". One flat hal.parameters.defaults block serves both entrypoints; build_hal drops the keys each constructor doesn't accept. Drift-guarded against robots/openarm/robot.yaml.

python/hal/src/openral_hal/openarm_real.py

Real-hardware adapter for the Enactic OpenArm v2. Commands openarm_bringup's ros2_control stack (750 Hz) rather than SocketCAN directly: Skill → Action → this adapter → four command topics → controller_manager → openarm_hardware SystemInterface → openarm_can → SocketCAN → Damiao motors.

  • module const _LEFT_ARM_CONTROLLER: str (L93) — default left-arm JointTrajectoryController name.
  • module const _LEFT_GRIPPER_CONTROLLER: str (L94) — default left-gripper controller name.
  • module const _RIGHT_ARM_CONTROLLER: str (L95) — default right-arm controller name.
  • module const _RIGHT_GRIPPER_CONTROLLER: str (L96) — default right-gripper controller name, matching the udev-pinned CAN dev-id convention.
  • module const _JOINT_STATE_TOPIC: str (L97)
  • module const _LEFT_CAN_INTERFACE: str (L98) — default left CAN interface name.
  • module const _RIGHT_CAN_INTERFACE: str (L99) — default right CAN interface name.
  • module const _LEFT_ARM_SLICE (L102) — left-arm slice of the 16-DoF action vector.
  • module const _LEFT_GRIPPER_SLICE (L103) — left-gripper slice.
  • module const _RIGHT_ARM_SLICE (L104) — right-arm slice.
  • module const _RIGHT_GRIPPER_SLICE (L105) — right-gripper slice.
  • class OpenArmRealHAL(RosControlHAL) (L120) — 16-DoF real-HW adapter, same action layout as OpenArmMujocoHAL. Guards three silent-failure modes: fan-out (one action sliced atomically across the bimanual bringup's four controller topics, so a rejection can't leave one arm on a new chunk and the other stale), read-by-name (read_state matches /joint_states by joint name, raising rather than zero-filling a missing joint), and bus preflight (connect() refuses a missing/wrong-type/down CAN link rather than reporting connected over a dead bus). estop_recovery = RESETTABLE.
  • __init__(description=None, *, left_can_interface='openarm_left', right_can_interface='openarm_right', left_arm_controller=..., left_gripper_controller=..., right_arm_controller=..., right_gripper_controller=..., joint_state_topic='/joint_states', publish_fn=None, state_fn=None, staleness_limit_s=None, require_can_links=True) — Rejects a manifest that is not 16-joint or whose joints lack sim_joint_name (the URDF joint name, i.e. the logical→ros2_control mapping).
  • connect() -> None (L302) — Bus preflight (openral_core.can.preflight_can_links) then delegates to RosControlHAL.connect; refuses a CAN link that is missing, is not a CAN device, or is down.
  • ros2_control_joint_names() -> list[str] (L258) — 16 URDF names in action-vector order.
  • controller_names() -> list[str] — The four controllers estop deactivates, in fan-out order (left arm, left gripper, right arm, right gripper); the fleet conformance test pins that every command_bindings topic belongs to one of them. (L290)
  • estop_recovery = RESETTABLE — the deactivated controllers hold and the CAN bus stays configured, so the inherited reset_estop re-activates all four through the same controller_manager seam and reconnects only once the manager confirms every one active.
  • read_state() -> JointState (L335) — Matches /joint_states by ros2_control joint name and returns manifest order; raises rather than zero-filling when a joint is absent. An absent or wrong-length effort channel yields effort=[], never zeros (a zero-filled channel would fake an effort reading that does not exist).
  • command_bindings() -> dict[str, ControllerKind] (L275) — The four /…/joint_trajectory topics, all JOINT_TRAJECTORY: openarm_bringup configures each gripper as a 1-DoF JointTrajectoryController, so grippers and arms take the same message.
  • health() -> HALHealthReport (L555) — Cached per-bus state from connect(); performs no I/O.
  • send_action(action) (L393) — ADR-0102 slot groups: a tick_group_size > 1 action is staged (SlotGroupStager, which refuses a tick at or below last_committed_tick) and composed into one 16-DoF action by compose_slot_group_action before the same all-or-nothing publish; the tick is committed only after all four messages went out. An ungrouped ticked action (starting-pose ramp, approach) is checked and committed against the same watermark (SlotGroupStager.admit / commit_tick). A standalone gripper action raises ROSConfigError — the four bimanual controllers share one joint vector, so there's nowhere to route it. All four messages carry the same time_from_start_s from RosControlHAL.time_from_start_s (issue #303).
  • (property) last_committed_tick -> int — Tick of the last slot group published (0 = none since disconnect); lets the lifecycle node acknowledge a grouped tick on commit instead of counting slots. (L508)
  • (property) last_committed_session -> int — runner_session_id of last_committed_tick (0 = none / session-less). (L518)
  • (property) last_applied_action -> Action | None (L497) — The command last published to the four controllers: the composed 16-DoF action for a slot group, the action itself otherwise; read from the SlotGroupStager, which records it only after the publish, in step with last_committed_tick. Fed to the grasp trigger by the lifecycle node.
  • estop() -> None (L536) — Discards the half-staged tick (keeping the committed watermark, so a pre-stop tick replayed after the stop is still refused) before delegating, so a slot staged when the stop landed cannot outlive it and corrupt the first post-e-stop tick. The downstream stop itself (all four controllers deactivated and confirmed) is the base implementation's.
  • discard_staged_slots() -> int (L522) — SlotGroupStager.discard (watermark kept); called by the HAL lifecycle node on every /openral/estop latch and /openral/estop_cleared. Returns the slots dropped.
  • disconnect() -> None (L486) — Also resets the stager (and its committed watermark), so a slot staged before disconnect cannot raise an incomplete-group error on the first tick after reconnect.
  • Real hardware needs an upstream patch. openarm_bringup generates the real robot_description via xacro, and on OpenArm v2.0 the left_can_interface/right_can_interface arguments are silently dropped, falling back to can1/can0 (v1.0 is unaffected). Unpatched, the arms don't move and the CAN preflight passes because those buses really are up — it's the description pointing elsewhere. robots/openarm/patches/ carries the fix and an idempotent apply.sh.
  • The bus preflight is not implemented here — connect() delegates to openral_core.can.preflight_can_links, passing the two udev-named interfaces and OpenArm-specific remedy text; the mechanism lives in layer 0 for other CAN robots to reach. Narrower than openral_detect.probes.can, the richer read behind openral detect.
  • const OPENARM_REAL_DESCRIPTION = make_real_description(OPENARM_DESCRIPTION, sdk_kind="open") (L117) — Shares kinematics, safety envelope and HAL entrypoints with the sim baseline.

python/hal/src/openral_hal/_slot_group.py

Reassembles a slot-dispatched inference tick into one whole-robot command (ADR-0102): _dispatch_slots emits one Action per non-discard slot (four for OpenArm v2 bimanual), each published separately through the safety kernel, so a HAL that commands one vector must recombine them.

  • module const GRIPPER_MODES (L46) — (GRIPPER_POSITION, GRIPPER_BINARY), the modes composed from Action.gripper + ee_name rather than from a joint row.
  • compose_slot_group(actions, joint_names) -> list[float] (L60) — Merges one tick's slots into a full-dof target row, addressing by name (never position): JOINT_* slots via Action.joint_names, gripper slots via ee_name. Raises ROSConfigError for an unnamed joint slot, an unknown joint/end-effector, a joint claimed twice, or any joint left uncommanded; the placement itself is slot_group_targets, after refusing any mode it cannot place.
  • slot_group_targets(actions, joint_names) -> list[float | None] (L113) — compose_slot_group's placement with the same strictness for a group that need not cover every joint: skips jointless slots (BODY_TWIST, Cartesian, FOOT_PLACEMENT, COMPOSITE_MODE; private _JOINTLESS_MODES), returns None for uncommanded joints, and raises ROSConfigError for any unreadable joint-addressing slot (no payload / ee_name / joint_names, unknown name, row too short, a non-position joint-space mode) or a joint written twice — so a None means "not commanded", never "commanded unreadably". Used by sim_attached.gripper_targets_action.
  • compose_slot_group_action(group, joint_names) -> Action — compose_slot_group wrapped into the one-step full-dof JOINT_POSITION Action (first slot's stamp/confidence) that MujocoArmHAL and OpenArmRealHAL apply. (L214)
  • refuse_stale_tick(tick, last_committed) -> int — The session-less slot-group tick rule (hazard log Entry 036), applied by TickWatermark only to actions with no runner_session_id (0): raises ROSRuntimeError ("stale slot group") when 0 < tick <= last_committed (a replayed group of an already-committed tick), EXCEPT tick == 1 while last_committed > 1, a restarted runner's fresh numbering, which is adopted by returning 0. SlotGroupStager only checks with it and moves its watermark on commit, so one delayed tick-1 slot of the old runner cannot drop the watermark before a new group is applied. A replay of tick 1 after tick 1 committed is refused. Reached through TickWatermark.check. (L238)
  • class TickWatermark (L289) — The committed (runner_session_id, tick_index) every slot-group HAL refuses replays against (Entry 036 Amendment 3). Same session: tick <= tick watermark is refused, tick 1 included. A different non-zero session is a new runner: admitted, adopted only by commit (never by check, so a stray slot cannot move it), and on adoption the old session is retired — every later action carrying it raises ("superseded"). Session-less 0 falls back to refuse_stale_tick. reset() (disconnect) clears the watermark but keeps retired sessions refused. Attributes tick, session. Shared by SlotGroupStager and SimAttachedHAL.
  • check(tick, *, session) -> None (L331) — Raise on a replay; the watermark does not move.
  • commit(tick, *, session) -> None (L354) — Record an applied (session, tick); retires the previous session on a change.
  • reset() -> None (L361) — Clear the watermark, keep the retired set.
  • class SlotGroupStager (L367) — Buffers at most one tick, keyed by (runner_session_id, tick_index), and holds the committed watermark (TickWatermark). stage(action) -> list[Action] | None returns the group when the last slot lands, else None; it first refuses a replay (TickWatermark.check); the watermark moves, and a new runner session is adopted, only on commit(group). admit(action) / commit_tick(action) apply the same watermark to UNGROUPED ticked actions (the runner's starting-pose ramp and approach share its tick counter), so a restarted runner's ramp renumbers the HAL instead of leaving its first policy tick under the old watermark. discard() (E-stop) keeps the watermark, so a pre-stop tick replayed after the stop is refused; reset() (disconnect) clears it. A tick change mid-group raises ROSRuntimeError rather than committing survivors and leaving one arm stale; the slot that exposed it opens the new tick instead of being dropped with it. A slot from a different non-zero runner session instead drops the old session's partial tick silently (logged hal.slot_group.stale_partial_dropped) — a dead run's half tick is nobody's loss. Mirrors SimAttachedHAL._stage_action_group. Used by every MujocoArmHAL twin and OpenArmRealHAL.
  • admit(action: Action) -> None (L441) — Check an ungrouped ticked action against the watermark before it is applied (TickWatermark.check); the watermark does not move.
  • commit_tick(action: Action) -> None (L459) — Record that an ungrouped action was applied: moves the watermark to its (runner_session_id, tick_index) when ticked, and always records it as last_applied_action.
  • stage(action) -> list[Action] | None (L493) — see above.
  • pending -> int [@property] (L406) — count of slots in flight.
  • last_committed_tick -> int [@property] — tick of the last group committed (0 = none since reset). (L411)
  • last_committed_session -> int [@property] — its runner_session_id (0 = none / session-less). (L416)
  • commit(group, *, applied: Action) -> None — record that a released group was applied as applied (the composed command sent); call only after the apply succeeded (the lifecycle node acks this tick). (L430)
  • last_applied_action -> Action | None [@property] — the command the last commit / commit_tick applied (None since reset; discard keeps it). The one accessor the stager-backed HALs expose as their own last_applied_action. (L421)
  • discard() -> int — drop the buffered slots, keep the watermark (estop latch / clear); returns how many slots were dropped. (L469)
  • reset() -> None (L483) — drop the buffered slots, the watermark and last_applied_action (disconnect).

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. Differs from the Enactic v2 twin only in two joint ranges (J1, J6) and a wrist support bracket, baked into the fetched MJCF, so the HAL stays a thin manifest-driven subclass.

  • class AnvilOpenArmV2MujocoHAL(MujocoArmHAL) — 16-DoF (7 arm + 1 gripper per side) bimanual HAL driving models/anvil_openarm_bimanual.xml from the pinned bensonlee5/anvil-openarm-mujoco clone. All wiring lives in ANVIL_OPENARM_V2_DESCRIPTION.sim. Structurally identical to OpenArmMujocoHAL — the Anvil-ness is entirely in the asset. (L370)
  • __init__(*, mjcf_path=None, settle_steps=None, gravity_enabled=True, staleness_limit_s=0.5) — Forwards to self._init_from_description(ANVIL_OPENARM_V2_DESCRIPTION, …). (L411)
  • _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)
  • module const _ANVIL_LEFT_ARM_JOINTS: tuple[str, ...] (L89) — left-arm joint names.
  • module const _ANVIL_RIGHT_ARM_JOINTS: tuple[str, ...] (L90) — right-arm joint names.
  • module const _ANVIL_LEFT_GRIPPER_JOINT: str (L91) — left gripper joint name.
  • module const _ANVIL_RIGHT_GRIPPER_JOINT: str (L92) — right gripper joint name.
  • module const _ANVIL_LEFT_ARM_POSITION_LIMITS: dict[str, tuple[float, float]] (L103) — left-arm per-joint position limits, incl. the Anvil-specific J1/J6 ranges.
  • module const _ANVIL_RIGHT_ARM_POSITION_LIMITS: dict[str, tuple[float, float]] (L112) — right-arm per-joint position limits.
  • module const _ANVIL_LEFT_GRIPPER_POSITION_LIMITS: tuple[float, float] (L122)
  • module const _ANVIL_RIGHT_GRIPPER_POSITION_LIMITS: tuple[float, float] (L123)
  • module const _ANVIL_ARM_EFFORT_LIMITS: dict[str, float] (L128) — per-joint effort limit table.
  • module const _ANVIL_GRIPPER_EFFORT_LIMIT: float (L144)
  • module const _ANVIL_ARM_VELOCITY_LIMIT: float (L148)
  • module const _ANVIL_GRIPPER_VELOCITY_LIMIT: float (L149)
  • const ANVIL_OPENARM_V2_DESCRIPTION = RobotDescription(...) (L215) — Sim baseline (name="anvil_openarm_v2", 16 revolute joints, Anvil's J1/J6 ranges, two wrist RGB SensorSpecs). sdk_kind="open", hal.sim=None (derived MujocoArmHAL), hal.real=None (sim-only; a real driver wrapper is a tracked follow-up). Drift-guarded against robots/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 pinned-SHA asset fetchers: shallow clone into a staging dir, checkout the pinned SHA, optional submodule init, os.rename into place (loser of a concurrent race discarded). Raises ROSConfigError when git is missing or any step fails. Reuse watch: the canonical pinned-clone fetcher. (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 clones bensonlee5/anvil-openarm-mujoco at a pinned SHA, initialises its mesh submodule, and returns the bimanual MJCF path. Raises ROSConfigError if git is missing or the clone/submodule init fails. Mirrors _openarm_v2_assets.ensure_openarm_v2_mjcf plus the submodule step. (L60)
  • module const _ANVIL_PINNED_SHA: str (L40) — bump when the generator or the local Anvil spec changes.
  • module const _ANVIL_REPO_URL: str (L41) — the pinned clone's URL.
  • module const _ANVIL_MJCF_REL: str (L42) — the bimanual MJCF's path within the clone.
  • module const _ANVIL_MESH_SUBMODULE: str = "upstream/openarm_mujoco" (L46) — mesh submodule the generated MJCF's meshdir points into; initialised by ensure_anvil_openarm_v2_mjcf.

python/hal/src/openral_hal/_openarm_description_assets.py

Fetch Enactic's public openarm_description ROS package (Apache-2.0) — the package://openarm_description/... meshes robots/openarm/openarm.urdf references — for hosts without a ROS workspace that builds it.

  • ensure_openarm_description() -> Path (L30) — Idempotently clones enactic/openarm_description at a pinned SHA into $OPENRAL_CACHE_DIR/openarm_description/<sha>/ and returns the package root. Raises ROSConfigError when the clone fails or has no package.xml. Registered in openral_hal.ros_package_overlay.PUBLIC_ROS_PACKAGES (Isaac import + Foxglove asset fetch).

python/hal/src/openral_hal/ros_package_overlay.py

Make fetched public ROS description packages resolvable by ROS tools (resource_retriever in foxglove_bridge), which find package:// roots only through the ament index.

  • PUBLIC_ROS_PACKAGES: dict[str, Callable[[], Path]] (L31) — known public description packages → fetcher returning the package root (openarm_description → ensure_openarm_description). The one registry; openral_sim.backends.isaac_sim._fetch_public_package reads it.
  • fetch_public_package(pkg) -> Path | None (L38) — the known public package's root (fetched if needed), None for any other.
  • public_package_overlay(urdf_xml) -> Path | None (L57) — an ament prefix at $OPENRAL_CACHE_DIR/ament_overlay registering every public package the URDF's package:// URIs name that no AMENT_PREFIX_PATH entry indexes; None when none is needed. deploy_e2e.launch.py prepends it to foxglove_bridge's AMENT_PREFIX_PATH so the 3D panel can fetch the URDF meshes.

python/hal/src/openral_hal/_openarm_v2_assets.py

Vendor the upstream enactic/openarm_mujoco v2 MJCF until robot_descriptions bumps its own pin to match.

  • ensure_openarm_v2_mjcf() -> str — Idempotently clones enactic/openarm_mujoco at a pinned v2 SHA into $OPENRAL_CACHE_DIR/openarm_v2/<sha>/, returns the bimanual MJCF path. Raises ROSConfigError when git is missing or the clone fails. Mirrors the pattern used by python/sim/src/openral_sim/backends/so100_robosuite/_assets.py. (L57)
  • module const _OPENARM_V2_PINNED_SHA: str (L40) — bump to track upstream v2 updates.
  • module const _OPENARM_REPO_URL: str (L41) — the pinned clone's URL.
  • module const _OPENARM_V2_MJCF_REL: str (L42) — the bimanual MJCF's path within the clone.

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; takes description= (loaded manifest; None → G1_DESCRIPTION). Default mode drives mujoco_menagerie/unitree_g1/g1.xml; walking mode swaps in the pinned policy-tuned MJCF + ONNX controller. Floating-base joint remains implicit world state. (L379)
  • __init__(*, mjcf_path=None, settle_steps=None, gravity_enabled=True, staleness_limit_s=0.5, body_twist_dt_s=0.05, walking_enabled=False) (L418)
  • connect() -> None (L492) — Connect and re-arm the base pin from the fresh qpos; in walking mode, resets to the knees_bent keyframe and constructs/initializes the G1WalkingController. Raises ROSConfigError (and disconnects) if the walking MJCF lacks that keyframe.
  • disconnect() -> None (L512) — Release the walking controller, then the MuJoCo model.
  • base_pose -> tuple[float, float, float] (property) — Current glide pin or live walking base pose. (L480)
  • base_twist -> tuple[float, ...] (property) — Last commanded 6-vec base twist (base_link frame), zeroed by any non-BODY_TWIST action. (L488)
  • send_action(action) — Routes BODY_TWIST to the glide or walking controller; every other mode defers to the joint path. Non-planar twist components raise ROSConfigError. (L517)
  • idle_step(wall_dt_s=None) -> bool — HOLD-step with the base pinned; walking state resets so a stale velocity command is never replayed. Opts out of the bare-arm _step_while_active path since an active BODY_TWIST must not be overridden by HOLD. (L564)
  • reset_to_pose(pose: list[float]) -> None (L576) — Reset the joint pose plus the base pin and (when walking) the gait state.
  • _per_step_update(targets) / _pin_base() / _pin() — The upright pin: qpos[0:7] fixed to the glide pose (roll/pitch clamped to 0), qvel[0:6] zeroed. Replacing the pin with a balance controller is the designed S0 upgrade seam. (L604)
  • module const _PLANAR_TWIST_EPS (L80) — tolerance for detecting a non-planar BODY_TWIST component (send_action's validation).
  • module const _G1_LEFT_LEG_JOINTS: tuple[str, ...] (L92) — left-leg joint names.
  • module const _G1_RIGHT_LEG_JOINTS: tuple[str, ...] (L100) — right-leg joint names.
  • module const _G1_WAIST_JOINTS: tuple[str, ...] (L108) — waist joint names.
  • module const _G1_LEFT_ARM_JOINTS: tuple[str, ...] (L113) — left-arm joint names.
  • module const _G1_RIGHT_ARM_JOINTS: tuple[str, ...] (L122) — right-arm joint names.
  • module const _G1_JOINT_NAMES: tuple[str, ...] (L131) — full 29-DoF joint order (legs + waist + arms).
  • module const _G1_POSITION_LIMITS: dict[str, tuple[float, float]] (L150) — per-joint position limits.
  • module const _G1_VELOCITY_LIMITS_BY_GROUP: dict[str, float] (L186) — per-kinematic-group velocity limits.
  • module const _G1_EFFORT_LIMITS_BY_GROUP: dict[str, float] (L195) — per-kinematic-group effort limits.
  • _g1_group(joint_name) -> str — Return the kinematic group token (hip / knee / ankle / waist / shoulder / elbow / wrist) for joint_name. (L209)
  • _g1_parent_child(joint_name) -> tuple[str, str] — Return (parent_link, child_link) for a G1 joint, following the menagerie URDF convention. (L214)
  • _g1_joint_specs() -> list[JointSpec] — Build the 29 JointSpecs from the joint-name tuples and the per-joint limit tables. (L264)
  • const G1_DESCRIPTION = RobotDescription(...) (L292) — Sim baseline; sdk_kind="open", hal.sim="openral_hal.g1:G1MujocoHAL", hal.real=None (sim-only); in-code mirror — build_hal passes the loaded robots/g1/robot.yaml as description=. Advertises supported_control_modes=[joint_position, body_twist], mobile_base in embodiment_tags, and a forward head RGB camera so BODY_TWIST nav skills (InternVLA-N1) match. All MuJoCo wiring lives in G1_DESCRIPTION.sim; drift-guarded against robots/g1/robot.yaml.

python/hal/src/openral_hal/_g1_walking.py

ADR-0089's private, sim-only walking implementation.

  • ensure_g1_walking_assets() -> tuple[str, str] (L84) — downloads four files from pinned MuJoCo Playground commit 43d180a, verifies SHA-256, links the cached Menagerie meshes, flattens the included MJCF, and returns (mjcf_path, policy_path).
  • module const _PINNED_SHA: str (L19) — pinned MuJoCo Playground commit.
  • module const _RAW_ROOT: str (L20) — raw-GitHub root URL derived from _PINNED_SHA.
  • module const _ASSETS: dict[str, str] (L21) — the four pinned-asset relative paths mapped to their expected SHA-256 hex digests, verified by ensure_g1_walking_assets.
  • class G1WalkingController (L128) — 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.
  • initialize(data: mujoco.MjData) -> None (L157) — Capture data.qpos[7:] as the standing default-angle reference, then reset_state().
  • reset_state() -> None (L161) — Zero the last-action history and gait phase, reset the decimation counter.
  • apply(model: mujoco.MjModel, data: mujoco.MjData, command: tuple[float, float, float]) -> None (L166) — Every _CONTROL_DECIMATION-th physics step, build the 103-D observation (base linear velocity, gyro, projected gravity, command, joint-angle delta, joint velocity, last action, phase sin/cos), run the ONNX policy, and scale/clip/apply the resulting joint targets.

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 mujoco_menagerie's trs_so_arm100/so_arm100.xml with the same 6-DoF action layout as SO100FollowerHAL. Maps lerobot-style joint names to the Menagerie joints and normalises the revolute Jaw range [-0.174, 1.75] to [0, 1]. (L65)
  • __init__(*, mjcf_path=None, settle_steps=None, gravity_enabled=True, staleness_limit_s=0.5) (L106)

python/hal/src/openral_hal/so100_sim.py

SO100DigitalTwin — in-process simulator for the SO-100 follower arm.

  • module const _JOINT_NAMES: list[str] (L37) — canonical joint order, mirrors lerobot's SO100Follower motor dict.
  • module const _DEFAULT_POSITIONS: dict[str, float] (L48) — default joint positions in lerobot's native units (degrees; gripper [0, 100], mid-range).
  • class SO100DigitalTwinConfig(RobotConfig) — Config for the digital twin. (L59) field: initial_positions
  • class 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, plus the typed downstream e-stop seam every ros2_control robot stops through (issue #295).

  • class ControllerKind(StrEnum) — Which ros2_control controller sits behind one command topic, and therefore which message type commands it: JOINT_TRAJECTORY (trajectory_msgs/JointTrajectory — every ros2_control robot in this repo, OpenArm's 1-DoF grippers included) and FORWARD_COMMAND (std_msgs/Float64MultiArray, the forward_command_controller family; no in-repo robot yet). Declared rather than assumed because publishing the wrong type is silent — DDS never delivers it, so the arm does not move and nothing logs an error. Re-exported from openral_hal. (L72)
  • class ControllerSwitchReport — Frozen dataclass: acknowledged outcome of one controller_manager switch. ok is the service's verdict and the post-switch confirmation (every controller listed in the requested state); states is what list_controllers reported per name; detail names what did not move. (L108)
  • class TriggerReport — Frozen dataclass: success + message of one std_srvs/Trigger-shaped vendor call. (L125)
  • class DownstreamStopReport — Frozen dataclass: what a HAL knows about its last downstream stop — stopped (only when controller deactivation and the vendor stop were acknowledged; a local latch never counts), controllers, controller_states, vendor_stop (service/topic label or ""), detail. fields() flattens it for /diagnostics (downstream_stop=acknowledged|unacknowledged, …). Read by the lifecycle node, which logs FATAL on an unacknowledged stop. (L133)
  • fields() -> dict[str, str] — Flattens the report for /diagnostics and the log line: downstream_stop=acknowledged|unacknowledged, downstream_controllers, downstream_controller_states, downstream_vendor_stop, downstream_stop_detail. (L151)
  • class ControllerStopSeam(Protocol) — What a transport must expose for a ros2_control HAL to stop its controllers: deactivate_controllers(names, *, timeout_s), activate_controllers(names, *, timeout_s) (both → ControllerSwitchReport), call_trigger(service, *, timeout_s) -> TriggerReport, publish_empty(topic). Production: RosControlTransport; unit lane: SimTransport. (L165)
  • deactivate_controllers(names, *, timeout_s) -> ControllerSwitchReport — The stop: switch every named controller to inactive and confirm. (L178)
  • activate_controllers(names, *, timeout_s) -> ControllerSwitchReport — The RESETTABLE re-arm: switch to active and confirm. (L184)
  • call_trigger(service, *, timeout_s) -> TriggerReport — One declared std_srvs/Trigger vendor service. (L190)
  • publish_empty(topic) -> None — One std_msgs/Empty on a declared vendor-stop topic. (L194)
  • class ControllerStoppable(Protocol) — The stop-side counterpart of RosControlDrivable: controller_names(), vendor_stop_services(), vendor_stop_topics(), attach_controller_stop(seam). The lifecycle node refuses to configure a RosControlDrivable real HAL that is not also this; RosControlHAL answers all four. Pinned per manifest by tests/unit/test_real_hal_estop_fleet_conformance.py. (L200)
  • controller_names() -> list[str] — Every controller_manager controller estop must deactivate. (L212)
  • vendor_stop_services() -> list[str] — std_srvs/Trigger services the vendor stop calls. (L216)
  • vendor_stop_topics() -> list[str] — std_msgs/Empty topics the vendor stop publishes on. (L220)
  • attach_controller_stop(seam) -> None — Bind the ControllerStopSeam at wire-up. (L224)
  • class DownstreamStopReporting(Protocol) — last_stop_report -> DownstreamStopReport | None property; what the lifecycle heartbeat and FATAL log read. (L230)
  • (property) last_stop_report -> DownstreamStopReport | None (L234)
  • class RosControlHAL — ros2_control-backed HAL adapter. Implements LifecycleEStopHAL with estop_recovery = RESTART_REQUIRED by default (the conservative policy; OpenArm opts into RESETTABLE). (L261)
  • __init__(description, controller_name, *, joint_state_topic='/joint_states', command_topic=None, publish_fn=None, state_fn=None, staleness_limit_s=None, stop_timeout_s=5.0) (L324) — staleness_limit_s=None reads description.safety.joint_state_staleness_limit_s via resolve_staleness_limit_s (no constructor default; refused when undeclared). Reads the control rate from description.action_spec.control_freq_hz (the field the runner ticks at and the recorder stamps as fps; every committed ros2_control manifest declares 30 Hz); refuses to construct with ROSConfigError naming the field when it is missing or not positive, so build_hal(mode="real") and the lifecycle node's configure stop there (issue #303).
  • attach_transport(publish_fn, state_fn, stamp_fn=None) -> None — Bind a live transport after construction; build_hal runs before any ROS node exists, so a real deployment cannot pass one to __init__. stamp_fn is the transport's per-message arrival clock and is what makes the staleness check a freshness check rather than a time-since-connect check. Rejects a non-callable stamp_fn at wire-up. (L375)
  • attach_controller_stop(seam) -> None — Bind the ControllerStopSeam (same rationale as attach_transport); rejects a non-seam at wire-up with ROSConfigError. (L414)
  • controller_names() -> list[str] — Every controller_manager controller estop deactivates; [controller_name] by default, overridden by the bimanual OpenArm alongside command_bindings. (L437)
  • vendor_stop_services() -> list[str] — std_srvs/Trigger services and std_msgs/Empty topics the vendor stop uses; declared up front so the transport creates the clients/publishers at wire-up and refuses an undeclared name loudly. Empty in the base. (L446)
  • vendor_stop_topics() -> list[str] — std_msgs/Empty topics the vendor stop publishes on, declared for the same reason. Empty in the base; Sawyer overrides. (L455)
  • (property) last_stop_report -> DownstreamStopReport | None (L460)
  • command_bindings() -> dict[str, ControllerKind] — Every controller topic this HAL publishes to, each with the wire format its controller speaks; one entry becomes one typed transport publisher. Insertion order is the send_action fan-out order. This is the override point for a new robot — overridden by the bimanual OpenArm (4 topics, all JOINT_TRAJECTORY). (L464)
  • command_topics() -> list[str] — Derived from command_bindings() so the topic list and the declared formats cannot drift; override command_bindings, not this. (L478)
  • ros2_control_joint_names() -> list[str] — Joint names in ros2_control's namespace, action order; what /joint_states is keyed by. Overridden where URDF names differ from manifest names (OpenArm). (L486)
  • (property) joint_state_topic -> str — The aggregated sensor_msgs/JointState topic this HAL reads. (L496)
  • (property) controller_name -> str — Name of the primary ros2_control controller this HAL commands (the one command_topic defaults from). Hoisted here from the Franka / Sawyer wrappers so every subclass answers it. (L501)
  • connect() -> None (L507)
  • disconnect() -> None — inherited from HALBase (flag-and-log default; no extra teardown needed).
  • read_state() -> JointState — Age is measured from stamp_fn() when a transport supplied one, else from connect(). (L527)
  • send_action(action) -> None — Publish JointTrajectory; carries joint_names so no transport keeps a second copy of the mapping, and time_from_start_s from time_from_start_s(action). (L573)
  • time_from_start_s(action) -> float — Trajectory deadline horizon / action_spec.control_freq_hz: a chunk is reached just as its replacement is due; the transport spreads the chunk's rows evenly up to it. Issue #303: at 30 Hz that default re-planned a steep segment every 33 ms and amplified each step between targets. (L607)
  • estop() -> None — Drop the connection flag first (nothing more leaves this HAL), then _stop_downstream(), then raise ROSEStopRequested whose message says whether the downstream stop was acknowledged. The outcome lives in last_stop_report, not the exception type, so the HAL Protocol's "always ROSEStopRequested" holds. With no seam attached the report is stopped=False / "no controller stop seam attached" — never a claimed stop. (L630)
  • _stop_downstream() -> DownstreamStopReport [private] — Deactivates controller_names() through the seam (STRICT switch + listing confirmation), then runs _vendor_stop; both are attempted even if the first fails, so the report names everything that did and did not acknowledge. (L663)
  • _vendor_stop(seam) -> str [hook] — Vendor-specific stop beyond controller deactivation; returns its label for the report or raises a ROSError. Base: none (for a plain ros2_control robot deactivation is the stop; for franka_hardware it is what calls libfranka's stopRobot()). Overridden by UR (dashboard stop Trigger) and Sawyer (super-stop Empty). (L721)
  • _vendor_reset(seam) -> None [hook] — Vendor re-arm before controllers are re-activated; no-op by default. (L733)
  • reset_estop() -> None — Only for RESETTABLE adapters: _vendor_reset, then activate_controllers through the seam, and only once the manager confirms every controller active is the HAL marked connected again. Raises ROSRuntimeError for a RESTART_REQUIRED policy ("in-process reset is forbidden"), a missing seam, or an unacknowledged re-activation — so the lifecycle latch can never clear ahead of the vendor reset. (L737)
  • private: _require_connected, _validate_action

python/hal/src/openral_hal/ros_control_transport.py

Production rclpy transport shared by every real RosControlHAL robot.

  • class RosControlDrivable(Protocol) — runtime-checkable, structural: what a HAL must expose to be driven by RosControlTransport (command_bindings, ros2_control_joint_names, joint_state_topic, attach_transport). The lifecycle node's wiring gates on this, not on isinstance(hal, RosControlHAL), so a ros2_control robot that reimplements the fan-out on HALBase rather than inheriting can opt in by shape instead of being silently skipped. Non-method member (joint_state_topic is a property) so isinstance works but issubclass raises — gate with isinstance. (L80)
  • command_bindings() -> dict[str, ControllerKind] (L93) — see RosControlHAL.command_bindings.
  • ros2_control_joint_names() -> list[str] (L97) — see RosControlHAL.ros2_control_joint_names.
  • (property) joint_state_topic -> str (L102)
  • attach_transport(publish_fn, state_fn, stamp_fn=None) -> None (L106) — see RosControlHAL.attach_transport.
  • class RosControlTransport — Bridges one RosControlHAL to live ros2_control topics and implements ControllerStopSeam over controller_manager. Built only from what the HAL reports about itself (command_bindings(), ros2_control_joint_names(), joint_state_topic), so a one-controller UR and the four-controller bimanual OpenArm are one code path and a new robot needs no transport code. Wired automatically by the lifecycle node under hal_mode:=real. Reuse watch: the canonical real-HW ros2_control bridge — do not hand-roll publishers in a per-robot HAL. (L128)
  • __init__(node, *, command_topics, joint_names, joint_state_topic='/joint_states', command_kinds=None) — Refuses an empty topic or joint list, and a command_kinds entry naming a topic not in command_topics (a typo there would silently leave the real topic on the JOINT_TRAJECTORY default). Topics absent from command_kinds default to JOINT_TRAJECTORY. (L157)
  • publish(topic, msg) -> None — One command dict → the message type this topic's declared ControllerKind calls for. Publishes the chunk's final step only (a point is an absolute target the controller interpolates toward). Raises on a topic the HAL never declared, and on a payload it cannot express (no joint_targets key — e.g. a {"position": …} gripper command): dropping that quietly would be a silent no-op on the actuation path. An empty joint_targets list is the distinct, benign "nothing to send this tick". (L293)
  • state() -> dict[str, object] — Newest joint state projected onto the HAL's joint order; merged by name, never index, since split controllers publish independently. An unheard joint's position/velocity read 0.0; effort is [] unless every joint carried an effort value. (L405)
  • last_arrival() -> float — time.monotonic() of the newest message; 0.0 before the first. Feeds RosControlHAL.attach_transport(stamp_fn=...). (L430)
  • deactivate_controllers(names, *, timeout_s) -> ControllerSwitchReport — The stop. controller_manager/switch_controller with deactivate_controllers=names, strictness=STRICT, then list_controllers to confirm every name reads inactive; ok is both. Discovery, the switch and its confirmation share one timeout_s deadline, so a stalled manager cannot stretch the stop past the budget. A deactivated JointTrajectoryController holds its last position command (zeroes velocity / effort), drops late trajectories (its subscriber goes inactive) and writes nothing until re-activated — verified against ros2_controllers jazzy, which is also why an empty trajectory is not used as a stop: ROS 2 rejects it ("Empty trajectory received"). (L436)
  • activate_controllers(names, *, timeout_s) -> ControllerSwitchReport — Mirror for a RESETTABLE re-arm; confirms active. (L449)
  • call_trigger(service, *, timeout_s) -> TriggerReport — One declared std_srvs/Trigger (UR /dashboard_client/stop); ROSConfigError for an undeclared service, success=False when absent or unanswered; discovery and the call share one timeout_s deadline. (L455)
  • publish_empty(topic) -> None — One std_msgs/Empty on a declared vendor-stop topic (Sawyer /robot/set_super_stop); ROSConfigError for an undeclared one. (L487)
  • controller_states(*, timeout_s) -> dict[str, str] — {name: state} from list_controllers; {} if the manager did not answer; discovery and the call share one timeout_s deadline. The lifecycle node logs a warning at wire-up when the HAL's controllers are not yet listed active. (L504)
  • close() -> None — Destroy the helper node the service clients live on (lifecycle cleanup). (L517)
  • __init__ also takes controller_names, trigger_services, empty_topics (from hal.controller_names() / vendor_stop_services() / vendor_stop_topics()) and controller_manager='/controller_manager'. Service calls run on a private helper node + executor because the e-stop callback runs on the lifecycle node's single-threaded executor and a future issued from inside a callback can only complete if something else spins the client.
  • seen_joints() -> set[str] (L586)
  • missing_joints() -> list[str] (L590)
  • private: _time_from_start_s(msg, topic) — the chunk deadline the HAL supplied; no fallback — a command without a positive time_from_start_s raises ROSConfigError (a fixed 100 ms constant in its place is the defect of issue #303). publish turns every chunk row into a JointTrajectoryPoint, evenly spaced up to that deadline, so the controller interpolates through the chunk's shape (a single-row tick is one point at one control period). _on_joint_state
  • module const _COMMAND_DEPTH = 1 (L120) — command QoS depth (RELIABLE, VOLATILE, shallow — CLAUDE.md §2).
  • module const _STATE_DEPTH = 10 (L125) — state QoS depth.
  • module const _STRICT = 2 (L76) — controller_manager_msgs/SwitchController.STRICT: a switch that cannot fully apply is refused rather than partially applied.
  • _left(deadline) -> float — Seconds left before a time.monotonic() deadline, floored at zero; every stop-seam phase (wait_for_service, the call, the confirmation) is budgeted through it. (L636)
  • _message_type(kind) -> type — The one place a ControllerKind becomes a ROS message class. Adding an enum member without extending this raises at wire-up rather than publishing a plausible-but-wrong type onto the actuation path; tests/unit/test_ros_control_transport.py::test_every_controller_kind_maps_to_a_message_type pins that. (L641)

python/hal/src/openral_hal/interbotix_transport.py

Production rclpy torque-stop seam for the Interbotix XS arms (ALOHA) — the stop only, not the command path (#250).

  • class InterbotixXSTransport — Implements openral_hal.aloha.InterbotixStopSeam over real interbotix_xs_msgs/srv/TorqueEnable clients, one per arm_namespaces entry, created at wire-up on a private helper node (same callback-deadlock rationale as RosControlTransport). Raises ROSConfigError at construction when interbotix_xs_msgs is not installed, so the ALOHA e-stop fails at configure, not on the first stop. Attached by the lifecycle node under hal_mode:=real to any InterbotixStoppable HAL. (L38)
  • torque_enable(robot_name, *, group, enable, timeout_s) -> TriggerReport — /<robot_name>/torque_enable with cmd_type='group'; TorqueEnable has an empty response, so the acknowledgement is the call completing — absent or unanswered → success=False. ROSConfigError for an undeclared arm. (L88)
  • close() -> None — Destroy the helper node. (L131)

python/hal/src/openral_hal/sim_transport.py

SimTransport — in-memory simulated ros2_control transport and ControllerStopSeam; SimTorqueSeam — in-memory InterbotixStopSeam. The in-repo controller simulators the unit lane proves the e-stop path on.

  • class SimTransport — In-memory transport simulating JointTrajectory controllers behind a controller_manager table. Mirrors the two verified facts about a deactivated JointTrajectoryController: a command on an inactive controller's topic is dropped (recorded in dropped_calls, not applied) and nothing moves until re-activated. (L59)
  • __init__(n_joints, *, controllers=None, trigger_responses=None, switch_faults=None) — controllers given → STRICT table (an unknown name refuses a switch like an unloaded controller); omitted → names register active on first use. trigger_responses maps a service to the (success, message) its call_trigger returns (simulate a refusing UR dashboard); switch_faults maps "deactivate" / "activate" to the exception that switch raises instead of answering (the rclpy-shaped fault the HAL must contain in its report). (L103)
  • publish(topic, msg) -> None — Record msg, apply joint_targets — unless the topic's controller is inactive. (L128)
  • state() -> dict[str, object] — Current simulated joint state. (L152)
  • deactivate_controllers(names, *, timeout_s) -> ControllerSwitchReport — Flip the table to inactive; refuse unknown names under STRICT; raise the switch_faults["deactivate"] exception when configured. (L167)
  • activate_controllers(names, *, timeout_s) -> ControllerSwitchReport — Mirror for the re-arm; switch_faults["activate"] likewise. (L173)
  • call_trigger(service, *, timeout_s) -> TriggerReport — Recorded; answered from trigger_responses (default: success). (L179)
  • publish_empty(topic) -> None — Recorded in empty_publishes. (L185)
  • controller_state(name) -> str — active / inactive / unloaded. (L189)
  • call_count -> int [@property] — Number of commands delivered to an active controller. (L196)
  • last_call -> tuple | None [@property] — The most recent delivered (topic, msg). (L201)
  • calls -> list[tuple] [@property] — Every delivered (topic, msg), in order. (L206)
  • dropped_calls -> list[tuple] [@property] — Every command that reached an inactive controller and was dropped. (L211)
  • switch_calls -> list[tuple] [@property] — Every ("deactivate" | "activate", names) switch, in order. (L216)
  • trigger_calls -> list[str] [@property] — Every std_srvs/Trigger service called, in order. (L221)
  • empty_publishes -> list[str] [@property] — Every std_msgs/Empty topic published on, in order. (L226)
  • class SimTorqueSeam — In-memory InterbotixStopSeam: a simulated xs_sdk torque table. torque_enable(robot_name, *, group, enable, timeout_s) -> TriggerReport flips the arm's torque, refuses an arm not in arms (absent service) or listed in failing, and raises the exception faults maps an arm to (the rclpy-shaped fault the HAL must contain while still stopping the other arms); torque(name) / calls for assertions. (L266)
  • torque_enable(robot_name, *, group, enable, timeout_s) -> TriggerReport — Record the call and flip the arm's torque unless it is absent, listed in failing, or mapped in faults (then raise that exception). (L306)
  • torque(robot_name) -> bool — Whether the arm is currently torqued on. (L322)
  • calls -> list[tuple[str, str, bool]] [@property] — Every (robot_name, group, enable) call, in order. (L327)

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 publishers, the /openral/safe_action + /openral/estop subscribers, the 1 Hz DiagnosticsHeartbeat, the per-tick hal.read_state/hal.send_action OTel spans, the estop latch, the publish_rate_hz param (0, the default, = the manifest's control_rate_hz so proprio is published once per runner tick; a manifest with no action_spec.control_freq_hz warns and uses 30 Hz — issue #303), and the full configure → activate → deactivate → cleanup → shutdown transition wiring. The /openral/action_applied ack is monotonic per runner session, with the same renumbering the HAL adopts: a new non-zero runner_session_id renumbers it, and for a session-less runner (0) tick 1 after a higher acked tick resets it (_publish_action_applied_if_complete), so a restarted runner is not left waiting on tick 1. (L538)
  • _create_hal(self) -> HAL — Subclass hook (required): construct and return a HAL instance, reading ROS-parameter-driven constructor args via self.get_parameter(...). (L638)
  • _heartbeat_extra_fields(self) -> dict[str, str] — Subclass hook (optional): extra key/values for the /diagnostics payload. Default {}. (L650)
  • on_configure_post_hal(self) -> TransitionCallbackReturn — Subclass hook (optional): robot-specific setup after the HAL connects. Default SUCCESS. (L707)
  • on_activate_post_subs(self) -> TransitionCallbackReturn — Subclass hook (optional): robot-specific timers/publishers after the base wires its subs. Default SUCCESS. (L716)
  • on_deactivate_pre_teardown(self) -> None — Subclass hook (optional): stop robot-specific timers before base teardown. Default no-op. (L724)
  • on_cleanup_pre_disconnect(self) -> None — Subclass hook (optional): tear down robot-specific resources before HAL.disconnect(). Default no-op. (L731)
  • shutdown_hal(self) -> None — Disconnects the HAL on a signal-driven process teardown, since rclpy's SIGINT handler raises out of spin without requesting the lifecycle shutdown transition, so on_shutdown/on_cleanup (and HAL.disconnect) would otherwise never run. Called from both main() factories' finally, before destroy_node; exactly-once and idempotent. Deliberately HAL-only — no publisher/timer teardown, since the rclpy context is already down. SIGKILL stays uncatchable. (L1019)
  • _publish_joint_state(self) -> None — Timer callback: reads self._hal.read_state() under a hal.read_state span and publishes /joint_states; when the HAL exposes read_policy_state, also publishes /openral/policy_state once per new ProprioFrame (never a latched republish). Subclasses may override + call super() to extend (OpenArm does, for viewer-sync). (L1153)
  • _on_safe_action(self, msg) -> None — /openral/safe_action callback. Decodes the openral_msgs/ActionChunk into an openral_core.Action and forwards via _send_action_traced(action, source="safe_action"). (L1262)
  • _send_action_traced(self, action, *, source) -> bool — False when no HAL is bound or send_action raised (logged on the span), else True; Forwards action to self._hal.send_action inside a hal.send_action span; source disambiguates the origin on the dashboard's Commands card. (L1445)
  • _on_estop(self, msg) -> None — /openral/estop callback. Ordered latch → stop → report: sets _estopped (so _on_safe_action drops commands), calls _invoke_hal_estop, then in a finally drops any half-staged slot group (_discard_staged_slots) and reports via _emit_estop_telemetry, so an e-stop is counted even if the vendor stop path raises. Nothing is added ahead of the physical stop.
  • _discard_staged_slots(self, boundary) -> None — Calls the HAL's discard_staged_slots() when it has one, for every HAL (latch-only ones included — the MuJoCo twin never receives estop()), on the /openral/estop latch and on every /openral/estop_cleared (before the latch check); logs openral_hal.slot_group_discarded when slots were dropped, and once openral_hal.slot_group_discard_skipped for a HAL without the method (it stages no groups). Without it a tick a kernel stop cut short survived openral estop reset into the next goal (Thor twin pass, 2026-10-03).
  • _invoke_hal_estop(self) -> None — Calls self._hal.estop() for HALs implementing LifecycleEStopHAL; on the expected ROSEStopRequested it reads _downstream_stop_fields() and logs ERROR "hardware estop completed" only when the HAL's DownstreamStopReport says acknowledged, else FATAL "hardware estop NOT acknowledged downstream — only the local latch holds"; any other exception is logged at fatal — the latch must survive a vendor stop-path failure.
  • _downstream_stop_fields(self) -> dict[str, str] — Flattens the HAL's last_stop_report (DownstreamStopReporting) for the log line and the /diagnostics heartbeat: downstream_stop=acknowledged|unacknowledged|unproven plus controllers / states / vendor stop / detail. _heartbeat_status merges these into the estop latched payload so an operator sees whether the physical stop was proven.
  • _emit_estop_telemetry(self) -> None — Emits the openral.event.estop_requested span event and increments openral.hal.estop.count. The only producer of either signal — the dashboard ingests OTLP, not /openral/estop topics, so this is the sole chokepoint every robot HAL shares on the actuation side. Never raises; telemetry must not disturb the stop path.
  • make_lifecycle_main(node_name, hal_factory) -> Callable[[], None] — Builds a main() entry point for a zero-parameter HAL adapter, via a _FactoryHALLifecycleNode whose _create_hal() returns hal_factory(). Superseded for standard arms by make_lifecycle_main_from_manifest; retained for bespoke nodes. (L405)
  • _launched_node_name(default, argv) -> str — The node name a __node:= / __name:= remap in argv sets, else default. make_lifecycle_main_from_manifest's main() derives the OTel service.name from it, so every robot sharing the one openral_hal_node executable still reports as openral.hal.<robot_id>. (L384)
  • class ManifestHALLifecycleNode(HALLifecycleNodeBase) — Public generic manifest-driven lifecycle node. Reads robot_yaml + hal_mode + sensor knobs as ROS params and builds its HAL via openral_hal.build_hal, so a robot's construction kwargs come from hal.parameters.defaults — no bespoke _create_hal subclass needed. hal_transport_json (a JSON object deploy sim|run fills from DeployScene hal.defaults + --hal) carries every constructor kwarg, so one the node does not declare by name (galaxea host, openarm left_can_interface) is forwarded, not dropped; the named port/robot_ip/fci_ip/id/calibration_dir params win per key; a non-object raises ROSConfigError. Attaches SimSensorBridge in on_activate_post_subs. Under hal_mode:=real it attaches a RosControlTransport to any RosControlHAL and drops this node's global /joint_states publisher so the vendor's joint_state_broadcaster stays the sole writer; since issue #295 it also attaches that transport as the HAL's ControllerStopSeam (attach_controller_stop, built from controller_names() / vendor_stop_services() / vendor_stop_topics()), refuses to configure (ROSConfigError) a drivable HAL that is not ControllerStoppable, warns when controller_manager does not yet list its controllers active, and attaches an InterbotixXSTransport to an InterbotixStoppable HAL (ALOHA) via _attach_interbotix_transport, closing both seams' helper nodes in on_cleanup; it opens /openral/<robot>/reset_to_pose iff the HAL exposes reset_to_pose, and /openral/<robot>/reset_episode (std_srvs/Trigger, reply seed=<n>; proprio snapshot refreshed and republished; the vision attachment bridge is rebuilt empty, so a payload held in the previous episode is not checked against the new pose) iff it exposes reset_episode (sim-attached HALs only), and subscribes /openral/reward/active_task (std_msgs/String, RELIABLE / VOLATILE / KEEP_LAST 1) into the HAL's set_active_task iff it exposes one (_subscribe_active_task). When a scene composition is declared, the named composer's MJCF is threaded in as the HAL's mjcf_path, read from the scene_composition_json ROS param — which takes precedence over the manifest's scene_defaults.composition fallback, so the scene owns its arena and the manifest describes the robot. When the manifest declares a planar base (base_joints), also attaches a MobileBaseBridge. A back-compat alias _ManifestHALLifecycleNode is retained. (L1727)
  • spin_until_shutdown(node) -> None (L454) — rclpy.spin(node) until a signal-driven shutdown, which ends it quietly: KeyboardInterrupt / ExternalShutdownException, and a RuntimeError (RCLError from a timer callback that published on the context the SIGINT handler had just invalidated — the HAL exited 1 on Ctrl-C once a depth camera published — or a take racing the shutdown). A RuntimeError with the context still up propagates. Both HAL main()s spin through it.
  • make_lifecycle_main_from_manifest(node_name) -> Callable[[], None] — Builds a main() that spins up ManifestHALLifecycleNode, reading robot_yaml + hal_mode ("sim"|"real") ROS params and constructing the HAL via build_hal(description, mode=hal_mode) — one node class serves both modes for every robot. deploy sim/deploy run inject the respective mode; a robot lacking it raises ROSCapabilityMismatch. The one caller is packages/openral_hal_node (node_name="openral_hal_node"); the launch renames the node openral_hal_<robot_id>. (L477)
  • joint_state_republish_stamp_ns(node_now_ns, sample_stamp_ns, *, wall_now_ns=None) -> int (L96) — Stamp for the HAL node's ~/joint_states republish: the node clock's now minus the sample's wall-clock age (JointState.stamp_ns), so a stamp-pairing consumer (the robot self-filter's max_joint_state_skew_s) sees a stale sample as stale. A negative or >5 s age (another clock domain) falls back to the node's now.
  • urdf_named_joint_state(msg, urdf_names) -> JointState (L131) — A copy of a manifest-named JointState under the URDF's joint names (resolver.urdf_joint_names), joints the URDF lacks ("") dropped. Published by the HAL node on ~/urdf_joint_states when its urdf_joint_names parameter is set (sim twins only; a real ros2_control robot's broadcaster already uses the URDF names), for robot_state_publisher.
  • sim_attachment_heartbeat(*, hal_mode: str, vision_attachment_enabled: bool) -> bool (L159) — Whether the HAL node builds SimSensorBridge with its "nothing attached, fresh" attachment heartbeat: only for hal_mode == "sim" with the vision attachment leg off. On a real arm that claim has no evidence behind it, so real mode publishes no attachment state unless the vision leg is on, and the kernel's attached-payload check fails closed rather than trusting it.
  • twin_jaw_evidence_timeout_s(*, hal_mode: str, hal_idle_steps: bool, idle_hold_s: object, configured_s: float, rate_hz: float) -> float | None (L187) — The vision leg's jaw-evidence timeout on the idle-stepping sim twin: max(configured_s, idle_hold_s + 4 / rate_hz), only for hal_mode == "sim" with a callable HAL idle_step and a float idle_hold_s; None otherwise (the configured timeout stands, and the evidence run resets on gaps). The node sets VisionAttachmentConfig.evidence_run_spans_gaps exactly when it is not None. The real node also carries a SimSensorBridge, so the bridge's presence must never be the gate (that stretched the real 0.5 s timeout past the kernel's 500 ms attached deadline).
  • decode_action_chunk(msg) -> Action | None — Inverse of ros_publishing_hal._flatten_action_payload: decodes the ActionChunk wire shape back into a typed openral_core.Action with the per-mode payload populated. Returns None for degenerate chunks or modes the F1/F5 publisher doesn't encode (CARTESIAN_POSE, FOOT_PLACEMENT, DEX_HAND_JOINT). Preserves tick_index / tick_group_size / runner_session_id (absent session decodes to 0, session-less), joint_names (ADR-0102; absent decodes to None, a whole-vector action) and cartesian_delta_scale (predictive-safety metadata only — the raw delta passes through unchanged). (L240)

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) routes through.

  • build_sim_env_from_yaml(sim_env_yaml: str, *, robot_id_fallback: str | None = None) -> tuple[SimRollout, int | None] — Loads a SimScene/BenchmarkScene YAML, resolves its scene id in openral_sim.SCENES, and instantiates the env; relative paths resolve against the source file's parents (ROS param values are cwd-naïve). Returns (env, seed) for SimAttachedHAL(env_reset_seed=seed). Binds the robot via SCENES.resolve_robot (the YAML's robot_id, else a fixed scene's default, else robot_id_fallback for free-axis scenes). Raises ROSConfigError if the YAML is missing, the scene id is unregistered, the robot is not one the scene can build, or validation fails. Scene-id agnostic: continuous-mode ignore_done is the backend's enable_continuous. (L123)
  • hal_transition_timeout_s(deploy_config: str | None) -> str — Per-transition budget for the HAL's tools/lifecycle_autostart.py, derived from the scene: max(300.0, scene.backend_options.boot_timeout_s + 60.0). Falls back to the 300 s floor for no config, an unreadable path, or a malformed value. A sidecar backend boots the simulator inside on_configure, so this bounds the same call build_sim_env_from_yaml makes. (L222)
  • const HAL_TRANSITION_TIMEOUT_FLOOR_S = 300.0 / HAL_TRANSITION_TIMEOUT_MARGIN_S = 60.0 — The floor covers a robocasa-kitchen first boot; the margin covers the import + first reset following a sidecar handshake. Also the ceiling on how long a wedged HAL stays unreported. (L216)
  • const HAL_TRANSITION_TIMEOUT_MARGIN_S = 60.0 (L219) — see above.
  • _synthesise_deploy_task_id(scene_id) -> str (L53) — The inert "<scene_id>/_hal_deploy_noop" task id for a taskless DeployScene; each backend interprets it (LIBERO → task 0), so no scene-id list lives here.

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.

  • module const _TASK_SUCCESS_LOGGER (L72) — dedicated openral.sim.task_success structlog logger (dotted under openral. so the OTel bridge attaches).
  • module const _EVENT_TASK_SUCCESS (L77) — the sim.task_success event name _emit_task_success logs.
  • module const _EVENT_TASK_SUCCESS_FINAL (L78) — the sim.task_success_final event name, emitted from disconnect.
  • module const _EVENT_TASK_SUCCESS_PROBE_FAILED (L79) — the sim.task_success_probe_failed event name, emitted once when the backend's success predicate raises.
  • module const _ROBOSUITE_GROUP_PREFIX (L190) — robosuite body-name prefix (robot0_/robot1_) stripped by normalized_joint_index's prefix-fallback.
  • module const _PLANAR_TWIST_EPS (L235) — tolerance for detecting a non-planar BODY_TWIST component.
  • module const _PENDING_GROUP_STALE_NS (L242) — how long idle_step waits before loudly discarding a pending mid-flight atomic action group.
  • ActionPacker — Type alias Callable[..., np.ndarray]. Per-composition translator between an OpenRAL Action and the env's flat action vector; default factory is pack_action_for_env. The optional trailing prev arg lets a packer carry an untouched slot (e.g. the gripper while the arm steps) across a split policy step. Pass a custom instance to SimAttachedHAL.__init__ for whole-body/dexterous-hand layouts. (L252)
  • gripper_targets_action(group: list[Action], description: RobotDescription) -> Action | None (L129) — What a committed slot group set for every role: gripper joint, as a one-row compact JOINT_POSITION action (joint_names = every gripper in manifest order) that VisionAttachmentBridge.observe_command reads positionally. Placement is _slot_group.slot_group_targets (the composer's own strictness, never a laxer re-implementation). All or nothing: None when the manifest has no gripper, the group left any gripper uncommanded, or a slot is unreadable (gripper slot with no/unknown ee_name or empty payload, joint row too short for a joint it names) or overlapping — a partial record would leave the uncovered jaw's stale command in the grasp trigger (a phantom attach), while None clears every jaw. SimAttachedHAL._commit_group records it as last_applied_action for a group compose_slot_group_action refuses (a base twist on a mobile manipulator), so the jaw command still reaches the grasp trigger. A record, never a command to apply.
  • normalized_joint_index(model_joint_names: list[str]) -> dict[str, int] — Maps MJCF joint names (exact + robosuite-prefix-stripped) to model index; exact names always win, robot0_joint1 → joint1 is added only when it doesn't shadow or collide. Used by SimAttachedHAL.read_state so one manifest serves both native MjSpec and robosuite scenes. (L193)
  • pack_action_for_env(action: Action, description: RobotDescription, env_action_dim: int, prev: np.ndarray | None = None) -> np.ndarray — Default ActionPacker: translates JOINT_POSITION, BODY_TWIST (slots 0-2), CARTESIAN_DELTA (arm slots), and GRIPPER_POSITION (last slot) into the env's flat action vector. Raises ROSConfigError for unsupported modes or mismatched row widths. prev carries an untouched slot (e.g. gripper) across a policy step that splits into two Actions on a non-composite env — without it each Action zeroed the other's slots. (L270)
  • class SimAttachedHAL — HAL Protocol adapter wrapping an in-process SimRollout. Reads live joint state via normalized_joint_index + mj_name2id; sends actions via a caller-supplied ActionPacker, else the env's own pack_action(action, prev) hook when it defines one (a backend that knows its slot layout — the Isaac manifest scene: name-addressed, absolute; also used for its BODY_TWIST path and its slot groups — which may then carry a BODY_TWIST slot, the twist kept across the tick's slots and latched for /odom — with None rather than zeros as the first prev), else pack_action_for_env, into env.step(). connect() drops the last packed vector, so pre-reset targets never ride along. Exposes read_images(), mujoco_handles(), sim_time_ns(), base_pose, base_twist, base_pose_6dof() for the lifecycle node's camera, viewer, clock, and odom wiring. (L431)
  • __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 (L460)
  • connect() -> None — Resets the env at env_reset_seed; probes env_action_dim via _probe_env_action_dim, raising ROSConfigError naming the backend if not introspectable and no override given; then _check_time_base. Idempotent. (L630)
  • _check_time_base() -> None (L647) — For a backend that declares sim_dt_per_tick_s (the Isaac sidecar, RoboCasa, LIBERO), refuses via openral_core.check_time_base (ROSConfigError) an env whose step is not one control period of the robot (1 / description.control_rate_hz, 1 % tolerance _TIME_BASE_TOL) or a manifest with no control rate to hold it to; records the tick and logs sim.time_base (backend, sim_dt_per_tick_s, control_freq_hz, robot) once per connect so a trace carries the time base it was captured on (issue #355). A backend without the field is not checked (RoboCasa/LIBERO step at their own control rate).
  • set_active_task(prompt) -> None — Deploy-sim per-goal placement grading (logging only): forwards the dispatched goal's prompt to the env's set_task (Isaac placement_success), then logs sim.placement_grade per grade change (read from step info["placement_grade"]), sim.placement_grade_final (closing + best grade) when the goal ends ("", the next prompt, or disconnect), and sim.placement_grade_unavailable once for a prompt the scene does not grade (WARNING + latch off if set_task raises). No-op for an env without set_task. Fed by ManifestHALLifecycleNode from /openral/reward/active_task. (L1449)
  • reset_episode() -> int (L681) — Starts a fresh episode on the live env without rebuilding it (sim only): the same env.reset + per-episode bookkeeping as connect, with seed = base reset seed + episode index (1, 2, …), so per-reset randomisation (Isaac pose_noise) draws a new, reproducible layout each episode. Returns the seed. Served by ManifestHALLifecycleNode as /openral/<robot>/reset_episode.
  • disconnect() -> None — Emits the terminal sim.task_success_final line, then releases the env handle (idempotent). (L766)
  • read_state() -> JointState — Walks description.joints, resolves each via normalized_joint_index, reads live qpos/qvel from MJCF and effort via joint_actuator_effort. Without MuJoCo handles (Isaac sidecar) it reads obs["joint_positions"|"joint_velocities"|"joint_efforts"]. Effort is all or nothing: a joint absent from the MJCF, no joint_efforts key, or a short vector yields effort=[], never zero rows. (L789)
  • send_action(action: Action) -> None (L949) — Packs the action via composite-split or ActionPacker, calls env.step. BODY_TWIST Euler-integrates qpos directly on a MuJoCo backend, else routes through _apply_body_twist_via_env_step. Stamps last_action_ns so the idle stepper yields.
  • _stage_action_group(action, group_step) -> None — For backends exposing action_group_size/step_action_group, stages each safety-approved slot by (Action.runner_session_id, Action.tick_index) and commits one sim step only when the complete tick is present (atomicity); a different key arriving with an incomplete group drops that group and raises ROSRuntimeError, but stages the exposing slot as the new tick (as SlotGroupStager does) so one lost slot costs one tick rather than wedging every later tick, and so does a replay (TickWatermark.check: the same session at or below last_committed_tick, a superseded session, or a session-less tick refuse_stale_tick refuses; a new session is adopted only when its group commits). estop() keeps the watermark; connect() clears it. Latches the group's BODY_TWIST slot into _last_body_twist on commit.
  • read_policy_state() -> list[float] | None (L2411) — Cached simulator-native checkpoint proprioception (e.g. BEHAVIOR's 61-D R1Pro vector) for /openral/policy_state; only from an explicit obs["policy_state"], never inferred.
  • reset_estop() -> None (L2407) — Clear the e-stop latch; caller asserts the cause has been resolved.
  • idle_step() -> bool — Sim-only free-running stepper: advances the SimRollout one HOLD tick (np.zeros(env_action_dim)) so cameras keep rendering when idle. Returns False when not connected, estop-latched, or no live MuJoCo handles. Defined only on SimAttachedHAL — real HALs never define it, since a zero vector is HOLD in sim but "drive to 0 rad" on a real arm. (L1578) Refuses to interleave a HOLD inside a mid-flight atomic action group, discarding a stalled pending group after 5 s. A step it runs zeroes base_twist: the HOLD leaves the base still, so /odom stops reporting the last commanded twist.
  • read_images() -> dict[str, Any] — Return latest rendered camera frames keyed by camera name from the cached _last_obs. (L2188)
  • read_depth_clouds() -> dict[str, NDArray] (L2236) — Per-depth-sensor (N,3) base_link point clouds from _last_obs["depth_points"] (a non-MuJoCo backend like Isaac deprojects them, so the HAL never re-derives geometry); {} when the backend renders no depth. SimSensorBridge publishes them as PointCloud2 for octomap.
  • read_image_times() -> dict[str, int] (L2217) — Sim time (ns) each cached camera frame shows, keyed like read_images (depth cameras by sensor name), from a backend's "image_time_ns" obs slot plus the cross-reset offset, so sim_time_ns() - read_image_times()[k] is that frame's age. The Isaac sidecar reports it because its RTX pipeline delivers frames steps behind the physics (issue #361); {} when the backend reports none. SimSensorBridge stamps each frame with it.
  • read_depth_frames() -> dict[str, dict[str, Any]] (L2260) — The newest registered colour + metric depth frame per depth sensor from a non-MuJoCo backend's "depth_frames" obs slot ({name: {"depth", "rgb", "k", "optical_in_base"}}, the Isaac manifest scene's _depth_frames), kept across the steps that carry none (_keep_depth_frames); {} when the backend renders none. SimSensorBridge._publish_depth_frame publishes each frame once.
  • update_attached_objects(objects: list[AttachedCollisionObject]) -> None (L877) — Atomically resolves a complete sim attachment snapshot from evidence_ref="mujoco_body:<name>", including descendant bodies; preserves the previous snapshot on malformed/unknown identities. Without MuJoCo handles (Isaac) it records the set and masks nothing — the octomap bridge clears the payload by its pose, as on real hardware (#349).
  • read_attached_objects() -> list[AttachedCollisionObject] (L936) — Stable payload state SimSensorBridge unions with the robot self-filter so carried objects are absent from world occupancy.
  • read_attached_body_ids() -> frozenset[int] (L940) — Exact MuJoCo body ids of everything currently attached.
  • add_post_step_observer(observer: Callable[[], None]) -> None (L944) — Registers an idempotent synchronous hook after each real simulator transition; automatic attachment evidence uses this so grasp/release changes publish before the next policy tick is acknowledged.
  • read_scan() -> NDArray | None (L2274) — 2-D LaserScan range fan (base_link) from _last_obs["scan"] on a non-MuJoCo backend that ray-casts a lidar (Isaac); None otherwise. Read by SimSensorBridge._compute_scan_ranges for /scan.
  • mujoco_handles() -> tuple[Any, Any] | None — Forward the env's (model, data) MJCF handles. (L2044)
  • task_success() -> bool | None (L1331) — Forwards the wrapped rollout's optional task_success() predicate (RoboCasa: the task's own _check_success()); None means "no predicate, or one that raised", never "failed". Observability only — feeds no termination/reset/reward/action path. Logged as sim.task_success (both edges, since RoboCasa success isn't latched) and sim.task_success_final at disconnect. Full signal table in docs/reference/telemetry.md.
  • last_committed_tick -> int [@property] (L2068) — Most recent complete atomic action group committed to the simulator.
  • last_committed_session -> int [@property] (L2073) — runner_session_id of that group (0 = none / session-less).
  • last_applied_action -> Action | None [@property] (L2056) — The command the simulator was last stepped with: for a slot group, compose_slot_group_action over the manifest joints, set in step with last_committed_tick (private _commit_group), or, when the group is not one joint-position command (BODY_TWIST / Cartesian slot, uncommanded joints, a joint slot without joint_names), its gripper targets as a compact action (gripper_targets_action; None when it set none placeable); for an ungrouped action, the action itself; cleared on connect. Fed to the grasp trigger by the lifecycle node (it had none, so slot-group jaw commands never reached it).
  • sim_time_ns() -> int | None (L2108) — Cross-reconnect-monotonic elapsed sim time in ns; connect folds elapsed time into an offset so a lifecycle reset doesn't rewind it.
  • clock_authority() -> ClockAuthority (L2133) — Returns ClockAuthority.simulation(<backend>, timestep_s=<the env's declared sim_dt_per_tick_s, else body_twist_dt_s>) when sim_time_ns() is live, else ClockAuthority.host_wall().
  • discard_staged_slots() -> int (L1054) — Drops a half-staged action group (watermark kept); called by the HAL lifecycle node at every /openral/estop latch and /openral/estop_cleared, so a tick a kernel stop cut short never reaches the next goal as an incomplete group. Returns the slots dropped.
  • estop() -> None — Latch e-stop; subsequent send_action calls are dropped. (L2036)
  • (property) env -> SimRollout (L2168) — Direct access to the wrapped sim env (for tests / advanced wiring).
  • (property) estop_latched -> bool (L2173)
  • base_pose -> tuple[float, float, float] [@property] — Current (x, y, yaw), from MJCF qpos on a MuJoCo backend or obs["base_pose"] on Isaac; (0,0,0) if the backend reports neither. Feeds the /odom publisher. (L2291)
  • base_twist -> tuple[float, float, float, float, float, float] [@property] — Last commanded body twist (vx, vy, vz, wx, wy, wz); zeroed by a non-BODY_TWIST action or an idle HOLD step. (L2344)
  • _apply_body_twist_via_env_step(row: list[float]) -> None — Non-MuJoCo BODY_TWIST: validates the planar twist, latches it for /odom, packs (vx, vy, wz) into the scene's final base-twist slots, zeros arm/gripper, and steps.
  • base_pose_6dof() -> tuple[...] | None — Full 6-DoF (xyz, quat_xyzw) from robocasa raw_proprio; None for non-robocasa backends. (L2354)
  • last_action_ns -> int [@property] — Monotonic ns of the last real action through send_action; 0 until the first one. Read via should_idle_step to yield the idle stepper to an active skill. (L2178)

python/hal/src/openral_hal/convex_distance.py

Certified signed distance between two MuJoCo convex geoms — replaces mujoco.mj_geomDistance, which under mujoco 3.8.0 returns confidently wrong values (silently) on some geom pairs the collision-evidence probes adjudicate. Represents each geom as conv(core) ⊕ ball(radius), solving via GJK (separated cores) or exact SAT (overlapping), with curved geoms bracketed by inscribed/circumscribed polytopes; runs only in evidence collection (~2-4 ms/pair), never on the 100 Hz path.

  • ConvexDistance [@dataclass(frozen=True)] — One certified signed distance with its proof: distance_m (the closest end of the bracket — never overstates separation; negative is penetration depth), lower_m/upper_m, certified, uncertified_reason, witness_a/witness_b, direction, duality_gap_m, method. direction (unit b→a, or the MTD axis when overlapping) is the only reliable contact direction at a flush contact, where the two witnesses coincide. as_record() serialises non-finite bounds as None, never Infinity, for strict-JSON round-tripping. (L93)
  • ConvexDistance.as_record() -> dict[str, object] (L140) — see above.
  • module const _ARC_SEGMENTS: Final[int] = 256 (L78) — default polygonisation segment count for a round-type (cylinder/ellipsoid) bracket.
  • module const _BRACKET_TOL_M: Final[float] = 1e-4 (L80) — certify a bracket this tight or tighter.
  • module const _DUALITY_TOL_M: Final[float] = 1e-9 (L82) — certify a separating-axis duality gap this tight or tighter.
  • module const _OVERLAP_TOL_M: Final[float] = 1e-12 (L86) — below this, GJK's terminal simplex has collapsed onto the origin (overlap is SAT's branch, not GJK's).
  • module const _MAX_SAT_AXES: Final[int] = 8_000_000 (L89) — refuse an exact penetration depth beyond this many SAT axes rather than spend unbounded time or silently subsample.
  • ConvexBody [@dataclass(frozen=True)] — A geom as conv(core) ⊕ ball(radius) plus the faces SAT needs; faces is None for a visual mesh (no compiled hull graph), costing nothing until the cores actually overlap. (L167)
  • convex_geom_distance(model, data, geom_a: int, geom_b: int, *, distmax_m: float | None = None, arc_segments: int = 256) -> ConvexDistance — The instrument. distmax_m enables a certified window rejection (method="beyond-window") via a separating-axis bound valid in every direction. certified is False, with a reason, for a geom with no bounded convex hull, a round-type bracket wider than 0.1 mm, or an unclosed/oversized-axis-set overlap; no plausible number is ever substituted for a defensible one. (L586)
  • witness_clearance_m(model, data, geom_id: int, point, *, arc_segments: int = 256) -> float — Certified lower bound on how far point lies outside geom_id; positive proves it's outside. Used to contradict a claimed nearest-point witness (both ends of a real nearest-pair segment lie on their own geoms). Falls back to the bounding sphere for a geom with no hull, -inf for one with neither. (L724)
  • separating_axis_bound(points_a, points_b, direction) -> float — Weak-duality lower bound on the distance along one direction: min_B u·b − max_A u·a. Evaluated at the direction a witness claims, turning a GJK answer into a proof. Returns -inf for a degenerate direction. (L449)
  • mesh_hull(model, mesh_id: int) -> tuple[Any, Any | None] — The hull MuJoCo itself collides, (vertices, faces) in mesh-local frame, decoded from the compiled hull graph rather than recomputed. faces is None for a mesh with no graph — distance stays exact, only penetration depth loses its axis set. (L188)
  • geom_convex_bracket(model, data, geom_id, *, arc_segments=256) -> tuple[ConvexBody, ConvexBody] | None — World-frame (inscribed, circumscribed) ConvexBody pair bracketing one geom (exact for box/mesh/sphere/capsule, polygonised for cylinder/ellipsoid); None for a geom with no bounded convex hull. (L280)

python/hal/src/openral_hal/sim_sensor_bridge.py

Shared sim-sensor + viewer bridge for scene-attached HAL lifecycle nodes: republishes RGB camera frames, /scan, depth PointCloud2 (MuJoCo ray-cast, or a non-MuJoCo backend's own clouds), a live MuJoCo viewer, and the sim-only idle stepper for any manifest-driven node. Also owns the attachment-state, ADR-0097 place-declaration, and E-stop ground-truth snapshot wiring; a place declaration naming a target absent from the scene is refused and logged, and its region/geometry is always this producer's own measurement, never a relayed value. rclpy imported lazily.

  • frame_stamp_ns(node_now_ns: int, frame_time_ns: int | None, sim_now_ns: int | None) -> int (L237) — Header stamp for a sim sensor frame: the node's now minus the frame's age on the sim clock (sim_now_ns - frame_time_ns), by the same rule as joint_state_republish_stamp_ns (no frame time, no sim clock, or a negative / >5 s age keeps the node's now). Used by SimSensorBridge for the RGB images, depth clouds and RGB-D frames of a backend that reports read_image_times (issue #361).
  • should_idle_step(now_ns: int, last_action_ns: int, idle_hold_ns: int, *, step_while_active=False) -> bool — Pure predicate for the sim-only wall-time stepper: scene-attached envs return True only after the idle-hold window; bare MujocoArmHAL passes step_while_active=True so its target keeps integrating during active skills. Used by SimSensorBridge._idle_step_tick. (L152)
  • module const _THUMB_INTERVAL_NS = 1_000_000_000 (L37) — throttle dashboard thumbnail emission to ~1 Hz per camera; the live ROS topic itself stays at camera_rate_hz.
  • module const _IMAGE_DIM = 3 (L38) — HWC ndarray rank.
  • module const _RGB_CHANNELS = 3 (L42)
  • module const _MAX_PREATTACH_CELLS = 2_000_000 (L46) — cap on the pre-attach occupancy snapshot; an over-large grid is refused/truncated rather than partially sampled.
  • module const _DEGENERATE_QUAT_NORM = 1e-12 (L50) — below this a quaternion carries no usable direction; keep identity rather than divide by ~0.
  • module const _NEAREST_PROBE_DISTMAX_M = 0.10 (L64) — near-miss probe bounding-sphere prefilter gap for the e-stop ground-truth snapshot.
  • module const _NEAREST_PROBE_MAX_CALLS = 4096 (L65) — exact-distance call budget for one snapshot's near-miss probes.
  • module const _NEAREST_PROBE_MAX_PAIRS = 32 (L66) — closest-pairs cap reported per probe.
  • module const _VOXEL_BACKING_RAYS_PER_AXIS = 3 (L73) — rays per cube axis for voxel_backing_record's ray-fan sampling.
  • module const _QUAT_NORM_TOL = 1e-6 (L77) — tolerance on |q|^2 for a published grid orientation; wide enough for a wire round-trip, too tight for an unset all-zero field.
  • module const _VOXEL_BACKING_EPS_FRACTION = 0.01 (L81) — ray start stand-off outside a voxel cube face, as a fraction of cell size.
  • module const _VOXEL_BACKING_MAX_GEOMS = 8 (L84) — cap on backing geoms reported per cell.
  • module const _XYZ = 3 (L86) — Cartesian dimension count for a grid size / origin / half-extent triple.
  • module const _CONTACTS_CAVEAT (L91) — the robot_world_contacts==0 does NOT mean no interpenetration string emitted verbatim on every estop_ground_truth_snapshot stop line.
  • module const _CANDIDATE_CHUNK_HISTORY = 3 (L107) — candidate chunks retained (ring buffer) for predicted-horizon reconstruction.
  • module const _ESTOP_EVIDENCE_WINDOW_NS = 500_000_000 (L110) — a CollisionEvidence older than this is not attributed to the stop being snapshotted.
  • module const _DOP_VERTEX_EPS = 1e-9 (L979) — slack (metres/determinant) for the 3x3 solves behind _dop_vertices.
  • module const _VOXEL_BACKING_MAX_LAYERS = 4 (L1270) — how many non-collidable shells one ray looks past inside a single cell before giving up.
  • constant_scan_no_hit_ranges(*, n_beams: int, max_range_m: float) -> list[float] — Pure synthetic /scan fan: every beam clamped to max_range_m ("no hit everywhere"), the honest reading for an in-process digital twin with no scene to ray-cast. Used by SimSensorBridge._compute_scan_ranges's no-handles branch. (L136)
  • estop_ground_truth_snapshot(model, data, *, robot_body_ids: frozenset[int], attached_body_ids: frozenset[int] = frozenset(), probe_body_ids: frozenset[int] | None = None, base_frame_body: str | None = None, joint_state: Any = None, description: Any = None, evidence_voxel: Any = None, distmax_m: float = 0.10, max_pairs: int = 32, max_calls: int = 4096) -> dict[str, object] — Pure MuJoCo ground truth for one safety stop (attached-payload or robot-world): contacts, poses, and near-miss probe pairs across robot↔world, payload↔world, payload↔robot-link, and link↔link. Contact lists are not a penetration oracle — contype/conaffinity exclusions can suppress a real contact entirely — so the near-miss probes are the adjudicator, measured via convex_geom_distance (never mujoco.mj_geomDistance, which returns confidently wrong values on some pair classes under mujoco 3.8.0). Every probe side is filtered to solid geometry only (a geom with neither contype nor conaffinity is never a real penetration) and reports its own coverage (candidate/probed pairs, certified/uncertified, noncollidable exclusions) so an absent pair reads as "looked and found nothing," not silence. description adds an adjudication_budget (mesh-vs-model slop) so a kernel OBB↔voxel distance and a probe mesh↔mesh distance can be compared fairly, widening (never narrowing) the probe window to the admissible gap; evidence_voxel adds evidence_voxel_backing (the only part of this record that reads the published map rather than MuJoCo alone) and must only be passed for evidence judged fresh. Called by SimSensorBridge._on_estop_ground_truth. (L2066)
  • kernel_checked_body_ids(model, description) -> frozenset[int] — Pure MJCF bodies for exactly the links the safety kernel collision-checks (the manifest's collision_geometry is the kernel's model, so a link with no entry is deliberately invisible). Resolved exactly via each joint's sim_joint_name, never name-mangled; empty when the manifest declares no collision geometry. Scopes the near-miss probes so a mobile base's floor contacts can't bury the arm's. Sibling of depth_cloud.robot_self_body_ids (that answers "what is the robot"; this answers "what does the kernel check"). (L705)
  • kernel_checked_link_bodies(model, description) -> dict[str, int] — Pure name-keyed form of kernel_checked_body_ids, resolving each collision_geometry link name to its MJCF body id the same exact way. Needed wherever a diagnostic must line a manifest OBB up with its MuJoCo geometry (collision_model_mesh_slop is the caller); unresolved links are reported, not guessed. (L730)
  • collision_model_mesh_slop(model, description) -> dict[str, object] — Pure measurement of how far the kernel's manifest-OBB collision model reaches beyond the real meshes per link, so a kernel distance (OBB↔voxel) and a probe distance (mesh↔mesh) can be compared fairly — the admissible gap is corner_slop(link) + voxel_half_diagonal. Returns per-link obb_half_extents_m/face_slop_m/corner_slop_m/hull_overhang_m (has_stage2_hull distinguishes "no hull" from "unmeasured") plus max_corner_slop_m; {} when the manifest declares no collision geometry, so the caller reports "no budget" rather than assuming zero. Robot side only — an attached-payload self stop also needs attached_payload_mesh_slop. (L807)
  • _dop_vertices(dop_lo, dop_hi) -> NDArray[np.float64] — Vertices of a 26-DOP from its 26 halfspaces, by direct plane-triple enumeration (no hull-intersection library). Needed because distance-to-a-convex-set is a convex function, so its maximum over a polytope occurs at a vertex — the same reason a box's slop term is its corner, not something face-sampled.
  • _payload_model_overhang_m(prim, points) -> float — How far one payload primitive's kernel model (its DOP where one ships, else the box) reaches past the real mesh — the payload-side analogue of a link's hull_overhang_m, used by a world-voxel payload stop's budget. Deliberately the DOP and not a tighter stage-2 hull even when one ships, since the kernel falls back to the DOP whenever its hull budget caps out. inf for a degenerate refinement, so the consumer reads "no budget" rather than scoring against zero.
  • attached_payload_mesh_slop(model, data, *, attached_body_ids: frozenset[int], max_primitives: int = 16) -> dict[str, object] — Pure measurement of how far a carried payload's kernel primitives reach beyond its own meshes — the payload side of the adjudication budget an attached-payload self stop needs (that check is OBB-vs-OBB on both sides, unlike a world-voxel stop's single OBB). Calls extract_body_primitives directly so the budget can't drift from the geometry the kernel was actually handed; only box primitives can be loose (sphere/capsule primitives have no corner to overhang). Returns {"objects": {<body>: {n_primitives, n_box_primitives, corner_slop_m, ...}}, "max_corner_slop_m", "unresolved_objects", "method"}; {} when nothing is carried, so the caller reports "no budget" rather than assuming zero. (L1084)
  • voxel_backing_for_cell(model, data, cell: Mapping[str, Any], *, robot_body_ids: frozenset[int], attached_body_ids: frozenset[int], base_frame_body: str | None) -> dict[str, object] | None — The single place a cached /openral/world_voxels cell dict is unpacked into voxel_backing_record's arguments, so the in-snapshot and late-evidence call sites can't disagree about which fields (notably grid_orientation_xyzw) they pass — omitting it silently places the probe cube by the wrong axes on a rotated base. Returns None when the cell names no index. (L2019)
  • voxel_backing_record(model, data, *, voxel_index: int, grid_origin: Sequence[float], grid_resolution: float, grid_size: Sequence[int], robot_body_ids: frozenset[int], attached_body_ids: frozenset[int] = frozenset(), base_frame_body: str | None = None, grid_orientation_xyzw: Sequence[float] = (0.0, 0.0, 0.0, 1.0), rays_per_axis: int = 3) -> dict[str, object] — Pure answer to what MuJoCo geometry, if any, backs one occupancy voxel — the near-miss probes never look at /openral/world_voxels, so this is the only check of whether the map and the world agree. Decodes the kernel's b=voxel_<n> index against the live grid, lifts it to world coordinates, then classifies via ray-fan sweep as solid_world, attached_payload, self_occupancy_suspect, noncollidable_world, or unbacked, with verdict picking by adjudication order (real world geometry always explains a stop first). rays_cast/rays_hit make unbacked readable as "looked and found nothing"; an index outside the grid returns verdict: "out_of_range". (L1480)
  • attached_model_slip(model, data, *, description: Any, attached_objects: Sequence[Any]) -> list[dict[str, object]] — Pure answer to where the kernel thinks each carried payload is (forward-kinematics from attach_link + pose_in_link, fixed at the attach) versus where the simulator actually has it — a payload that slips, settles, or pivots in the gripper is then charged as collision depth against cells the real object isn't near. One record per object: object_id, attach_link, resolved, kernel_world_xyz, sim_world_xyz, slip_m, rotation_deg, body_radius_m, max_point_slip_m (translation plus the rotation's sweep at the body's radius, since slip_m alone understates a pivoting payload). An object whose link/body/pose can't be resolved reports resolved: False, never a silent zero. Emitted on every ground-truth snapshot as payload_model_slip. (L1776)
  • grid_with_origin(history: Sequence[Mapping[str, object]], origin: Sequence[float], *, tol_m: float = 1e-9) -> Mapping[str, object] | None — The cached /openral/world_voxels geometry whose published origin matches the one the kernel discloses (CollisionEvidence.world_grid_origin_m); exact where grid_current_at's stamp match is ambiguous, since two grids published within one /clock tick can be indistinguishable by stamp while naming different cells. Newest match wins. None when the kernel disclosed nothing or no cached grid matches; the caller then falls back to the stamp proxy and records which mechanism resolved it (grid_decode.decode_source). (L1714)
  • grid_current_at(history: Sequence[Mapping[str, object]], stamp_ns: int) -> Mapping[str, object] | None — The newest cached /openral/world_voxels geometry whose header stamp is at or before stamp_ns, chosen by stamp, never by arrival order: decoding a stop's cell index against whichever grid arrived last is wrong the moment the base drifts across one lattice boundary, misplacing world_xyz by a full grid resolution. None when nothing in history is old enough; the caller then falls back to the latest grid and records fallback_to_latest: true rather than papering over the gap. (L1747)
  • occupied_cell_keys(*, occupancy: Sequence[int] | Any, grid_origin: Sequence[float], grid_resolution: float, grid_size: Sequence[int], grid_orientation_xyzw: Sequence[float], base_rot: Any, base_offset: Any, max_cells: int = 2_000_000) -> tuple[frozenset[tuple[int, int, int]], bool] — Pure reduction of one published OccupancyVoxels grid to the set of world cells (keyed by floor(centre_world / resolution), not rounded or by voxel index — both are wrong once the base has moved) it marks occupied, so a later stop can ask whether its cell predates a given moment (e.g. a grasp). An occupied count above max_cells returns (frozenset(), True) rather than a partial set, since a partial set would wrongly answer "not pre-existing" for cells it never looked at. (L1873)
  • preattach_verdict(*, world_xyz: Sequence[float], resolution: float, preattach_keys: frozenset[tuple[int, int, int]] | None) -> dict[str, object] — Did the cell a stop tripped on already exist before the payload was masked? Returns {available, preexisting, within_one_cell, cell_key, preattach_cells}, or {available: False, reason} when no snapshot was taken — never a bare False, which would misread as "the cell is new." preexisting alone is not a payload-authored finding (world geometry is always pre-existing too); that requires the conjunction with voxel_backing_record's verdict being attached_payload. Consumed by SimSensorBridge._annotate_preattach. (L1961)
  • candidate_chunk_digest(*, stamp_ns: int, control_mode: int, horizon: int, n_dof: int, flat: Sequence[float], cartesian_delta_scale: Sequence[float] = (), ee_name: str = "", frame_id: str = "", rskill_id: str = "", trace_id: str = "", tick_index: int = 0, runner_session_id: int = 0) -> dict[str, object] — Pure JSON-safe digest of one openral_msgs/ActionChunk, reshaped into horizon rows of n_dof under ticks (a mismatched length is kept verbatim with shape_mismatch: true, never truncated). Records runner_session_id beside tick_index (0 = a session-less producer), since a tick number alone is ambiguous across a runner restart. The command-side record of a predicted-horizon stop — the configuration the kernel actually adjudicated is instead CollisionEvidence.joint_positions_rad, the authority. Cached per chunk by SimSensorBridge._on_candidate_action. (L2491)
  • initial_configuration_stop_record(snapshot: Mapping[str, object], *, stop_seq: int, last_action_ns: int, candidate_chunks_seen: int) -> dict[str, object] | None — Pure classifier separating a scene-initialization stop from a mid-task one: None once anything has been applied (last_action_ns != 0), else a record naming the stop as one the scene reset produced rather than one the robot was driven into — no policy, chunk, or margin change can clear it. candidate_chunks_seen is reported but doesn't gate, since a chunk the kernel rejected never reached send_action. Diagnostics only — never suppresses, delays, or alters the stop. Called by SimSensorBridge._log_initial_configuration_stop. (L2561)
  • isaac_estop_ground_truth_record(contacts: Mapping[str, Any], *, joint_state: Any = None, evidence_voxel: Mapping[str, object] | None = None, attached_object_ids: Sequence[str] = ()) -> dict[str, object] — Pure Isaac counterpart of estop_ground_truth_snapshot, built from the sidecar contacts reply (_IsaacSimSidecar.contact_ground_truth): backend: "isaacsim", sim_time_s, robot_world_contacts (one record per robot-collider/other-collider pair, min_separation_m negative = penetration), max_penetration_m, nearest_robot_world_pairs: None + nearest_unavailable_reason (PhysX has no shape-to-shape distance), contacts_caveat, robot_joint_state, evidence_voxel, attached_object_ids. No payload sections, adjudication_budget or voxel backing (MuJoCo-only). Called by SimSensorBridge._on_estop_isaac_ground_truth. (L2435)
  • class SimSensorBridge — Wires and tears down sim-sensor publishers and the MuJoCo viewer on a HAL lifecycle node, gated on the robot manifest + HAL capability. attachment_heartbeat=False (the HAL node sets it via sim_attachment_heartbeat when its vision attachment leg is on, and always under hal_mode:=real, where "nothing attached" has no evidence behind it) opens no /openral/attachment_state publisher, timer or evidence tracker at all — whatever the HAL, including one WITH the attachment API (SimAttachedHAL, the Isaac / scene-attached path, which would otherwise keep publishing its empty revision-0 set beside the vision leg, so the leg's set and grasp/place declarations would alternate on the latched topic) — so one authority publishes on it. Its staging subscriptions (/openral/attachment_state, /openral/attachment_state_applied, /openral/world_voxels) stay for a HAL with the attachment API, so the other authority's revisions are still masked and still held on the attachment barrier (tests/integration/test_hal_attachment_barrier_live.py). Owns /scan for both a live MJCF ray-cast (SimAttachedHAL) and a constant_scan_no_hit_ranges fan for the bare digital twin. (L2646) Attachment heartbeat for every sim HAL (2026-09-22): _setup_attachment_state() creates the /openral/attachment_state publisher and its 0.2 s timer for every sim HAL when attachment_heartbeat is true (the default); only the staging path (subscriptions + the MuJoCo evidence tracker) still requires the HAL's update_attached_objects / read_attached_objects API. The deploy launch enables the kernel's attached-payload check for every sim robot with capsules, and that check fails closed on a world state whose attachment_stamp_ns is still 0 — so a MujocoArmHAL twin (no attach mechanics, zero publishers) had every JOINT chunk dropped as attached_overflow on the OpenArm twin. "Nothing attached, fresh" is the exact truth for such a HAL; covered by tests/integration/test_sim_sensor_bridge_attachment_heartbeat.py.
  • (property) idle_hold_s -> float (L2874) — The idle stepper's quiet window in seconds (the longest joint-state gap the twin produces while a skill pauses between VLA chunks). The HAL node raises the vision leg's jaw-evidence timeout to idle_hold_s + 4 joint-state periods on the idle-stepping sim twin only (twin_jaw_evidence_timeout_s; never under hal_mode:=real, whose node also builds this bridge) so the attachment heartbeat survives re-inference pauses.
  • __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 = 2000.0, attachment_heartbeat: bool = True, on_step: Any = None, on_attachment_perception_ready: Any = None) -> None — on_step refreshes the proprio snapshot after idle sim steps. on_attachment_perception_ready releases a deferred grouped-action acknowledgement only once a newly attached payload is transparent in both a depth frame and a newer /openral/world_voxels raster.
  • setup() -> None — Activates every stream the manifest + HAL support: RGB camera + CameraInfo publishers (advertised lazily per camera on its first real frame — see _advertise_camera), the sim-only idle stepper, and the passive MuJoCo viewer (always mjCAMERA_FREE, opening pose from initial_viewer_camera; a GL/DISPLAY failure warns and continues). Idempotent per activate. (L2882)
  • _advertise_camera(name: str) -> Any — Creates (once) and returns the Image + camera_info publisher for one camera, on its first real rendered frame — never eagerly for the manifest's full camera list, since a manifest may declare a camera the current scene can't render and an eagerly-advertised topic would be indistinguishably silent forever. A declared camera that never yields a frame gets one warning and no topic. (L3029)
  • _publish_depth_optical_mount(spec) -> None — Latches <mount> -> <mount>_optical_frame (BODY_TO_OPTICAL_QUAT_XYZW) on /tf_static for a MuJoCo depth sensor whose frame_id is a body-convention mount with a manifest pose; its sim camera sits at that mount (SensorSpec.sim_placement), so raster, cloud and colour are placed where they were cast.
  • _mujoco_depth_frame(name, kwargs, model, data, stride) -> tuple[dict, clearing] — One MuJoCo depth camera as an openral_sim.depth_frames frame: the frame's single ray-cast (synthesize_depth_frame, strided raster + K/stride + the self-filter's clearing mask), the same MJCF camera's colour at that raster (_render_registered_colour), and the camera's live optical pose in base_frame (identity when no base body resolves; the static mount then carries the TF).
  • _render_registered_colour(name, model, data, camera_name, w, h, k) -> NDArray | None — The depth camera's colour rendered at its raster from the same MjData (renderer cached per model: RoboCasa rebuilds the model each reset). None (warned once, camera disabled) when K is not the square, centred pinhole the camera's fovy renders.
  • _publish_depth_frame(name, spec, frame, stamp, *, tf_stamp) -> None — Every backend's path (the MuJoCo bridge's own frames and SimAttachedHAL.read_depth_frames): one RGB-D frame → depth 32FC1 + colour rgb8 Images sharing one CameraInfo (the backend's K), all in depth_optical_frame_id(spec) and stamped stamp (the time the frame shows), plus base_frame -> <optical> on /tf stamped tf_stamp (now: the backend measured that pose at the current step, so the TF history stays true). Colour/depth size mismatch publishes depth only, warned once; an empty rgb publishes depth only; tf_stamp=None publishes no TF.
  • _frame_times() -> tuple[dict[str, int], int | None] / _frame_stamp(now, frame_time_ns, sim_now_ns) — The HAL's read_image_times() and sim_time_ns(), and frame_stamp_ns as a header stamp in the node's clock: _publish_images (each RGB image + its CameraInfo) and _publish_depth_clouds_from_obs (clouds, RGB-D frames) stamp every frame with the time it shows instead of the publish time (issue #361).
  • _colour_publishers(name) -> tuple[Publisher, Publisher] — The depth camera's colour Image + CameraInfo publishers on camera_topic(name), created once (RGB-camera QoS).
  • teardown() -> None — Cancel timers (incl. the idle-step timer), destroy publishers, close viewer. Called from on_deactivate / on_cleanup. (L2893)
  • _setup_estop_ground_truth() / _on_estop_ground_truth(msg) — Diagnostics only — nothing here gates, delays, or alters actuation. Gated on live MuJoCo handles or an Isaac contacts source (_contact_ground_truth_source: the wrapped env's contact_ground_truth), not attachment support, so a pre-grasp arm↔world stop still gets ground truth. Subscribes /openral/estop, /openral/candidate_action (a ring of cached candidate_chunk_digests), /openral/failure/safety (the kernel's CollisionEvidence), and the /openral/world_voxels grid geometry, then emits one sim.estop_ground_truth_snapshot line per stop plus a separate sim.estop_ground_truth_evidence line when the kernel's evidence lands late (never delayed for it, since sim state must be captured at the stop instant). Also runs _log_initial_configuration_stop, emitting sim.estop_initial_configuration when nothing has been applied yet. Without MuJoCo handles the stop goes to _on_estop_isaac_ground_truth: the same line from isaac_estop_ground_truth_record (a sidecar ROSError lands in contacts_error, never raised). Full signal table in docs/reference/telemetry.md.
  • attachment_action_ack_ready() -> bool (L4537) — Attachment transaction gate for the lifecycle node: an addition stays unacknowledged until kernel acceptance, one transparent depth frame, and the following voxel raster all land. An empty detach snapshot is immediately ready, since detach unmasks before the kernel drops the old payload.
  • _on_attachment_state_applied(msg) — Applies the staged revision once the kernel accepts it, then arms the perception barrier only when the revision masks geometry not already masked (compared exactly by (object_id, evidence_ref)); a revision masking nothing new (e.g. a re-published place witness) releases the barrier immediately instead of deadlocking a successful place.
  • describes_mobile_base(description: RobotDescription) -> bool (mobile_base_bridge.py L40) — Pure predicate: does the manifest declare a planar base (base_joints)? The single answer to "does something already own odom → base_frame", so MobileBaseBridge's attach and SimSensorBridge's TF skip-guard can't drift apart; reading any other field risks a second base_link parent and a split /tf tree.
  • class MobileBaseBridge (mobile_base_bridge.py L66) — Generic planar-mobile-base ROS wiring (sibling of SimSensorBridge): owns /odom, the odom->base_link TF, and the /cmd_vel→BODY_TWIST bridge (bypasses the safety supervisor — out of scope here). Frame ids come from RobotDescription; ManifestHALLifecycleNode attaches it in on_activate_post_subs iff the manifest declares base_joints.
  • __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 via publish_from_snapshot, reading the snapshot not the simulator; None (real HALs) keeps the legacy odom timer.
  • setup() -> None (mobile_base_bridge.py L109) — Create the /odom publisher + TF broadcaster + /cmd_vel subscription; the odom timer is created only when proprio is None (sim HALs publish odom off the node's thread).
  • publish_from_snapshot() -> None (mobile_base_bridge.py L163) — Dedicated-thread entry: publish one /odom + TF sample from the proprio snapshot (never the simulator). Thin alias over _publish_odom (which branches on proprio).
  • teardown() -> None (mobile_base_bridge.py L150) — 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 (L38) — Frozen dataclass: one coherent proprio sample (state, base_pose, base_pose_6dof, base_twist, sim_time_ns, policy_state). Plain immutable data, no live simulator handles, so it's safe to publish from a different thread than the one that stepped the sim.
  • class ProprioSnapshot (L66) — Lock-guarded holder for the latest ProprioFrame: the executor thread calls set after each step, the publisher thread calls latest; the frame is swapped under the lock so a reader never sees a torn frame or reaches the HAL directly.
  • set(frame: ProprioFrame) -> None (L94) — Atomically publish frame as the latest sample (executor thread only).
  • latest() -> ProprioFrame | None (L99) — Return the most recent frame, or None before the first capture.

python/hal/src/openral_hal/_sim_attachment_evidence.py

MuJoCo attachment evidence — the counterpart of _vision_attachment_evidence.py, emitting the identical AttachedCollisionObject contract. Only the contact-force and payload-lowering halves are inventoried here; support-contact and place-region predate this section.

  • module const _LOGGER (L39) — module structlog logger.
  • module const _DEGENERATE_NORM = 1e-9 (L43) — below this a summed support normal carries no direction; used by support_contact_witness.
  • module const _SUPPORT_PROBE_GAP_M = 0.001 (L58) — beyond this gap nothing is resting on anything (the safety kernel's own attached_contact_tolerance_m).
  • module const _SUPPORT_MAX_PENETRATION_M = 0.01 (L64) — the safety kernel's support_witness_max_penetration_m; a claim past this fails the whole attachment message, so the producer never constructs one.
  • module const _SUPPORT_MAX_PATCH_RADIUS_M = 0.5 (L65) — the safety kernel's support_witness_max_patch_radius_m mirrored here.
  • module const _SUPPORT_PROBE_MAX_CALLS = 1024 (L75) — exact-distance call budget for one support attestation.
  • module const _CONTACT_FORCE_SCALE_ENV = "OPENRAL_SIM_CONTACT_FORCE_N_PER_UNIT" (L88) — ADR-0100 contact-force scale env var read by contact_force_calibration.
  • module const _CONTACT_FORCE_CALIBRATION_REF_ENV = "OPENRAL_SIM_CONTACT_FORCE_CALIBRATION_REF" (L89) — companion calibration-reference env var; both must be set for calibrated=True.
  • module const _CONTACT_FORCE_DEFAULT_SCALE = 1.0 (L90) — identity scale used when calibration env vars are unset.
  • module const _NORMAL_AGREEMENT_MIN = 0.5 (L97) — minimum cosine agreement between a surface normal and the probe's own contact direction before a support hit is attested.
  • class SimObjectMobility(str, Enum) (L100) — Kinematic mobility of a contacted non-robot MuJoCo body: FREE, HINGE, SLIDE, ARTICULATED, FIXED. Returned by classify_body_mobility.
  • subtree_region_box(model, data, *, root_body_id) -> tuple[NDArray, NDArray] | None (L198) — Sim producer for a declared place target's PlaceRegion: bounds one body's whole collision subtree by a box axis-aligned in the declared body's own frame, computed once at declaration time from the model, never inferred from the payload's position. Returns (centre_in_body_frame, half_extents), or None when the subtree has no (or degenerate) collision geometry.
  • lift_region_off_support(region_local, rotation_base_from_body, *, resolution) -> tuple[NDArray, NDArray] | None (L245) — The sim mirror of _grasp_target.target_region_from_mask's HZ-0115-6 rule: shrinks the body-frame box's face on the body axis closest to base +z by resolution / |cos tilt|, so the box's lowest point rises exactly one voxel off the support (the target's own lowest point in sim) and the support cells stay outside the grasp region. None when the target is no taller than one voxel (the real NO_HEIGHT_ABOVE_SUPPORT). Kept local, not shared with _grasp_target, because the real producer fits a gravity-aligned box from a cloud.
  • classify_body_mobility(model, body_id) -> SimObjectMobility (L309) — Classifies the contacted body's root as FIXED (no joint), FREE, HINGE/SLIDE (all one joint type), or ARTICULATED (mixed).
  • geom_is_polytope(model, geom) -> bool (L413) — Is conv(geom_surface_points(geom)) the geom's own solid? True for mesh/box, false otherwise: for a curved surface (e.g. a sphere sampled at its poles) that containment runs the wrong way and can under-measure a payload's real extent past a voxel's half-diagonal. Gates both lowering paths; in _clustered_box_primitives one ungroundable geom disqualifies the whole cluster.
  • geom_surface_points(model, geom) -> NDArray[np.float64] (L459) — Points lying on one geom's own surface in its local frame (mesh vertices; box corners; sphere/capsule poles); empty for geometry with no enumerable surface (plane, heightfield, SDF). Deliberately not a bounding cube, which would overstate the geometry and understate the slop both callers measure.
  • _tight_geometry_from_points(points, half_extents) -> TightCollisionGeometry | None — Refines one payload box into the kernel's staged 26-DOP + exact hull online at attach (the payload twin of tools/generate_tight_geometry.derive_tight_geometry), so that mesh ⊆ hull ⊆ DOP ⊆ box holds exactly. No convex-hull solver runs — stage 2 is a support scan over the deduplicated points. Over MAX_TIGHT_HULL_VERTICES it returns the DOP alone, disclosed by an empty vertex tuple, never a truncated hull. None only when there are fewer than four surface points to refine.
  • module const _HULL_DEDUP_CEILING = 2 * MAX_TIGHT_HULL_VERTICES (L522) — bounds the sort _tight_geometry_from_points runs when deduplicating hull vertices.
  • extract_body_primitives(model, data, *, root_body_id, object_id, max_primitives=16) -> list[AttachedCollisionPrimitive] (L871) — Extracts conservative collision primitives for one unknown sim object: one per collision geom in the body's subtree, or _clustered_box_primitives when there are more geoms than max_primitives. Called by collision_model_mesh_slop/attached_payload_mesh_slop to keep their slop budget from drifting off the kernel's actual geometry. Raises ROSConfigError when the subtree has no collision geometry.
  • support_contact_witness(model, data, *, root_body_id, robot_body_ids, stamp_ns, support_roots=None) -> SupportContactWitness | None (L1258) — Attests one payload's bounded support contact from certified signed distances (openral_hal.convex_distance, not the solver's contact list or mj_geomDistance), so a grasped object resting on the counter it was picked from isn't indistinguishable from driving it through a wall. Only a non-free environment body counts as a support; the place phase restricts support_roots to the declared target's own subtree. None when no eligible support contact exists.
  • contact_force_calibration() -> tuple[float, bool, str | None] (L1388) — Resolves the sim contact-force calibration from OPENRAL_SIM_CONTACT_FORCE_N_PER_UNIT + OPENRAL_SIM_CONTACT_FORCE_CALIBRATION_REF; calibrated=True only when both are set and the scale parses finite and positive — a sim magnitude is a calibration knob, not a newtons claim, and a scale nobody can name must not silently arm the kernel's force gate.
  • probe_contact_force(model, data, *, payload_geoms, target_root_body, target_name, stamp_ns) -> ContactForceWitness | None (L1432) — Walks MuJoCo's solver contact list for payload↔declared-target pairs and attests the total normal load via mj_contactForce (never just the largest single contact, which understates a multi-corner rest). None is not evidence of absent contact — contype/conaffinity exclusions can suppress a pair entirely — it only means the gate does not arm; that blind spot is survivable because this check can only add a refusal, never remove one.
  • class SimAttachmentEvidenceTracker(model, description, *, stable_ticks=3, release_ticks=3, translation_tolerance_m=0.01, rotation_tolerance_rad=0.15) (L1554) — Confirms free-object grasp/release from exact MuJoCo contacts and motion; fed every physics step by SimSensorBridge. Resolves the manifest's gripper-role joints to one attach link + touch links at construction; raises ROSConfigError for none, several parent links, or too few contact branches for the end effector's kind.
  • update(data, *, stamp_ns) -> list[AttachedCollisionObject] | None (L1690) — Returns a new complete attachment set only when the attachment changes (attach, detach, or a place-witness arm/disarm); None otherwise, so a heartbeat never re-arms an exemption the kernel had killed.
  • set_place_declaration(declaration: PlaceDeclaration | None) -> None (L1866) — Installs, replaces, or retracts the active place-phase declaration; resolves target_id to a MuJoCo body at install time, so an unresolvable target is refused (ROSConfigError) rather than silently no-op'd per tick.
  • place_declaration(data, *, stamp_ns) -> PlaceDeclaration | None (L1929) — The live declaration, with its region and geometry posed into the robot base frame; overwrites an incoming region/geometry it can measure and drops what it cannot, so a value a dispatcher relayed can never reach the kernel wearing this producer's authority.
  • set_grasp_declaration(declaration: GraspDeclaration | None) -> None (L2053) — The grasp mirror of set_place_declaration: resolves target_id (sim:<body>) to a MuJoCo body at install time, refusing an unresolvable one with ROSConfigError (the previous grasp state is cleared either way); None or a retracted declaration clears it. Touches nothing else in the tracker.
  • set_voxel_resolution(resolution_m: float) -> None (L2138) — Record the occupancy lattice's cell edge for the grasp region's one-voxel lift; SimSensorBridge._fill_grasp_declaration passes the last /openral/world_voxels grid's resolution. Not finite and positive → ROSConfigError.
  • grasp_declaration(data, *, stamp_ns) -> GraspDeclaration | None (L2091) — The live grasp declaration with its region measured from the target's model subtree and posed in the base frame — box only, no geometry (v1). Its lower face is lifted one voxel off the support (lift_region_off_support), so no region is published until set_voxel_resolution has been called (fail closed; an attach before that freezes the region-less state for that declaration). Re-validated against GraspDeclaration's caps (a receptacle-sized box yields region=None); an incoming region is always replaced or dropped. Re-measured at the target's live pose only until the declared object (object_id, empty = any) attaches; from that edge the region is frozen at the attach-instant measurement (its stamp_ns kept) through carry and release, so the kernel's handover rule can fire — only a new set_grasp_declaration clears the freeze. None once retracted or past timeout_s. SimSensorBridge subscribes /openral/grasp_declaration (RELIABLE + TRANSIENT_LOCAL, depth 1) only where this tracker exists and fills it onto every AttachmentState envelope without bumping the revision; a twin without the attachment API relays no declaration.

python/hal/src/openral_hal/_vision_attachment_evidence.py

Real-hardware attachment evidence from a segmenter mask plus wrist depth — the counterpart of _sim_attachment_evidence.py, emitting the identical AttachedCollisionObject contract so the safety kernel cannot tell the two producers apart. Every fail-closed gate is geometric; the segmenter's own confidence is recorded but never thresholded. Scoped to free objects only — vision gives geometry, not kinematic class.

  • module const _MIN_CLOUD_POINTS = 24 (L97) — minimum points a cloud needs before a PCA orientation means anything; below this clustered_obb_primitives stays axis-aligned.
  • module const _BOX_EPSILON_M = 1e-4 (L101) — padding added to every fitted box half-extent, matching the sim producer's own epsilon so neither producer emits a zero-thickness slab.
  • module const _VISION_CONFIDENCE = 0.7 (L107) — confidence stamped on an accepted vision attachment (below the sim producer's 1.0 since this one infers geometry from one partially-occluded viewpoint). Calibration point, not measured.
  • module const _FALLBACK_CONFIDENCE = 0.2 (L112) — confidence stamped on the conservative jaw_span_primitive fallback box. Calibration point, not measured.
  • class VisionGateConfig — Frozen dataclass of gate thresholds: containment_radius_m=0.12, max_payload_extents_m=(0.10, 0.25, 0.25), max_payload_volume_m3=0.004, min_depth_valid_fraction=0.30, min_depth_m=0.02, max_depth_m=1.0, view_ray_inflation_m=0.02, jaw_span_m=0.05, max_primitives=16. Every value is a chosen calibration point, not a measured constant; the extent/jaw-span caps aren't derivable from the manifest today (EndEffectorSpec has no jaw-aperture field). (L116)
  • class VisionAttachmentReport — Frozen dataclass carrying what the gates measured, for the trace: accepted, rejections ("no_candidates" / "empty_mask" / "depth_validity" / "payload_extent" / "payload_volume" / "jaw_containment" / "no_valid_depth"), depth_valid_fraction, point_count, extents_m, volume_m3, centroid_distance_m, candidate_index/candidate_count, mask_score_advisory (recorded only, never thresholded or used to select). (L185)
  • backproject_masked_depth(mask, depth_m, intrinsics, *, min_depth_m, max_depth_m) -> tuple[NDArray, float] — Back-projects a mask's pixels through a metric depth frame into the camera optical frame (REP-103), returning (N, 3) points and the fraction of masked pixels with usable depth. An empty mask returns ((0, 3), 0.0) — not an error. Raises ROSConfigError on shape disagreement or mismatched-resolution intrinsics. (L227)
  • clustered_obb_primitives(points_in_link, *, object_id, max_primitives=16, inflation_m=0.0, inflation_axis_in_link=None) -> tuple[list[AttachedCollisionPrimitive], NDArray, NDArray] — Reduces a payload cloud to bounded oriented boxes, the same reduction as the sim producer's _clustered_box_primitives but split in the cloud's PCA frame so boxes orient to the object. inflation_axis_in_link pads each axis by its share of the view ray, compensating for the unobserved far side. Clouds under 24 points stay axis-aligned rather than orienting off noise. (L317)
  • palm_plane_m(tcp_in_link, palm_in_link) -> float (L407) — How far along the attach-link-origin → tool-point approach the palm plane (the jaw joint's origin) lies; 0 at or behind the origin. A held object never lies behind it: the producer drops mask points behind it (behind_palm when none are left), poses the jaw-span fallback cube centred on the tool point but no further back than the palm plane, and the bridge clips every payload there (#349). VisionAttachmentEvidenceProducer.palm_in_link is the jaw joint's origin.
  • jaw_span_primitive(*, object_id, jaw_span_m) -> AttachedCollisionPrimitive — The conservative fallback box: a cube of half-extent jaw_span_m centred on the TCP, deliberately crude so a rejected mask still leaves geometry in front of the collision checker. (L432)
  • class VisionAttachmentEvidenceProducer(description, *, gripper_joint=None, config=None) — Event-driven producer (no per-frame update, unlike the ticked sim tracker). With gripper_joint set it serves that one hand: attach_link is its parent_link, touch_links its child_link (one producer per hand on a bimanual robot). With None it serves every gripper-role joint, which must share one parent link. Raises ROSConfigError when there are none, the name is not a gripper joint, or (unnamed) they don't share one parent. (L469)
  • on_grasp(*, masks, depth_m, intrinsics, t_link_from_cam, tcp_in_link, object_id, stamp_ns, mask_scores_advisory=()) -> tuple[AttachedCollisionObject, VisionAttachmentReport] — Fits and gates one payload from the SegmentInView reply's candidate masks; among candidates clearing every gate, the largest fitted volume wins (over-approximation is the conservative error), never the model's own score. Always returns an attachment: any rejection degrades to the jaw_span_primitive fallback at low confidence, never None. An accepted mask is stamped VISION_SEGMENTATION; mass_kg/center_of_mass_m/inertia_kg_m2 stay None (vision gives geometry only). (L679)
  • on_release() -> list[AttachedCollisionObject] — Detach: an empty list, a complete attachment set meaning "nothing is held", mirroring the sim producer's release path. (L562)
  • (property) attach_link -> str (L543)
  • (property) touch_links -> tuple[str, ...] (L553) — finger links allowed to touch the payload: the jaws and their mimic followers.
  • (property) palm_in_link -> tuple[float, float, float] (L548) — the jaw joint's origin in the attach link (palm_plane_m).
  • (property) config -> VisionGateConfig (L558)

Not covered (see the module docstring): articulated objects, support contact, transparent/thin objects, self-occlusion, mass/CoM/inertia, deformables, multi-object grasps, and the attach trigger itself.

python/hal/src/openral_hal/_grasp_target.py

Pre-grasp target geometry (design note docs/reference/real-pick-place-design.md §2.2): seed from the voxel map, SAM prompt projection, a measured region from mask + depth, a map cross-check and a re-prompt tracking gate. Pure numpy, no ROS; the producer node that wires it is a follow-up. Every threshold is a calibration point; the caps are the Safety-WG placeholders.

  • class TargetRefusal(StrEnum) (L114) — TOO_FEW_CELLS, AMBIGUOUS (another cluster ≥ ambiguity_ratio of the anchored target's — HZ-0115-2), TOO_FEW_POINTS, NO_HEIGHT_ABOVE_SUPPORT, NOT_ON_SUPPORT (the mask cloud ends > 2 voxels above the support — HZ-0115-6), HALF_EXTENT_CAP, VOLUME_CAP, STACKED (a candidate mask holding a nested candidate refused NOT_ON_SUPPORT with ≥ 2 voxels of height — HZ-0115-6). _REFUSAL_SEVERITY orders them most severe first.
  • class VoxelLattice(frame_id, origin, orientation_xyzw, resolution, size, occupancy) (L158) — The OccupancyVoxels fields, same convention as bucket2_markers.occupied_voxel_centers (re-derived, cross-checked in tests/unit/test_grasp_target.py). Raises ROSConfigError on a length mismatch, non-positive resolution or non-unit orientation. occupied_centers() is computed once per lattice and cached read-only (the producer legs warm it at grid receipt, outside the vision bridge's lock).
  • rotation() -> NDArray (L202) — lattice axes → frame_id.
  • occupied_centers() -> NDArray (L206) — (N, 3) occupied cell centres in frame_id, vectorised.
  • class TargetSeed (L225) — point | None, refusal | None, cluster_sizes (the anchored target's first, the rest descending), bottom_z | None (the selected cluster's lowest cell's bottom face).
  • module const EXTRINSIC_ERROR_M = 0.01 (L141) — Camera-mount error budget of a target fit (target_region_from_mask's default). Calibration point.
  • fit_pad_m(resolution, extrinsic_error_m=EXTRINSIC_ERROR_M) -> float (L144) — The padding a target fit adds to its sides and top: half a cell diagonal plus the mount error.
  • class TargetRegionFit (L243) — region: PlaceRegion | None, refusal | None, point_count, depth_valid_fraction, half_extents, box: PlaceRegion | None = None (the fitted box on a NOT_ON_SUPPORT refusal — the visible part — for the leg's occlusion test, _refused_fit), z_span: float = 0.0 (trimmed cloud height, top minus lowest point; target_region_from_masks vetoes on it), support_z: float | None = None (the support the region stands on: measured, or one voxel under the target's own lowest kept point when unseen — the lower face is always support_z + resolution), support_unseen: bool = False.
  • occupied_centers_in_box(grid, box: PlaceRegion) -> NDArray (L291) — Occupied centres inside an oriented box (the declaration's search box — reasoner-grounded, or a direct-dispatch scene's); refuses a frame mismatch with ROSConfigError.
  • support_top_from_voxels(grid, centers, *, near_xy, min_cells, probe_margin_m=0.05, surface_centers=None) -> float | None (L333) — The measured support the target stands on (HZ-0115-6): scanning the column's z-layers top-down, the first layer holding ≥ min_cells top-surface cells (nothing occupied directly above) in the ring between one cell and probe_margin_m around the footprint of the component above it holding the anchor (the highest cell of the occupied column nearest near_xy, the same cell at every layer, so a taller neighbour never turns the target's top into its support, HZ-0115-2) — a surface extends past what stands on it, the target's own layers (a dense top included) do not; the probe scales with the target. The ring is counted on surface_centers (default centers) — it lies outside the target's footprint, so mostly outside a tightly padded search column; the leg passes the column grown by probe_margin_m + one cell. None when no layer qualifies. Ceiling: a target flaring > 1 cell can stop the scan inside it (region stands higher — less exemption). ROSConfigError on a tilted (non-z-up) lattice or a probe_margin_m under two cells (an empty ring; never silently widened — the leg checks first and reports a probe_margin_under_two_cells lost view).
  • target_seed_from_voxels(grid, centers, *, near_xy, support_z, min_cells, ambiguity_ratio=0.5) -> TargetSeed (L505) — Drops cells within one voxel of the support plane, labels 26-connected components on the lattice, returns the top-centre of the cluster holding the anchor (the highest cell of the occupied column nearest near_xy — never merely the largest); refuses too few cells or any other cluster ≥ ambiguity_ratio of the target's size (HZ-0115-2: a taller neighbour is ambiguous, not the target).
  • project_point(point_base, t_cam_from_base, intrinsics) -> tuple[float, float] | None (L569) — Base-frame point → REP-103 optical pixel (u, v) (pixel-centre convention of backproject_masked_depth); None behind the camera or outside the image. SAM 2.1's positive point prompt.
  • target_region_from_mask(mask, depth_m, intrinsics, t_base_from_cam, *, support_z: float | None, resolution, frame_id, evidence_ref, stamp_ns=0, erode_px=2, extrinsic_error_m=0.01, trim_percentile=1.0, min_points=200, min_depth_m=0.1, max_depth_m=3.0, max_half_extent_m=0.20, max_volume_m3=0.03) -> TargetRegionFit (L680) — Erode → backproject_masked_depth → base frame → percentile-trimmed, gravity-aligned (yaw-only, 2-D PCA footprint) box; lower face fixed at support_z + resolution (never below — HZ-0115-6) — with the support unseen (support_z=None, or a measured layer more than a voxel above the target's own lowest kept point: a neighbour's top, a bin rim) at the target's own lowest kept point instead, never lower, and no NOT_ON_SUPPORT check; sides and top padded by √3·resolution/2 + extrinsic_error_m; refuses on the caps, and NOT_ON_SUPPORT when the trimmed cloud's lowest point sits more than two voxels above support_z (a target on a same-footprint box or a hidden riser clusters with it in the voxel seed, but the mask names the target alone — and a view of its top face only is refused too).
  • target_region_from_masks(masks, depth_m, intrinsics, t_base_from_cam, *, resolution, nested_fraction=0.9, **fit_kwargs) -> TargetRegionFit (L843) — The segmenter's candidates (subpart/part/whole) through target_region_from_mask: the first that fits in the segmenter's order, except one holding ≥ nested_fraction of a smaller candidate refused NOT_ON_SUPPORT whose cloud spans ≥ 2 voxels (the body it stands on would be exempted) is refused STACKED; a top face alone (< 2 voxels, a can's lid) vetoes nothing. With no candidate fitting, the first such body holding no other one is re-fitted with the support unseen: its region stops at its own lowest point, so what it stands on (a bin floor, a riser) stays outside. A set refused whole reports its most severe refusal (_REFUSAL_SEVERITY), not the last tried. Ceiling: a tall subpart vetoes its whole object (less exemption). Empty → TOO_FEW_POINTS.
  • mask_without_removed_points(mask, depth_m, intrinsics, t_base_from_cam, kept_points_base, *, cell_m=0.005) -> NDArray[np.bool_] (L621) — mask without the pixels whose depth point the robot self-filter removed from the same capture (kept_points_base: robot_self_filter's output in the base frame): a pixel survives only with a kept point in its own or a neighbouring cell_m cell. Reuses the self-filter's own verdict (collision model at the measured joint state) instead of re-modelling the robot; only ever shrinks the fit. A pixel's verdict does not depend on the mask, so the leg filters the union of the segmenter's candidates once per capture and intersects each candidate with it.
  • region_covers_occupied(grid, region, *, min_fraction=0.5) -> tuple[int, bool] (L942) — Occupied cells with centres in the region vs. min_fraction of the region's footprint cell count; a region the map does not see is refused.
  • track_region(previous, current, *, max_centroid_shift_m, extents_tol_m) -> bool (L989) — The 2-5 Hz re-prompt gate: same frame, centroid shift and (sorted-horizontal) half-extent change within tolerance.
  • cell_closed_region(region, grid, *, max_half_extent_m) -> tuple[PlaceRegion, bool] (L1070) — (closed, clamped): a yaw-only fit grown so every lattice cell it intersects has its centre inside (the kernel's cell-centre exemption test): r/2·Σ|(Rᵀ_box R_grid)_ij| per local axis — r/2·(|cos θ|+|sin θ|) ≤ r/√2 horizontally against a z-up lattice — and r/2 up only, the bottom kept (the support layer stays non-exempt). Clamped per half-extent at max_half_extent_m. Applied only to the region the grasp-target leg publishes; ROSConfigError on another frame or a tilted region.
  • lowered_to_support(region, support_z) -> PlaceRegion (L1134) — region with its lower face moved down onto support_z, the rest unchanged (region itself when the face is not above it). The vision-attached region payload is lowered from one voxel above the measured support onto its top, so the target's own bottom layer is payload, not an obstacle under it once lifted (Isaac i56/i57); the support's cells stay outside.
  • margin_grown_region(region, margin_m, *, down) -> PlaceRegion (L1180) — The grasp-target bloat (HZ-0115-32): region grown by margin_m along its own axes on the four sides and the top, and the bottom too when down (the kernel's pre-handover region; down=False is the attached payload, never into the support). margin_m = 0 returns region itself; ROSConfigError on a negative or non-finite margin.
  • region_within(inner, outer, *, tol_m) -> bool (L1033) — Every corner of inner inside outer grown by tol_m (false across frames): tells a re-fit shrunk by the approaching hand's occlusion from one that reaches outside the held region.
  • occupied_touching_outside(grid, region, *, support_z) -> NDArray (L1234) — Occupied cell centres more than one voxel above support_z, outside region and 26-adjacent to an occupied cell inside it. Empty iff the region holds the whole 26-connected map component of the cells it contains (any path out crosses that ring), so it looks one cell around the region and needs no bound on the component. The grasp-target leg runs it on the kernel's (cell-closed) region before accepting a fit; a non-empty result sends the fit to map_completed_region (partial_fit when that refuses).
  • map_completed_region(grid, fit, column, *, support_z, max_half_extent_m, max_volume_m3) -> tuple[PlaceRegion | None, int, str] (L1302) — (completed, added, reason): the camera's fit grown to the target's whole map component (Isaac i45: the hovering hand hides the far side; the map remembers it from earlier views). The component: occupied cells more than one voxel above support_z, 26-connected, seeded from those in cell_closed_region(fit), flooded inside column (the leg's search column). The result is the smallest box in the fit's yaw holding the fit and every component cell's centre, its bottom the fit's own (never down); its cell closure (what the kernel gets) then holds every component cell whole. None with the reason when a component cell's 26-neighbour lies outside column (touches the search edge), the box exceeds the caps, or its closure holds an occupied above-support cell not of the component (another body in its corners — checked over the closure's own extent, so a box in a yaw other than the column's has its corners tested too). added = component cells beyond the fit's closure. A body within one voxel of the target merges into it (HZ-0115-30). ROSConfigError on a frame mismatch or a tilted fit.

python/hal/src/openral_hal/_object_primitives.py

Object instances before contact (docs/reference/object-primitives-design.md, issue #349): camera-originated, map-confirmed, tracked. The grasp-target leg (premeasure=True) prompts SegmentInView where instance_prompt says, fits each mask with the camera path's own fit, and keeps the box only when map_confirms it; PrimitiveTracker holds identities, nearest_primitive picks the instance a hand approaches. The map never originates a box. Pure numpy, no ROS, no clock; reuses _grasp_target's lattice, components, cover and tracking gates.

  • class PrimitiveFit(region, cell_count, support_z) (L59) — One camera instance fit, map-confirmed: the box (target_region_from_masks), the occupied map cells inside it, the support top it was fitted on.
  • class ObjectPrimitive(primitive_id, region, cell_count, support_z, first_seen_ns, last_seen_ns) (L76) — A tracked instance; last_seen_ns is its newest camera fit's stamp (its confirmation), what staleness counts from.
  • map_confirms(grid, region, *, support_z, min_cover, pad_m=0.0, own=()) -> tuple[str, str] (L97) — ("", "") when the map confirms a camera box, else (kind, why): map_disagrees (region_covers_occupied below min_cover: the object gone, or never in the map) or map_split (the occupied above-support cells in the box's cell closure are more than one 26-connected body). A touching neighbour is one body with it, so a packed pair passes, each instance with its neighbour's sliver, never the merged blob. ROSConfigError on a frame mismatch. A second body confined to the band pad_m inside the box's sides and top (the fit's padding, fit_pad_m) is a neighbour's sliver, not a split; one wholly inside the own boxes (the robot's hands) is the robot's own, not a split.
  • instance_prompt(grid, column, covered, *, near_xy, skip=()) -> tuple[point, cell] | None (L188) — Where to prompt the segmenter next (SegmentInView has no prompt-free mode): the raised top-surface cell (no occupied cell above it, more than one cell above the column's lowest top surface) nearest near_xy, in no covered box and not in skip, preferring interior cells (all four side neighbours raised tops) so a packed pair's shared top is prompted one cell inside one body, not on the seam. Returns the cell's top-face centre and the cell (the skip key).
  • point_box_gap_m(point, region) -> float (L263) — Distance from a point to an oriented box (0 inside).
  • nearest_primitive(regions, hand_points, *, ambiguity_m) -> tuple[int | None, str] (L282) — The instance with the smallest gap to any hand point; ambiguous when the runner-up is within ambiguity_m (one voxel) of it (HZ-0115-2), or when a named search box (no hand points) holds more than one. (index, "") or (None, reason).
  • class PrimitiveTracker(*, max_misses=3, max_tracks=16) (L336) — Identities across fits, label-free: a fit within track_region's one-voxel tolerance refreshes its instance; one whose centre lies in an instance's box (or holds its centre) is the same object nudged (identity kept, box replaced); anything else is new. Past max_tracks the instance seen longest ago is dropped. Single-threaded by contract (the leg, under the bridge lock).
  • update(fits, *, stamp_ns, tol_m, scanned) -> list[ObjectPrimitive] (L417) — Fold fits in; an instance whose centre lies in scanned that nothing matched accrues a miss and is dropped after max_misses; one outside is kept. Returns the fits' instances, largest first.
  • prune(keep) -> list[ObjectPrimitive] (L459) — Drop every instance keep refuses (the leg's staleness and map-contradiction rules); returns the dropped.
  • (property) primitives -> list[ObjectPrimitive] (L398) — every tracked instance.

python/hal/src/openral_hal/_grasp_trigger.py

When to ask perception "what is in the jaws?" — a debounced state machine over the gripper joint's commanded target and measured position (no effort: the real OpenArm driver hard-codes it to 0.0). Pure: no ROS, no numpy, no clock. _vision_attachment_evidence.py is told a grasp happened; this is what tells it. Thresholds are the joint's manifest closure_calibration; an uncalibrated gripper refuses to arm.

  • class GraspEvent(str, Enum) (L93) — ATTACH (empty → loaded: segment, gate, attach), REGRASP (still loaded but the jaw re-settled materially elsewhere, so the fitted geometry is stale: segment again), DETACH (loaded → empty: publish an empty attachment set; no segmentation).
  • module const GAP_PERIODS = 4.0 (L90) — sample periods for_rate tolerates between accepted samples before a window restarts.
  • class PositionStallConfig (L114) — Frozen dataclass, robot-independent windows only, in seconds of sample time (JointState.stamp_ns) so they mean the same at any joint-state rate: consecutive_s=0.06, settle_s=0.13 (the spans 3 and 5 samples covered at 30 Hz), plus minimum sample counts consecutive_samples=3, settle_samples=5 (every window must hold on both time and count, so late ticks cannot confirm early), max_gap_s=0.1 (a longer gap between accepted samples restarts every window), min_sample_interval_s=5e-4. Calibration points.
  • for_rate(rate_hz, *, max_gap_s=None) -> PositionStallConfig [@classmethod] (L176) — The windows for a stream fed at rate_hz: only max_gap_s is rate-bound, max(0.1, GAP_PERIODS / rate_hz) (a fixed 0.1 s is one period at 10 Hz, so neither the trigger nor the heartbeat evidence ever confirmed); max_gap_s overrides it. The time windows stay (their sample-count floors already stretch them at slow rates). ROSConfigError on a non-positive / non-finite rate or a degenerate override.
  • validate() -> None (L152) — ROSConfigError on a non-finite or negative window, settle_s == 0, max_gap_s <= min_sample_interval_s, consecutive_samples < 1 or settle_samples < 2.
  • is_gap(stamp_ns, last_ns) -> bool (L209) — a sample stamped more than max_gap_s after the last accepted one; restarts the trigger's windows and the bridge's _JawEvidence run.
  • is_repeat(stamp_ns, last_ns) -> bool (L220) — a sample stamped no later than last_ns + min_sample_interval_s repeats (or predates) the last accepted one; shared by the trigger and the bridge's _JawEvidence.
  • gripper_joints(description) -> list[JointSpec] (L232) — Every role: "gripper" joint, in manifest order (one per hand on a bimanual robot). Raises ROSConfigError only on zero.
  • gripper_joint(description, joint_name=None) -> JointSpec (L258) — One gripper joint: the named one (ROSConfigError if it is not gripper-role), or with None the manifest's single one — zero or several raise.
  • class PositionStallTrigger(description, *, joint_name=None, config=None) (L299) — ATTACH when the command is within stall_gap of closed_position, the jaw is settled (span ≤ settle_tolerance over settle_s) and sits further from closed than the command by more than closed_rest_offset + stall_gap; DETACH when, still commanded closed, that gap collapses to ≤ rest + stall_gap/2 (slip / closed on nothing), or when commanded open and the jaw opens more than stall_gap/2 past its hold; REGRASP when still stalled but stall_gap/2 from the hold. "Settled" is the span over at least settle_samples samples covering the last settle_s; every transition needs at least consecutive_samples agreeing samples spanning consecutive_s; a gap over max_gap_s restarts both. A repeated stamp (is_repeat) is dropped and counted in repeated_samples. Raises ROSConfigError on an unresolvable joint, no closure_calibration, no position sensor, a closed_position that is not an end of position_limits, or a degenerate window.
  • command(target) -> None (L420) — Fold in the jaw's commanded target (joint units); non-finite ignored.
  • clear_command() -> None (L429) — Forget the command (it is unknown): no ATTACH / REGRASP / DETACH until the next command; a held payload stays held. Measuring against a stale close is how an open, stationary jaw reads as a grasp.
  • update(state) -> GraspEvent | None (L438) — Fold one snapshot in. A tick with no finite position for the joint increments missing_position_ticks and clears the settle window; a tick before any command increments uncommanded_ticks and can never attach; a repeated stamp increments repeated_samples, a gap past max_gap_s increments gap_resets and restarts every window.
  • (property) joint_name -> str (L396) — the resolved gripper joint's manifest name.
  • (property) attached -> bool (L401) — current latched grasp state.
  • (property) last_command -> float | None (L406) — the last commanded target, None before the first.
  • (property) thresholds -> tuple[float, float, float, float] (L416) — (closed_position, closed_rest_offset, stall_gap, settle_tolerance) in force.
  • (property) closing -> bool (L411) — the last command is within stall_gap of closed, the only band ATTACH reads.
  • attributes missing_position_ticks, uncommanded_ticks, repeated_samples, gap_resets (a gap past max_gap_s restarted every window) — health counters; last_short_of_command, last_settle_spread — the last classified sample's margin to the command and the jaw's position span over the settle window (None before one). Read by VisionAttachmentBridge._log_close; no decision reads them.

python/hal/src/openral_hal/vision_attachment_bridge.py

The ROS wiring that turns _grasp_trigger events into openral_msgs/srv/SegmentInView calls, feeds the replies to VisionAttachmentEvidenceProducer, and publishes the resulting AttachmentState the safety kernel already consumes. The real-hardware, opt-in sibling of SimSensorBridge's attachment leg (the two must not both drive /openral/attachment_state) — torch-free by construction, since everything model-shaped lives behind the service in openral_perception_ros.segmenter_node.

The deferred-ack barrier. The HAL holds a grouped tick's action_applied until attached-payload perception settles; segmentation runs inside that same wait. The wait is bounded (deadline_s), never skipped (every failure path ends in the conservative jaw-span box at GRIPPER_CLOSURE), and visible (every fallback logs its typed reason).

  • module const DEFAULT_SEGMENT_SERVICE (L199) ("/openral/perception/segment_in_view") — shared with segmenter_node.DEFAULT_SEGMENT_SERVICE; a unit test asserts the two halves agree.
  • module const MAX_RELEASE_CLEAR_M (L207) (0.1) — Ceiling on VisionAttachmentConfig.release_clear_m (refused above, ROSConfigError at bridge construction, i.e. at HAL activate): the deploy's derivation is the kernel's world margin + one octree cell (0.04 m on the real cell); 0.1 m leaves headroom for a 5 cm margin over a coarse 5 cm cell. It also bounds the region payload's support-witness rise tolerance, so a params-file 0.3 cannot keep the support exemption through a 30 cm carry.
  • class VisionAttachmentConfig (L1078) — Frozen dataclass: self_filtered_cloud_topic="" (the robot self-filter's output for this camera, set by the deploy launch when the filter runs — the grasp target fit drops the robot's own points with mask_without_removed_points), camera="" (empty picks the manifest's first camera with intrinsics; only the id and its existence come from the manifest), depth_topic="" (empty → camera_topic(camera, CameraTopicKind.DEPTH_IMAGE); its header.frame_id is the optical frame tf2 is asked for), camera_info_topic=None (empty/None → camera_topic(camera, CameraTopicKind.DEPTH_CAMERA_INFO); HAL param vision_attachment_camera_info_topic; the only K ever projected through — no message, an uncalibrated one, or a size that differs from the depth raster is a typed GRIPPER_CLOSURE fallback, never the manifest's nominal K), service_name=DEFAULT_SEGMENT_SERVICE, deadline_s=0.25, tcp_frame="" (empty → the gripper joint's child_link, an approximation — no schema field carries a calibrated TCP today), jaw_tip_frames=(), object_id="grasped_payload" (suffixed :<gripper joint> per leg), evidence_timeout_s=0.5 (calibration point; how old the newest sample with a finite jaw position for every gripper may be before the attachment heartbeat stops; HAL param vision_attachment_evidence_timeout_s), evidence_run_spans_gaps=False (sim twin only, set by the HAL node from twin_jaw_evidence_timeout_s: a sample gap shorter than evidence_timeout_s does not restart the _JawEvidence run), tf_frames={} (manifest link → tf2 frame, used only for tf2 lookups; each entry must be the same rigid body, e.g. OpenArm openarm_left_link7 → openarm_left_ee_base_link; keys must be manifest links), and the pre-grasp target leg's grasp_target_enabled=False, grasp_target_rate_hz=3.0 (2-5 Hz, else ROSConfigError), grasp_target_freeze_s=None (unset = 2 × grid_max_age_s; HAL param vision_attachment_grasp_target_freeze_s, 0 = unset), grasp_target_min_cells=8, grasp_target_min_cover=0.5, grasp_target_support_search_below_m=0.15 (≤ 0.5 m), grasp_target_support_probe_margin_m=0.05 (≥ two grid cells, else a lost view), grasp_target_occluder_margin_m=0.05 (≤ 0.10 m), grasp_target_approach_m=None (the approach-armed target, off; HAL param vision_attachment_grasp_target_approach_m, 0 = off), grasp_target_margin_m=VisionAttachmentRuntime.DEFAULT_GRASP_TARGET_MARGIN_M (0.0 — no bloat unless the scene names one; the grasp-target bloat, [0, 0.05] m else ROSConfigError from GraspTargetLeg; HAL param vision_attachment_grasp_target_margin_m, 0 = unbloated; HZ-0115-32), grasp_target_premeasure=False (pre-measure object instances from head-camera segmentation before any hand arms, map-confirmed — _object_primitives, GraspTargetLeg(premeasure=True); HAL param vision_attachment_grasp_target_premeasure; prototype, issue #349) (HAL params vision_attachment_grasp_target_*; the bridge hands the last three to GraspTargetLeg; calibration points), and the release window's release_clear_m=0.04 (the deploy passes the kernel's world-voxel margin + one voxel resolution; 0.04 is the real cell's 20 mm + 20 mm; the default serves direct construction (tests) only — ManifestHALLifecycleNode refuses the leg with it unset; HAL param vision_attachment_release_clear_m; also the region payload's support-witness rise tolerance; at most MAX_RELEASE_CLEAR_M = 0.1 m, else ROSConfigError) and release_timeout_s=3.0 (HAL param vision_attachment_release_timeout_s; calibration points; both must be positive, else ROSConfigError), grid_max_age_s=1.0 (oldest world-voxel grid the grasp-target and place-fixture legs use; the deploy passes the kernel's world_voxel_deadline_s, 1.0 s is the real cell's; the default serves direct construction (tests) only — ManifestHALLifecycleNode refuses the leg with it unset; HAL param vision_attachment_grid_max_age_s; must be positive), and mask_depth_max_skew_s=0.1 (HAL param vision_attachment_mask_depth_max_skew_s; largest |mask capture stamp - depth stamp| a SegmentInView mask is back-projected across — a same-grab ZED pair is 0, 0.1 s admits one frame of cache lag at ≥ 10 Hz; beyond it the attachment path falls back to GRIPPER_CLOSURE and the grasp-target leg takes a mask_depth_skew lost view; must be positive; calibration point). tcp_frame/jaw_tip_frames are single-gripper only and raise on a multi-gripper manifest; there the TCP is each gripper joint's origin_xyz, read without TF. deadline_s is sized off a warmed GPU segmenter call plus round trip; a CPU-only host must raise it deliberately or every grasp falls back. Place leg: place_target_enabled=False, place_target_rate_hz=2.0, place_target_freeze_s=None (= and at most 2 * grid_max_age_s), place_target_search_depth_m=0.20, place_target_extrinsic_error_m=MAX_PLANAR_ERR_M (HAL params vision_attachment_place_target_*; calibration points).
  • class SegmentOutcome (L1295) — Frozen (use_masks: bool, reason: str).
  • resolve_segment_outcome(*, timed_out, ok, failure_reason, mask_count) -> SegmentOutcome (L1309) — Pure decision for one round trip: a deadline miss wins as ROSDeadlineMissed:; ok=False uses the server's typed failure_reason (or a ROSPerceptionStale: default); ok=True with zero masks is also ROSPerceptionStale:. No input combination yields an unexplained fallback.
  • build_segment_request(*, stamp_ns, camera, t_link_from_cam, tcp_in_link, negatives_in_link=()) -> SegmentInView.Request (L1428) — The request the bridge sends: frame_id="" (the segmenter's "already in my optical frame") with the TCP and jaw-tip prompts moved into the optical frame by inv(t_link_from_cam), so the segmenter never re-resolves a link its TF tree may lack. Precondition: the segmenter's RGB is registered to the depth stream (true for the ZED rgb/color/rect / depth/depth_registered pair).
  • decode_mono8_mask(data, *, height, width) -> NDArray[np.bool_] (L1358) — Reader half of segmenter_node.mono8_bytes_from_mask; any non-zero pixel is set. Raises ROSConfigError on a length mismatch.
  • mask_depth_skew_reason(mask_stamps_ns, depth_stamp_ns, *, max_skew_s) -> str (L1388) — ROSPerceptionStale: … when any mask's capture stamp is more than max_skew_s from the depth frame it would be back-projected through, else "". Applied by the bridge's _on_reply (→ GRIPPER_CLOSURE fallback, reason logged) and the grasp-target leg (→ mask_depth_skew lost view).
  • mask_stamps_ns(masks) -> list[int] (L1423) — Each sensor_msgs/Image mask's header.stamp in ns (the segmenter copies the RGB frame's capture stamp).
  • class VisionAttachmentBridge(node, description, *, on_perception_ready=None, config=None, gate_config=None, trigger_config=None) (L1474) — trigger_config is one PositionStallConfig (debounce) for every hand and the heartbeat evidence; None derives it from the manifest's control_rate_hz (PositionStallConfig.for_rate, the 30 Hz-tuned defaults when undeclared) — the HAL node passes one derived from its own joint-state rate; the thresholds are each gripper joint's closure_calibration (an uncalibrated gripper is a ROSConfigError). One leg (private _GripperLeg: PositionStallTrigger, producer, TCP, current attachment, in-flight request, deadline timer, generation, region_spent, support_anchor — (frame, ATTACH origin, rise tol, slide tol), regrasp_hold: AttachedCollisionObject | None — the region payload a REGRASP began on, kept when its segmentation is rejected, HZ-0115-34) per role: gripper joint; every publish carries the union of the legs' attachments. __init__ creates no ROS entities. Owns the depth and depth-CameraInfo subscriptions (sensor-data QoS), tf2 listener, SegmentInView client and the /openral/attachment_state publisher (RELIABLE/TRANSIENT_LOCAL, matching the sim bridge). Raises ROSConfigError at construction when the manifest can't support the producer or the trigger. setup() seeds the revision from the node clock (host wall time on real) so a re-activate never moves it backwards (+1 per event; nothing persisted), and starts a 0.2 s heartbeat that republishes the union at the current revision with a fresh stamp — only while jaw-position evidence is live (private _JawEvidence: every gripper reported a finite position within evidence_timeout_s, in an unbroken run of new — not repeated-stamp — samples spanning PositionStallConfig.consecutive_s and at least consecutive_samples of them, re-earned after a monotonic timeout or a sample-time gap over max_gap_s — over evidence_timeout_s instead with evidence_run_spans_gaps, the twin), no grasp is being resolved, and every trigger agrees with its leg's attachment; otherwise it is withheld (logged once per transition) so the kernel's snapshot ages into a drop rather than a stale "nothing attached". Back-projection runs in the cached depth frame's header.frame_id (empty → typed ROSPerceptionStale fallback; logged once at info when it differs from the manifest's frame_id), through the live CameraInfo intrinsics, with masks resampled nearest-neighbour onto the depth raster only when the aspect agrees within 1e-3 (else a typed ROSConfigError fallback). With grasp_target_enabled it owns a _grasp_target_leg.GraspTargetLeg and every publication (event and heartbeat) carries its grasp_declaration_valid / grasp_declaration; an ATTACH on a leg whose jaw link (_GripperLeg.jaw_link, the gripper joint's child_link) the declaration names stops re-measurement, and — when the envelope (TTL/expiry applied) still holds a region and the jaw is at it (jaw_at) — attaches that region — closed over the map cells it touches, the box the kernel latched (GraspTargetLeg.kernel_region, Isaac i50; tight vs closed half-extents logged once) — as the payload (region_attachment, private _region_payload) without a SegmentInView call (design §2.2 "Handover") — once per leg per measured region (region_spent = (target_id, region.stamp_ns)), handed to GraspTargetLeg.on_attach with the region it was built from; a segmented ATTACH (_finish) goes through GraspTargetLeg.on_attach(region=None), which hands the region over only for a payload on it; a REGRASP, or a later ATTACH offered the same region, segments; otherwise it segments, logging why the region was not used. With place_target_enabled it owns a _place_target_leg.PlaceTargetLeg: every publication carries its place_declaration_valid / place_declaration (dispatch's goal-scope one — the leg never declares on its own — with the slab measured under the carried payload as region) and the attachment set passes through PlaceTargetLeg.decorate (the map-support proximity witness); observe_joint_state feeds PlaceTargetLeg.on_joint_state. A DETACH opens that leg's ReleaseWindow (the payload stays published, frozen on the base link) instead of dropping it; the heartbeat polls every window first and publishes at a fresh revision when one closes. Every publish at a fresh revision whose set lost or replaced an object, or is empty, first calls GraspTargetLeg.on_attachment_changed (emptied passed: a mid-grasp hand's identity — never its region — is kept across a change that leaves the set non-empty), so that very snapshot's envelope already names the other hand's fresh identity with no pre-change region (the kernel retires an armed declaration at every detach edge, HZ-0115-3). Every DETACH (also one that finds nothing latched) and every barrier release re-checks the hand's pick (GraspTargetLeg.on_hand_settled). Threading: one re-entrant bridge lock (_lock, replacing the former publish and release locks) serializes every entry point that reads or mutates leg, tracker or attachment state — observe_joint_state / observe_command / clear_command / attachment_action_ack_ready / missing_position_ticks, the heartbeat and release poll, every publish, the segmentation reply/deadline commits, teardown, and the producer legs' ticks, replies, deadlines and declaration callbacks (the legs alias it). Order: bridge lock → GraspTargetTracker's lock, never the reverse. Slow pure work runs outside it and commits only under an unchanged generation: a reply's mask decode + back-projection (_finish, GraspTargetLeg._measure) and the grasp leg's column scan (GraspTargetLeg._seed); tf2 lookups (latest, no timeout) run under it. A malformed SegmentInView reply (mask bytes vs shape, a bad field) resolves like the deadline — the GRIPPER_CLOSURE box, the barrier released — never an exception out of the executor. The bridge owns the ONE /openral/world_voxels subscription (_on_voxels, the kernel's RELIABLE/VOLATILE/KEEP_LAST 1, created when either producer leg is enabled): each grid is decoded once into _grid (lattice, source_stamp ns, receive time), shared by both legs, its whole-grid occupied-cell scan deferred to first use and cached on the VoxelLattice. Real hardware runs every caller on the executor's one thread, so behaviour there is unchanged. Covered by tests/unit/test_grasp_target_leg.py::test_every_bridge_and_leg_entry_point_is_serialized and ::test_the_lock_order_is_bridge_then_tracker_never_the_reverse. Construction refuses a base_frame that is not the kinematic tree's root (_release_base_link). Covered live by tests/integration/test_vision_attachment_heartbeat_live.py, tests/integration/test_vision_attachment_optical_frame_live.py, tests/integration/test_grasp_target_leg_live.py and tests/integration/test_vision_attachment_release_window_live.py.
  • setup() -> None (L1622) — Create the ROS entities.
  • teardown() -> None (L1705) — Destroy the ROS entities. Idempotent and always reopens the barrier, so a mid-flight deactivate can never leave the node deferring an acknowledgement forever.
  • tf_frame(link) -> str (L1756) — The tf2 frame a manifest link is looked up as (tf_frames, else the link itself).
  • jaw_point(jaw_link, frame) -> tuple[float, float, float] | None (L2665) — Where the hand whose jaw link is jaw_link is, in tf2 frame: the leg's attach link through tf2 (tf_frames-mapped) composed with its TCP — the attachment prompt's own point, never a tf lookup of the jaw link (the OpenArm's openarm_<side>_finger_pair is a manifest-only link). None for a link no leg owns or a missing transform. Used by the grasp-target leg's occlusion gate.
  • jaw_at(jaw_link, region, *, reach_m) -> tuple[bool, str] (L2692) — Whether the hand whose jaw link is jaw_link is at the held region, with the measure as log text: its TCP or its closing midpoint within region grown by reach_m (separating-axis gap of the point on the region's axes). The closing midpoint is the mean centre of jaw_link's manifest collision primitives (bounded boxes, private _link_boxes — shared with ReleaseWindow.open) posed by the attach link's tf2 pose · gripper joint origin · _joint_motion(q) at the last read jaw angle — between the fingers; never a box overlap, which on the OpenArm's opening-spanning finger_pair box (half 0.029 × 0.071 × 0.081 m) accepted a neighbour ~7 cm beside the jaw (PR #346 review). Unknown angle / no geometry / no tf2 → the TCP alone / not at it, said in the measure. The ATTACH test of _region_payload and GraspTargetLeg.on_attach (segmented payload); the TCP may be the finger hinge (OpenArm), a finger length above what the jaws close on (Isaac i41: midpoint 13.5 mm off the can, hinge 9 cm).
  • _on_kept_cloud(msg) -> None (L2788) — Caches the self-filter's PointCloud2 (skip_nans, x/y/z) by capture stamp and frame, the last 8; the subscription exists only with self_filtered_cloud_topic set and is destroyed (cache cleared) by teardown.
  • kept_points(stamp_ns, frame) -> NDArray[np.float64] | None (L2796) — The robot self-filter's kept points for the capture nearest stamp_ns (within mask_depth_max_skew_s; clouds are best-effort and some are lost, and a neighbouring capture can only remove more mask pixels; last 8 clouds on self_filtered_cloud_topic), in frame; None without that capture or its tf2 — the grasp target leg then refuses the capture as the lost view unfiltered.
  • (property) gripper_joint_names -> tuple[str, ...] (L1768) — one leg per name, manifest order.
  • attachment_action_ack_ready() -> bool (L1748) — True only when no leg is pending. The same shape SimSensorBridge exposes, so ManifestHALLifecycleNode's deferred-ack path treats both identically.
  • observe_joint_state(state) -> None (L1775) — Called from _publish_joint_state with the same typed snapshot the HAL just read (no second source of truth). Fed to every leg (and to PlaceTargetLeg.on_joint_state when that leg is on); also caches every joint position by name (the jaw angles for the release test). DETACH moves that leg's attachment into a ReleaseWindow (no tf2 hand pose → dropped at once, logged) and republishes; ATTACH/REGRASP closes an open window (reason=attach), closes the barrier for that leg and dispatches one bounded request. Every event first supersedes the leg's in-flight request (private _supersede: generation +1, deadline timer cancelled and destroyed, future dropped then cancelled); replies and deadlines carry their generation and a stale one is dropped, so a DETACH in flight resolves to no attachment and releases the barrier.
  • _log_close(leg, event) -> None (L1938) — Per leg, from observe_joint_state: one line when a close starts (command within stall_gap of closed, jaws empty) and one at its outcome — the ATTACH, or the command leaving that band without one — with that close's gap_resets / repeated_samples and the trigger's last short_of_command / settle spread. Logging only.
  • observe_command(action) -> None (L1972) — Called by _on_safe_action with the command the HAL applied (called by HALLifecycleNodeBase._fold_applied_command from _on_safe_action: an ungrouped action as sent; for an ADR-0102 slot group the HAL's last_applied_action — the composed full-dof action — once its last_committed_tick/_session match the slot's, a slot that only staged changes nothing; a committed group whose command is unknown — last_applied_action is None, or the HAL has none, logged once — calls clear_command instead, never keeping the old command); the last row (the trajectory goal) is the command. The bridge stages no slots: a slot (tick_group_size > 1) handed to it is ignored and logged once. An ungrouped JOINT_POSITION row: full dof → padded, read at each owned joint's manifest index; as long as its joint_names → compact, positional; any other length → logged once, ignored. Other modes leave every leg's last command untouched.
  • clear_command() -> None (L2012) — PositionStallTrigger.clear_command on every leg: the HAL applied a command nobody can read.
  • (property) missing_position_ticks -> int (L2051) — The legs' missing/non-finite jaw-position counters, summed.
  • _retire_lifted_supports() -> None [private] (L2556) — From observe_joint_state, throttled to the place leg's 20 Hz witness period: drops a region payload's witness for good (same object and stamp, so the kernel's (object, support, stamp) key never re-arms) once the payload frame, in the support plane's frame (z up), rose more than max(resolution, extrinsic_error_m, release_clear_m) (the kernel's world margin plus a cell: the payload's lower face rests on the support top; release_clear_m capped at MAX_RELEASE_CLEAR_M) or moved horizontally more than max(resolution, extrinsic_error_m) (the margin buys nothing sideways) from its ATTACH pose (_GripperLeg.support_anchor), or tf2 cannot place it; sinking keeps it (same plane, the kernel bounds the penetration) — the kernel's own liveness reads the map, and the octomap bridge withholds the witness band, which rides with the payload over the carried object's own lowest cells. Republishes at the current revision while the heartbeat is open.
  • clip_payload_behind_tool_point(obj, tcp_in_link) -> AttachedCollisionObject (L658) — Every box primitive of obj clipped so nothing lies behind the tool point along the approach (an axis within 30°, front face kept); other shapes and tcp_in_link=None unchanged. The bridge applies it to every segmented / fallback payload (#349: a mask that bled into the hand put a box against the carrying wrist).
  • region_attachment(declaration, *, attach_link, touch_links, t_link_from_region, stamp_ns, support_z=None, extrinsic_error_m=MAX_PLANAR_ERR_M, tcp_in_link=None, inner_region=None) -> AttachedCollisionObject (L740) — The declaration's latched region as one box primitive posed in the attach link; given inner_region (the measured, unpadded region), it rides as the box's stage-2 tight_geometry (clipped like the box, clamped inside it) so the kernel checks the payload against the robot's own links as the measured box; given tcp_in_link, a box axis within 30° of the approach is shortened so nothing lies behind the tool point (#349: the margin's top bloat reached the carrying wrist); object_id = the declaration's object_id (the kernel handover's match key) or its target_id; AttachmentEvidenceKind.GRASP_TARGET_REGION, confidence 0.7. ROSConfigError without a region. Given support_z (the support top the grasp-target leg measured under exactly this region, GraspTargetLeg.measured_support), the payload carries the ADR-0092 D6 SupportContactWitness on that plane (_place_target_leg.plane_witness, support_id="map_support_under:<target_id>", stamped at the ATTACH, patch = the published box's horizontal footprint) — Isaac i42's payload, still on its table, was stopped 90 ms after the handover; None attests nothing. The bridge passes the region's cell closure (GraspTargetLeg.kernel_region), so the target's own boundary cells are embedded residue at the kernel's attach-time snapshot (Isaac i50).
  • box_gap_lower_bound_m(a, b) -> float (L488) — Separating-axis (15 axes) lower bound on the distance between two oriented (centre, rotation, half_extents) boxes; exact on a face normal, never above the true distance, <= 0 on overlap. The release window's separation test. Not a kernel mirror (see 14-duplication-watch.md "Convex distance"): it only has to be a sound lower bound.
  • freeze_released_attachment(held, *, base_link, t_base_from_link) -> AttachedCollisionObject (L529) — The released payload re-expressed on the collision model's root link at its DETACH pose: geometry, evidence and force attestation kept, support_contact dropped (the held payload's own is the grasp-time map_support_under:* witness, a claim about the ATTACH pose that would ride the record misplaced through the window; the place leg's set-down witness is decorated per publish, PlaceTargetLeg.decorate, and still rides the record); touch_links = the held attach link + its touch links (nothing the held record did not already exempt); evidence_ref suffixed |frozen_release. Root, not hand: the kernel places a record at link_world[attach_link] in every predicted configuration, so a hand-attached record would follow the retreat.
  • primitive_poses(obj, t_frame_from_link) -> list[NDArray] (L881) — frame <- primitive (4, 4) for each primitive of an AttachedCollisionObject, given frame <- attach link; shared by ReleaseWindow.clearance_m (the frozen record, link = base), GraspTargetLeg.on_attach's segmented-payload test, and _place_target_leg (payload_witness, and PlaceTargetLeg's posed published payloads each tick; the old private _posed_primitives copy was removed).
  • class ReleaseWindow (L895) — Frozen dataclass (record, opened_s, hand_link, hand_boxes, jaws) — one leg's released payload (design note §2.3 "Release").
  • open(description, held, *, base_link, t_base_from_link, now_s) -> ReleaseWindow (L926) — Freeze held and collect the hand's and its jaws' manifest collision primitives (bounded as boxes); a MimicJoint follower of a jaw joint hinged on the same hand (the OpenArm's second finger, its own link since #349) is a jaw too.
  • clearance_m(t_base_from_hand, positions) -> float | None (L979) — Smallest box_gap_lower_bound_m over (exempted link primitive, payload primitive); jaws posed by origin · joint motion(q), a mimic follower at value_at of its leader's angle. None when a jaw angle is unknown — never "clear".
  • close_reason(*, now_s, clearance_m, clear_m, timeout_s) -> str (L1022) — "timeout", "separation" (strictly > clear_m), or "".
  • _release_base_link(description) -> str (L1052) — base_frame, proven the root of joints + fixed attachments, else ROSConfigError.
  • _tree_links(description) -> tuple[set[str], set[str]] (L1043) — (parents, children) of every joint and fixed attachment; also the tf_frames key check.
  • bounding_half_extents(shape) -> NDArray (L411) — Half extents of the box containing a box / sphere / capsule primitive (distances on it are lower bounds); public for the grasp leg's HZ-0115-11 guard.
  • _xyz_rpy_matrix(xyz, rpy) -> NDArray (L437) — (4, 4) from translation + URDF fixed-axis RPY.
  • _joint_motion(joint, q) -> NDArray (L452) — Revolute/continuous (Rodrigues) or prismatic motion after the joint origin; any other type is ROSConfigError.

python/hal/src/openral_hal/_grasp_target_leg.py

The pre-grasp target producer (design note docs/reference/real-pick-place-design.md §2.2), owned by VisionAttachmentBridge (single /openral/attachment_state authority), default off. Subscribes /openral/grasp_declaration (RELIABLE + TRANSIENT_LOCAL, depth 1) and reads the bridge's one shared /openral/world_voxels grid (VisionAttachmentBridge._on_voxels); at grasp_target_rate_hz measures the support plane from the map (support_top_from_voxels over search_column(box, below_m=support_search_below_m), ring probe support_probe_margin_m around the target's footprint, counted on the column grown by that margin + one cell — never the box's bottom face, a lifted bbox min-z that can sit below the table top; none is a no_support contradiction), seeds from the occupied cells of that column above it (whose lowest cell must sit within one voxel + one of tolerance of the support, else a not_on_support contradiction — a bench under the shelf board the target stands on; the mask fit refuses the same way when the masked cloud ends > 2 voxels above the support — a target stacked on another object), sends the seed as the single positive SegmentInView point (optical frame, no negatives, the leg's own deadline), fits target_region_from_mask on the depth frame the request was made against, cross-checks region_covers_occupied and gates track_region at one voxel. Contradicting evidence (no_support, not_on_support, ambiguous, caps, no_height_above_support, map_disagrees on any re-fit the map does not cover, target_moved on a re-fit reaching outside the held region grown by one voxel, unoccluded_refit on one shrunk inside it with no declared contact link near, frame/calibration mismatch) retracts at once; a lost view (no seed cells, seed off-image, no mask, a mask more than mask_depth_max_skew_s from its depth frame (mask_depth_skew), deadline, too_few_points, stale or missing grid/depth/TF/CameraInfo, hand_at_target — a fit refused outright with a region held and the hand over it (_hand_over_target: the jaw's hand point over the held footprint up to approach_m above its top, the measure occluded_refit uses too) — and partial_fit — a fit passing every other gate whose kernel (cell-closed) region leaves an occupied cell of the target's map component outside (occupied_touching_outside: more than a voxel above the measured support, touching the region's cells) and that the map cannot complete (map_completed_region inside the armed search column refused: the component touches the column's edge, exceeds the caps or leaves another body in the box; raised only where the fit would be accepted, never re-classing another refusal; a fit the map does complete is held, tracked, handed over and published as the completed box, Isaac i45) — and occluded_refit — a map-covered re-fit failing the tracking gate but inside the held region grown by one voxel while a declared contact link's hand point (VisionAttachmentBridge.jaw_point: its leg's TCP via the attach link, since jaw links such as OpenArm's finger_pair are not tf frames) is within one voxel + occluder_margin_m of it, the robot's own hand occluding part of the target; it never replaces the held region) freezes the last accepted region for grasp_target_freeze_s from its stamp_ns (the older of the depth stamp and the grid's source_stamp, so a stalled octomap whose bridge republishes a fresh header.stamp ages the region out), then retracts. Each transition logged once. A grid received longer than grid_max_age_s ago (the deploy's kernel voxel deadline, grid_stale), with no source_stamp (grid_source_unknown) or whose source_stamp is older than the freeze (grid_source_stale) is not used — lost views; the freeze defaults to twice grid_max_age_s and may not exceed it (the kernel's grasp_region_max_age_s = 2 × its voxel deadline). Approach-armed target (approach_m, default off): a live declaration with no search_box (the runner's goal-scope one, or a named one without a box) is measured where ONE hand approaches — each tick the hands (openral_core.gripper_hands) whose links the declaration names are located (jaw_point), approach_box around each; a hand with ≥ grasp_target_min_cells occupied cells in its box above the box's lowest occupied layer (approach_cell_count: the table under the hand is not a target) and holding nothing (no payload, release window or segmentation on its legs) is approaching; exactly one arms target_id="approach:<first link>:<n>" (n the tracker's pick counter, seeded from the wall clock at construction — GraspTargetTracker(first_pick=None) — and advanced per completed pick, so a re-activated HAL never reuses an identity), contact_links = that hand, the box as search_box, the goal's attribution/stamp/timeout; two at once arm none; the box follows the TCP; the hand leaving retracts at once (approach_ended) — except the armed hand while its legs hold or segment (its ATTACH resolves the arming). A grasp-leg request in flight when the tracker's generation moves (handover, re-arm, retraction, declaration change) is discarded on reply or deadline. A retracted arming (before any handover) re-arms for that hand only once its box centre moved more than one voxel or one freeze window elapsed (no arm-refuse-re-arm flood), under the same identity — except a hand that left the approach distance and was then seen away (located, its box off the last target's column) continuously for a freeze window, which arms under a fresh identity (liveness after a kernel grid_frame_changed / attachment_rejected retirement, which the producer cannot see). Every publish whose attachment set lost or replaced an object, or is empty, first drops every pre-handover arming's region and re-arms it under a fresh identity, accepting only a region measured after the change (on_attachment_changed; the kernel retires an armed declaration at every detach edge) — except that a hand mid-grasp (an ATTACH being resolved: a segmentation in flight or jaws loaded with nothing latched, GraspTargetLeg._resolving; a carried payload or release window is not mid-grasp) keeps its identity across a change that leaves the set non-empty, though still not its region, so its ATTACH is handed over with no region (fail closed); an emptying change refreshes it regardless. Handover is per hand: only an ATTACH on the armed hand (or a named search_box declaration's link) hands over, and only an ATTACH on what was measured (GraspTargetLeg.on_attach) hands the region over — else attach_off_target (handed over with no region; also when confirm raises). Multi-pick per goal: nothing arms while a hand is handed over; the bridge's DETACH (GraspTargetLeg.on_detach, with the release window's frozen record) drops that hand's region at once and keeps the handover (GraspTargetTracker.on_release); once the hand's legs hold nothing — re-checked on the DETACH, the window's close and every barrier release (on_hand_settled), never polled — the pick completes (on_pick_complete): the counter advances and the hand re-arms behind the backoff, but not until the column its next measurement would search (search_column of its approach box, support_search_below_m) clears the payload it just released by more than one voxel (_near_spent: box_gap_lower_bound_m against each primitive's bounding_half_extents box; no record = no re-arm for the rest of the goal; HZ-0115-11). A guarded hand is not a candidate: it does not count toward "two hands at once" (HZ-0115-12 is the kernel's one-hand rule). The release poll and a re-ATTACH are serialized by the bridge's one lock and a loaded trigger counts as holding, so the poll racing a re-ATTACH never completes the live regrasp's pick in either ordering. The kernel retires every pick's identity at its release and never re-arms one (HZ-0115-3). A named declaration stays handed over after its pick. A named search_box wins. Threading: every leg entry point (tick, reply, deadline, declaration, teardown) and bridge hook runs under the bridge's one re-entrant lock (the bridge's ATTACH runs on the HAL's proprio thread); the tracker's own RLock nests inside it. Only pure work on a snapshot runs outside — the tick's column scan (_seed) and the reply's decode/back-projection/gates (_measure, given the grid, hand points and held region read under the lock) — and is committed under the lock only under the snapshotted tracker generation; teardown sets a torn-down flag under the lock that _tick / _on_deadline check, so no request or deadline timer outlives it.

  • class GraspTargetTracker(*, freeze_s, log, first_pick=None) (L383) — Pure state machine on a caller-supplied ROS-clock now_ns; thread-safe (every method under its own re-entrant lock, which in the HAL always nests inside the vision bridge's lock). first_pick seeds the pick counter in approach:<link>:<n>; None seeds it from time.time_ns() so a re-activated HAL never re-mints a retired identity.
  • on_declaration(declaration) -> None (L539) — active=False clears; a new (target_id, stamp_ns) resets the region; the latched copy again is a no-op.
  • rebase(resolve) -> None (L573) — Re-expresses dispatch's search_box / workspace_box through resolve(field, box) from the boxes as declared (never compounding); identity, generation and region kept. The leg passes rebase_box into the grid frame every tick, with tf2 at the grid's source_stamp (VisionAttachmentBridge._lookup(stamp_ns=)), so the box sits where the base was when the map was built; no tf2 at that time leaves it unresolved (fail closed), logged once per field and state.
  • wants_approach(*, now_ns) -> bool (L604) — Approach detection should run: a live declaration with no search_box, not handed over.
  • on_approach(near, *, now_ns, move_m, holding=(), generation=None, located=None) -> None (L615) — Given generation (the tick's), ignored when the tracker moved on since (a pick completed on the HAL's thread). near = (hand links, approach box) per approaching hand this tick; only hands the declaration names count. Holds one hand: arms on exactly one, follows its box, retracts at once (approach_ended) when it leaves — unless it is in holding (legs holding, releasing or segmenting: arming and box kept for the ATTACH); two at once arm none (a backed-off hand still counts). A hand whose arming was retracted re-arms only after its box centre moves > move_m or freeze_s elapses, under the same identity — unless its arming ended by leaving (approach_ended) and every sample for a continuous freeze_s since showed it in located (the approach boxes of the hands located and holding nothing this tick) with its box off the column over its last target: then a fresh identity (liveness after a kernel fault retirement the producer cannot see). An unlocated, holding or back-over-the-target sample restarts that window; absence from near alone is not away.
  • on_attachment_changed(*, now_ns, emptied=True, holding=()) -> None (L882) — The bridge is publishing a set that lost or replaced an object, or is empty (the kernel's detach edge retires an armed declaration before its handover too). An arming not handed over drops its region and, approach-armed, advances to a fresh identity — the counter also advances when an identity was armed since the last advance and has since retracted (the kernel may still hold it); no region with stamp_ns <= now_ns is accepted afterwards (any arming). An arming whose hand is in holding (mid-grasp: an ATTACH being resolved) keeps its identity unless emptied (the kernel retires on the empty edge regardless, so that grasp then hands nothing over) — but always drops its pre-change region, so its handover carries none. A handed-over pick follows its own path; a named declaration keeps dispatch's identity (the kernel refuses it once retired).
  • presence_unknown() -> None (L767) — A tick that could not sample the hands (no fresh grid, a request in flight, a stale verdict): every away window restarts — unknown is not away.
  • on_attach(contact_link, *, confirm) -> None (L786) — confirm receives the held region or None; Per hand: a link of the armed declaration (approach-armed hand, or a named search_box one) attached → no further measurement or approach arming until that hand's pick completes (on_pick_complete); the held region is kept for the kernel's handover only if confirm(region) (run under the lock, before any state changes) says the payload is its target, else dropped (attach_off_target, also when confirm raises). An ATTACH on a hand with nothing armed (the goal-scope declaration names every hand) hands nothing over and is logged.
  • on_release(hand, record) -> None (L855) — The bridge's DETACH on the handed-over hand: drop its region at once, keep the handover (nothing re-latches it), remember record (the frozen release record, or None) as the hand's spent payload. A named search_box declaration drops its region too (nothing spent recorded: it never re-arms); a hand not handed over is untouched.
  • on_pick_complete(hand, *, now_ns) -> bool (L946) — The handed-over hand holds nothing: drop the arming (backoff as a refusal), advance the pick counter, release the handover (spent defaulted to None when no on_release came). Named search_box declarations stay handed over (returns False).
  • spent(hand) -> tuple[bool, AttachedCollisionObject | None] (L976) — (guarded, released record) of the hand's last completed pick this goal; cleared by a new or cleared declaration.
  • clear_spent(hand, record) -> None (L981) — End the guard, only while record is still the guarded one; the ending is logged once (the hand may arm again).
  • measured() -> tuple[GraspDeclaration | None, int] (L483) — (target, generation) read atomically, to tag one measurement request.
  • (property) generation -> int (L507) — bumped on every change of what is measured (declaration, arming, retraction, handover); the leg tags each request with it and drops a reply or deadline under an older one.
  • (property) handed_over -> tuple[str, ...] | None (L494) — links of the hand whose ATTACH ended re-measurement.
  • (property) region_generation -> int (L499) — the generation the held region was accepted under; a map re-stamp of the same measurement keeps it, a new declaration or pick bumps it.
  • wants_measurement(*, now_ns) -> bool (L988) — Live, has a search_box, not handed over.
  • accept(region, *, generation=None, map_cells=0) -> None (L999) — Hold a region (map_cells > 0: completed from the map — that many cells came from the map alone, logged with the completed half-extents on entering that state); one that breaks GraspDeclaration's caps is refused (declaration_bounds). Ignored once handed over, under a generation other than the current one, or when the region was measured at or before the last attachment-set change (on_attachment_changed; measured_before_change, logged once).
  • refuse(kind, detail, *, retract, now_ns, generation=None) -> None (L1049) — Retract now, or freeze under the TTL. Ignored once handed over, or under a generation other than the current one.
  • envelope(*, now_ns) -> GraspDeclaration | None (L1085) — Dispatch's fields verbatim + the held region; expiry clears, an over-age unfrozen region is retracted.
  • (property) declaration -> GraspDeclaration | None (L473) — dispatch's live copy, region-less.
  • (property) target -> GraspDeclaration | None (L478) — what is measured and published: the approach-armed one-hand declaration while held, else dispatch's.
  • (property) region -> PlaceRegion | None (L489) — the last accepted region still held.
  • APPROACH_TARGET_PREFIX = "approach:" (L322) — target_id prefix of an approach-armed declaration; the hand's first link and :<n> (the pick counter) follow — one identity per pick.
  • approach_cell_count(grid, box) -> int (L1242) — Occupied cells in an approach box above its lowest occupied layer (the support under the hand); one layer only, so a box bottom cutting a small object still counts it.
  • approach_box(points, *, approach_m, frame_id, cap_m=GraspDeclaration.MAX_HALF_EXTENT_M) -> PlaceRegion (L1206) — Gravity-aligned box spanning a hand's TCP points grown by approach_m, each half-extent capped at cap_m (the pre-measurement box lifts it); the approach test and the approach-armed target's search box. ROSConfigError on no points.
  • lattice_from_msg(msg) -> VoxelLattice (L1106) — openral_msgs/OccupancyVoxels → VoxelLattice.
  • rebase_box(box, t_frame_from_box, frame) -> PlaceRegion (L1129) — A gravity-aligned declared box re-expressed in frame through tf2 frame <- box, still yaw-only (the transform's tilt, at most 0.5°, dropped; more, or a tilted box, is a ROSConfigError). GraspTargetLeg._tick applies it every tick to a search_box / workspace_box a scene declares in a fixed frame (odom, map) via GraspTargetTracker.rebase(resolve) — which re-expresses the boxes as declared, never compounding, keeping identity, generation and region — so they stay put while a mobile base moves; with no tf2 the box stays in its own frame and the existing frame_mismatch refusal / workspace retraction applies.
  • search_column(search_box, *, below_m) -> PlaceRegion (L1174) — The search box extended below_m downward, the column the support is measured in; a tilted box is a ROSConfigError.
  • _hand_over_target(hands, held, *, reach_m, hand_rise_m) -> bool [private] (L1303) — A declared contact link's hand point (the jaw TCP, a finger length above the tips) over the held region's footprint, up to hand_rise_m above its top, grown by reach_m; the measure hand_at_target and occluded_refit share.
  • _refused_fit(kind, detail, previous, hands, *, reach_m, hand_rise_m, visible=None, tol_m=0.0) -> _Refusal [private] (L1352) — A refusal from target_region_from_mask typed for the tracker: too_few_points is a lost view; a not_on_support with a region held, its visible box (TargetRegionFit.box) inside the held region grown by tol_m (one voxel) and the hand over it (_hand_over_target) is the hand_at_target lost view (the hand occluding the target's lower part, Isaac i25; the held region stays under its freeze TTL, never replaced); every other refusal (no height, the caps) and a visible part reaching outside the held region (target knocked over or gone) is the contradiction it names, hand or not.
  • _within_caps(region, grid) -> bool [private] (L1390) — Whether region's cell closure fits GraspDeclaration's half-extent and volume caps unclamped.
  • _capped_margin(region, grid, margin_m, *, down=True) -> tuple[float, str] [private] (L1398) — The largest margin <= margin_m whose bloat (margin_grown_region(down=down)) still fits the caps once cell-closed — the margin shrinks, never the measured box (bisection, rounded down; noted when reduced, 0 when the unbloated closure is already at the caps). The note names the state, never the bisected value, so logging on change logs once per state.
  • _kernel_closure(region, grid, *, margin_m=0.0, down=True) -> tuple[PlaceRegion, str] [private] (L1428) — The region as the kernel gets it over grid and a note when it is not as configured: bloated by the capped margin (margin_grown_region, every face with down, sides and top without), then cell_closed_region clamped at GraspDeclaration.MAX_HALF_EXTENT_M (noted), the tight fit when the closure exceeds MAX_VOLUME_M3 (noted). margin_m=0 is the unbloated closure of region exactly. Used by kernel_region (published region and payload, with the capped margin) and, unbloated, by _gate_refit (what the whole-target gate checks).
  • _gate_refit(grid, region, previous, *, support_z, min_cover, column, hands=(), occluder_margin_m=0.05, hand_rise_m=0.0, complete=True) -> tuple[PlaceRegion, int] [private] (L1478) — (accepted, added). Map cover on the fit (map_disagrees); the whole-target gate: when occupied_touching_outside on _kernel_closure(region) is non-empty the fit is completed from the map (map_completed_region inside column, Isaac i45) and the completed box is the candidate — what is tracked, held, handed over and published (cell-closed); then the tracking gate on that candidate (target_moved / occluded_refit / unoccluded_refit), so a later fit of more or less of the target completes or fits to about the same box. Against a held region (previous) the leg only logs its outcome: a held region is frozen and only the map retracts it (GraspTargetLeg._map_hold, design Revision 4). A refused completion leaves the tight fit as the candidate and, where it would otherwise be accepted, raises the lost view partial_fit (never re-classing another refusal; nothing new accepted, a held region kept under its freeze TTL). added = the cells the map added (0 for a fit holding the whole component).
  • WORKSPACE_MIN_COVER = 0.10 (L355) — The fraction of a declared workspace_box's footprint that occupied cells centred in it must cover for the leg to confirm it (region_covers_occupied); a calibration point (issue #349).
  • class GraspTargetLeg(node, bridge, config, *, support_search_below_m=0.15, support_probe_margin_m=0.05, occluder_margin_m=0.05, approach_m=None, premeasure=False) (L1612) — ROS wiring; premeasure (from VisionAttachmentConfig.grasp_target_premeasure, default off) pre-measures object instances before any hand arms: every SegmentInView request is then an instance prompt (_premeasure: at most _PREMEASURE_MAX_HZ = 2 Hz, one in flight, around each free hand within _PREMEASURE_REACH_APPROACHES = 2 approach distances or in the named/armed box; instance_prompt or a refresh of an instance older than half a freeze, every instance in the armed box due each prompt), its reply fitted by the camera path's _fit and kept only when map_confirms it (_on_instance_reply → PrimitiveTracker); instances older than freeze_s or no longer covered by the map are dropped every tick. The armed region comes from the instances with no segmenter call (_tick_instances: with nothing held, the instance nearest the armed hand, refused on a tie, or the lone one in a named box, through _gate_instance — its whole region inside that box plus one voxel, map_confirms, _gate_refit(complete=False) — then accepted). Frozen once held (design Revision 4, both paths): _map_hold(grid, held, source_ns, hands) every tick — map_confirms on the held box and its measured support; map_disagrees retracts, map_split retracts unless every second body lies in a declared hand's own space (_hand_boxes, passed as map_confirms(own=…); then a lost view, the freeze TTL runs), confirmed re-stamps the same box with the grid's source_stamp; re-fits still run but their outcome is only logged once per change (_refit_ignored: grasp target re-fit ignored, region frozen (map-confirmed) — …); on the camera path _hold_frozen runs _map_hold each tick and _refuse_refit logs instead of applying every re-fit refusal (seed, request, deadline, reply) while a region is held; approach_m (from VisionAttachmentConfig.grasp_target_approach_m, None = off) turns on the approach-armed target and must be in (0, GraspDeclaration.MAX_HALF_EXTENT_M]; reuses the bridge's depth/CameraInfo caches, tf2 buffer and SegmentInView client. ROSConfigError on a rate outside 2-5 Hz, a non-positive cell count / cover / probe margin, a freeze outside (0, 2·grid_max_age_s] (the kernel's grasp_region_max_age_s), a search depth outside (0, 0.5] m or an occluder margin outside [0, 0.10] m (upper bounds keep the gates meaningful), or config.grasp_target_margin_m outside [0, VisionAttachmentRuntime.MAX_GRASP_TARGET_MARGIN_M]; the setup line logs the margin. The bridge passes the support and occluder tunables from VisionAttachmentConfig.grasp_target_support_search_below_m / _support_probe_margin_m / _occluder_margin_m; the keyword defaults serve direct construction.
  • setup() -> None (L1779) — The declaration subscription + the measurement timer (the grid is the bridge's).
  • (attribute) unfiltered_fits -> int — Consecutive fits that found no self-filtered cloud for their capture although self_filtered_cloud_topic is set (the self-filter dropped it, the 8-deep cache rolled over, or no tf2): the capture is then not fitted (lost view unfiltered), since the robot's own points would enter it. Entering that state logs one warning (topic, depth stamp, cache size and stamp span); the next filtered fit logs the count and resets it (CLAUDE.md §1.4).
  • _kept_points(stamp_ns, frame) -> NDArray[np.float64] | None (L3254) — VisionAttachmentBridge.kept_points for _measure, keeping unfiltered_fits and its two log lines.
  • teardown() -> None (L1819) — Idempotent, under the bridge lock; marks the leg torn down and cancels any in-flight request's deadline.
  • fill(msg, *, now_ns) -> None (L1835) — Sets grasp_declaration_valid / grasp_declaration on one AttachmentState; the region goes out cell-closed (kernel_region): before the handover bloated on every face, from the handover on (tracker.handed_over; the bridge's on_attach precedes the ATTACH snapshot the kernel latches from) the payload box (payload=True), so the latched region is the payload — no downward bloat after the handover, the payload origin inside it at every margin. The tracker keeps the tight fit. Since #349 the declaration also carries workspace_region = published_workspace(...) when the leg holds a confirmation of its workspace_box.
  • published_workspace(declaration, *, now_ns) -> PlaceRegion | None (L2099) — The confirmed manipulation workspace (issue #349) to publish with declaration: only the confirmation of its own workspace_box (same frame, pose, extents; never grown) and only within freeze_s of its stamp (the confirming grid's source_stamp). _confirm_workspace(now_ns) (every tick, under the bridge lock) confirms the live declaration's box on a fresh grid (_fresh_grid) when occupied cells centred in it cover ≥ WORKSPACE_MIN_COVER of its footprint, retracts at once on map_disagrees / frame_mismatch / no box declared, and keeps the last confirmation on a stale or missing grid until it ages out; each state change is logged once.
  • kernel_region(target_id, region, *, payload=False) -> PlaceRegion (L1866) — The held region as the kernel gets it (payload=False, before the handover: bloated by grasp_target_margin_m on every face, downward too) or as the region payload's box (payload=True, VisionAttachmentBridge._region_payload and fill from the handover on, Isaac i50: lowered onto the measured support top by lowered_to_support (Isaac i56/i57), then bloated on the sides and top only — the region the kernel latches) (HZ-0115-32), then cell_closed_region over the newest grid, clamped at GraspDeclaration.MAX_HALF_EXTENT_M; a closure over MAX_VOLUME_M3, no grid or a grid in another frame publishes the tight box. The capped margin is cached per (region, grid frame/resolution/orientation, margin) per box kind, so an unchanged region costs no bisection per publish. Every such case is logged once per change of (box kind, note), never per refit near the caps.
  • on_detach(jaw_link, record) -> None (L2597) — The bridge's DETACH (after it dropped the leg's attachment), with the release window's frozen record or None: tracker.on_release, then the pick completes if the hand's legs hold nothing (no attachment, window, pending segmentation or loaded trigger). Event-driven, never polled.
  • on_attachment_changed(*, emptied) -> None (L2613) — Called by VisionAttachmentBridge._publish_attachment before it fills a snapshot whose set lost or replaced an object, or is empty: tracker.on_attachment_changed at the node clock with the links of every hand with an ATTACH being resolved (_resolving: a segmentation in flight or loaded jaws, nothing latched) — producer-side liveness for the kernel's detach-edge retirement, HZ-0115-3; a mid-grasp hand keeps its identity unless the set emptied, never its region.
  • on_hand_settled(jaw_link) -> None (L2637) — The leg's release window closed (not for a re-ATTACH), its barrier was released, or it DETACHed with nothing latched: completes the pick if the hand holds nothing (a trigger reading the jaws loaded counts as holding). Event-driven, never polled.
  • measured_support(region) -> float | None (L3040) — The support top (support_top_from_voxels) the request whose reply the tracker accepted as exactly region was measured on (_on_reply records it; target_region_from_mask stood the box one voxel above it), in region.frame_id; None for any other region. What _region_payload passes to region_attachment(support_z=…).
  • on_attach(jaw_link, payload, *, region) -> None (L2651) — The bridge's one entry for a resolved ATTACH: region set (the payload was built from it, _region_payload) hands over only while the tracker still holds exactly that region (dropped or replaced since → attach_off_target, never "absent"); region=None (segmented, _finish) hands over only when the jaw is at the held region (VisionAttachmentBridge.jaw_at, reach occluder_margin_m) and every payload primitive centre lies in it grown by one voxel — else attach_off_target.

python/hal/src/openral_hal/_place_target_leg.py

The real place producer (design note docs/reference/real-pick-place-design.md §2.3; drafted, unapproved ADR-0097 / ADR-0092 D6 amendments in docs/reference/real-pick-place-adr-drafts.md), owned by VisionAttachmentBridge, default off. Nothing is surveyed and nobody has to name a place target, but nothing arms outside a goal: at place_target_rate_hz, only under a live dispatch /openral/place_declaration (else no_declaration), against the newest /openral/world_voxels (the bridge's shared grid; received, or its source_stamp data, older than grid_max_age_s, or source_stamp unset/future → grid_stale, a lost view), it measures the surface directly under the held, loaded payload — a column of the payload's lattice-axis footprint grown by one voxel + place_target_extrinsic_error_m, from its bottom down place_target_search_depth_m; the first occupied layer must cover the whole footprint (partial_support otherwise) with the payload's height + one voxel + the extrinsic bound free above it (no_headroom); cells within one voxel of any published payload are ignored. The patch is latched as a one-voxel slab region (grid frame, no geometry, stamp_ns = the grid's source_stamp — the world data's capture time, the kernel's own voxel data-age clock, never header.stamp, which a bridge republishing a stalled octree keeps fresh) and re-verified (not re-chosen) while the payload stays over it: new occupancy above it retracts at once, missing support cells / a missing or stale grid freeze it for at most place_target_freeze_s (default and ceiling 2 * grid_max_age_s, the kernel's place-region age bound) from its last verification. The dispatch /openral/place_declaration (RELIABLE + TRANSIENT_LOCAL, depth 1; region never relayed) is required — the runner's goal-scope one (place_approach_enabled, target_id="surface") or a named one; the leg never declares on its own. Its fields ride the envelope (the measured region attached, object_id narrowed to the measured payload when empty) and its search_box refuses a patch outside it (outside_hint). A retraction (goal end, cancel, E-stop) or expiry retracts the patch and the witness (no_declaration) and nothing re-arms until a new declaration; a new declaration drops a latched patch (redeclared) so it is re-measured under the new hint. The witness (MAP_SUPPORT_PROXIMITY, proximity to a map-measured plane, not sensed contact, not a proven support) is attested once per (declaration, payload); it dies with the region (contradiction, the freeze TTL = the kernel's region age bound, retraction or a payload change), so no part of the place allowance outlives a region the kernel would drop as region_stale.

  • class PlaceRefusal(StrEnum) (L139) — Typed reasons: no_payload, payload_changed, no_grid, grid_stale, tilted, no_surface, partial_support, no_headroom, outside_hint, frame_mismatch, free_volume_occupied, support_occluded, freeze_ttl, grid_moved (the lattice re-snapped since the latch — a contradiction), no_declaration (no goal: retraction, expiry or none yet), redeclared (a new declaration drops the old latch); the grid ones and support_occluded are lost views that freeze a latched patch.
  • class PlacePatch (L168) — Frozen dataclass: lattice layer, i0/j0/ni/nj, free_layers, plane_z, the slab region, and the latch grid's lattice ((origin, orientation_xyzw, resolution), which the indices are into).
  • measure_under_payload(grid, payload, *, extrinsic_error_m, search_depth_m, exclude=(), evidence_ref="") -> PlacePatch | tuple[PlaceRefusal, str] (L297) — The surface under the carried payload (see the module note); region.evidence_ref carries map_measured_support:plane_z=…;patch=<ni>x<nj>@layer<k>.
  • verify_patch(grid, patch, payload=()) -> tuple[PlaceRefusal, str] | None (L382) — Re-check on a newer grid: grid_moved (origin/orientation/resolution off the latch lattice by > _LATTICE_TOL = 1e-6 — the indices name other cells; contradiction), free_volume_occupied (contradiction) or support_occluded (lost view); None = holds.
  • payload_witness(obj, t_base_link, patch, *, support_id, resolution, extrinsic_error_m, stamp_ns) -> tuple[SupportContactWitness | None, str] (L441) — Lowest primitive point within max(resolution, extrinsic_error_m) of the measured plane and centre over the patch → the witness (plane_witness).
  • plane_witness(obj, t_base_link, *, plane_z, support_id, extrinsic_error_m, detail, stamp_ns) -> SupportContactWitness (L485) — The one map-support proximity witness construction, shared by payload_witness (place) and vision_attachment_bridge.region_attachment (vision pick): contact point under the payload's centre on z = plane_z, normal +z, both in the object frame; patch_radius_m = the horizontal footprint (per primitive: horizontal offset + its local box's farthest corner projected onto the plane — a yaw-only box's hypot(hx, hy), never the 3-D half-extent norm, whose vertical term widened the exemption) ≤ 0.5; max_penetration_m = min(extrinsic_error_m, 0.01); confidence=0.5; MAP_SUPPORT_PROXIMITY.
  • class PlaceTargetTracker(*, freeze_s, extrinsic_error_m, log) (L560) — Pure state machine on a caller-supplied ROS-clock now_ns.
  • on_declaration(declaration) -> None (L635) — Dispatch's goal-scope copy; active=False drops it with the patch and the witness (no_declaration); a new one drops a latched patch (redeclared) and the witness; the latched copy again is a no-op.
  • latch(patch, *, payload_key, resolution) -> None (L671) — Hold a measured patch.
  • reverified(grid_stamp_ns) -> None (L684) — Restamp the region with the verifying grid's source_stamp (an unchanged stamp never makes it younger).
  • refuse(reason, detail, *, now_ns) -> None (L693) — Lost view freezes a latched patch; anything else retracts.
  • live(*, now_ns) -> PlaceDeclaration | None (L714) — Dispatch's live declaration; expiry ends the allowance like a retraction.
  • envelope(*, now_ns) -> PlaceDeclaration | None (L726) — Dispatch's live declaration (+ the held region, scoped to its payload when dispatch names no object), else None; expires an over-age region.
  • attest(candidates, *, now_ns) -> bool (L744) — Arm or drop the witness from (attachment, T_grid_from_attach_link | None, loaded) per leg; True when the published set must change.
  • decorate(objects) -> list[AttachedCollisionObject] (L803) — The set with the held witness on the object it names.
  • (property) declaration -> PlaceDeclaration | None (L604) — dispatch's copy in force, region-less.
  • (property) patch -> PlacePatch | None (L609) — the latched patch.
  • (property) payload_key -> tuple[str, int] | None (L614) — the payload the patch was measured under.
  • place_tick(tracker, grid, grid_stamp_ns, *, carried, published, extrinsic_error_m, search_depth_m, now_ns) -> None (L838) — One pure measurement tick (grid_stamp_ns = the grid's source_stamp): nothing without a live declaration (no_declaration); re-verify the latched patch while its payload is over it (or released onto it), else refuse a dispatch hint in another frame than the grid (frame_mismatch, before measuring), then measure under the carried payload, applying the hint (outside_hint).
  • class PlaceTargetLeg(node, bridge, config) (L931) — ROS wiring; reuses the bridge's tf2 buffer, legs and publisher. ROSConfigError on a non-positive rate / freeze / depth / extrinsic bound, or a freeze past 2 * grid_max_age_s.
  • setup() -> None (L999) — The declaration subscription + the measurement timer (the grid is the bridge's shared one).
  • teardown() -> None (L1032) — Idempotent.
  • fill(msg, *, now_ns) -> None (L1044) — Sets place_declaration_valid / place_declaration on one AttachmentState.
  • decorate(objects) -> list[AttachedCollisionObject] (L1051) — PlaceTargetTracker.decorate.
  • on_joint_state() -> None (L1056) — Witness re-evaluation (throttled to 20 Hz); republishes on a change.

Node wiring (ManifestHALLifecycleNode): vision_attachment_* params (enabled defaults to False; vision_attachment_tf_frames takes "link=frame" strings; no trigger thresholds are params — they are the manifest's closure_calibration; _on_safe_action feeds the HAL's applied command to observe_command (_fold_applied_command, which clears it via clear_command when a committed group's command is unknown); the trigger windows are PositionStallConfig.for_rate of the node's resolved joint-state rate (publish_rate_hz, else the manifest's control_rate_hz), with vision_attachment_trigger_max_gap_s (default 0.0 = derived) as an explicit gap override; vision_attachment_grasp_target_support_search_below_m / _support_probe_margin_m / _occluder_margin_m (defaults 0.15 / 0.05 / 0.05 m) reach GraspTargetLeg through the matching VisionAttachmentConfig fields; vision_attachment_grid_max_age_s / _release_clear_m default 0.0 = unset and are REQUIRED with the leg on — the kernel's voxel deadline and margin + resolution, passed by deploy_e2e.launch.py; unset is a ROSConfigError naming the param at activate, no cell's values as fallback); built in _setup_vision_attachment on activate, torn down on deactivate/cleanup. _attachment_barrier_holders() / _attachment_perception_ready() require every present holder (sim bridge + vision bridge) to clear before a tick is acknowledged. _publish_action_applied_if_complete re-checks readiness right after storing a deferred tick (_on_attachment_perception_ready), so a holder settling on another thread between the check and the store cannot strand it (a lost wakeup); _publish_action_applied_tick's check-and-publish runs under _ack_publish_lock (calls no holder), so that re-check and a holder's notify ack a tick once.