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
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.
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 canconnect(), checkget_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 withset_control_mode()(position/velocity/force). The reconnect machinery targets the impedance workflow — a session that died in another control mode is asked todisable()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, thenenable(). - 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, thendisable()andenable(). enable()also refuses (raisingMotorError) 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); runaxol motor.set-zero-pos --guidedto (re)zero. Seemotor.set-zero-pos.disconnect()closes the buses without touching torque, so the robot keeps holding and a later process can reconnect. Usedisable()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.
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:
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:
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:
gravity-comp CLI command to hold the arms in gravity-compensation mode interactively.