Architecture

A short tour of how openbricks is organized and why. If you’ve read Pybricks’ pbio codebase, a lot of this will look familiar — the layering is borrowed directly, and for the same reason: openbricks ships as a custom MicroPython firmware, not a library you install on top of stock MicroPython. Pybricks does exactly this for LEGO hubs; we do it for commodity MCUs.

Owning the firmware shapes several decisions:

  • Background control loops (MotorProcess) can run always-on off a hardware timer — nobody else is contending for that peripheral.

  • Platform selection means picking which firmware image to flash, not runtime-dispatching between adapters.

  • Hot control code can be compiled in as a native C extension later without a separate install step.

  • We can extend or add machine-level primitives (custom timers, a hub abstraction) because we build the machine module.

Four layers

┌─────────────────────────────────────────────────────────┐
│  User code     (main.py, robotics.DriveBase, …)          │
├─────────────────────────────────────────────────────────┤
│  Abstract interfaces   (Motor, Servo, IMU, ColorSensor)  │
├─────────────────────────────────────────────────────────┤
│  Concrete drivers      (st3032, icm45686, tcs34725, …)   │
├─────────────────────────────────────────────────────────┤
│  MicroPython HAL       (machine.Pin, I2C, UART, PWM)     │
├─────────────────────────────────────────────────────────┤
│  openbricks firmware image — custom MicroPython build    │
│  for this specific MCU, with all the above baked in      │
└─────────────────────────────────────────────────────────┘

The two middle layers are what make this different from “a pile of MicroPython scripts.” Interfaces (openbricks/interfaces.py) define the contract each family of component obeys; drivers implement that contract; everything above the interface line depends only on interfaces, not on specific chips. That’s why swapping a JGB37-520 DC motor for an ST-3032 serial servo only changes the driver you instantiate — the DriveBase class asks for “a Motor” and doesn’t know or care what’s underneath.

This is the same split Pybricks has: pbio/include/pbio/*.h is the interface, pbio/src/*.c is the library, pbio/drv/* is the driver layer. We take the same approach in C — native/user_c_modules/openbricks/ holds the hot control code that runs at the scheduler tick rate. Targeted pbio-parity on control quality is the reason that code is C and not Python.

Pybricks-parity control, in C

All four of the big pbio control-quality items are ported and shipped in native/user_c_modules/openbricks/. Each corresponds to a pbio source file and keeps its structure close — pbio is MIT-licensed so the ports are direct where they can be.

  1. State observer (pbio observer.c) — our observer.c is a two-state α-β filter. Less capable than pbio’s full model-based observer (no motor model, no PWM coupling, no current/flux estimation) but a ~60× variance reduction over raw finite-differencing for little code. Upgrading to a model-based observer is later roadmap work.

  2. Trajectory planning (pbio trajectory.c) — our trajectory.c computes trapezoidal (and triangular fall-through) speed profiles with explicit accel / cruise / decel phases. servo.run_target() and DriveBase.straight() / .turn() sample it each tick.

  3. Cooperative multitasking (pbio motor_process.c + os.c) — our motor_process.c. Always-on 1 kHz tick off a machine.Timer. Native subscribers (Servo, DriveBase) register via a fast C-function-pointer path (~1 µs/tick); Python callables are still accepted on a slower dispatch path for user extensibility. Honesty note: on esp32, machine.Timer callbacks are dispatched through micropython.schedule’s bounded queue — a main thread blocked in one long C call delays or silently drops ticks, so 1 kHz is nominal, not guaranteed (unlike pbio, whose loop runs under the VM). Since 1.37.0 the controllers’ clock is immune to that: on real hardware the tick advances now_ms by measured mp_hal_ticks_ms deltas (wall time, enabled by the frozen boot.py), so dropped ticks cost control updates but no longer dilate trajectory time. Since 1.38.0 the firmware also carries a hard tick (native/patches/esp32-openbricks-hard-tick.patch): a periodic C hook on the esp_timer service task, below the Python scheduler entirely — verified on hardware via motor_process.hard_tick_selftest() / hard_tick_count(). Existing controllers still dispatch through the scheduler (their encoder reads call into Python objects, which the hard context must never do); the serial-bus motor path lives there: since 1.45.0 DriveBase (openbricks.robotics) adopts serial-bus Motor objects transparently onto the hard-tick engine — it releases their machine.UART (explicit ownership handover, no peripheral double-claim) and runs the 2-DOF controller inside the hard tick on serial-bus servo slots (~220 Hz odometry per wheel, floor-verified 0.3% odometry closure on a square), with use_gyro(True) fed by the ICM-45686 read inside the hard tick itself (1 kHz heading correction, no Python in the loop; bench-verified +0.6° over a four-turn square) — or, for an I2C IMU like the BNO055, by a Python outer loop at ~50-100 Hz. Since 1.89.0 the wheels themselves run “dumb mode” by default: the servo is switched to its open-loop duty mode and the engine’s own integer FF+PI closes the speed loop over raw duty sync-packets — every layer of the drive loop is openbricks code (DriveBase(..., drive="wheel") restores the servo’s internal speed controller). There is exactly ONE drivebase class and NO Python control loop: on a runtime with neither the native bus nor the sim’s emulated bus, constructing a serial-bus DriveBase raises instead of silently degrading. The sim emulates the st_bus surface (_SimStBus over MuJoCo wheels), so the same controller code path runs everywhere.

  4. Drivebase coupling (pbio drivebase.c) — our drivebase.c runs two coupled controllers in (sum, diff) coordinates with position feedback on both. Exit criterion: asymmetric-friction test (one wheel at 0.9× commanded speed) keeps heading error under 5% of forward distance — the pure-Kp M1 fallback fails it.

Host tooling

Everything above describes what runs on the hub. There’s a parallel host-side surface — a single PyPI package called openbricks that ships:

  • A console CLI: openbricks flash | list | run | upload | stop | log for hub interaction over BLE / USB. See tools/openbricks/openbricks_dev/.

  • A MuJoCo-backed simulator: openbricks sim {preview, run} opens a physics sim with the same C control cores as the firmware (*_core.c files compile into both targets, so the sim’s hot-path math is byte-identical). Lives under tools/openbricks/openbricks_sim/. Optional via pip install openbricks[sim].

  • A driver shim that lets from openbricks.drivers.st3032 import ST3032Motor (and ST3215Motor / JGB37Motor / BNO055 / TCS34725 / HC-SR04 / VL53L0X / VL53L1X) run unchanged in MuJoCo — openbricks sim run main.py installs no-op machine fakes and replaces the I2C driver classes with sim-aware versions.

  • Per-run log capture on the hub: every program execution tee’d to /openbricks_logs/slot_N.log (10 slot files reused in place, 64 KB each; run N overwrites slot N % 10 and carries its run index in the file’s header line — truncate-reuse instead of delete+create keeps littlefs commits at fresh-filesystem cost. Ten slots are enough that a few diagnostic openbricks run -c sessions don’t rotate away the failing run they’re investigating). Each line is prefixed with a raw UTC Unix-epoch-milliseconds stamp; openbricks log -n NAME reads the file back over BLE and renders the stamp as [YYYY-MM-DD HH:MM:SS.mmm] in your local timezone — useful for untethered runs where no live console was attached. The CLI syncs the hub’s RTC from the host clock on every connect (run / upload / log), so runs started after any connect carry real wall-clock time; a hub that powered up and never saw a connect stamps from 2000-01-01, which is self-diagnosing. Button presses leave stamped entries too: a run’s log opens with a header line — started: button press | firmware 1.12.0 | program /program.py | uptime N ms | free N B — and a stop press writes button pressed -> stop the moment it lands, followed by estop engaged and a final stopped: KeyboardInterrupt (N ms after press, M retries) debrief, so a misbehaving stop chain is diagnosable from the log alone. Since 1.44.0 the stop chain is also bounded: the program button is sampled on the hard tick (core 0) — a debounced press while a program runs fires the KeyboardInterrupt injection and the native-bus torque-off from C within ~2 ms, regardless of scheduler state (the Python watcher, which stays as defence in depth and as the classic-bus e-stop, measured gaps to 981 ms under load). An uncaught exception writes its full traceback, not just the exception’s repr — on an untethered run the file is the only record, and a bare OSError(19,) doesn’t say which call failed.

  • Log writes are asynchronous. print only appends to a RAM buffer; the bytes reach flash from the launcher’s Timer tick (log.pump()). A flush() on littlefs forces a metadata commit measured at ~60-90 ms on the ESP32 bench, and doing that per line ran synchronously on the main thread between the user program’s own bytecodes — logging cost more than the work it logged and distorted the timing of whatever the robot was controlling. Real commits are paid where durability matters: program end, the stop button, and every write_text (the started: / stopped: / Exception: lines and button notes). So a hard reset can lose recent print output — never the run’s framing. Measure it on your own hub with openbricks run -n NAME examples/log_write_benchmark.py.

  • The wired UART console is asynchronous too (a build-time patch, native/patches/esp32-uart-repl-tx-nonblocking.patch). Upstream’s UART stdout busy-waits until every byte has left the wire — ~5.1 ms for a typical line at 115200, paid by print() on the calling thread even with nothing attached to the UART pins. With the patch, print copies into a 2 KB ring and the UART interrupt drains it in the background; a print storm deeper than the ring drops the remainder on the wired console only — BLE and the run log (both already asynchronous) keep every line. Net effect: a print costs string formatting plus three RAM buffer appends, ~1 ms, regardless of what is or isn’t listening on any transport.

The Python module names on the host are deliberately split (openbricks_dev for the CLI, openbricks_sim for the sim) so they don’t shadow the firmware-side openbricks package, which is sometimes imported on the host by the sim’s driver shim.

Status

All foundational milestones are landed. Roadmap items completed:

  • M1 — always-on 1 kHz scheduler in C (motor_process.c).

  • M2 — observer + trajectory + servo state machine, all in C.

  • M3 — 2-DOF coupled drivebase in C, with optional gyro-feedback (use_gyro(True)) for slip-immune heading via an attached IMU.

  • M4hub abstraction (status LED, user button) + SSD1306 OLED. ESP32 + ESP32-S3 firmware images both build from the same codebase.

  • M5 — per-platform firmware images auto-published on every push to main (rolling latest) and on v* tags (versioned).

Sim phases (host-side): A (chassis + worlds) → B (shared C cores) → C (runtime + driver shim) → D (sensors + scenario reset / scoring) all landed. Phase E1 — pixel-accurate colour-sensor texture sampling — landed via CPU-side sampling: the sensor reads model.tex_data directly, computes UV from the geom-local hit point, and indexes the texel. No offscreen GL context, no platform divergence, works on macOS / Linux / Windows. Originally scoped as “Linux EGL headless rendering” but the EGL machinery is only needed for scenes with shadows / lighting / overlays over the textured plane — the WRO use case is a flat printed mat where the texture IS the answer.

Phase F (WRO 2026 RoboMission, 0.10.8 → 0.10.12) is feature-complete:

  • F1 — high-fidelity mat textures rasterised at 150 dpi (~14000×6750 px) from the official “Game Mat Printing File” PDFs. Drives Phase E1’s sensor sampling against the real printed artwork. scripts/regen-wro-mat-textures.sh re-fetches and re-rasterises when WRO updates the source PDFs.

  • F2 — every visible LEGO prop in all three age categories (Elementary, Junior, Senior) modelled as LDraw assemblies. Per- prop .ldr files are the source of truth; world.py expands <lego_prop ldr=".../*.ldr"/> placeholders into MJCF bodies at load time via openbricks_sim.lego_mjcf. 13 LDraw part types in the registry today; new parts plug in by adding one _PartSpec entry. Senior also wires the WRO-published 3D-printed “mosaic frame” STL as a static MuJoCo <mesh>.

  • F3 — per-round randomization (WRO General Rules glossary “Robot Round” definition). Same seed → same layout. Specs are per-world tuples of _RandomizationSpec driven by one shared seeded RNG, so a Senior round shuffles all four cement colour groups deterministically from a single seed=N.

  • F4 + F5 — closed the F2 deferreds (mosaic frame mesh, dual- colour Senior barriers) and lifted Junior + Senior randomization slot coordinates from estimates to mat-extracted positions (same pixel-inspection flow Elementary used in 0.10.10).

Remaining in Phase E: broader worlds library, more example walkthroughs. EGL offscreen rendering would unlock simulation of scenes more complex than a printed mat (e.g. coloured 3D obstacles that cast shadows onto the colour sensor’s view); not yet prioritised.

Upgrading the α-β observer to a pbio-style model-based observer (voltage/current coupling + motor model) is on the longer-term list — a precision lift we pick up once we have real hardware to measure against.