robotics — DriveBase
Drive a robot, not two motors: DriveBase couples a left and a
right motor into one chassis with moves in millimeters and
body-degrees.
from openbricks.drivers.st3032 import ST3032Motor
from openbricks.robotics import DriveBase
left = ST3032Motor(servo_id=1, uart_id=1, tx=14, rx=41)
right = ST3032Motor(servo_id=2, uart_id=1, tx=14, rx=41, invert=True)
db = DriveBase(left, right, wheel_diameter_mm=88, axle_track_mm=138)
db.settings(straight_speed=250, turn_rate=200,
acceleration=1000, turn_acceleration=800)
for _ in range(4): # a 300 mm square
db.straight(300)
db.turn(90)
For direct control of each wheel — line-following, tank-style
teleop, or any controller that computes its own per-wheel outputs —
move_wheels takes two speeds in wheel-deg/s:
db.move_wheels(200, 120) # gentle right-hand arc
time.sleep_ms(500)
db.stop()
Both setpoints leave in a single sync-write packet on serial-bus
motors, so the wheels change speed at the same packet boundary.
Reach for this rather than a SyncServoGroup
over the wheels: a DriveBase hands their UART to the native bus
driver when it adopts them, so a SyncServoGroup can’t drive them at
all.
A wheel that stops answering the bus — no power, a knocked-loose
TX/RX wire, the wrong servo_id — raises instead of quietly doing
nothing, and the error names the motor:
OSError: motor is not responding on the bus: right wheel
(servo id 1, slot 1) on UART1 tx=14 rx=6 — 0 replies, 137 failed
reads (137 in a row). Check the servo's power and TX/RX wiring,
and that it really has that bus id — `openbricks servo-id --scan`
lists the ids actually answering on the bus.
Both wheels are verified when the DriveBase is constructed, and on
every move afterwards. If one goes silent mid-move the controller
halts immediately rather than winding that wheel’s command to the
rail — a frozen odometry reading would otherwise look like “infinite
error” to the heading loop. db.check_motors() runs the same check
on demand.
With an IMU attached, use_gyro(True) steers by measured body
rotation instead of the encoder differential — immune to wheel slip.
The preferred IMU is the ICM45686:
it is read inside the 1 kHz control tick over SPI, so the heading
correction runs every millisecond in C with no Python in the loop
(bench: +0.6° total drift over a four-turn square):
from openbricks.drivers.icm45686 import ICM45686
imu = ICM45686(sck=12, mosi=13, miso=11, cs=17)
db = DriveBase(left, right, wheel_diameter_mm=88,
axle_track_mm=138, imu=imu)
db.use_gyro(True)
(A legacy BNO055 on I2C still
works — its fused heading is pumped from Python between ticks,
which corrects noticeably slower, typically +0.5° to +1.8° per
turn. New builds should use the ICM-45686.)
Accurate wheel_diameter_mm / axle_track_mm values matter more
than any tuning — calibrate both with two short test drives:
Measuring wheel diameter & axle track.
Two-wheel differential drivebase.
Thin Python wrapper over _openbricks_native.DriveBase — the C
implementation at native/user_c_modules/openbricks/drivebase.c that
runs 2-DOF coupled control at 1 kHz. Both motors are driven by a
single forward-progress trajectory and a heading-hold trajectory; a
heading-error feedback term keeps them in sync even when one wheel has
more friction than the other.
Public API matches the M1 Python version so existing code and tests don’t need to change:
db = DriveBase(left, right, wheel_diameter_mm=56, axle_track_mm=114) db.settings(straight_speed=200, turn_rate=180) # deg/s at wheels db.straight(500) # mm, blocking db.turn(90) # deg body heading, blocking db.drive(100, 0) # non-blocking kinematic mapping
Serial-bus motors (ST-3215 / ST-3032) are adopted transparently onto
the hard-tick engine (firmware) or the emulated bus (sim) — same class,
same code, one controller. There is no Python control loop: motor
pairs with neither a native servo nor a serial-bus adoption path get
open-loop drive()/stop() only, and straight()/turn()
raise.
Open-loop drive() bypasses the coupled controller; it just maps
(speed_mm_s, turn_rate_dps) → (left_dps, right_dps) and hands them to
each servo’s run_speed. Useful for interactive control where
profile-based moves would feel sluggish.
- class openbricks.robotics.drivebase.DriveBase(left, right, wheel_diameter_mm, axle_track_mm, imu=None, drive='duty')[source]
Bases:
objectA two-wheel differential drive robot: two motors, one chassis.
Pybricks-compatible surface:
straight(distance_mm),turn(angle_deg),drive(speed_mm_s, turn_rate_dps),stop(then=...),settings(...),use_gyro(True)and non-blocking moves viawait=False+done(). Positiveturnis right/clockwise viewed from above.Give it any two closed-loop motors and it picks the right controller automatically:
Encoder servos (
JGB37Motor,MG370Motor) — the native C 2-DOF coupled controller at 1 kHz.Serial-bus servos (
ST3032Motor,ST3215Motor) — the motors are adopted onto the hard-tick native bus engine (~220 Hz odometry per wheel, immune to Python stalls). Their wheel-mode motor API keeps working after adoption.Open-loop motors (
L298NMotor) — kinematicdrive()/stop()only; moves by distance need feedback and raise.
Example:
from openbricks.drivers.st3032 import ST3032Motor from openbricks.robotics import DriveBase left = ST3032Motor(servo_id=1, uart_id=1, tx=14, rx=6) right = ST3032Motor(servo_id=2, uart_id=1, tx=14, rx=6, invert=True) db = DriveBase(left, right, wheel_diameter_mm=88, axle_track_mm=138) db.straight(300) # forward 300 mm db.turn(90) # turn right 90 degrees
Accurate
wheel_diameter_mm/axle_track_mmvalues matter more than any controller gain — see Measuring wheel diameter & axle track for how to calibrate both in two short test drives.- settings(straight_speed=None, turn_rate=None, acceleration=None, turn_acceleration=None)[source]
Tune cruise + ramp parameters for subsequent moves.
- Parameters:
straight_speed – cruise speed for
straight(), wheel-deg/s.turn_rate – cruise rate for
turn(), wheel-deg/s.acceleration – STRAIGHT (and curve) trajectory acceleration, wheel-deg/s² — Pybricks’
straight_acceleration. Serial-engine default 1500 (Pybricks parity: their 2000 dps² motor accel × 3/4) — lower it if the robot pitches or lifts its rear on launch. In mm/s² that’sacceleration * wheel_circumference / 360. Applies on both paths: the native (encoder-servo) controller arms its C trajectory with it, and the serial-bus engine forwards it to the hard-tick controller.turn_acceleration –
turn()ramps’ own acceleration, wheel-deg/s², independent ofacceleration— Pybricks parity (serial default 1500). Serial-bus drivebase only; passing it on an encoder/DC pair raises.
- use_gyro(enable)[source]
Switch the heading feedback source between encoder-diff (default) and the attached IMU (when True). Pybricks-style.
Requires an
imu=argument to the constructor. With the gyro, heading is slip-immune — wheel slip or wildly asymmetric friction won’t throw the robot off course, because the IMU sees actual body rotation regardless of what the wheels did. Works on both the native (encoder-servo) path and the serial-bus engine.
- drive(speed_mm_s, turn_rate_dps)[source]
Start driving at a given forward speed + body turn rate.
Kinematic one-shot — no coupled feedback. Call again (or
stop()) to change. Positive turn rate = right turn (clockwise viewed from above), Pybricks convention.
- check_motors()[source]
Raise if a wheel has stopped answering the bus.
Serial-bus wheels are checked at construction and on every move, so you rarely need this directly — reach for it in a long-running open-loop control loop (
move_wheelsin awhileloop already calls it for you), or to verify the chassis before a run. The error names the motor: side, bus id, slot, UART and pins.
- move_wheels(left_wheel_speed, right_wheel_speed)[source]
Drive the two wheels at independent speeds, in wheel-deg/s.
Positive is forward on both sides (each motor’s
invertis already applied), somove_wheels(200, 200)drives straight andmove_wheels(200, -200)spins in place.Non-blocking and continuous, like
drive(): the wheels hold these speeds until you call it again, issue another move, orstop(). It supersedes any move in flight.Use this instead of building a
SyncServoGroupover the wheels. On serial-bus motors both setpoints leave in a single sync-write packet, so the wheels change speed at the same packet boundary — and aSyncServoGroupcould not drive them anyway, because adopting them into a DriveBase hands their UART to the native driver. On encoder servos both targets are set and both servos subscribed inside one native call.Where
drive(speed_mm_s, turn_rate_dps)speaks chassis kinematics, this speaks wheels directly — the right tool for line-following, tank-style teleop, or any controller that computes per-wheel outputs itself.Example:
db.move_wheels(200, 120) # gentle right-hand arc time.sleep_ms(500) db.stop()
Open-loop motor pairs (no encoder, no serial bus) are supported but cannot batch: the two speeds are written one after the other.
- stop(then='coast')[source]
Halt both wheels. Also clears any pending
wait=Falsemove (new command supersedes, pybricks-style).thenselects the end-state:"coast"(default) — both motors free-wheel."brake"— both motors actively resist motion at zero velocity."hold"— both motors actively hold their current angle. Requires motors that implementhold()(e.g.ST3215Motor); open-loop drivers raiseNotImplementedError.
Both wheels are always commanded together, never one motor at a time. On serial-bus (adopted) motors the whole stop is staged atomically in the C engine and reaches the wheels at the same bus-packet boundary — one sync-torque packet for coast, one sync-speed packet for brake, same-instant pose capture for hold. On encoder servos
coast/brakelikewise apply to both bridges inside one native call, so the second wheel’s 1 kHz control tick can’t keep driving while the first is already released.
- done()[source]
Pybricks-style status check for in-flight
straight(wait=False)/turn(wait=False). ReturnsTrueif no move is pending or the active move has reached its target (andstop(then=…)has run). ReturnsFalsewhile the move is still progressing.The controller runs the trajectory independently on the hard tick (native path: 1 kHz C scheduler; serial path: the st_bus pump);
done()checks a flag — plus, on the serial path with the gyro enabled, feeds the IMU heading into the hard-tick heading hold. The natural polling cadence istime.sleep_ms(10).
- straight(distance_mm, then='coast', wait=True)[source]
Drive forward by
distance_mm. 2-DOF coupled.thenis forwarded tostop()— see its docstring for coast/brake/hold semantics.wait=True(default) blocks until the move completes.wait=Falsereturns immediately after arming the move; the caller pollsdone()to check completion, and thethen=dispatch is deferred untildone()reports the target was reached. Concurrent use with another wait=False move on a separateDriveBase(or with motorrun_angle(wait=False)calls) is the intended pattern.Any subsequent move command supersedes the previous pending wait=False move (pybricks “new command wins”).
Raises
RuntimeErrorfor open-loop motor pairs — moves by distance need feedback; usedrive()/stop().
- turn(angle_deg, then='coast', wait=True)[source]
Turn in place by
angle_degbody heading (positive = right/clockwise viewed from above, Pybricks convention).Same
then/waitsemantics asstraight()— see its docstring.
- curve(radius, angle, then='coast', wait=True)[source]
Drive an arc along a circle of
|radius|mm, changing heading byangledegrees — PybricksDriveBase.curve(), including the parameter names, so Pybricks-style keyword calls (curve(radius=150, angle=90)) work verbatim. The one deviation: ourthendefaults to"coast"like every openbricks move (Pybricks defaults to hold) — passthen="hold"for the Pybricks end state.Positive
angleturns right (clockwise from above, the system-wide sign convention, same asturn()); the SIGN ofradiuspicks the travel direction along the arc (positive = forward, negative = backward).curve(150, 90)sweeps a forward quarter-circle to the right around a centre 150 mm to the robot’s right;curve(150, -90)the mirror to the left.The forward and turn profiles run simultaneously with proportional speed AND acceleration, so heading stays proportional to distance at every instant — the path is a true circle through the accel/decel ramps, not just at the endpoints. The centre speed is the
straight_speedsetting scaled by|R| / (|R| + track/2)so the OUTER wheel never exceedsstraight_speed.curve(0, angle)degrades to a turn in place.Same
then/waitsemantics asstraight().