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. 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

Pass 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 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) and drive with set_positions_velocity() / set_velocity(). The bus stays in Python and no core is started, so motion_control() is unavailable until a later enable(). 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).

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 (its GET_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.
Both return a 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.
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(). 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:
See the gravity-comp CLI command to hold the arms in gravity-compensation mode interactively.