Runtime services
These modules run behind the scenes on the hub — the frozen main.py
wires them up at boot. They’re documented here because their behavior
(button semantics, log rotation, BLE persistence) is user-visible.
Reserved-GPIO guard
Reserved-GPIO guard: fail pin misuse at construction time, loudly.
Drivers that take raw GPIO numbers call check() before touching
machine.Pin. Two kinds of mistakes are caught:
Chip-reserved pins — pins that can never work for user wiring on the running chip: nonexistent numbers, the SPI-flash pins, the ESP32-S3’s native-USB pair, and (for output roles) the classic ESP32’s input-only range. Claiming these either crashes the chip, bricks the USB connection, or fails somewhere far from the real mistake; the guard names the pin and the reason instead.
Pins the firmware runtime already owns — the program button, the Bluetooth-toggle button, and the status LED register themselves via
claim()when the hub / launcher wire them at boot. A driver constructed on one of those pins gets an error naming the owner (“in use as the program button”) plus the constructor argument that moves the owner elsewhere.
Chip detection reads os.uname().machine and recognizes the two
supported targets (ESP32, ESP32-S3). Off-chip — CPython tests, the
MuJoCo sim, unix MicroPython — no chip is detected and the
chip-reserved rules are skipped (the runtime-claims check still
applies); pins there are fakes, not wiring. Tests pin a chip
explicitly with set_chip().
Strapping pins (0/3/45/46 on the S3) are deliberately not blocked:
they work as regular GPIOs after boot and are routinely used. The
wiring guides in docs/hardware.md cover the soft cases.
- exception openbricks.pins.ReservedPinError[source]
A GPIO was requested that can’t (or shouldn’t) be user-wired.
- openbricks.pins.set_chip(chip)[source]
Force the chip model the guard validates against.
chipis"esp32","esp32s3", orNoneto return to autodetection. Used by tests; harmless elsewhere.
- openbricks.pins.claim(pin, role, hint='')[source]
Record that the firmware runtime owns
pin(e.g. a button).
- openbricks.pins.release(pin)[source]
Forget a claim — for callers that hand a pin back (e.g. after
hub.bluetooth_toggle.stop()). Unknown pins are a no-op.
- openbricks.pins.check(pin, role, output=True)[source]
Validate that
pinis usable forroleon this chip.- Parameters:
pin – GPIO number the caller is about to wire.
role – human-readable purpose (“L298N IN1”, “HC-SR04 echo”) — appears in the error message.
output – the pin must drive (True) or only read (False). Only matters on the classic ESP32, where GPIO 34-39 are input-only.
- Raises:
ReservedPinError – with the pin, the role, and the reason.
Program launcher
Button-gated user-program launcher.
Pybricks-style workflow:
openbricks uploadstages a script at/program.pybut does not run it — the user presses the program button to launch.openbricks runstages the same script and triggers the launcher immediately. Output streams back to the client; pressing the program button stops the program; when the program stops, the terminal exits.
Each press is a full press-release cycle. The program button has its
own GPIO (default 39), separate from the BLE-toggle button
watched by openbricks.bluetooth_button (default 38). Two
pins → no duration-based dispatch — every press on the program pin
means start-or-stop, and every press on the BLE pin means toggle-BLE.
Wiring:
Press while idle → start
/program.py(on release, with a post-stop lockout against bounce).Press while running → the stop fires on press-DOWN: the e-stop latch engages (motors halt + motion commands raise — see
openbricks.estop), and aKeyboardInterruptinjection is requested and retried until the program is actually dead.
The watcher runs off a machine.Timer kept alive for the whole hub
uptime (we never deinit it), so button-press-to-run survives
openbricks run interrupting the main idle loop.
Typical main.py (the firmware ships a frozen default; users can
override by writing to /main.py in VFS):
from openbricks import bluetooth, launcher bluetooth.apply_persisted_state() launcher.run() # installs watcher + blocks on the idle loop
- class openbricks.launcher.Launcher(button, program_path=DEFAULT_PROGRAM_PATH, poll_ms=DEFAULT_POLL_MS)[source]
Shared state for the program-button watcher.
Tests instantiate this directly and drive
_tickwith a fake Pin; production code uses_ensure_launcher()below, which installs a singleton +machine.Timer.- START_LOCKOUT_MS = 500
- STOP_RETRY_MS = 300
- STARVE_NOTE_MS = 5000
- DEBOUNCE_TICKS = 2
- RUN_START_GRACE_MS = 400
- START_PRESS_OPEN_MS = 600
- RELEASE_CHATTER_MS = 200
- note_external_stop()[source]
Attribute a stop delivered OUTSIDE this watcher’s own machinery — the hard-button path, or a REPL Ctrl-C.
The 1.48.2 start gates check state (_lockout_until_ms, press lifecycle) that only the watcher’s stop path armed. When the hard path wins the race (stop in ~2 ms, before the watcher’s next 50 ms tick), that state never arms: the stopping press’s echoes — PCNT edges, the hard latch’s post-disarm confirmation — read as fresh idle presses and dispatch a phantom start (bench 2026-08-03, second occurrence: gates in place, lockout never armed, next BLE session dead again). Called from the program teardown, so EVERY interrupt-unwound run arms the same suppression.
- openbricks.launcher.program_running()[source]
True while a user program is executing — whatever started it (button press,
openbricks run, or a scheduled start; all paths maintain the same flag).This is the hub-wide “robot is running” signal. The BLE toggle watcher polls it from its 50 ms tick to flash the status LED while a program runs (
openbricks.bluetooth_button), which is why it must stay cheap: one attribute read, no allocation.
- openbricks.launcher.run(program_path=DEFAULT_PROGRAM_PATH, button_pin=DEFAULT_BUTTON_PIN, poll_ms=DEFAULT_POLL_MS, timer_id=0)[source]
Install the button watcher and block on the cooperative drain loop. Called from the frozen
main.py.timer_id=0is the first ESP32-S3 hardware timer. The previous default-1(virtual timer) was supported by older MicroPython builds but raisesValueError: invalid Timer numberon the v1.27+ MP we vendor — esp32-s3 only exposes hardware timers 0..3.Intentionally blocks forever. If
openbricks runlater sends a Ctrl-C over the REPL to interrupt this loop, the Timer stays alive (we neverdeinitit) so subsequentrun_program/ button-press start continue to work.
- openbricks.launcher.run_program(program_path=DEFAULT_PROGRAM_PATH)[source]
Client-triggered entry for
openbricks run.Sets the
_runningflag, then exec’s the program in the main thread (_exec_program_rawarms the native stop button for the duration). PropagatesKeyboardInterruptso the raw-REPL disconnect signals “stopped” back to the client (which then exits).Wipes
motor_processstate before exec’ing the user script.openbricks runinterrupts whatever was running before (typically a long-lived main.py) but the C-side motor_process callback list isn’t tied to Python GC — without this reset, the new program inherits dead servo/drivebase tick-callback pointers from the previous program andregister_ceither silently rejects new subscriptions (list full) or schedules them alongside garbage, leavingDriveBase.straight()blocked forever in thewhile not is_done()loop.
Run logs
Per-run log capture: tee print(...) output to a file on flash so
untethered runs can be inspected later via openbricks log.
The launcher wraps every program execution with log.session() so
the user’s print output streams to both the live USB / BLE
console (when one’s listening) and a rotating file on flash. With
nobody listening on the live channel, the file is the only record.
openbricks log reads the most recent files back over BLE.
Storage layout:
/openbricks_logs/run_0.log
/openbricks_logs/slot_1.log
/openbricks_logs/slot_2.log
Each run gets the next index; the index lives in the file’s header
line ("<epoch> -- run_N --"), NOT the filename. The
MAX_RUNS slot files are reused in place (run N overwrites
slot_(N % MAX_RUNS)) — the earlier delete+create rotation
churned littlefs directory metadata until every commit’s allocator
traversal cost ~400 ms (bench 2026-08-09); truncate-reuse keeps
commits at fresh-filesystem cost. Flash usage stays bounded and
indices grow monotonically, exactly as before.
Every line is prefixed with a raw int64 UTC Unix epoch in
milliseconds (e.g. 1783950123456 left ambient: 33). No
formatting, no timezone on the hub — the host CLI converts to the
user’s local time at display. The ESP32 RTC starts at 2000-01-01 on
power-up; the CLI syncs it from the host clock on every connect, so
runs started after any openbricks run / log / upload
carry real wall-clock stamps (an unsynced run shows year-2000
dates, which is self-diagnosing).
The session is also bytes-capped: once a run’s log file passes
MAX_BYTES bytes, further writes are dropped from the file (the
live console still gets them). This keeps a runaway
while True: print(...) from filling the entire flash partition.
Implementation note: MicroPython doesn’t expose sys.stdout as a
re-bindable attribute on every port, so we tee at the
builtins.print level instead. This catches every print(...)
call — including ones with file=sys.stderr — but does not catch
direct sys.stdout.write() calls. User code on the firmware path
overwhelmingly goes through print(), so this trade-off is fine.
The launcher additionally calls log.write_text(...) from its
exception handler so tracebacks are captured.
- openbricks.log.note(text)[source]
Write one stamped line to the active run’s log file.
No-op when no program is running (there is no file to write to). File only — the live console is deliberately not touched; callers that want a console message print one themselves. Used by the launcher so every button press that starts or stops a run leaves a timestamped entry in that run’s log.
- openbricks.log.worst_write_ms()[source]
Slowest single filesystem call of the active session, in ms. 0 when nothing has been written or no session is active. The launcher includes this in its tick-starvation note: if the two numbers are of the same order, littlefs (block erase under a write) owns the starvation; if this stays small while the gaps are large, the blocker is elsewhere.
- openbricks.log.pump()[source]
Drain the active session’s buffered output to its file.
Called from the launcher’s Timer tick, which is what makes logging asynchronous:
printappends to RAM and returns, this moves the bytes to flash off the program’s hot path. No-op when no program is running. Never raises — the tick that calls this also owns the stop button.
- openbricks.log.flush()[source]
Commit the active session’s buffered output NOW.
For the moments durability beats speed: the stop button firing, or any other point where the next thing that happens might be a reset. No-op when no program is running.
- openbricks.log.session()[source]
Construct a fresh
_LogSession.Use as a context manager:
with log.session() as sess: run_user_program() # sess.write_text(extra) for non-print output if needed.
- openbricks.log.list_runs()[source]
Return a list of
(index, full_path)tuples, oldest first. Used by the on-hub helper thatopenbricks loginvokes via raw-paste to enumerate available runs. Slot files are listed by their header index; legacyrun_N.logfiles (pre-slot firmware) still appear until the next run’s migration removes them.
Bluetooth (BLE REPL & console)
Persistent Bluetooth on/off state for the hub.
The firmware ships with BLE compiled in (so mpremote-over-BLE
code transfer works). At runtime we let the user toggle the advertising
stack on and off; the choice is persisted in NVS so reboots don’t lose
the state. Default (no key in NVS yet) is on.
Typical usage from main.py right after the firmware boots:
from openbricks import bluetooth bluetooth.apply_persisted_state()
Then toggle programmatically (or from the hub’s button — see
openbricks.hub.ESP32DevkitHub once that integration lands):
bluetooth.toggle() # flip current state, persist, apply bluetooth.set_enabled(False) # explicit off
Activating BLE requires a hub name (openbricks.HUB_NAME) — flash
the image with scripts/flash_firmware.py --name NAME first. We
refuse to advertise with no name rather than defaulting to a shared
value, since two hubs with the same advertising name can’t be
individually addressed.
esp32.NVS and bluetooth.BLE are imported lazily so desktop
tests running under unix MicroPython don’t need to have the firmware-
only modules present — they install fakes in tests/_fakes_ble.py.
- exception openbricks.bluetooth.HubNameNotSetError[source]
Raised when the firmware was flashed without
--name NAME.
- openbricks.bluetooth.is_enabled()[source]
Return the persisted flag (
Trueif no value has ever been written).
- openbricks.bluetooth.set_enabled(enabled)[source]
Persist
enabledand apply it to the BLE stack immediately.Raises
HubNameNotSetErrorwhen turning BLE on with no hub name flashed. Turning BLE off is always allowed — a nameless hub can still be silenced.When enabling, also starts the NUS REPL bridge (
openbricks.ble_repl) soopenbricks run/stopcan push scripts over BLE. When disabling, the bridge is torn down first so dupterm isn’t left pointing at an inactive stack.
- openbricks.bluetooth.add_state_listener(callback)[source]
Register
callback(enabled)to run after each state change. Adding the same callback twice is a no-op.
- openbricks.bluetooth.remove_state_listener(callback)[source]
Unregister a callback added with
add_state_listener(). Unknown callbacks are a no-op.
- openbricks.bluetooth.apply_persisted_state()[source]
Read the persisted flag and apply it to the BLE stack.
Call this at boot (e.g. top of
main.py) so the BLE radio reflects whatever the user last chose before the reboot — or comes up enabled on the very first boot.Tolerant of a missing hub name: if the persisted state says BLE-on but no hub name is flashed (typical on a freshly-flashed chip before the first
openbricks flash --name ...run), prints a one-line warning and skips activation. Better than raisingHubNameNotSetErrorfrommain.py, which (per the 1.0.4 silent-boot bug) could lose its traceback to USB-Serial-JTAG timing and brick the boot.
Nordic UART Service bridge for the MicroPython REPL over BLE.
Vendored from upstream MicroPython’s
examples/bluetooth/ble_uart_peripheral.py (the BLEUART
class) and ble_uart_repl.py (the BLEUARTStream dupterm
adapter). Both files are MIT-licensed under MicroPython’s project
LICENSE. The one structural change is the advertising payload —
upstream advertises only the device name + appearance; we add the
NUS 128-bit service UUID so clients filtering by service can find
us. Everything else (connection-set tracking, append-mode rx
buffer, scheduled-flush write batching, io.IOBase inheritance)
is the upstream pattern, unmodified.
Why vendored: writing this from scratch using only the docs (the 1.0.0-1.1.1 history) produced a long parade of “invisible until hardware” bugs — each fix required a hardware reflash + paste diagnostic round. Starting from upstream’s working example would have caught all of them at once.
Public surface (what openbricks.bluetooth.apply_persisted_state
calls):
start()— bring up the NUS bridge. Idempotent.stop()— tear down. Idempotent.is_running()— query.
- openbricks.ble_repl.dump_log()[source]
Print the in-memory BLE event log over USB-Serial-JTAG.
Why a helper rather than
print(_LOG):printroutes through dupterm which routes through this module’s stream, so a long log print adds entries to itself while running. Iterating + printing item by item is bounded; the bytes added during the print sit in _tx_buf until later and don’t grow the log.
- openbricks.ble_repl.pump_tx()[source]
Re-arm the TX flush if bytes are sitting in the buffer.
Liveness backstop, called from the launcher’s poll tick. A flush chain dies in two ways:
micropython.schedule’s queue was full at re-schedule time, or a notify failed and the retry is deliberately paced (see_flush). In both cases the buffered bytes wait for the nextwrite()— which never comes when the writer already finished. That was theopenbricks logend-of-dump stall: the file content and the raw-REPL terminator sat in_tx_bufforever while the host timed out. The pump gives the chain a fresh start at tick cadence; it is idempotent and no-ops when the buffer is empty or the bridge is down.