Axol opens one SocketCAN bus per arm on entry, enables all 16 motors, and calibrates the gripper open-stop. Once enabled, the 240 Hz control loop runs in the Rust realtime core (axol-rt), which owns the CAN buses for the session; Python keeps the model math and streams motion_control targets to it. 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__. left_joints / right_joints restrict an arm to the motors actually on its bus, for a partial bench arm (e.g. {Joint.WRIST_2, Joint.WRIST_3, Joint.GRIPPER} for a wrist assembly); absent joints are never constructed or enabled, every (8,) array keeps its Joint-enum shape, and their elements read 0.0 with commands ignored.
This is the same object every CLI flow uses — axol teleop, axol gravity-comp, axol waypoints, the control panel — so code written against Axol exercises exactly the controller the robot ships with.
Shared settings
Axol() with no arguments is configured exactly as the control panel and axol teleop configure the robot: config is built from the robot’s shared settings file — ~/.almond/settings.json, the file the panel edits — over the calibrated defaults, and the CAN channels come from its robot.left_channel / robot.right_channel values. Stiffness, per-joint gains, link masses and centres of mass, gripper limits and has_gripper saved once therefore drive an SDK session too (so, for example, SDK gravity compensation runs the same gravity model as the panel’s). The file’s axol section is an AxolConfig (same field names, nested the same way), so what you read in settings.json is what Axol() runs with — shared_axol_config(load_store()) below returns that very object; see the Settings File page for the layout.
Every argument overrides its setting: Axol(config=AxolConfig()) runs the bare calibrated defaults, Axol(right_channel=None) disables the right arm regardless of the file. The same rule applies to Mantis (its channels come from mantis.left_channel / mantis.right_channel), Jelly (jelly.*), and VRTeleop (teleop.*, kinematics.*, vr_server.*).
The helpers behind this live in almond_axol.settings if you need them directly:
Lifecycle methods
Who owns the bus. Between
enable() and disable() / disconnect() the realtime core owns the CAN buses. get_positions(), get_velocities(), get_torques(), left.positions, left.torques, and torque_residuals() keep working — they read the caches the core’s telemetry fills, sending no CAN from Python. Register-level calls (connect(), get_temperatures(), get_holding(), get_gains(), clear_errors(), set_control_mode(), every set_* except motion_control) raise MotorError while enabled; use them on a connect()-ed robot before enable(), or after disable(). motion_control() and gravity_compensate() require an enabled robot.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 end-stop calibration is restored from the values persisted by the last full bring-up instead of re-running the sweep that closes and then opens the jaws;
- 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) and drive withset_positions_velocity()/set_velocity(). The bus stays in Python and no core is started, somotion_control()is unavailable until a laterenable(). 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 closed and 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).
Faults and limp
Faults never drop the arms: a disabled arm falls, a holding or limp arm waits for the operator.
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.
The Mantis rig (
almond_axol.robot.Mantis) has the same shape: Mantis(config, left_channel, right_channel, *, defer_gripper_enable), the same lifecycle, and the gripper slot of each motion_control target reaching the hardware.Jelly
Jelly controls the Jelly mobile base and telescoping lift. JellyConfig
contains its wheel, lift, heading-hold, and command-safety settings.
detect_jelly narrows a config to the Jelly hardware actually attached (the
wheel bus can_alm_axol_b, the lift bus) and returns None when there is
none — the same inference axol teleop and data collection use, so nothing
is ever enabled by hand.
Battery
Jelly’s charge is estimated from the 24 V rail that the jelly_legs lift controller measures (itsGET_POWER telemetry). The estimate uses the resting
LiFePO4 curve of the two 24 V 50 Ah packs wired in parallel (8S, 100 Ah).
While Jelly is enabled with its lift, jelly.battery updates about once a
second. It returns None before the first reading, with no pack connected (the
board is running from USB), or on firmware that doesn’t answer GET_POWER.
read_battery() takes a single reading without enabling anything. It never
sends a motion command.
BatteryStatus:
battery.remaining_ah converts percent into amp-hours of the 100 Ah capacity.
The curve is EVE’s published LiFePO4 resting-voltage chart per cell (3.40 V
full, 3.26 V half, 3.20 V at 20%), times eight for Jelly’s 8S packs. LiFePO4
voltage barely moves through the middle of its charge (about 0.16 V across the
pack from 40% to 60%), so treat percent as a band. The estimate also can’t be
better than the board’s ADC, which can 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.
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).
Which gripper is fitted is AxolConfig.gripper (below): "parallel", the stock two-finger gripper, or "parcel", the hinged-plate parcel gripper. It sets the sign of the motor rotation that closes each jaw (gripper_close_direction(gripper, is_left)): both parallel jaws close toward positive motor angles, while the parcel gripper’s right motor is mounted the other way round and closes toward negative ones.
The gripper’s stroke is not hardcoded. On every cold enable() it is calibrated against its own hardware: the jaw is swept closed (in its close direction) until it stalls on the closed stop, then swept back until it stalls on the open stop, so it finishes open. The two measured stops define the [0 = closed, 1 = open] normalisation for that session and are persisted for reconnects. The measured stroke is then checked against the configured type — about 290° for the parallel gripper, 185° for the parcel gripper — and a stroke nearer the other type’s fails the bring-up with a MotorError naming the right setting (a wrong type would leave the parcel gripper’s right jaw closed after calibration).
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(). The built production config sets shoulder_1.kd_host_q=3 symmetrically: it keeps the shoulder damper centred on its pose-tracked impedance mode while excluding the separate 12.5–13.6 Hz mast/forearm mode seen in hardware traces. Other joints with kd_host_q=None use the shared 0.8. 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.
The same model also serves the arm’s kinematics on the control side: GravityCompensator.mount_jacobian(arm_q, is_left=...) returns the gripper mount’s pose (the URDF gripper link — the frame the IK solver targets) and its (6, 7) geometric Jacobian at any joint configuration, from mj_kinematics alone.
Payloads
The link masses above describe the robot itself. Whatever a gripper picks up is added at runtime withset_payload, so the arm carrying it holds its commanded pose instead of sagging by unmodelled torque / kp, gravity-comp hand-guiding stays weightless, and torque_residuals() does not read the load as a contact:
com is in metres in the gripper link frame — the end-effector frame forward kinematics reports — and GRIPPER_TIP_OFFSET is where the fingers close. Use "both" for one object carried between the two grippers, giving each arm its share of the mass. The feedforward changes on the next control cycle, so call it when the grasp actually closes and clear it when it opens: a payload the gripper is not yet carrying lifts the arm slightly until it is. Masses above 5 kg (and CoMs more than 0.5 m from the gripper) are refused as unit slips. A payload is not persisted; a permanently heavier end-effector belongs in the calibration instead (tune.factory --mass wrist_3=<kg>). Sim.set_payload validates the same arguments and changes nothing, so a script runs identically in sim.
Squeeze lean (box mode)
The force side of box mode lives in the IK worker, not the robot (almond_axol.teleop.box.squeeze_lean; see box_squeeze_lean). What the robot contributes is the model: GravityCompensator.mount_jacobian above, and AxolArm.kp — each arm’s impedance stiffness per joint (Nm/rad, after the stiffness blend), which with the Jacobian gives the arm’s Cartesian compliance at the mount, J diag(1/kp) J^T. The teleop core reads both arms’ cached measured positions and kp before every box-mode frame (almond_axol.teleop.core.measured_arms) and forwards them to the worker, which offsets the gripper targets so the run-ahead the operator jogs becomes a pure force through the tool’s contact points. Nothing on the robot’s command path is touched: the arms are position controlled as ever, and the realtime core sees ordinary targets. (An earlier version reshaped every command on the robot from its measured pose; it worked on the bench and cost the pair its alignment on the robot, and is gone.)