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=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.settings(straight_speed=200, turn_rate=150)

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 BNO055 on the I2C bus works too — its fused heading is pumped from Python between ticks, which is accurate but slower to correct (typically +0.5° to +1.8° per turn):

from machine import I2C, Pin
from openbricks.drivers.bno055 import BNO055

imu = BNO055(i2c=I2C(0, sda=Pin(15), scl=Pin(16), freq=400_000))
db = DriveBase(left, right, wheel_diameter_mm=88,
               axle_track_mm=138, imu=imu)
db.use_gyro(True)

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)[source]

Bases: object

A 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 via wait=False + done(). Positive turn is 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) — kinematic drive() / 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_mm values 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)[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 – trajectory acceleration, wheel-deg/s², shared by straight() and turn() ramps. Default 1000 (2 wheel-rev/s²) — lower it if the robot pitches or lifts its rear on launch. In mm/s² that’s acceleration * 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.

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_wheels in a while loop 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 invert is already applied), so move_wheels(200, 200) drives straight and move_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, or stop(). It supersedes any move in flight.

Use this instead of building a SyncServoGroup over 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 a SyncServoGroup could 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=False move (new command supersedes, pybricks-style). then selects 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 implement hold() (e.g. ST3215Motor); open-loop drivers raise NotImplementedError.

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 / brake likewise 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). Returns True if no move is pending or the active move has reached its target (and stop(then=…) has run). Returns False while 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 is time.sleep_ms(10).

straight(distance_mm, then='coast', wait=True)[source]

Drive forward by distance_mm. 2-DOF coupled.

then is forwarded to stop() — see its docstring for coast/brake/hold semantics.

wait=True (default) blocks until the move completes. wait=False returns immediately after arming the move; the caller polls done() to check completion, and the then= dispatch is deferred until done() reports the target was reached. Concurrent use with another wait=False move on a separate DriveBase (or with motor run_angle(wait=False) calls) is the intended pattern.

Any subsequent move command supersedes the previous pending wait=False move (pybricks “new command wins”).

Raises RuntimeError for open-loop motor pairs — moves by distance need feedback; use drive()/stop().

turn(angle_deg, then='coast', wait=True)[source]

Turn in place by angle_deg body heading (positive = right/clockwise viewed from above, Pybricks convention).

Same then / wait semantics as straight() — see its docstring.

curve(radius, angle, then='coast', wait=True)[source]

Drive an arc along a circle of |radius| mm, changing heading by angle degrees — Pybricks DriveBase.curve(), including the parameter names, so Pybricks-style keyword calls (curve(radius=150, angle=90)) work verbatim. The one deviation: our then defaults to "coast" like every openbricks move (Pybricks defaults to hold) — pass then="hold" for the Pybricks end state.

Positive angle turns right (clockwise from above, the system-wide sign convention, same as turn()); the SIGN of radius picks 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_speed setting scaled by |R| / (|R| + track/2) so the OUTER wheel never exceeds straight_speed. curve(0, angle) degrades to a turn in place.

Same then / wait semantics as straight().