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 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 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
KeyboardInterruptviamicropython.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
_tickwith 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=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/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.
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.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.