Skip to content

Drive step 05 / 05 ~7 min read

at the endYou know what the printed body under the arm is, how three servos, two decks and the folded arm fit around a 5600 mAh pack, and why nothing moves without a second adapter and a 300 ms lease.

The base

The arm sits on a printed three-wheel omni body, LeKiwi's layout on LeKiwi's kit parts: three more STS3215 servos (ids 7, 8, 9) on their own 12 V bus, three 60 mm omni wheels on the kit's brass hubs, the Seeed 5600 mAh pack, an UNO Q, the second Waveshare adapter and a 12 to 5 V converter, all under one arm floor.

Why this page exists: a leader arm that films is more useful when it can walk to the shot. The body gives it wheels without adding a screw between printed parts, and the wheel code can never be pointed at the arm.

The body

Three printed parts, no pillars, no pod-to-deck screws. Everything is in hardware/cad/base/, one script per part, one fitcheck per joint.

part what it is print
wheel_pod.py (x3) a tower the servo slides into from the top, a horn slot for the hub, a self-supporting gable fender over the wheel, two dovetail rails underneath floor down, no supports, 8h 2m / 130 g for all three
centre_deck.py a 235 mm disc at floor height: three open notches the pods drop into, dovetail skirts that lock them, pockets for the pack, the UNO Q, the adapter and the converter flat, no supports, 4h 8m / 106 g
upper_deck.py the arm floor at 71.7 mm resting on the three tower tops; a collar plugs over each tower and keeps its servo in; four heat-set bosses under the Base_SO101 holes and its cable hole upside down, no supports, 2h 34m / 75 g

Sliced files are in hardware/cad/base/plates/*.gcode.3mf (X2D, 0.20 mm, PLA Basic). Whole body about 14.7 h and 311 g. Screws: three M3 x 10 (deck to pod latch), four M3 x 12 (arm base to deck), twelve M2 x 5 self-tapping (servo tabs), seven M3 heat-set inserts.

The follower's camera is a fourth plate, plates/wrist.gcode.3mf: the Nicla wrist cradle and its lid, 40 min and 7 g. It screws to the follower's wrist part with two M2 x 6 self-tapping screws, which is why that part is printed from hardware/cad/wrist_roll_follower_cam.stl (the vendor Wrist_Roll_Follower_SO101 with two d1.6 pilots in its outer wall, nothing else changed) instead of the stock file. The lens looks down the gripper from 82 mm above the jaw tips.

How the pods stay put: two dovetail rails on each tower slide into skirts on the centre deck from above; the upper deck's collar closes over the tower top; the arm's weight and one M3 per pod hold the deck down. Pull the three screws and the whole stack comes apart by hand.

Where the numbers come from:

claim check result
servo, hub and every wheel from 58 to 65 mm fit the pod, horn drops in past the wall fitcheck_pod.py 0.00 mm3 overlap, tab pilots free
pods seat in the deck with 0.5 mm, rails captured, all four bay modules on plate and clear of each other fitcheck_deck.py 0.00 mm3, pushes of 1.5 mm hit the skirt
the real Base_SO101.step seats at 71.70 with all four screws through its floor holes; deck rests on every tower fitcheck_upper.py 0.00 mm3, footprint supported
the Nicla wrist cradle on the follower clears the gripper servo, the wrist part, the moving jaw closed to open, and wrist_link over roll 90 x flex 1.66; its two M2 screws bite 6 mm of the wrist part's wall; the lens ray down the gripper meets nothing fitcheck_wrist.py 6.2 / 0.3 (stand-off) / 1.4 / 17.0 mm; lens 81.6 mm above the jaw tips
the folded follower with the wrist rolled to 90 and the jaw closed to open touches nothing; the folded leader with the head panned to 60 and tilted 80 to +18 touches nothing fitcheck_arm_fold.py follower: 18.1 mm (back pod), leader: 25.8 mm (back pod), both 18.6 mm to the upper deck
the deck is the FOLLOWER's deck too: Base_SO101 and its motor holder are shared by both arms (only the wrist roll and the jaw differ), the vendor assembly's four floor holes land on the deck bosses, the pan axis sits 61.2 mm from the back edge, the holder and rear plate that overhang the base float clear fitcheck_follower.py holes 0.01 mm, pan axis 0.00 mm, 28.8 mm to the nearest body part

Whole robot with the follower folded: 288 x 366 x 262 mm, wheel contact radius 125 mm, arm floor 71.7 mm off the ground. The follower's fold is not the leader's: its 105 mm gripper would hang into the back pod, so the elbow rests at -1.10 rad instead of -1.44 (arm_pose.FOLDS), which is the pose the model above shows and the one the guard calls home for the follower (robot.fold_servo_deg).

BASE.md in the same folder is the decision log with every clearance and the two dead ends (a half-ring fender that needed 4 h of tree support, a 240 mm deck the slicer refused).

What runs

piece file does
kinematics strands_arm/base.py LeKiwi's matrix, body (vx, vy, wz) to three wheel speeds and back, Goal_Velocity sign-magnitude words
wheel bus strands_arm/base.py WheelBus one feetech.Bus on BASE_PORT; refuses the arm's port and any port where ids 1-6 answer
guard strands_arm/base.py BaseGuard bench gate, speed caps, 300 ms lease, idle torque-off, ~/.strands-arm/motion.log
CLI strands_arm/drive.py strands-arm drive: WASD, dry by default
dashboard strands_arm/dashboard/base_io.py, Drive.tsx state.base, POST /api/base/{arm,drive,stop}, the Drive panel
config ~/.strands-arm/base.json wheel radius, base radius, mount angles, wheel signs, caps, ids

Frame: +x forward, +y left, +wz counter-clockwise. Wheels sit at 60 (left), 180 (back) and 300 (right) degrees from +x; LeRobot's lekiwi.py writes the same three rows as [240, 0, 120] - 90, so a LeKiwi policy's numbers drive this base unchanged (tests/test_base_kine.py checks the two matrices agree).

Provisional values

hardware/cad/base/BASE.md fixes the mount angles and the 125 mm contact radius. Two numbers only the real base can supply: the wheel radius (the kit's 50.8 mm until you caliper yours) and wheel_sign, whether a positive word turns each wheel the way the matrix assumes, learned on the first roll. state.base.provisional stays true until both are in base.json, and the Drive panel says so.

Before a wheel moves

  1. BASE_PORT must be set. It is never guessed: the other adapter is the arm's, and find_ports() would hand it over.
  2. BASE_PORT and ARM_PORT must not resolve to the same device.
  3. The probe pings ids 1 to 6. If any answers, this is the arm bus and every motion method raises.
  4. Ids 7, 8, 9 must all answer. Their Mode, Min_Angle, Max_Angle and voltage are read and shown before anything is written.
  5. Mode is written to 1 (velocity) only on those three ids, only where it differs, unlock, write, lock. It is an EEPROM write, so it sits behind the bench flag like every move (POST /api/base/arm, the panel's "arm the wheels").
  6. Every drive command is clamped to caps (0.15 m/s, 0.6 rad/s by default, under LeRobot's 3000 ticks/s) and lives for 300 ms. The dashboard poller and a timer both call tick(); when the lease runs out the wheels get Goal_Velocity 0. Two seconds after the last stop torque is released.
  7. POST /api/base/stop and the panel's stop button work with no gate, no probe and no base.

The CLI's deadman is the same lease: holding a key makes the terminal autorepeat it and each repeat renews the lease. The panel re-sends every 150 ms while a key or the pad is held, and keys only count while the panel has focus, so typing in the chat never drives the base.

Try it without a base

strands-arm drive                 # dry: prints wheel deg/s and the words for each key, opens nothing
strands-arm dash --no-hw          # the panel on three fake wheels; arm, hold w, let go, watch it stop
strands-arm doctor                # amber "no BASE_PORT" while it is unset

With the base:

export ARM_PORT=/dev/cu.usbmodemAAAA BASE_PORT=/dev/cu.usbmodemBBBB
strands-arm doctor                # wheels 7/8/9, voltage, mode
strands-arm drive --go --i-know   # first roll: 0.1 speed, one key at a time, note which way each wheel turns

The first roll fills in wheel_sign in base.json; do it with the arm folded and the wheels off the table.