soarm-ws: A Full-Stack Workspace for SO-ARM100 Manipulator Research

Thanh D. V. Nguyen

Overview

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.

Architecture

soarm-ws dependency graph

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.

Teleoperation control loop, step by step

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.

m5teleop control loop: IMU sampling, ESKF attitude estimation, cascade orientation controller, differential IK, servo dispatch

StageModuleRateProduces
1. IMU sampleimu_sdk~100 Hzraw accel (g) + gyro (°/s)
2. ESKF attitudem5teleop/imu_ekf.py50 Hzquaternion q_imu, gyro bias
3. Orientation controllerm5teleop/orient_controller.py50 Hz6-D twist ω
4. Differential IKm5teleop/ik_solver.py (pink + pinocchio)50 Hzjoint configuration q
5. Servo dispatchlerobot SO100Follower50 Hzper-joint degree commands

1. IMU sampling

imu_sdk — a background serial thread, decoupled from the control loop

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)

2. Attitude estimation — error-state Kalman filter

m5teleop/imu_ekf.pyImuEKF

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 , gyro bias ) with a separate small-angle error state δx = [δθ, δb] ∈ ℝ⁶ and its 6×6 covariance P. Every tick is predict-then-update:

predict (gyro integration, every tick) q̄ ← q̄ ⊗ exp(½(ω_meas − b̄) dt) b̄ ← b̄ (bias random walk) F = [[I − [ω̄×]dt, −I·dt] , [0, I]] (6×6 error transition) P ← F P Fᵀ + Q update (accelerometer → gravity direction, when |‖a‖ − 1| ≤ acc_gate) ĝ = R(q̄)ᵀ · [0, 0, 1]ᵀ H = [[ĝ×], 0] (3×6) ν = a_meas_norm − ĝ S = H P Hᵀ + σ²_acc·I K = P Hᵀ S⁻¹ δx = K ν q̄ ← q̄ ⊗ [1, δθ/2] b̄ ← b̄ + δb P ← (I − KH) P

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]

3. Cascade orientation controller

m5teleop/orient_controller.pyOrientationController

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:

q_delta = q_imu_ref⁻¹ ⊗ q_imu (IMU rotation since zero_reset) q_target = q_ee_ref ⊗ q_delta (same relative rotation, applied to the EE)

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°:

outer (P): q_err = q_target ⊗ q_ee⁻¹ err = rotvec(q_err) ∈ ℝ³ ω_set = clamp(Kp_outer · err, max_omega) Kp_outer = 2.5 /s inner (P): ω_actual ≈ vee(Ṙ_ee · R_eeᵀ) (finite-difference of consecutive EE rotations) ω_cmd = clamp(ω_set + Kp_inner · (ω_set − ω_actual), max_omega) Kp_inner = 0.5 twist = [0, 0, 0, ω_cmd] (zero linear component — this loop only tracks orientation)

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

4. Differential inverse kinematics

m5teleop/ik_solver.pyIKSolver, wrapping pink + pinocchio

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:

target pose: p_target = p_ee + v·dt R_target = exp([ω·dt]×) · R_ee QP (per tick): minimize Σ_task w_task · ‖J_task v − ẋ_task‖² over v ∈ ℝ⁶ subject to joint position / velocity limits q ← q ⊕ v·dt (integrate on the configuration manifold)

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

5. Servo dispatch, logging, and (optional) recording

lerobot SO100Follower · Rerun · soarm_lerobot

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

RL training pipeline, step by step

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.

PipelineDecision makerComputedStatus
Teleoperationhuman + cascade PIDevery tick, 50 Hzactive
RL policy (this section)PPO-trained networkevery tick, sim @ training timein progress
Classical planning (next section)RRT + trajectory optimizeronce per goal, offlineplanned

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.

soarm_mjlab RL training loop: task setup, rollout, PPO update, checkpoint, planned sim2real deploy

1. Task & command setup

soarm_mjlab/tasks/reach/reach_env_cfg.py, config/so_arm100/env_cfgs.py

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

2. Manager-based MDP tick

tasks/reach/mdp/{observations,rewards,terminations}.py

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):

ObservationNoise (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_actionnone

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):

r = −‖p_target − p_ee‖ (distance_to_target, position only today) − 0.01 · ‖a_t − a_{t−1}‖² (action_rate_l2 — discourage jerky actions) − 10 · 1[near joint limit] (joint_pos_limits penalty) episode ends on: time_out | task_success (‖error‖ < 0.03 m for 10 steps) | joint_limit_violated | ee_ground_collision (contact force > 10 N)
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

3. PPO update

tasks/reach/config/so_arm100/rl_cfg.pyRSL-RL

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:

Â_t = Σ_l (γλ)ˡ δ_{t+l} δ_t = r_t + γ V(s_{t+1}) − V(s_t) (GAE, γ=0.99, λ=0.95) r_t(θ) = π_θ(a_t|s_t) / π_θ_old(a_t|s_t) L(θ) = E_t[ min( r_t(θ) Â_t, clip(r_t(θ), 1−ε, 1+ε) Â_t ) ] (ε = clip_param = 0.2)
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.

4. Validation ladder — before any checkpoint touches hardware

test pyramid, shaped around one constraint: real physics and GPU training can't run in CI

LayerChecksRuns where
Unit testsreward/observation/termination math, synthetic tensors, no MuJoCoCI, every push
Config/asset validationMJCF compiles, actuator regexes match joints, obs/action dims correctCI, every push
Env smoke testreset() + a few step()s, no NaN/InfCI, every push
Training smoke testfull RSL-RL wrapper → PPO update path, 2 iterations, CPUCI, every push
Full training runreal hyperparameters, thousands of envs, tensorboardmanual / GPU runner
Sim-replay validationcheckpoint through the deploy code path, not the training wrappermanual
Real-hardware validationtorque-limited dry run → supervised run → unattended soak testmanual, 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.

5. Sim2real deployment planned

deploy/reach_policy_runner.py — not yet written

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.

Classical joint-space planning & control planned

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.

Planned classical planning pipeline: goal spec, RRT path search, TOPP-RA trajectory timing, joint PD tracking, servo bus

1. Goal specification

planned — reuses m5teleop's existing URDF/pinocchio model

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.

2. Collision-free path search

planned — roboplan's RRT planner, joint space ℝ⁵

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:

q_rand ~ Uniform(C_free) q_near = argmin_{q ∈ Tree} ‖q − q_rand‖ q_new = q_near + min(η, ‖q_rand − q_near‖) · (q_rand − q_near) / ‖q_rand − q_near‖ if CollisionFree(q_near → q_new): Tree.add(q_new)

3. Trajectory timing

planned — roboplan wraps TOPP-RA

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:

path: q(s), s ∈ [0, 1] constraints: |q'(s) ṡ| ≤ v_max |q''(s) ṡ² + q'(s) s̈| ≤ a_max solve: minimize T such that s(0)=0, s(T)=1, constraints hold ⇒ q_des(t), q̇_des(t)

4. Joint-space tracking

planned — simple position/velocity control, no task-space feedback

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:

q_cmd(t) = q_des(t) + Kp · (q_des(t) − q_actual(t)) feedforward: q̇_des(t)

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.

Packages

soarm_sdk stable

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.

imu_sdk stable

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.

m5teleop active

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.

soarm_lerobot active

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.

camera_calibration stable

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.

soarm_mjlab in progress

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.

SO-ARM100 vendored, read-only

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.

Hardware stack

Mechanical fabrication
  • Printing cost: roughly €50 for a follower arm, €105 for a leader–follower pair within the EU when outsourcing prints.
  • Materials: PLA+ by default; PETG or nylon for higher temperature tolerance, >35% infill on load-bearing links.
  • Follow the official STL pack and BOM in the SO-ARM100 repo; check the LeRobot Discord for community upgrades (metal joint inserts, cable harnesses).
Actuation and electronics
ComponentNotes
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).
Sensing and peripherals
  • Cameras: UVC webcams, Intel RealSense D405/D435, or any RGB-D sensor supported via LeRobot camera adapters; calibrate with this workspace's own camera_calibration package.
  • IMU: M5StickC/ESP32 boards via imu_sdk, mounted on the wrist for inertial feedback in the teleop loop.
Compute, networking, and safety
  • Control workstation: Ubuntu 22.04 LTS or macOS ≥12, 8+ CPU cores, 16 GB RAM; an NVIDIA GPU if training diffusion/RL policies locally (required for soarm_mjlab's CUDA path).
  • Safety: hardware e-stop, safety relay, over-voltage protection; power servos down before swapping end effectors.
Calibration and troubleshooting
  • Homing vs. kinematics: LeRobot's calibration routine sets a consistent zero; for accuracy-critical geometric calibration from motion capture or camera data, use the FIGAROH toolbox.
  • Serial bus baud rate mismatches — confirm both controller and servos agree (some adapters top out below 1 Mbps).
  • Mixed firmware revisions — update all servos to the same version before chaining them.
  • Power polarity mistakes — double-check barrel jack wiring before powering up.

Simulation & learning

Two learning tracks are actively built in this workspace, both consuming the vendored SO-ARM100/Simulation assets (URDF for FK, MJCF for physics):

  • Imitation learning (soarm_lerobot): record teleop demonstrations through m5teleop --record, then train ACT or Diffusion Policy models on the resulting LeRobotDataset.
  • Reinforcement learning (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.

Installation

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