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.

chip is "esp32", "esp32s3", or None to 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 pin is usable for role on 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 upload stages a script at /program.py but does not run it — the user presses the program button to launch.

  • openbricks run stages 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 a KeyboardInterrupt injection 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 _tick with a fake Pin; production code uses _ensure_launcher() below, which installs a singleton + machine.Timer.

START_LOCKOUT_MS = 500
STOP_RETRY_MS = 300
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.dump_events()[source]

Print the launcher button-event ring, oldest first.

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=0 is the first ESP32-S3 hardware timer. The previous default -1 (virtual timer) was supported by older MicroPython builds but raises ValueError: invalid Timer number on the v1.27+ MP we vendor — esp32-s3 only exposes hardware timers 0..3.

Intentionally blocks forever. If openbricks run later sends a Ctrl-C over the REPL to interrupt this loop, the Timer stays alive (we never deinit it) so subsequent run_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 _running flag, then exec’s the program in the main thread (_exec_program_raw arms the native stop button for the duration). Propagates KeyboardInterrupt so the raw-REPL disconnect signals “stopped” back to the client (which then exits).

Wipes motor_process state before exec’ing the user script. openbricks run interrupts 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 and register_c either silently rejects new subscriptions (list full) or schedules them alongside garbage, leaving DriveBase.straight() blocked forever in the while 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/run_1.log
/openbricks_logs/run_2.log

Each run gets the next index; when the directory already holds MAX_RUNS files we delete the oldest before opening the new one. Indices grow monotonically (run_9 is the tenth run ever; only the newest MAX_RUNS files exist at a time), so flash usage is bounded while filenames stay unambiguous across rotations.

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: print appends 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 that openbricks log invokes via raw-paste to enumerate available runs.

openbricks.log.read_run(index)[source]

Read a single run’s log file by index. Raises OSError if no such run exists.

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 (True if no value has ever been written).

openbricks.bluetooth.set_enabled(enabled)[source]

Persist enabled and apply it to the BLE stack immediately.

Raises HubNameNotSetError when 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) so openbricks run / stop can 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.toggle()[source]

Flip the current state. Returns the new (post-toggle) value.

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 raising HubNameNotSetError from main.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.log_entries()[source]

The recorded events, oldest first (unwraps the ring).

openbricks.ble_repl.dump_log()[source]

Print the in-memory BLE event log over USB-Serial-JTAG.

Why a helper rather than print(_LOG): print routes 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.clear_log()[source]
openbricks.ble_repl.is_running()[source]
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 next write() — which never comes when the writer already finished. That was the openbricks log end-of-dump stall: the file content and the raw-REPL terminator sat in _tx_buf forever 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.

openbricks.ble_repl.start()[source]

Bring up the NUS bridge. Idempotent — second call is a no-op if already running.

Caller must have ble.active(True) before calling. Hub name comes from openbricks.HUB_NAME (NVS-backed); we refuse to advertise without one.

openbricks.ble_repl.stop()[source]

Tear down the NUS bridge. Idempotent.

BLE toggle button

Short press on the BLE-toggle button toggles BLE on/off.

Wires a machine.Timer-driven poll loop (default 50 ms) against a Button-conformant object and calls openbricks.bluetooth.toggle() once per press-release cycle. State is persisted via NVS by the bluetooth module, so the new value survives reboots.

The same poll loop doubles as the run indicator: while a user program is executing (launcher.program_running()), the status LED flashes at 2 Hz instead of holding a solid colour — blue when BLE is on, yellow when it’s off, plain on/off blinking on single-colour LEDs. When the program stops, the LED returns to its idle state (solid state colour on RGB hubs, dark on single-colour hubs). And it renders the press acknowledgment: every program-button press flashes the LED for a moment — red for the press that starts a run, green for the press that stops one (notify_press(), called by the launcher’s press detectors).

This is a different physical button from the one openbricks.launcher watches for program start/stop — the BLE toggle lives on its own GPIO (default 5, see openbricks.hub.Hub) while the program button is on GPIO 4. Two pins → no duration-based dispatch, every press on this pin means “flip BLE”.

Usage from main.py:

from openbricks import bluetooth from openbricks.bluetooth_button import BluetoothToggleButton from openbricks.hub import ESP32DevkitHub

bluetooth.apply_persisted_state() hub = ESP32DevkitHub() BluetoothToggleButton(hub.bluetooth_button).start()

The helper is deliberately standalone (not baked into Hub) so tests can exercise it in isolation, and so boards without a button — or users who want to drive the toggle from something other than a physical press — can skip it.

openbricks.bluetooth_button.notify_press(stop=False)[source]

Record a program-button press. stop=True marks it as the press that stops a run (green flash); the default is a start press (red). The active BluetoothToggleButton renders it on its next poll tick. Safe from any context — it only sets two module variables.

class openbricks.bluetooth_button.BluetoothToggleButton(button, led=None, poll_ms=DEFAULT_POLL_MS, timer_id=1, color_on=DEFAULT_COLOR_ON, color_off=DEFAULT_COLOR_OFF, program_running=None)[source]

Polls a button and toggles BLE on each press-release cycle.

Optional RGB LED feedback (blue = BLE on, yellow = off). Call start() to begin polling on a machine.Timer; stop() releases the timer. The toggled state persists across reboots.

start()[source]

Begin polling. Safe to call repeatedly — the second call is a no-op.

On first call, paints the LED (if one was provided) to reflect the current persisted BLE state, and registers with bluetooth.add_state_listener so the LED also follows state changes made without the button — bluetooth.set_enabled from user code or a tool over the REPL.

stop()[source]

Stop polling, release the timer, and unregister the LED state listener.