Skip to main content
Hardware controller for both arms. Axol opens one SocketCAN bus per arm on entry, enables all 16 motors, and calibrates the gripper open-stop. Sim is a drop-in replacement that renders the robot in a browser using viser (requires the sim extra).

Axol

Pass left_channel=None or right_channel=None to operate a single arm. Both arms are brought up concurrently on __aenter__.

Lifecycle methods

Reconnecting to a live robot

If a controlling process dies (or disconnects) while the robot is torqued on, the motors keep holding their last commanded pose. Startup code does not need to know whether that happened: enable() queries each motor and converges —
  • joints that are already holding are attached to with reads only (never reset — a reset reboots MyActuator motors and drops the arm for ~2 s), and a holding gripper keeps its grasp: its open-stop calibration is restored from the values persisted by the last full bring-up instead of re-running the sweep that forces the jaws open;
  • cold joints get the classic full bring-up;
  • a mixed robot (e.g. the previous session died mid-enable) simply gets the cold joints brought up while the holding ones are left alone;
  • with hold=True (the default) enable() finishes by commanding the measured pose once (configured gains + gravity feedforward — the arm is already there, so nothing moves), so “enabled” always means actively holding.
Notes:
  • connect() is optional — enable() opens the buses itself. Call it when you want to inspect state (get_holding(), positions, temperatures) before acting. A process that must never actuate can connect(), check get_holding(), and only proceed when every joint is already holding.
  • Custom control modes: pass enable(hold=False) to leave freshly brought-up joints enabled but limp, then pick your mode with set_control_mode() (position/velocity/force). The reconnect machinery targets the impedance workflow — a session that died in another control mode is asked to disable() and re-enable rather than being reattached.
  • To force a fresh bring-up of a live robot (re-run gripper calibration, reset motors), call disable() first, then enable().
  • One corner intentionally raises MotorError: a gripper that is holding but has no valid persisted calibration — re-measuring would sweep the jaws open and drop whatever it grips. Empty the gripper, then disable() and enable().
  • enable() also refuses (raising MotorError) if any arm joint’s encoder reading is implausible for a zero at its calibration end stop — the zero was never set, or is stale, and bringing the robot up on garbage joint-frame values is unsafe. The gate runs before anything is actuated (connect() and the read APIs still work, for inspecting such a robot); run axol motor.set-zero-pos --guided to (re)zero. See motor.set-zero-pos.
  • disconnect() closes the buses without touching torque, so the robot keeps holding and a later process can reconnect. Use disable() only when you actually want to torque off (with the arms in a safe pose).

State reads

Each returns (left_array, right_array) where the absent arm is None.

State writes

Individual arms are accessible via axol.left and axol.right (AxolArm), which expose the same methods operating on a single arm.

Sim

Sim implements the same interface as Axol. Use it to visualise motion without hardware. Requires the sim extra.
Open http://localhost:8002 in a browser to view the robot.
Sim renders whatever you command with motion_control. Running axol teleop --sim instead wraps a Sim in the full teleop stack — with no headset connected the arms just hold the rest pose. To drive them without a headset (e.g. in a test), stream VRFrame messages to the teleop VR WebSocket at wss://localhost:8000/ws (self-signed cert — disable TLS verification) with both l_lock and r_lock set true to engage tracking.

Configuration — AxolConfig, ArmConfig, JointConfig

Each arm joint is configured with a single JointConfig carrying its impedance gains, friction-comp model, and the inertial of the body it drives:
Or build a fully custom arm with dataclasses.replace (start from the AxolConfig defaults so you keep the per-side friction values that get injected at construction):

JointConfig fields

Gravity feedforward is computed centrally from the URDF — see Gravity compensation — and uses the per-joint mass and com directly. ArmConfig.gripper is a PositionForceConfig with torque_limit (Nm) and max_speed (rad/s); the gripper’s mass is already lumped into wrist_3.mass (the gripper joint is fixed). AxolConfig also exposes top-level parameters:
Both arms share the same ArmConfig defaults for gains and masses; the right arm gets CoMs mirrored across X via ArmConfig.mirror_to_right(). Per-motor friction values are identified separately for each arm (left/right motors measurably differ) — see _LEFT_FRICTION / _RIGHT_FRICTION in almond_axol/robot/config.py. Pass an explicit left= / right= to override either side.

Gravity compensation

almond_axol.robot.gravity.GravityCompensator builds a MuJoCo model from the bundled URDF and computes per-joint gravity torques as qfrc_bias with qvel=0 (Coriolis terms vanish). Because the URDF is the full kinematic chain, each parent joint’s gravity load includes the contribution of every child link — this is the main improvement over the previous per-joint ga·cos(q) + gb·sin(q) model, which silently ignored child-link mass. Per-link masses are not taken from the bundled URDF — the Onshape exporter leaves placeholder sub-gram values that produce essentially zero gravity. Real per-link mass and CoM live on each JointConfig.mass / JointConfig.com in almond_axol/robot/config.py (CoMs come from the CAD inertial origins; masses are tuned in place against measured joint torques and are typically lower than the CAD values, since Onshape often over-assigns aluminum-class densities to parts that are hollow / 3D-printed). If the arms sag or push back in gravity-comp mode, tune the relevant joint’s mass and com on AxolConfig and pass it to Axol:
See the gravity-comp CLI command to hold the arms in gravity-compensation mode interactively.