Sensor drivers
All sensors construct against a machine.I2C bus (or a
TCA9548A mux channel, which
quacks the same) except the GPIO-driven HC-SR04.
from machine import I2C, Pin
from openbricks.drivers.bno055 import BNO055
from openbricks.drivers.tcs34725 import TCS34725
from openbricks.drivers.tca9548a import TCA9548A
i2c = I2C(0, sda=Pin(15), scl=Pin(16), freq=400_000) # ESP32-S3 pins
mux = TCA9548A(i2c)
color = TCS34725(mux[0]) # two same-address sensors ...
color2 = TCS34725(mux[1]) # ... on separate mux channels
imu = BNO055(i2c=mux[3], address=0x29)
print(color.rgb()) # (r, g, b) each 0-255
print(color.ambient()) # clear channel, 0-100
print(imu.heading()) # degrees, CW-positive
ICM-45686 (raw IMU, hard-tick heading)
TDK ICM-45686 raw 6-axis IMU — the hard-tick heading source.
Unlike the BNO055 (fused heading over I2C, pumped from Python at
~50-100 Hz during moves), this part is read INSIDE the 1 kHz hard
tick over SPI (~13 µs per burst): gyro-Z integrates into a
continuous heading in C (imu_yaw_core — stationarity-gated bias
learning, Pybricks-Prime architecture), and a gyro DriveBase
consumes it every millisecond with no Python in the loop.
Wiring: 4 free GPIOs to the breakout’s SPI pins (SCLK/MOSI(SDI)/ MISO(SDO)/CS) — the I2C bus, mux and color sensors are untouched.
Example:
from openbricks.drivers.icm45686 import ICM45686
from openbricks.drivers.st3032 import ST3032Motor
from openbricks.robotics import DriveBase
imu = ICM45686(sck=8, mosi=9, miso=17, cs=18)
left = ST3032Motor(servo_id=2, uart_id=1, tx=14, rx=6, invert=True)
right = ST3032Motor(servo_id=1, uart_id=1, tx=14, rx=6)
db = DriveBase(left, right, wheel_diameter_mm=88,
axle_track_mm=138, imu=imu)
db.use_gyro(True) # heading now corrects at 1 kHz in C
Calibration: gyro bias learns automatically during stillness (fast
at boot rest, slow tracking after). save_calibration() persists
the learned bias to NVS; the next construction seeds from it (the
pbio trick), so boot-and-immediately-run starts corrected.
- class openbricks.drivers.icm45686.ICM45686(sck, mosi, miso, cs, hz=8_000_000, mode=3, scale=-1.0)[source]
Bases:
object- heading()[source]
Continuous body heading in degrees, CW-positive — the hard-tick integrator’s value. Same contract as
BNO055.heading()except unwrapped (multi-turn) rather than [-180, 180);DriveBaseaccepts both.
- calibrated()[source]
True once the bias estimator has locked (robot has been still for ~0.5 s since boot, or a saved calibration was loaded).
BNO055 (IMU)
BNO055 — re-export of the native BNO055 C type.
The implementation lives in native/user_c_modules/openbricks/bno055.c
so the drivebase tick can read imu.heading() every ms (when
use_gyro=True) without a Python frame on the hot path. This module
exists so existing user imports stay the same:
from openbricks.drivers.bno055 import BNO055
The class itself is implemented in C (so the drivebase can read the heading on its 1 kHz tick); its Python-facing API:
- class openbricks.drivers.bno055.BNO055(i2c, address=0x28)
Bosch BNO055 9-axis IMU in 6-DOF fusion mode (accelerometer + gyro; the magnetometer is deliberately unused — motor magnets and steel in floors bend the local field, and a drive robot only needs relative heading). Heading zeroes where the robot points at construction.
- Parameters:
i2c –
machine.I2C(or a mux channel).address – 0x28, or 0x29 on breakouts whose ADR pin straps high.
- heading()
Body heading in degrees, wrapped to [-180, 180). CW-positive: turning right (clockwise viewed from above) increases it — compass and Pybricks convention. This is what
DriveBase(imu=...)reads.
- euler()
(heading, roll, pitch)tuple in degrees.
- angular_velocity()
(x, y, z)gyro rates in deg/s.
- acceleration()
(x, y, z)accelerometer in m/s².
TCS34725 (color)
AMS TCS34725 RGB + clear light-to-digital sensor.
The TCS34725 returns four 16-bit channels (clear, red, green, blue) over I2C at address 0x29. There’s an onboard LED that we leave under user control — some breakout boards wire it to the LED pin on reset, others require GPIO control.
Reference: TCS34725 datasheet (AMS / ams-OSRAM), sections 2.4 and 3.
- I2C command byte format (from datasheet):
bit 7 (CMD) = 1 (always for command byte) bits 6:5 (TYPE) = 01 (auto-increment) or 00 (single) bits 4:0 (ADDR) = register address
- class openbricks.drivers.tcs34725.TCS34725(i2c, address=_ADDR, integration_ms=2.4, gain=16)[source]
Bases:
ColorSensorRGB + clear color sensor, fixed at I2C address 0x29.
Implements the
ColorSensorcontract:rgbc()raw 16-bit channels,reflection()and the calibrated helpers built on it. Two or more on one robot need aTCA9548Amux (the address is not configurable).
QTR / QTRX (reflectance array)
Pololu QTR / QTRX reflectance sensor arrays (analog outputs).
A row of IR emitter/phototransistor pairs a few millimetres above the mat: dark line = high reading, light mat = low. Unlike a pair of colour sensors, the array gives a CONTINUOUS line position — a weighted centroid across all elements — so a follower steers on a real analog error instead of edge-crossings.
Wiring (QTRX-HD-15A on ESP32-S3): the analog outputs go to ADC1 pins
(GPIO 1..10 — ADC2 fights the radios). Any subset of the array’s
channels works; pass the pins left-to-right as mounted, and set
pitch_mm to the spacing of the channels you actually wired
(4 mm for adjacent QTRX-HD channels, 8 mm if every other one).
CTRL (emitter enable) may be tied high or given a pin.
Readings are ratiometric to whatever height and mat you mounted over,
so the array MUST be calibrated once per session: sweep it across the
line while calibrate() runs. Reading before calibration raises —
an uncalibrated centroid is a plausible-looking wrong number.
Example (bench: channels 15..9 left-to-right on ADC1, GPIO 6 is the servo-bus RX and stays free):
from openbricks.drivers.qtr import QTRArray, QTRChannel
line = QTRArray(pins=(1, 2, 3, 4, 5, 7, 8), pitch_mm=4.0)
branch = QTRChannel(pin=9) # array channel 1, far right
line.calibrate(duration_ms=3000) # sweep across the line now
branch.calibrate(duration_ms=3000)
while True:
pos = line.position() # mm, +right of centre, or None
- class openbricks.drivers.qtr.QTRArray(pins, pitch_mm=4.0, ctrl=None, dark_threshold=300)[source]
Bases:
objectAnalog QTR/QTRX reflectance array on ESP32 ADC pins.
- Parameters:
pins – ADC-capable GPIO numbers, LEFT to RIGHT as mounted.
pitch_mm – physical spacing between the wired channels.
ctrl – optional emitter-control GPIO (QTRX CTRL). Driven high at construction (emitters on).
None= tied high.dark_threshold – calibrated value (0..1000) above which an element counts as “over the line” for
position()/dark_count().
- calibrate(duration_ms=3000, poll_ms=5)[source]
Learn each element’s mat/line extremes.
Sweep the array across the line while this runs (rotate the robot, or slide it by hand). Extends any previous calibration rather than replacing it, so repeated calls refine.
- dark_count(readings=None)[source]
How many elements are over the line — the intersection / stop-bar signal (a full-width bar darkens most of the array, a branch stub only one side).
- position(readings=None)[source]
Line centre in mm relative to the array centre; positive = line is to the RIGHT.
Nonewhen no element sees the line — uselast_side()to know which way it escaped.
- class openbricks.drivers.qtr.QTRChannel(pin, dark_threshold=300)[source]
Bases:
QTRArrayOne reflectance element with the array’s calibrate/read contract — for a DETECTOR channel wired apart from the line cluster (a branch / marker flag).
Kept out of
QTRArrayon purpose: a flag element folded into the steering centroid would yank the position toward every marker it passes. Steer on the array; DECIDE on this.Example (bench: QTRX channel 1, far right, on GPIO 9):
branch = QTRChannel(pin=9) branch.calibrate(duration_ms=3000) # same sweep as the array if branch.dark(): ... # marker under the flag channel
HC-SR04 (ultrasonic distance)
HC-SR04 ultrasonic distance sensor.
The HC-SR04 has two pins: trig (input — we drive it) and echo
(output — it pulls high for as long as the round-trip echo took).
Sequence:
Drive
trighigh for ~10 µs.Read the duration of the resulting pulse on
echo.distance_mm = pulse_us × speed_of_sound_mm_per_us / 2.
Speed of sound in air at room temperature is ~0.343 mm/µs, so a 60 cm target gives ~3.5 ms of echo pulse — well within the 30 ms default timeout.
This driver is pure Python — there’s no closed-loop control on a
range sensor, so the 1 kHz hot-path concern doesn’t apply. The
machine.time_pulse_us builtin does the actual measurement;
typical call latency is ~1 ms (pulse round-trip) which is fine for
the cold-path use cases (line-following, wall avoidance, mission
“approach until N mm” loops at <100 Hz).
- class openbricks.drivers.hcsr04.HCSR04(trig, echo, timeout_us=_DEFAULT_TIMEOUT_US)[source]
Bases:
DistanceSensorHC-SR04 ultrasonic distance sensor.
- Parameters:
trig – GPIO pin number wired to the sensor’s
TRIGpin.echo – GPIO pin number wired to
ECHO.timeout_us – how long to wait for the echo.
time_pulse_usreturns-1past this; we map that to-1fromdistance_mm()to mean “no return”.
VL53L0X (laser distance)
ST VL53L0X laser time-of-flight distance sensor.
The VL53L0X talks I2C at default address 0x29. A measurement cycle:
Write 0x01 to
SYSRANGE_START(register 0x00).Poll
RESULT_INTERRUPT_STATUS(0x13) bit 0 until set (typical 33 ms at 33 Hz default rate).Read
RESULT_RANGE_STATUS + 10(0x14 + 10 = 0x1E) as a 16-bit big-endian integer — the raw distance in millimetres.Write 0x01 to
SYSTEM_INTERRUPT_CLEAR(0x0B).
Out of range / blocked targets show as 8190 mm in the raw register;
we surface that as -1 to match the DistanceSensor
contract.
This driver implements the minimum register sequence to get usable single-shot readings on a power-on-default VL53L0X. ST’s reference API ships an extensive calibration / VHV / measurement-budget setup that improves accuracy on tuned hardware — that’s a follow-up patch when we have boards to validate against. The default-init ranging here is what most off-the-shelf MicroPython VL53L0X drivers use, and is good for the WRO use cases (line-of-sight, 30–1500 mm).
- class openbricks.drivers.vl53l0x.VL53L0X(i2c, address=_DEFAULT_ADDR, timeout_ms=200)[source]
Bases:
DistanceSensorST VL53L0X laser time-of-flight distance sensor.
- Parameters:
i2c – a
machine.I2C(or compatible) instance.address – 7-bit I2C address (default 0x29). XSHUT-strapping multiple sensors onto one bus requires assigning each a unique address before constructing — outside this driver’s scope.
timeout_ms – how long to poll for a measurement to finish. Default 200 ms; the chip is typically done in 33–50 ms.
VL53L1X (laser distance, long range)
ST VL53L1X laser time-of-flight distance sensor.
Successor to the VL53L0X with 4 m range (vs 2 m on the L0X) and a different register map. I2C default address 0x29 (same as L0X — XSHUT strapping needed if both share a bus).
The chip-ID lives at 16-bit register 0x010F and reads back 0xEACC for a VL53L1X (or 0xEBAA for VL53L4CD, an L1X-pin-compatible variant).
Like the L0X driver this module ships the minimum register sequence
to get usable single-shot ranging on a power-on-default chip. ST’s
reference API does an extensive calibration / VHV / SPAD-array
selection pass for tuned operation; that lives behind a separate
calibrate() method we’ll add when we have hardware to validate
against.
Measurement cycle:
Write 0x40 to
SYSTEM_INTERRUPT_CLEAR(16-bit reg 0x0086).Write 0x40 to
SYSTEM_MODE_START(16-bit reg 0x0087) — kicks a single-shot ranging.Poll
GPIO_TIO_HV_STATUS(16-bit reg 0x0031) bit 0 until clear.Read
RESULT_FINAL_CROSSTALK_CORRECTED_RANGE_MM_SD0(16-bit reg 0x0096) as a 16-bit big-endian value — distance in mm.Write 0x01 to
SYSTEM_INTERRUPT_CLEAR(16-bit reg 0x0086).
VL53L1X registers are 16-bit (vs L0X’s 8-bit) — addresses go through
two byte-swaps on the I2C wire. The driver wraps that in
_read_u8_16 / _write_u8_16 helpers.
- class openbricks.drivers.vl53l1x.VL53L1X(i2c, address=_DEFAULT_ADDR, timeout_ms=200)[source]
Bases:
DistanceSensorST VL53L1X laser time-of-flight distance sensor (4 m range).
- Parameters:
i2c – a
machine.I2C(or compatible) instance.address – 7-bit I2C address (default 0x29).
timeout_ms – how long to poll for a measurement to finish. Default 200 ms; the chip is typically done in ~50 ms.