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.

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 4), separate from the BLE-toggle button watched by openbricks.bluetooth_button (default 5). 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.

  • Press while running → raise KeyboardInterrupt via micropython.schedule, cleanly stopping the program.

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.

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 are recycled rather than monotonically growing so flash usage is bounded.

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

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.

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)[source]
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, so the boot indicator matches reality.

stop()[source]

Stop polling and release the timer.