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 tomp_hal_stdout_tx_strn(print, traceback, REPL echo,sys.stdout.write) is prefixed with#FR:OUT <len>\nbefore 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:OUTframe. 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 byprotocol.pyto mirror the stream to BLE NUS. PassNoneto detach. Called under the GIL; exceptions insidecbare 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 typicalprint(...)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 explicitflush_output().sys.stderron esp32 flows through the samemp_halpath, so tracebacks appear as#FR:OUTframes too. SeparateSTDERRtagging would require a distinct C hook.Framing survives soft-reset because the flag lives in a plain C global, not in
MP_STATE_*.main.pyre-enables it explicitly in case that changes.