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); DriveBase accepts both.

reset_heading()[source]

Zero the heading frame (calibration is kept).

gyro()[source]

(x, y, z) rates in dps from the last hard-tick sample.

acceleration()[source]

(x, y, z) in g from the last hard-tick sample.

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

save_calibration()[source]

Persist the learned gyro bias to NVS so the next boot starts corrected instead of waiting for stillness.

stats()[source]

(reads_ok, read_errors, configured) — hard-tick health.

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:
  • i2cmachine.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: ColorSensor

RGB + clear color sensor, fixed at I2C address 0x29.

Implements the ColorSensor contract: rgbc() raw 16-bit channels, reflection() and the calibrated helpers built on it. Two or more on one robot need a TCA9548A mux (the address is not configurable).

raw()[source]

Return the raw (clear, red, green, blue) 16-bit readings.

rgb()[source]

Return (r, g, b) scaled to 0..255 using the clear channel.

Dividing by the clear channel normalizes for ambient brightness, so a white object reports roughly (255, 255, 255) at any light level within the sensor’s range.

ambient()[source]

Return clear-channel brightness scaled to 0..100.

100 means the clear ADC is saturated for the configured integration time — 1024 counts per 2.4 ms cycle, capped at 65535 (datasheet “MAX COUNT”). Scale non-linearly would be nicer but keep it simple.

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: object

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

read()[source]

Calibrated readings, one per element, 0 (mat) .. 1000 (line), left to right.

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. None when no element sees the line — use last_side() to know which way it escaped.

last_side()[source]

+1 if the line was last seen right of centre, -1 left, 0 if it has never been off-centre. The recovery hint for a follower that lost the line entirely.

emitters(on)[source]

Drive the CTRL pin (no-op when CTRL is tied high).

class openbricks.drivers.qtr.QTRChannel(pin, dark_threshold=300)[source]

Bases: QTRArray

One 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 QTRArray on 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
value()[source]

Calibrated reading, 0 (mat) .. 1000 (marker/line).

dark()[source]

True when the element is over a marker/line.

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:

  1. Drive trig high for ~10 µs.

  2. Read the duration of the resulting pulse on echo.

  3. 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: DistanceSensor

HC-SR04 ultrasonic distance sensor.

Parameters:
  • trig – GPIO pin number wired to the sensor’s TRIG pin.

  • echo – GPIO pin number wired to ECHO.

  • timeout_us – how long to wait for the echo. time_pulse_us returns -1 past this; we map that to -1 from distance_mm() to mean “no return”.

distance_mm()[source]

Return the round-trip distance to the nearest reflector ahead, in millimetres, or -1 if no echo arrived inside timeout_us.

VL53L0X (laser distance)

ST VL53L0X laser time-of-flight distance sensor.

The VL53L0X talks I2C at default address 0x29. A measurement cycle:

  1. Write 0x01 to SYSRANGE_START (register 0x00).

  2. Poll RESULT_INTERRUPT_STATUS (0x13) bit 0 until set (typical 33 ms at 33 Hz default rate).

  3. Read RESULT_RANGE_STATUS + 10 (0x14 + 10 = 0x1E) as a 16-bit big-endian integer — the raw distance in millimetres.

  4. 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: DistanceSensor

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

distance_mm()[source]

Distance ahead in millimetres; -1 if no echo / out of range.

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:

  1. Write 0x40 to SYSTEM_INTERRUPT_CLEAR (16-bit reg 0x0086).

  2. Write 0x40 to SYSTEM_MODE_START (16-bit reg 0x0087) — kicks a single-shot ranging.

  3. Poll GPIO_TIO_HV_STATUS (16-bit reg 0x0031) bit 0 until clear.

  4. Read RESULT_FINAL_CROSSTALK_CORRECTED_RANGE_MM_SD0 (16-bit reg 0x0096) as a 16-bit big-endian value — distance in mm.

  5. 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: DistanceSensor

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

distance_mm()[source]

Distance ahead in millimetres; -1 if no echo / out of range.