Motor drivers
Every motor class implements the same Motor contract
(Interfaces), so higher layers — and your code — swap motor
types without changes. Speeds are output-shaft degrees per second,
angles are degrees, dc() duty is -100..100.
from openbricks.drivers.st3032 import ST3032Motor
m = ST3032Motor(servo_id=1, uart_id=1, tx=14, rx=6)
m.run_speed(120) # wheel mode: spin at 120 deg/s
print(m.angle()) # multi-turn accumulated degrees
m.coast()
from openbricks.drivers.jgb37_520 import JGB37Motor
e = JGB37Motor(in1=12, in2=14, pwm=27, encoder_a=18, encoder_b=19)
e.run_angle(360, 720) # two turns at 360 deg/s, blocking
Serial bus servos (recommended)
ST-3032
ST-3032 (Feetech STS3032) serial bus servo.
Smaller sibling of the ST-3215 — same SCS protocol, same 4096-count 12-bit magnetic encoder, same operating modes (0 = position, 1 = wheel), same default 1 Mbps bus. The only differences are mechanical / electrical.
Full datasheet: docs/datasheets/feetech_sts3032.pdf (model
ST-3032-C062, Edition A/0 2025-11-30). Headline specs at typical
12 V operation, all ±10% per Feetech:
Working voltage 9–14 V (typ 12 V) ST-3215 typ 6–12.6 V
No-load speed 0.067 s/60° = 148 RPM = 888 °/s
(``ST3032Motor`` defaults ``max_dps`` to
this — the ST-3215's protective 600 default
capped the wire command well below what the
servo can do)
Stall torque 10 kg·cm (139 oz·in) ST-3215 ~30 kg·cm
Stall current 1.6 A
Rated torque 3.3 kg·cm (1/3 of stall)
Rated current 500 mA
No-load current 100 mA
Idle current 20 mA
Kt 6.3 kg·cm/A
Size 23 × 12 × 27.5 mm ST-3215 larger
Weight 20.6 g
Gear 1/205, coreless motor
Backlash ≤ 1.0°
Over-temp shutdown 80 °C
Over-current > 80 % stall for 2 s
Max position update 1 ms
Because the wire protocol is identical, ST3032 and ST3032Motor
are thin marker subclasses of ST3215 / ST3215Motor with no
behavioural override. Use them when your bench actually carries an
ST-3032 — the typed name documents the hardware and gives us a place
to specialise defaults later (lower max_dps, torque caps) if hardware
testing surfaces a real difference.
ST-3032 and ST-3215 instances on the same UART share the bus registry, so mixing them on one daisy chain is fine — each servo is addressed by its 1-byte ID regardless of model.
⚠ Voltage rail: an ST-3032 will brown out below ~9 V. Don’t share a 6 V bus with ST-3215s; budget a separate 12 V rail.
- class openbricks.drivers.st3032.ST3032(servo_id, uart_id=1, tx=17, rx=16, baud=1_000_000, dir_pin=None, min_raw=0, max_raw=4095, range_deg=360)[source]
Bases:
ST3215One ST-3032 in position-servo mode (Servo interface).
Behaves identically to
ST3215— see that class for the full API. Subclassed only to let user code spell the actual hardware in place.
- class openbricks.drivers.st3032.ST3032Motor(servo_id, uart_id=1, tx=17, rx=16, baud=1_000_000, dir_pin=None, invert=False, steps_per_dps=_DEFAULT_STEPS_PER_DPS, max_dps=ST3032_NO_LOAD_DPS, accel_dps2=1500.0, raise_on_stall=False, stall_idle_ms=1000)[source]
Bases:
ST3215MotorOne ST-3032 in wheel/continuous-rotation mode (Motor interface).
Behaves identically to
ST3215Motor— see that class forrun_speed/angle/run_anglesemantics — except for ONE specialised default:max_dpsmatches the ST-3032’s datasheet no-load speed (888 °/s) instead of the ST-3215’s 600, so the driver clamp can’t cap the servo below its own spec.- STALL_TORQUE_MNM = 980.0
ST-3215
ST-3215 (Waveshare/FeeTech) serial bus servo.
These servos daisy-chain on a half-duplex UART bus: one TX line shared by host and all servos, each servo addressed by a 1-byte ID. Packet format (as in the FeeTech SCServo / Dynamixel-style protocol):
0xFF 0xFF ID LEN INSTR PARAM… CHECKSUM
CHECKSUM = ~(ID + LEN + INSTR + sum(PARAM)) & 0xFF LEN = number of params + 2
Common instructions:
0x01 PING -> probe the servo
0x02 READ -> READ reg len
0x03 WRITE -> WRITE reg value...
Key registers (ST-3215):
0x21 Operation Mode — 0 = position, 1 = wheel/continuous
0x28 Torque Switch — 1 = enable, 0 = coast
0x2A Goal position (low, high) — int16, 0..4095 over ~360°
0x2E Goal speed (low, high) — sign-magnitude; in wheel mode this
is the velocity setpoint (bit 15 of high byte = direction)
0x38 Present position (low, high) — read only. 12-bit absolute
angle within one revolution: 0..4095 over 360°. Wraps to 0
at every full turn; we accumulate multi-turn revolutions in
software via a wrap heuristic in ``ST3215Motor.angle``.
Half-duplex wiring: most ST-3215 boards use a single data line driven by a
TX/RX switching circuit, but MicroPython UART pins are usually separate. If
your adapter exposes TX and RX separately, just wire them normally. If you
have a true half-duplex bus, you’ll need a driver chip or a direction-enable
GPIO — set dir_pin below.
Two classes here:
ST3215— position-mode (Servo interface), for grippers / lifts / sensor turrets.move_to(angle)blocks until reached.ST3215Motor— continuous-rotation mode (Motor interface), for drivebase wheels. Switches the servo into mode=1 at construction and exposesrun_speed(dps)/angle()/brake()so it drops intoDriveBasethe same wayMG370Motordoes.
The ST-3032 (smaller sibling — 12 V, ~10 kg·cm, same SCS protocol)
ships as marker subclasses in openbricks.drivers.st3032.
Only a minimal subset of the protocol is implemented here. PR welcome.
- class openbricks.drivers.st3215.ST3215(servo_id, uart_id=1, tx=17, rx=16, baud=1_000_000, dir_pin=None, min_raw=0, max_raw=4095, range_deg=360)[source]
Bases:
ServoOne ST-3215 servo on a shared bus.
- move_to(angle_deg, speed=None, wait=True)[source]
Move to
angle_degwithin the configured position range.speed(raw goal-speed register units) is optional; withwait=Truepolls until within 2% of target or a 3 s timeout.
- class openbricks.drivers.st3215.ST3215Motor(servo_id, uart_id=1, tx=17, rx=16, baud=1_000_000, dir_pin=None, invert=False, steps_per_dps=_DEFAULT_STEPS_PER_DPS, max_dps=600.0, accel_dps2=1500.0, raise_on_stall=False, stall_idle_ms=1000)[source]
Bases:
MotorOne ST-3215 in wheel/continuous-rotation mode.
Implements the openbricks
Motorinterface so it drops directly intoDriveBase. The servo’s internal velocity loop handles closed-loop speed tracking — we just write the setpoint and read the multi-turn accumulated angle.angle()accumulates in software because the servo’s Present-Position register is a 16-bit signed counter that wraps every ~3 turns (4096 counts/rev × ~8 turns to wrap at ±32767). Same wrap-correction shape asPCNTEncoder.- STALL_TORQUE_MNM = 2940.0
- STALL_LOAD_PCT = 80
- STALL_SPEED_DPS = 20.0
- dc(duty)[source]
True Pybricks
Motor.dc(): raw duty, no speed regulation. Switches the servo to its open-loop mode (2) and writes duty straight to the output stage — speed sags under load, exactly like a plain DC motor at fixed voltage.On a motor adopted by the native engine the bus belongs to the hard tick, so duty still maps onto
run_speedscaled tomax_dpsthere (transitional until the engine grows its own duty drive).
- speed()[source]
Measured shaft speed in deg/s from the present-speed register (sign-magnitude, bit 15; steps/s scaled by
steps_per_dps). ReturnsNoneif the bus is silent, matchingangle(). On an adopted motor (native DriveBase) the value comes from the hard-tick pump’s widened feedback read — no extra bus traffic.
- load()[source]
Estimated shaft torque in mNm — Pybricks
Motor.load()shape. The present-load register reports 0.1 %-of-stall units (sign in bit 10, per the Feetech SCServo SDK); scaled by the model’s datasheet stall torque (STALL_TORQUE_MNM), so treat it as an estimate, not a measurement.Noneif the bus is silent. On an adopted motor the value comes from the hard-tick pump’s widened feedback read (user frame — the slot carries this motor’s invert).
- stalled()[source]
Truewhen the servo is pushing hard (load magnitude at leastSTALL_LOAD_PCTpercent of stall) but barely moving (speed magnitude at mostSTALL_SPEED_DPS) — the PybricksMotor.stalled()contract, from the servo’s own feedback registers. RaisesOSErrorif the bus is silent (a silent bus must not read as “not stalled”).
- brake()[source]
Ramp to zero velocity and hold (servo’s internal loop).
Deliberately RAMPED at
accel_dps2like every other speed change (user decision, reverting 1.18.1’s instant bypass): one uniform rule — the acceleration default governs all commanded speed transitions, brake included. The SAFETY stop is unaffected: the e-stop andcoast()cut the torque register, which the ramp does not govern, and remain instant. A hard local stop without torque-off isaccel_dps2=0at construction.
- classmethod migrate_bus_to_native(bus, skip=())[source]
Move every motor still on
busonto native slots.Called by DriveBase adoption right after it takes the UART: the wheels become slots 0/1 by their own path, and anything else that was sharing the MicroPython bus has to come across too or it is left talking into a closed UART.
- hold()[source]
Actively hold the current shaft angle so the position PID resists rotation. Subsequent
run_speed/brake/coastcalls transparently restore wheel mode.On an adopted motor (native DriveBase) the hold is a per-slot position lock on the hard tick (st_move_core) — it corrects disturbances continuously at the bus feedback rate.
Holding never crosses a turn boundary (the shaft is meant to stay put), so the mechanism is chosen to avoid disturbing the servo’s turn model:
If this motor has already been switched to multi-turn step mode (a prior
run_angle), hold with a zero-count step — step mode keeps holding its current target.Otherwise (e.g. a
DriveBasewheel that has only ever run in velocity mode), hold in single-turn position mode with goal=present. This deliberately does NOT zero the angle-limit registers, so a wheel motor keeps its stock single-turn present-position reads and its odometry stays intact.
- done()[source]
Pybricks-style status check for an in-flight
run_angle(wait=False)move. ReturnsTrueif no move is in flight (the normal case) or the active move has parked (its remaining-to-target register has counted down to within tolerance). ReturnsFalsewhile the move is still running.Calling
done()is what advances the move: each call reads the step-mode remaining register once. For a move larger than 7 turns (issued as back-to-back steps),done()writes the next step once the current one parks, and on the final step runs the end-of-movethen=dispatch and clears the pending state. So polling cadence matters for >7-turn moves — the wheel sits idle at each step boundary until the nextdone()advances it. With a typicaltime.sleep_ms(10)poll that’s a single-tick gap; if you never poll, a multi-step move stalls after the first step.On an adopted motor the C controller advances the move on the hard tick;
done()just checks the arrival flag and runs the deferredthen=dispatch.
- angle()[source]
Return shaft angle in degrees, multi-turn accumulated.
In STEP mode the present-position register reads remaining-to- target rather than absolute position, so we can’t derive the angle from it — return the software accumulator, which
run_angleadvances by the executed step counts as each step parks. In wheel/position mode, rebuild the accumulator from the encoder via the wrap heuristic.
- run_angle(deg_per_s, target_angle, wait=True, tolerance_deg=0.5, kp=None, poll_ms=None, debug=False, then='coast')[source]
Rotate by
target_angledegrees at up todeg_per_s, ending withintolerance_degof the target.On an adopted motor (native DriveBase) the move runs as a per-slot trapezoid position move on the hard tick (st_move_core) — same trajectory + arrival semantics as the drivebase’s own moves;
tolerance_degis fixed at the C core’s arrival tolerance there.target_angleis RELATIVE and UNBOUNDED —run_angle(200, 360)rotates one full turn forward,run_angle(200, 1080)three turns,run_angle(200, -540)one and a half turns back. Direction is the sign oftarget_angle.Implementation: the servo is driven in step mode (op_mode=3) for the move. In step mode the goal-position register is a signed relative step — write N counts and the shaft advances N counts from where it is, with no single-turn 0/4095 wrap to cross (the prerequisite is angle limits = 0, set once on the first call by
_ensure_step_limits). This is what makes moves past 180° / past one full turn work: the older single-turn position mode (op_mode=0) clamps to 0..4095 and a target across the boundary was executed the wrong way round, capping real motion at roughly half a turn.A single step write covers up to ±7 turns (the STS multi-turn envelope); larger moves are issued as back-to-back ±7-turn steps, each parking before the next — still no boundary to cross. The servo’s internal PID handles convergence (≈0.088° per encoder count); completion is detected by reading the step-mode remaining-to-target register and waiting for it to count down to ~0 (see
_read_step_remaining). The shaft-angle accumulator is advanced by the counts actually travelled soangle()/reset_anglestay correct across the move.thenselects the end-state, pybricks-style:"coast"(default) — cut torque; wheel free-wheels. The nextrun_speed/brake/run_angletransparently re-enables torque and restores the mode it needs."brake"— restore wheel mode and write goal_speed=0 so the servo’s velocity loop actively holds zero rotation rate."hold"— leave the servo in step mode; its position PID is already holding the target and resisting rotation.
wait=Falsekicks off the move and returns immediately without blocking. Use it for concurrent multi-motor moves, pybricks-style:left.run_angle(60, 720, wait=False) right.run_angle(60, 720, wait=False) while not (left.done() and right.done()): time.sleep_ms(10)
Multi-revolution targets are supported in
wait=Falsemode too. For a move within ±7 turns the whole thing is one step write anddone()simply reports convergence; for a larger movedone()issues each subsequent ±7-turn step once the previous one parks, so you must keep polling. The end-statethen=dispatch is deferred untildone()reports the final step has converged.Any subsequent motion command (
run,run_speed,brake,coast,hold,run_angle) supersedes a pendingwait=Falsemove — the new command takes over and the pending state is dropped (pybricks “new command wins”).The legacy
kp/poll_ms/debugarguments are accepted for back-compat with the velocity-mode implementation but no longer apply — the PID lives on the servo, not in Python.
- class openbricks.drivers.st3215.SyncServoGroup(servos)[source]
Bases:
objectCoordinated multi-servo writes via SCServo SYNC WRITE.
All servos must share one
_SCServoBus(same UART). Mixed servo types (ST3215,ST3215Motor,ST3032,ST3032Motor) are fine since they all speak the same SCS protocol — SYNC WRITE just blasts the same register on all listed IDs.Use this whenever you have multiple servos that should apply a setpoint at the same packet boundary (a multi-finger gripper, a multi-axis arm) — each servo receives its byte slot of the broadcast packet at the same instant, instead of N serialised individual writes.
NOT for drivebase wheels: use
DriveBase.move_wheels(left, right)instead. It gives the same one-packet guarantee, and a SyncServoGroup cannot drive adopted wheels at all — a DriveBase hands their UART to the native bus driver, so the MicroPython bus this class writes through is closed.Example
from openbricks.drivers.st3215 import ST3215Motor, SyncServoGroup thumb = ST3215Motor(servo_id=1) index = ST3215Motor(servo_id=2) group = SyncServoGroup([thumb, index]) # Both fingers start moving at the same packet boundary — # one SYNC WRITE instead of two individual writes. group.set_goal_speeds([200, 200])
- set_goal_speeds(speeds_dps)[source]
Write goal-speed on every servo in one SYNC WRITE packet.
speeds_dpsis a list parallel to the servos given at construction. Each servo’s own_encode_goal_speedis used, so per-servoinvert/steps_per_dps/max_dpsare respected.Servos that don’t expose
_encode_goal_speed(i.e. the position-modeST3215class) raiseTypeError.
DC gear motors with encoders
JGB37-520
JGB37-520 geared DC motor with magnetic quadrature encoder.
This is a 6-wire motor: two power leads (into an H-bridge, typically L298N), Vcc and GND for the hall sensors, and two encoder channels A and B.
- Typical spec sheet values (varies by gear ratio variant):
Encoder CPR at motor shaft: 11
Gear ratio (example): 1:30 -> output shaft CPR = 11 * 30 * 4 = 1320 edges (multiply by 4 because we count both edges on both channels)
This class is a thin Python wrapper over the C Servo type in
_openbricks_native (see native/user_c_modules/openbricks/servo.c).
The wrapper’s job is to build the hardware handles (Pin / PWM / encoder)
from pin numbers and pass them to the native servo — the closed-loop
control tick runs in C at the motor_process tick rate (1 kHz default).
- class openbricks.drivers.jgb37_520.JGB37Motor(in1, in2, pwm, encoder_a, encoder_b, counts_per_output_rev=1320, invert=False, encoder_invert=False, kp=_DEFAULT_KP)[source]
Bases:
MotorJGB37-520 DC gear motor with quadrature encoder, closed-loop.
Runs the full
Motorcontract on the native 1 kHz servo core:run_speed(deg_per_s),run_angle(deg_per_s, target_angle),dc(duty),brake()/coast(),angle()/reset_angle(),speed(). Pair two of them in aDriveBasefor the coupled 2-DOF chassis controller.- Parameters:
in1 – H-bridge pins (e.g. L298N IN1/IN2/EN).
in2 – H-bridge pins (e.g. L298N IN1/IN2/EN).
pwm – H-bridge pins (e.g. L298N IN1/IN2/EN).
encoder_a – quadrature encoder channel pins.
encoder_b – quadrature encoder channel pins.
counts_per_output_rev – encoder edges per output-shaft revolution (4 × PPR × gear ratio; 1320 for the common 11-PPR 1:30 variant).
invert – flip motor command AND encoder together — for a motor wired backwards end-to-end (typically the mirrored side of a drivebase).
encoder_invert – flip ONLY the encoder reading — for mirror-mounted encoders that count down on motor-forward.
kp – speed-loop proportional gain (native servo core).
Example:
from openbricks.drivers.jgb37_520 import JGB37Motor m = JGB37Motor(in1=12, in2=14, pwm=27, encoder_a=18, encoder_b=19) m.run_angle(360, 720) # two turns at 360 deg/s, blocking print(m.angle())
- dc(duty)[source]
Run at a fixed raw duty cycle (-100..100), open loop — Pybricks
Motor.dc(). Non-blocking. This is the pre-1.21.0run().
- speed()[source]
Measured shaft speed in degrees per second — Pybricks
Motor.speed(). Closed-loop drivers implement it (encoder observer / servo present-speed register).
- run_angle(deg_per_s, target_angle, wait=True, accel_dps2=1500.0)[source]
Rotate by
target_angledegrees, following a trapezoidal profile at up todeg_per_s.The native servo builds the profile once and the scheduler samples it at 1 kHz, so acceleration / cruise / deceleration phases are smooth and the move stops cleanly at the target without overshoot (apart from small integration error).
With
wait=Falsethe scheduler keeps running the profile; the caller can pollself._servo.is_done()or eventually callbrake()/coast().
MG370
MG370 geared DC motor with GMR (giant magnetoresistance) quadrature encoder.
The GMR variant has a 500-PPR encoder on the motor shaft, giving massively
more resolution than a standard Hall-effect encoder — at a 1:34 gearbox
the output shaft sees 500 * 4 * 34.014 ≈ 68028 CPR. That edge rate is
too fast for the software QuadratureEncoder (Pin.irq() tops out around
5-10 kHz), so this driver uses the native PCNTEncoder — a C wrapper
over the ESP32 PCNT peripheral — baked into the firmware image.
Every MG370Motor instance needs its own PCNT unit — ESP32 has 8, ESP32-S3 has 4. Pick unit=0 for the first motor, unit=1 for the second, etc.
Apart from the encoder layer, MG370Motor is identical to JGB37Motor:
same native Servo underneath, same control tick, same closed-loop API.
- class openbricks.drivers.mg370.MG370Motor(in1, in2, pwm, encoder_a, encoder_b, pcnt_unit=0, pcnt_filter=1023, counts_per_output_rev=_DEFAULT_CPR, invert=False, encoder_invert=False, kp=_DEFAULT_KP)[source]
Bases:
MotorMG370 DC gear motor with high-resolution GMR encoder, closed-loop.
Same
Motorcontract and native 1 kHz servo core asJGB37Motor; the difference is the encoder path — the 500-PPR GMR encoder is counted by the ESP32 PCNT hardware peripheral (pcnt_unit), not GPIO interrupts, because its edge rate exceeds whatPin.irqcan service.- Parameters:
in1 – H-bridge pins.
in2 – H-bridge pins.
pwm – H-bridge pins.
encoder_a – encoder channel pins (into PCNT).
encoder_b – encoder channel pins (into PCNT).
pcnt_unit – PCNT peripheral unit, unique per motor (ESP32 has 8, ESP32-S3 has 4 — first motor 0, second 1, …).
pcnt_filter – PCNT glitch filter in APB cycles (default 1023 ≈ 12.8 µs; lower it only for encoders faster than ~35 kHz edge rate).
counts_per_output_rev – encoder edges per output-shaft revolution (default matches the 1:34 GMR variant).
kp (invert / encoder_invert /) – as in
JGB37Motor.
- dc(duty)[source]
Run at a fixed raw duty cycle (-100..100), open loop — Pybricks
Motor.dc(). Non-blocking. This is the pre-1.21.0run().
H-bridge drivers (open loop)
L298N
L298N H-bridge motor driver.
The L298N drives a brushed DC motor via two direction pins (IN1/IN2) and one
PWM pin (EN-A or EN-B). It’s open-loop: the class knows nothing about the
physical motor speed or position. For closed-loop use, wrap one of these in
jgb37_520.JGB37Motor (which adds an encoder).
Pinout recap for one channel:
IN1 = direction bit A
IN2 = direction bit B
PWM = enable / speed (tie to VCC for 100%, PWM for speed control)
IN1 IN2 result
--- --- ----------
0 0 coast
0 1 reverse
1 0 forward
1 1 brake
- class openbricks.drivers.l298n.L298NMotor(in1, in2, pwm, invert=False, pwm_freq=_PWM_FREQ_HZ)[source]
Bases:
MotorOpen-loop brushed DC motor on an L298N H-bridge channel.
No encoder, so only the open-loop subset of the
Motorcontract works:dc(duty),brake(),coast(). Closed-loop methods (run_speed,run_angle,angle,hold) raiseNotImplementedError— wrap the same H-bridge channel inJGB37Motorwhen the motor has an encoder.
TB6612FNG
TB6612FNG dual MOSFET H-bridge — re-exposes L298NMotor under the
TB6612 name.
Both chips drive each motor channel with the same three signals:
IN1 — direction bit 1 IN2 — direction bit 2 PWM — speed (PWM duty cycle)
…so the openbricks driver is identical. TB6612 is generally the better commodity choice — MOSFETs instead of Darlingtons, ~0.3 V drop instead of ~1.8 V, 3.3 V-logic compatible directly from an ESP32 GPIO, and 3.2 A peak per channel vs L298N’s 2 A.
Wiring difference to be aware of: TB6612 has a chip-level STBY pin that must be held high for either channel to operate. Tie it to 3.3 V on your breakout, or drive a spare GPIO high at boot — openbricks doesn’t model STBY because most breakout modules already pull it up.