Host protocol framing

The on-device program runner (ports/esp32/fs/protocol.py) speaks a length-prefixed frame protocol with the web IDE / host tool over USB CDC and BLE NUS. Frames look like:

#FR:<kind> <len>\n<payload>

kind is an ASCII tag (OUT, STDERR, OK, ERR, DATA, PING reply, etc.); payload is a binary blob of exactly <len> bytes. The mark #FR: is a printable multi-char sentinel chosen to survive both stdin and stdout paths on all supported transports.

Why the C-level hook

In this MicroPython build sys.stdout and sys.stderr are immutable dummy objects — assigning sys.stdout = my_stream does not redirect print(), which goes straight to the C function mp_hal_stdout_tx_strn. Wrapping program output in #FR:OUT frames therefore has to happen inside that C function, not in Python.

The port ships with a patched mphalport.c (in ports/esp32/ overriding the submodule copy) that adds a toggleable framing layer.

Transport fan-out

Framed and raw writes both flow through the shared C helper stdout_tx_strn_impl, which drives UART / USB CDC / USB-serial-JTAG directly. BLE NUS can’t be attached via os.dupterm (this build only exposes slot 0 = REPL and rejects Python streams there), so mphalport.c instead invokes a Python callback registered via set_frame_sink(). protocol.py sets a sink that forwards every framed flush and every raw_write() payload to ble_uart.instance().write_tx(). Called under the GIL from mp_hal_stdout_tx_strn code paths that originate in Python.

hub.set_framed_output(enabled: bool) None[source]

Enable or disable stdout framing at the C level. When True, every call to mp_hal_stdout_tx_strn (print, traceback, REPL echo, sys.stdout.write) is prefixed with #FR:OUT <len>\n before the payload hits UART/USB CDC/JTAG.

Idempotent to call. Off by default at boot.

hub.raw_write(buf: bytes) int[source]

Write bytes to stdout bypassing the framing wrapper. Returns the number of bytes written. Flushes any pending framed data first so raw bytes never interleave inside a framed payload. Used by the protocol layer to emit its own #FR:OK/#FR:DATA/etc. frames so they are not re-wrapped as #FR:OUT.

hub.flush_output() None[source]

Explicitly flush any buffered framed stdout data as a #FR:OUT frame. Normally not needed — the wrapper auto-flushes on newlines and when the internal buffer fills.

hub.set_frame_sink(cb: Callable[[bytes], None] | None) None[source]

Register a callback that receives the exact bytes emitted by every framed flush and every raw_write(). Used by protocol.py to mirror the stream to BLE NUS. Pass None to detach. Called under the GIL; exceptions inside cb are swallowed.

Typical usage

import hub

# Called once from main.py after ble_uart.start():
hub.set_framed_output(True)

# Subsequent print()s emerge as `#FR:OUT <len>\n<bytes>` on the wire.
print("hello")

# Protocol replies bypass framing so the parser doesn't see them as OUT:
hub.raw_write(b"#FR:OK PING\n")

Notes

  • Output is coalesced into a 240-byte buffer and flushed on newline, when the buffer fills, when framing is disabled, or when raw_write() is called. A typical print(...) therefore emits one frame per line rather than one per token.

  • Writes without a trailing newline (e.g. print("x", end="")) stay buffered until the next newline, a buffer-full event, or an explicit flush_output().

  • sys.stderr on esp32 flows through the same mp_hal path, so tracebacks appear as #FR:OUT frames too. Separate STDERR tagging would require a distinct C hook.

  • Framing survives soft-reset because the flag lives in a plain C global, not in MP_STATE_*. main.py re-enables it explicitly in case that changes.