Skip to main content
Axol controls both arms. On entry it opens the CAN bus of each arm, enables all 16 motors, and calibrates each gripper’s open stop. While Axol is enabled, the 240 Hz control loop runs in the Rust realtime core (axol-rt), which owns the CAN buses. Python computes the model terms and streams motion_control targets to the core. Sim has the same interface and renders the robot in a browser with viser. It requires the sim extra.

Axol

Pass left_channel=None or right_channel=None to run one arm only. Both arms start up in parallel in __aenter__. For a partial bench arm, pass left_joints or right_joints with the motors that are on its bus, for example {Joint.WRIST_2, Joint.WRIST_3, Joint.GRIPPER} for a wrist assembly. Missing joints are never created or enabled. Every (8,) array keeps its full shape: missing joints read 0.0, and commands to them are ignored. Every CLI flow uses this class, including axol teleop, axol gravity-comp, axol waypoints and the control panel. Code written against Axol runs the same controller the robot ships with.

Shared settings

Axol() with no arguments runs with the same configuration as the control panel and axol teleop. It builds config from the shared settings file, ~/.almond/settings.json, on top of the calibrated defaults. The CAN channels come from robot.left_channel and robot.right_channel in the same file. The file’s axol section has the same fields and nesting as AxolConfig. Stiffness, per-joint gains, link masses and centres of mass, gripper limits and has_gripper saved there apply to SDK sessions too. For example, SDK gravity compensation uses the same gravity model as the panel. shared_axol_config(load_store()) returns that AxolConfig, and the Settings File page shows the layout. Every argument overrides its setting:
  • Axol(config=AxolConfig()) runs the calibrated defaults and ignores the file’s axol section.
  • Axol(right_channel=None) disables the right arm whatever the file says.
The same rule applies to Mantis (mantis.left_channel, mantis.right_channel), Jelly (jelly.*) and VRTeleop (teleop.*, kinematics.*, vr_server.*). The helpers are in almond_axol.settings:

Lifecycle methods

enable() gives cold joints the full bring-up, including the gripper calibration. Then:
  • With hold=True (the default), the realtime core takes the buses and the arms actively hold their measured pose, with gravity feedforward and damping.
  • With hold=False, newly enabled joints stay enabled but limp, and Python keeps the buses with no core running. Use this when your code manages control modes itself.
Damiao motors switch control modes with a register write and don’t reboot.
Who owns the bus. The realtime core owns the CAN buses from enable() until disable() or disconnect(). During that time:
  • get_positions(), get_velocities(), get_torques(), left.positions, left.torques and torque_residuals() keep working. They read caches that the core’s telemetry fills, and send nothing on the bus.
  • Register-level calls raise MotorError: connect(), get_temperatures(), get_holding(), get_gains(), clear_errors(), set_control_mode() and every set_* method that writes to the motors except motion_control. Call them before enable() or after disable().
  • motion_control() and gravity_compensate() work only while the robot is enabled.

Reconnecting to a live robot

If a controlling process exits or crashes while the robot is torqued on, the motors keep holding their last commanded pose. Your startup code doesn’t need to know whether that happened, because enable() checks each motor:
  • A joint that is already holding is attached with reads only and is never reset. A reset reboots a MyActuator motor and drops the arm for about 2 s.
  • A holding gripper keeps its grasp. Its end-stop calibration is restored from the last full bring-up instead of running the sweep again, which would close and open the jaws.
  • A cold joint gets the full bring-up.
  • On a mixed robot, for example one whose last session died during enable(), only the cold joints are brought up.
  • With hold=True (the default), enable() ends by commanding the measured pose once, with the configured gains and gravity feedforward. The arm is already there, so nothing moves. An enabled robot is always actively holding.
Notes:
  • connect() is optional, because enable() opens the buses itself. Call it to inspect state first (get_holding(), positions, temperatures). A process that must never move the robot can call connect(), check get_holding(), and continue only when every joint is already holding.
  • For other control modes, call enable(hold=False), pick a mode with set_control_mode(), and drive the arms with set_positions_velocity() or set_velocity(). No core runs in this case, so motion_control() is unavailable until a later enable().
  • Reconnecting works for the impedance workflow only. A session that died in another control mode is not reattached: call disable(), then enable().
  • To force a fresh bring-up of a live robot (gripper calibration, motor reset), call disable() and then enable().
  • enable() raises MotorError if a gripper is holding but has no valid saved calibration. Measuring it again would close and open the jaws and drop what it holds. Empty the gripper, then call disable() and enable().
  • enable() also raises MotorError if an arm joint’s encoder reading doesn’t fit a zero set at its calibration end stop. This means the zero was never set or is stale. The check runs before anything moves, and connect() and the read APIs still work. Run axol motor.set-zero-pos --guided to set the zero (see motor.set-zero-pos).
  • disconnect() closes the buses and leaves torque on, so a later process can reconnect. Call disable() only when you want to turn torque off, with the arms in a safe pose.

Faults and limp

After a fault, disable() leaves the motors holding. While the core is limp, motion_control streams gravity compensation instead of tracking, so the operator can guide the arms to rest by hand and stop. Faults never drop the arms. A disabled arm would fall, so a holding or limp arm waits for the operator instead.

State reads

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

State writes

The gripper target in motion_control is in [0, 1] over the calibrated stroke, so 1 is always the open stop found at enable. Python computes the limits, gravity and pose-dependent feedforward terms, and the core runs the trajectory at 240 Hz. Individual arms are accessible via axol.left and axol.right (AxolArm), which expose the same methods operating on a single arm.
The Mantis rig (almond_axol.robot.Mantis) works the same way: Mantis(config, left_channel, right_channel, *, defer_gripper_enable), with the same lifecycle. motion_control sends the gripper element of each target to the hardware.

Jelly

Jelly controls the Jelly mobile base and its telescoping lift. JellyConfig holds the wheel, lift, heading-hold and command-safety settings. detect_jelly narrows a config to the Jelly hardware that is attached: the wheel bus can_alm_axol_b and the lift bus. It returns None when neither is attached. axol teleop and data collection detect Jelly the same way, so you never enable it by hand.

Battery

Jelly estimates its charge from the 24 V rail that its lift controller measures (the GET_POWER telemetry). The estimate uses the resting voltage curve of its two 24 V 50 Ah LiFePO4 packs, wired in parallel (8S, 100 Ah). While Jelly is enabled with its lift, jelly.battery updates about once a second. It is None:
  • before the first reading
  • when no pack is connected and the board runs from USB
  • on firmware that doesn’t answer GET_POWER
read_battery() takes a single reading without enabling anything. It never sends a motion command.
jelly.battery and read_battery() return a BatteryStatus: battery.remaining_ah converts percent into amp-hours of the 100 Ah capacity. While charging is True, percent reads near full and doesn’t reflect the charge. The curve is EVE’s published resting voltage chart per cell (3.40 V full, 3.26 V half, 3.20 V at 20%), times eight for the 8S packs. LiFePO4 voltage barely changes in the middle of the charge, about 0.16 V across the pack from 40% to 60%, so treat percent as a range. The board’s ADC can also be off by a few hundred millivolts.

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. axol teleop --sim instead wraps a Sim in the full teleop stack, and with no headset connected the arms hold the rest pose.To drive them without a headset, for example in a test, stream VRFrame messages to the teleop VR WebSocket at wss://localhost:8000/ws. Set both l_lock and r_lock to true to engage tracking. The certificate is self-signed, so turn off TLS verification.

Configuration: AxolConfig, ArmConfig, JointConfig

Each arm joint has one JointConfig with its impedance gains, friction model, and the mass properties of the link it drives:
To build a custom arm, use dataclasses.replace on the AxolConfig defaults. Starting from the defaults keeps each side’s friction values, which are added when the config is built:

JointConfig fields

Gravity feedforward is computed from the URDF with each joint’s mass and com (see Gravity compensation).

Host damping

The motor firmware’s velocity estimate is too filtered to damp the shoulders’ closed-loop resonance near 2 Hz, because the shoulders carry a lot of inertia. kd_host adds that damping on the host, from velocity differentiated from measured positions at the command rate. The elbow uses a small amount for the same reason. The wrists and shoulder_3 use 0, because their firmware kd is enough. Raise kd_host with care. Host damping uses a velocity that is one cycle old, so it destabilizes joints that ring above about 10 Hz. A kd above the firmware’s 0 to 5 range is clamped and is never moved into host damping. The band-pass keeps the damping on the joint’s resonance. With kd_host_hz at None, the centre follows the shoulders’ impedance mode √(kp/J(q)), which moves from about 2.2 Hz at rest to 5.4 Hz with the arm raised to the side. Set an explicit centre for structural modes that this can’t predict. The default kd_host_q of 0.8 is about an octave wide on each side, which suits a centre that follows the pose. When the centre sits low, that band reaches into intended motion below 1.5 Hz and slows the joint’s final approach. When you set kd_host_hz to a measured ring frequency, a q of 2 to 3 keeps the damping on the ring.

Torque limit

The realtime core enforces torque_limit every tick. It never sends a position more than torque_limit / kp from the last measured one, so kp · error stays bounded however far the target runs ahead of a blocked joint. At the wrists’ kp=130, 5 Nm is a window of about 2.2°. Gravity feedforward is not capped, because holding the arm’s own weight isn’t pressing. The friction, inertia and host-damping feedforwards follow the capped command, so they fall to zero while the joint is blocked. Every joint defaults to inf, which leaves it uncapped. Box mode caps the wrists for its session instead (VRTeleopConfig.box_wrist_torque, 5 Nm). To cap one joint from the CLI, pass --axol.left.wrist_2.torque_limit 2.0.

Grippers

ArmConfig.gripper is a PositionForceConfig with torque_limit (Nm) and max_speed (rad/s). The gripper joint is fixed in the model, so the gripper’s mass is included in wrist_3.mass. AxolConfig.gripper (below) sets which gripper is fitted: "parallel", the stock two-finger gripper, or "parcel", the hinged-plate parcel gripper. It sets which way each jaw’s motor turns to close (gripper_close_direction(gripper, is_left)). Both parallel jaws close toward positive motor angles. The parcel gripper’s right motor is mounted the other way round, so it closes toward negative angles. The gripper’s stroke is measured, not fixed. On every cold enable(), each jaw closes until it stalls on the closed stop, then opens until it stalls on the open stop, so it ends open. The two stops define 0 (closed) and 1 (open) for that session and are saved for reconnects. The measured stroke is then checked against the configured gripper: about 290° for the parallel gripper and 185° for the parcel gripper. If the stroke is closer to the other type’s, the bring-up fails with a MotorError that names the right setting. A wrong type would leave the parcel gripper’s right jaw closed after calibration.

AxolConfig fields

AxolConfig also exposes top-level parameters: Notes:
  • With has_gripper=False, the array shapes don’t change, and LeRobot datasets record 7 channels per arm instead of 8.
  • Set the gripper type with --axol.gripper parcel on the CLI, or with the Gripper setting on the control panel’s Robot tab.
  • At 30 Hz, a max_step_rad of 0.5 is about 15 rad/s, roughly 2.5 times the teleop speed limit.

Stiffness

A 7-tuple lists the joints in the order shoulder_1, shoulder_2, shoulder_3, elbow, wrist_1, wrist_2, wrist_3. The gripper isn’t included. Values below 1.0 only add compliance. At 0 the arm runs the soft gains, for example kp=40 on shoulder_1 against kp=250 tuned. kp and kd blend geometrically (in log space), which matches how stiffness feels. The soft end keeps a consistent damping ratio, so every setting is as well damped as the tuned gains. j_eff doesn’t change, and kd_host scales with √(kp(s)/kp). AxolConfig.resolved() applies the blend to the left and right gains once, when Axol is created. The stiffness fields keep the values you set, so a saved config loads back unchanged.
Both arms share the ArmConfig defaults for gains and masses. The right arm’s centres of mass are mirrored across X by ArmConfig.mirror_to_right(). Friction is measured separately for each arm, because the left and right motors differ (see _LEFT_FRICTION and _RIGHT_FRICTION in almond_axol/robot/config.py). Pass an explicit left= or right= to override either side. shoulder_1 sets kd_host_q=3 on both arms. This keeps its damper on the impedance mode and off a separate 12.5 to 13.6 Hz mode of the mast and forearm. Other joints leave kd_host_q at None, which uses 0.8.

Gravity compensation

almond_axol.robot.gravity.GravityCompensator builds a MuJoCo model from the bundled URDF. It computes each joint’s gravity torque as qfrc_bias with zero velocity, so the Coriolis terms drop out. Each joint’s load includes the weight of every link after it in the chain. Link masses don’t come from the URDF file, whose exported values are placeholders. They come from each joint’s JointConfig.mass and JointConfig.com in almond_axol/robot/config.py. The centres of mass come from CAD, and the masses are tuned against measured joint torques. If the arms sag or push back in gravity-comp mode, tune the joint’s mass and com on AxolConfig and pass it to Axol:
To hold the arms in gravity compensation interactively, use the gravity-comp command. The same model also provides the arm’s kinematics. GravityCompensator.mount_jacobian(arm_q, is_left=...) returns the pose of the gripper mount and its (6, 7) geometric Jacobian at any joint configuration. The gripper mount is the URDF gripper link, the frame the IK solver targets.

Payloads

The link masses describe the robot itself. Add whatever a gripper picks up at runtime with set_payload. The arm then holds its commanded pose instead of sagging, hand-guiding in gravity comp stays weightless, and torque_residuals() doesn’t read the load as a contact:
The payload is a point mass fixed to the gripper:
  • com is in metres, in the gripper link frame that forward kinematics reports. GRIPPER_TIP_OFFSET is where the fingers close.
  • Use "both" for one object carried by both grippers. Each arm takes its share of the mass.
  • The feedforward changes on the next control cycle. Set the payload when the grasp closes and clear it when it opens, because a payload the gripper isn’t carrying lifts the arm slightly.
  • Masses above 5 kg and centres of mass more than 0.5 m from the gripper are refused as likely unit mistakes.
  • The payload isn’t saved. For a permanently heavier end-effector, update the calibration with tune.factory --mass wrist_3=<kg>.
  • Sim.set_payload checks the same arguments and changes nothing, so a script runs the same way in sim.

Squeeze lean (box mode)

The force control of box mode runs in the IK worker, not on the robot (almond_axol.teleop.box.squeeze_lean, see box_squeeze_lean). The robot provides the model it needs:
  • GravityCompensator.mount_jacobian, described above.
  • AxolArm.kp, each arm’s impedance stiffness per joint (Nm/rad), after the stiffness blend.
From these the worker gets the arm’s Cartesian compliance at the mount, J diag(1/kp) J^T. Before every box-mode frame, teleop passes both arms’ measured positions and kp to the worker. The worker offsets the gripper targets so that jogging the grippers past the box becomes an even squeeze through the tool’s contact points. The arms stay position controlled, and the realtime core receives ordinary targets.