SO-ARM100 (and its SO-ARM101 refresh) is an
open-source 6-DOF manipulator that anchors a growing community of full-stack robotics work: teleoperation,
imitation learning, and reinforcement learning, all on hardware you can 3D-print yourself.
soarm-ws is my ongoing, modular workspace for that stack — a thin umbrella repository over
six independently-versioned packages, each its own GitHub remote tracked as a git submodule, with no
shared build system forcing them into lockstep. That structure is deliberate: hardware transport (servo
and IMU SDKs), the teleoperation loop, dataset recording, camera tooling, and simulation/RL training all
evolve at different speeds, and keeping them decoupled means a change in one doesn't force a release of
the others. This page tracks the workspace as it stands today, and will keep changing as the packages do.
imu_sdk and the vendored SO-ARM100 robot description feed into
m5teleop, the integration point that runs the real-time teleoperation loop — the five-stage
pipeline broken down step by step below. Recording is opt-in: pass --record and
m5teleop hands frames to soarm_lerobot, which buffers episodes into a
LeRobotDataset for ACT / Diffusion Policy training — with no import-level dependency back the other way.
soarm_sdk and camera_calibration are standalone: the former is a parallel,
self-contained Feetech servo SDK (used by its own calibration/dashboard tools, not currently wired into
the teleop loop), the latter a Rerun-based camera intrinsics/ArUco toolkit with no shared dependencies at
all. soarm_mjlab is the newest branch — RL training of SO-ARM100 in MuJoCo via
mjlab, still in progress, planned to deploy trained
policies through the same soarm_sdk.RobotInterface that drives real hardware.
Everything below runs inside m5teleop's teleop.py main loop, on a single
thread, at a fixed --hz (default 50 Hz, dt = 20 ms). There is no
message bus between stages — each tick reads the IMU queue, runs all five stages in order, and writes a
servo command before the next tick starts. That single-thread design is deliberate: it keeps tick-to-tick
jitter low and makes the whole loop traceable from one file.
| Stage | Module | Rate | Produces |
|---|---|---|---|
| 1. IMU sample | imu_sdk | ~100 Hz | raw accel (g) + gyro (°/s) |
| 2. ESKF attitude | m5teleop/imu_ekf.py | 50 Hz | quaternion q_imu, gyro bias |
| 3. Orientation controller | m5teleop/orient_controller.py | 50 Hz | 6-D twist ω |
| 4. Differential IK | m5teleop/ik_solver.py (pink + pinocchio) | 50 Hz | joint configuration q |
| 5. Servo dispatch | lerobot SO100Follower | 50 Hz | per-joint degree commands |
An M5StickC/ESP32 board (MPU6050/6500/6886/9250) streams JSON accelerometer + gyroscope readings over
serial at roughly 100 Hz, with gyro bias calibration done on-device at boot. A reader thread parses
each line into an ImuData record and pushes it onto a queue; the 50 Hz control loop drains
it non-blocking, so a late or dropped IMU frame never stalls a tick — it just falls back to whatever
the EKF last held.
imu: ImuData | None = None
try:
imu = imu_queue.get_nowait()
except queue.Empty:
pass # no new sample this tick — EKF simply isn't updated
if imu is not None:
ekf.step(imu.ax, imu.ay, imu.az, imu.gx, imu.gy, imu.gz, dt)
A raw gyro integral drifts, and a raw accelerometer is noisy and only observes gravity's direction —
never yaw. The ESKF fixes both: it tracks a nominal state (unit quaternion
q̄, gyro bias b̄) with a separate small-angle error state
δx = [δθ, δb] ∈ ℝ⁶ and its 6×6 covariance P. Every tick is predict-then-update:
A third update — ZARU (zero angular-rate update) — fires whenever the raw gyro norm
drops below a threshold: at rest, ω_meas ≈ bias directly, which is what pins down the
yaw-axis bias that gravity alone can never observe (accelerometer updates are blind to rotation about
the gravity vector). All six noise/gate constants are tuned offline by tune_ekf.py
against logged sessions rather than hand-picked.
def step(self, ax, ay, az, gx_dps, gy_dps, gz_dps, dt):
omega = np.deg2rad([gx_dps, gy_dps, gz_dps]) - self._bias
self._predict(omega, dt)
self._update(np.array([ax, ay, az])) # skipped under shock/free-fall
self._update_zaru(np.deg2rad([gx_dps, gy_dps, gz_dps]))
return self._q.copy() # q_imu, [w, x, y, z]
Pressing the IMU's button A calls zero_reset(), which memorises the current
q_imu_ref and q_ee_ref — the mapping is coordinate-frame agnostic, so
whatever way you're holding the IMU at that instant becomes "zero." From then on the target EE
orientation is the EE reference rotated by however much the IMU has rotated since reset:
Two proportional loops turn that target into a joint-space-agnostic 6-D twist. The outer loop converts orientation error to an angular-velocity setpoint using a rotation vector (axis × angle) rather than Euler angles — it has no gimbal lock and stays well-defined for any error up to 180°:
The inner loop matters because the outer loop alone only reacts to position error — it has no way to know the arm is already moving toward the target. Estimating the EE's actual angular velocity from consecutive forward-kinematics rotations and feeding that back damps overshoot without needing a second sensor.
if teleop_active and imu is not None:
q_imu = ekf.quaternion
q_ee = _get_ee_quaternion() # from IK solver's current FK
twist, orient_err = orient_ctrl.compute_twist(q_imu, q_ee, dt)
else:
twist = zero_twist # teleop off → arm holds position
The twist is first integrated into a target end-effector pose, then solved as a weighted least-squares
QP over joint velocities — pink's standard formulation, with two tasks:
FrameTask pulls the EE toward target_se3 (position cost 1.0, orientation cost
0.5); PostureTask (cost 1e-3) regularises the null space toward a neutral posture — with
only 5 revolute joints solving a 6-D pose task, the arm is at its kinematic limit, and this term is what
picks one specific solution instead of leaving the system under-constrained. The QP itself is solved
with quadprog.
def step(self, twist_world, dt):
current_ee = self.configuration.get_transform_frame_to_world(self._ee_frame)
new_translation = current_ee.translation + twist_world[:3] * dt
dR = pin.AngleAxis(norm(twist_world[3:] * dt), axis).toRotationMatrix()
self._ee_task.set_target(pin.SE3(dR @ current_ee.rotation, new_translation))
velocity = pink.solve_ik(self.configuration, [self._ee_task, self._posture_task],
dt, solver="quadprog")
self.configuration.integrate_inplace(velocity, dt)
return np.array(self.configuration.q) # joint radians
The five revolute joints convert radians → degrees and go out over the servo bus through lerobot's
SO100Follower; the gripper is driven separately by a toggle bound to button B, not through
IK. Every stage's intermediate state — raw IMU, EKF quaternion and bias, orientation error, twist, EE
pose, joint configuration — is logged to Rerun on the same tick, so a session can be replayed and
inspected stage-by-stage after the fact. Passing --record adds one more branch: frames are
handed to soarm_lerobot's TeleopRecorder, which detects episode boundaries and
writes LeRobotDataset episodes for later ACT / Diffusion Policy training.
deg_dict = ik.q_to_degrees(q_current) # 5 joints, radians → degrees
arm.send_joint_degrees(deg_dict)
if viz is not None and imu is not None:
viz.log_all_with_tracking(imu=imu, twist=twist, q=q_current,
ee_translation=ee_pose.translation,
ee_rotation=ee_pose.rotation,
ekf_euler=ekf.euler_deg, ekf_bias=ekf.bias_dps,
orient_err=orient_err, ...)
Teleoperation above is reactive and human-driven: a person supplies the target every tick, a
cascade PID tracks it. soarm_mjlab is this workspace's second way of moving the arm —
reactive but learned: a neural network policy, trained by trial and error in MuJoCo, maps
observations straight to joint targets with no explicit IK or controller in the loop at inference time.
The third way — deliberative, plan-then-execute — is covered in the next section.
| Pipeline | Decision maker | Computed | Status |
|---|---|---|---|
| Teleoperation | human + cascade PID | every tick, 50 Hz | active |
| RL policy (this section) | PPO-trained network | every tick, sim @ training time | in progress |
| Classical planning (next section) | RRT + trajectory optimizer | once per goal, offline | planned |
soarm_mjlab follows the manager-based RL config pattern shared by Isaac Lab and
unitree_rl_mjlab: a task is assembled from independent
observation/action/reward/termination/event terms, each a small typed function, rather than one
monolithic environment class. The whole loop below runs inside MuJoCo via
mjlab, trains with
RSL-RL's PPO implementation, and — once a
checkpoint clears validation — is meant to deploy through the same
soarm_sdk.RobotInterface that m5teleop already drives real hardware with.
The Reach task's target isn't a hand-picked box — it's derived from the robot itself. A throwaway MuJoCo model is compiled once, 20,000 random joint configurations are sampled within the arm's hard limits, forward kinematics gives the end-effector position for each, and after discarding near-ground samples the 10th–90th percentile box per axis becomes the command-sampling range: a reliably reachable region instead of the full, mostly-unreachable-in-practice workspace envelope. Every episode reset also applies domain randomization — joint positions offset ±0.1 rad from default, zero initial velocity — so the policy never sees the exact same start state twice.
samples = rng.uniform(joint_ranges[:, 0], joint_ranges[:, 1], size=(20_000, ndof))
for i in range(20_000):
data.qpos[:] = samples[i]
mujoco.mj_kinematics(model, data)
positions[i] = data.site_xpos[ee_site_id]
positions = positions[positions[:, 2] > min_ground_clearance]
lo, hi = np.percentile(positions, [10.0, 90.0], axis=0) # → per-axis command range
Every simulation step, five managers run in order: observations are assembled, the action term applies the policy's output, physics steps, rewards are summed from independent terms, and terminations are checked. Observations come in two flavors — an actor group with injected uniform noise (what the policy trains and infers on, since real sensors are noisy) and a clean critic group (privileged, noise-free — the value function can use ground truth because it never runs at deployment):
| Observation | Noise (actor) |
|---|---|
joint_pos_rel, joint_vel_rel | ±0.01 rad, ±1.5 rad/s uniform |
target_pose (commanded EE pose) | none |
ee_pose_error | ±0.01 uniform |
last_action | none |
The action term is a joint-position offset around each joint's default, and the reward is
deliberately the smallest set that produces a non-degenerate policy — orientation error is observed but
not yet scored (orientation_weight=0.0, raised only once orientation targets matter):
def distance_to_target(env, command_name, asset_cfg):
ee_pos_b, _ = subtract_frame_transforms(root_pos_w, root_quat_w, ee_pos_w, ee_quat_w)
position_error = torch.norm(command[:, :3] - ee_pos_b, dim=-1)
return -position_error # reward is negative error, orientation_weight=0.0
After collecting 24 steps of rollout per parallel environment, RSL-RL computes generalized advantage estimates and updates a 3-layer (128, 128, 64) ELU actor-critic with the standard PPO clipped-surrogate objective, 5 epochs over 4 minibatches per update:
algorithm=RslRlPpoAlgorithmCfg(
clip_param=0.2, entropy_coef=0.005, gamma=0.99, lam=0.95,
num_learning_epochs=5, num_mini_batches=4, learning_rate=1.0e-3,
schedule="adaptive", desired_kl=0.01, max_grad_norm=1.0,
)
# actor/critic: hidden_dims=(128, 128, 64), activation="elu", obs_normalization=True
The learning rate adapts to keep the policy's KL divergence per update near desired_kl
rather than using a fixed schedule. CI's own training smoke tests run CPU-only (2 iterations, no GPU
in the loop); a real run — thousands of parallel envs, full iteration count — targets a rented GPU
instead, since none of this workspace's own dev machines has one. Weights & Biases tracking is
on by default (reward curve, all episode termination/reward/metric scalars); see the
vast.ai
training guide for instance sizing and the full walkthrough.
| Layer | Checks | Runs where |
|---|---|---|
| Unit tests | reward/observation/termination math, synthetic tensors, no MuJoCo | CI, every push |
| Config/asset validation | MJCF compiles, actuator regexes match joints, obs/action dims correct | CI, every push |
| Env smoke test | reset() + a few step()s, no NaN/Inf | CI, every push |
| Training smoke test | full RSL-RL wrapper → PPO update path, 2 iterations, CPU | CI, every push |
| Full training run | real hyperparameters, thousands of envs, tensorboard | manual / GPU runner |
| Sim-replay validation | checkpoint through the deploy code path, not the training wrapper | manual |
| Real-hardware validation | torque-limited dry run → supervised run → unattended soak test | manual, physical arm |
A checkpoint is promoted to real hardware only after clearing a numeric bar decided before the run
(e.g. ≥90% task_success over 100 held-out seeds) — set in advance so the goalposts can't
move to match whatever the run happened to produce.
The deployment script is designed to be trivial by construction: it loads a promoted checkpoint and
calls the same RobotInterface protocol regardless of whether the concrete implementation
underneath is a MuJoCo SimRobotInterface or soarm_sdk.ServoRobot talking to
the real bus — one code path, no train/deploy consistency problem to solve because there's only one
implementation. Unlike a legged robot's balance controller, a 6-DOF arm's ~50 Hz loop already runs
comfortably in Python, so there's deliberately no C++ deployment stack to maintain in parallel.
Promotion already has a publish step ready ahead of this script:
scripts/push_to_hub.py pushes a promoted checkpoint (ONNX export, resolved configs,
a generated model card with training provenance) to a Hugging Face Hub model repo, so this
deployment script — or anyone else — can load one without needing access to the original
training run.
The third way of moving the arm is deliberative rather than reactive: given a goal, compute a full collision-free, time-optimal trajectory once, then track it open-loop with the simplest controller that can follow it — no human operator after the goal is set, no learned policy, no MuJoCo/GPU training involved at all. It's the natural fit for repeatable point-to-point moves — homing to a fixed pose, waypoint playback, or a physically-grounded baseline to sanity-check the RL policy's learned trajectories against on the same targets. Nothing in this section is implemented yet — soarm-ws has no roboplan dependency today; this is the design it's written against, using status tags the same way the Packages section below does per package.
A goal is either a target joint configuration directly, or a Cartesian end-effector pose resolved to
one via roboplan's IK solver — the same so100.urdf pinocchio model
m5teleop/ik_solver.py already loads for real-time differential IK, just consumed by a
different, offline solve rather than a 50 Hz velocity-level QP.
An RRT grows a tree from the start configuration, at each step sampling a random joint configuration,
steering the nearest tree node toward it by a bounded step, and keeping the new node only if the edge
is collision-free against roboplan's pinocchio-backed geometry model (self-collision and
environment). The result is a valid but geometrically jagged sequence of waypoints — path existence,
not path quality:
The RRT path is a geometric curve with no notion of time. TOPP-RA parameterizes it by arc length
s ∈ [0, 1] and solves for the fastest s(t) that keeps every joint's velocity
and acceleration within limits along the whole path — a minimum-time trajectory on a fixed geometric
path, not a hand-tuned trapezoidal velocity profile:
Execution is deliberately the least sophisticated controller that can follow a pre-computed
trajectory — the same "smallest thing that works" philosophy already applied to the RL reward:
stream q_des(t) to each STS3215 servo's own onboard position PID (the same mechanism
soarm_sdk's dashboard already uses for homing), optionally adding a joint-space
proportional term with velocity feedforward where the bus protocol supports it:
No Jacobian, no orientation controller, no learned dynamics model — the trajectory already encodes everything about the motion; the controller's only job is to not fall too far behind it.
Python SDK for the Feetech STS/SCS serial bus servo protocol — port handling, packet framing, batch calibration planning (ID reassignment, limits, torque, baud rate), and the tick↔radian conversion layer everything else builds on. Ships a 7-tab Viser browser dashboard with live 3-D FK for homing, PID tuning, and monitoring.
Transport + firmware for M5StickC/ESP32 IMU boards (MPU6050/6500/6886/9250, 6- or 9-axis). Firmware
streams JSON over serial at ~100 Hz with on-device gyro bias calibration; orientation itself is computed
host-side by m5teleop's error-state Kalman filter.
The integration point: a 50 Hz real-time loop wiring IMU → ESKF → cascade orientation controller → IK
(pink/pinocchio) → the arm over lerobot's SO100Follower, with a parallel Viser sim and full
Rerun logging. --record optionally streams frames into soarm_lerobot.
Dataset recording and imitation-learning training. TeleopRecorder detects episode
boundaries and writes LeRobotDataset episodes; dataset.py loads them into normalized,
chunked torch datasets for ACT and Diffusion Policy. Operates purely on recorded data — decoupled from
how it was captured.
Standalone camera intrinsics and ArUco tooling: chessboard calibration, live detection monitor, marker
generation, webcam parameter estimation — a unified CLI, Rerun for all visualization (no
cv2.imshow, works headless). Not wired into the teleop pipeline; built for future eye-in-hand
and workspace-calibration work.
RL training of SO-ARM100 in MuJoCo via mjlab, targeting
deployment through soarm_sdk.RobotInterface — the same interface real hardware uses, so a
trained policy won't care whether it's driving sim or the physical arm. The "Reach" task end to end, a
CI-safe test pyramid (32 tests), and two-job CI/CD are done (Phases 0–3). Tooling for a real training run
is ready (Phase 4): a step-by-step rented-GPU
guide with Weights & Biases tracking, and scripts/push_to_hub.py to publish a
promoted checkpoint to the Hugging Face Hub — the run itself and sim2real deployment (Phase 5) are still
ahead. See the
roadmap.
The hardware vendor's repo (TheRobotStudio),
tracked as-is: URDF/MJCF robot descriptions consumed by soarm_sdk's dashboard and
m5teleop's IK solver, plus CAD, 3D-print notes, and BOM/assembly docs for the physical arm.
| Component | Notes |
|---|---|
| FEETECH STS3215 servos | Follower arm: six identical servos (7.4 V/20 Nm or 12 V/30 Nm). Leader arm gears: 3× 1/147 (C046), 2× 1/191 (C044), 1× 1/345 (C001). |
| Servo bus adapter | Waveshare Serial Bus Servo Driver Board with USB-UART bridge. |
| Debug tools | Official FEETECH Windows software, FT_SCServo_Debug_Qt,
and soarm_sdk's own Viser dashboard. |
| Power | 7.4–12 V DC, sized for peak current (~6 A per arm). Match barrel jack polarity (5.5/2.1 mm or 5.5/2.5 mm). |
camera_calibration package.imu_sdk, mounted on the wrist for
inertial feedback in the teleop loop.soarm_mjlab's CUDA path).Two learning tracks are actively built in this workspace, both consuming the vendored
SO-ARM100/Simulation assets (URDF for FK, MJCF for physics):
soarm_lerobot): record teleop demonstrations
through m5teleop --record, then train ACT or Diffusion Policy models on the resulting
LeRobotDataset.soarm_mjlab, in progress): train in MuJoCo via
mjlab, following the manager-based config pattern from
unitree_rl_mjlab — no C++ deployment stack or
terrain/locomotion machinery, since a 6-DOF arm's control loop already runs comfortably in Python at
~50 Hz. Deploys through soarm_sdk.RobotInterface so trained policies drive sim and real
hardware identically.Beyond this workspace's own tracks, the broader SO-ARM ecosystem has useful options worth knowing about if you want to go further in a given direction: MoveIt/OMPL for classical motion planning, ros2_so_arm100 for a ROS2 description/control bring-up, IsaacSim/IsaacLab for GPU-accelerated, photorealistic RL at larger scale.
Clone the workspace with all submodules:
git clone --recurse-submodules https://github.com/thanhndv212/soarm-ws.git
cd soarm-ws
Each package installs independently — pip install -e . is the default:
cd soarm_sdk && pip install -e .
cd ../imu_sdk && pip install -e .
cd ../m5teleop && pip install -e .
cd ../soarm_lerobot && pip install -e .
cd ../camera_calibration && pip install -e .
soarm_mjlab is the one exception — it uses uv
because mjlab gates torch behind mutually-exclusive CPU/CUDA extras routed to
different package indices, which plain pip can't express:
cd soarm_mjlab
make sync-cpu # or: uv sync --extra cpu --group dev