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)

Moves block by default. Pass wait=False to return immediately and poll done() — the Pybricks pattern for driving while reading sensors; any new move command supersedes the pending one:

db.straight(600, wait=False)
while not db.done():
    if bumper_pressed():
        db.stop()
        break
    time.sleep_ms(10)

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.

Moves take a then= end state: "stop" (alias "coast", the default) decelerates to rest and free-wheels; "brake" / "hold" end actively. then="continue" on straight and curve — Pybricks Stop.NONE — does NOT decelerate at the end: the move finishes at cruise speed and the wheels keep it until the next command, so chained segments flow through their seams:

db.straight(300, then="continue")   # ends AT cruise
db.curve(150, 90, then="continue")  # picks the speed up
db.straight(300)                    # decelerates to rest

Move endings are SHAPED all the way down (2.6.0): the controller runs position integral action (pbio’s integrator rules — the same control law Pybricks uses) so tracking error is squeezed out near the target, and any residual left when a profile expires is closed by a small landing trajectory under the same acceleration limit as every other motion — never a raw feedback step. A robot that ends a mission simply comes to rest on its mark; a genuinely stuck robot still refuses to report done() and the stall watchdog raises.

stop() is Pybricks parity: it coasts and returns immediately. then picks the end state ("coast", "brake", "hold"). Short moves armed while the robot is already fast raise their own deceleration to land at rest exactly on target, so you rarely need more — but wait=True is available to block until both wheels’ measured speeds read ~0 (the decel ramp plus settle for brake/hold, the physical freewheel decay for coast). It raises ValueError on open-loop pairs (no measured speed) and, if the wheels never settle within 5 s, RuntimeError naming the measured speeds — a stopped robot that is still moving is a fault, not a detail to hide.

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.

(openbricks servo-id talks through the URT-2’s USB port. With the servo already wired to the hub, openbricks run -n NAME examples/servo_set_id.py scans and re-IDs through the hub instead — same safety contract.)

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

To re-zero the heading frame mid-mission (say, after squaring up on a line), call db.reset() between moves — afterwards the robot’s CURRENT pose is heading zero for both the drive base and imu.heading(), atomically:

db.straight(100)
db.turn(-90)
db.reset()          # here, now = heading zero
db.straight(130)    # drives straight along the NEW zero

imu.reset_heading() refuses (OSError) while a drive base steers by the gyro — same rule as Pybricks (“can’t reset heading while gyro in use”): zeroing the integrator under an armed heading controller shifts the measurement out from under the held target, and the next move veers chasing the old frame. Use db.reset(), or use_gyro(False) first. db.reset() itself raises while a move is in progress — stop first.

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: 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, 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’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.

  • turn_accelerationturn() ramps’ own acceleration, wheel-deg/s², independent of acceleration — 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.

reset()[source]

Re-zero the heading frame: after reset(), the robot’s CURRENT pose is heading zero — for the drive base’s controller and imu.heading() together (Pybricks DriveBase.reset()). Call it between moves; it raises while a move is active.

This is the supported way to re-zero mid-mission. imu.reset_heading() refuses while a drive base steers by the gyro, because zeroing the integrator under an armed controller shifts the measurement out from under the held target — the next straight() then veers chasing the old frame.

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.

Speed changes ramp at settings(acceleration=...) (the uniform-accel rule, 1.94.0) — proportionally across the two wheels, so an arc keeps its radius through the ramp.

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', wait=False)[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. brake and hold DECELERATE at settings(acceleration=...) first (the uniform-accel rule) — hold anchors where the robot actually stops; coast releases torque immediately (a freewheel has no controlled deceleration). 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.

Pybricks parity by default: the call returns immediately (their stop/brake do too — verified from their source). Since 2.4.0 short moves armed at speed raise their own deceleration to land at rest on target, so waiting is rarely needed; pass wait=True to BLOCK until both wheels’ MEASURED speeds read ~0 — for brake/hold the decel ramp finishing plus settle, for coast the physical freewheel decay. wait=True raises ValueError on open-loop pairs (no measured speed to wait on) and RuntimeError if the wheels never settle within the timeout.

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

then="continue" (Pybricks Stop.NONE) does not decelerate at the end: the move finishes AT cruise speed and the wheels keep it until the next command — chain straight/curve segments without stopping between them. "stop" is accepted as an alias of the default coast end state.

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 — except then="continue": a turn in place ends facing its target heading, so there is no speed worth carrying.

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(), including then="continue" — the arc hands its full speed to the next command.