Protocol Reference
The complete relay protocol — command grammar, radio framing modes, the data-plane streaming layer, and the device announcement.
micro:bit Radio Relay Protocol
Version: 1.0 · Target firmware: C++ / CODAL on micro:bit V2 · Status:
implemented. This page tracks the firmware in source/relay/RadioRelay.cpp; the
in-repo source of truth is docs/radio-relay-protocol.md.
New here? Start with the Overview and Using the Relay.
1. Purpose and scope
The radio relay is a micro:bit running C++/CODAL firmware that bridges a host
serial port (USB CDC, 115200 baud) to the nRF radio. It forwards data in both
directions and exposes a small line-oriented command grammar for configuration.
Any “dumb” serial client — a bash echo/cat, a Python pyserial script, a
serial terminal — can drive it; no special host library is required.
It supports two radio framing modes:
- RAW250 (default) — emits headerless payloads up to 250 bytes to a peer running C++/CODAL or MicroPython configured with the matching packet size. No CODAL/MakeCode header; the relay passes payload through the §5 framing layer directly. A stock MakeCode robot cannot participate in this mode.
- MAKECODE — emits 32-byte CODAL-compatible packets so a stock MakeCode
robot receives them on
on received stringwith no custom code. The relay constructs the full CODAL/PXT header itself.
The firmware is compiled with MICROBIT_RADIO_MAX_PACKET_SIZE = 250 (set in
codal.json). This is the radio packet size and the value the mode name tracks;
CODAL caps it at 250, and both ends must be built with the same value or the
larger packets are dropped on receive. MAKECODE mode constrains the payload to the
32-byte CODAL layout at runtime; it does not require a separate build. The relay
switches the on-air PCNF1.MAXLEN (32 vs. 250) at runtime when the mode changes,
so a single firmware speaks to both stock MakeCode robots and RAW250 peers.
Naming: earlier drafts called the wide mode RAW251 (after a theoretical 254 − 3 PHY ceiling). The configurable CODAL value is 250, so the mode is RAW250;
!MODE RAW251is accepted as a backward-compatible alias.
Out of scope
- The ESP8266 / Espressif AT command set. The ESP is downstream of the robot, configured once by the robot at boot over the robot’s own serial line. The host and the relay never see AT traffic, and radio payloads cannot collide with it.
2. Physical and lifecycle model
The relay has two planes:
- Command plane — line-oriented configuration grammar (§3). The radio is
already live here:
> textsends and<receives work before!GO. - Data plane — entered with
!GO; serial bytes become transparent radio payload, framed and chunked both ways (§5). There is no in-band escape sequence (no+++, no guard timing). The only way out of the data plane is a reset — close and reopen the port. This is what keeps the data plane fully transparent: no byte in the stream is reserved.
host opens serial port
-> relay resets, restores saved config from flash, boots into COMMAND plane,
emits DEVICE banner
host queries/configures: ?, !MODE RAW250, !CG 5 42, !P 6, !ECHO ON ...
host sends !GO
-> relay re-applies radio config (disable/reconfigure/re-enable), enters DATA plane
DATA plane: bytes are transparent payload, framed + chunked to radio, both ways
host closes port -> next open = reset = back to COMMAND plane (config preserved)
2.1 What resets and what persists
Only the plane is reset-volatile: every boot starts in the command plane, never the data plane. The configuration persists.
channel, group, power, mode, frag, and echo are saved to flash (via
uBit.storage / KeyValueStorage, key "relaycfg") on every explicit change and
reloaded at boot. They survive both a reset and a power-cycle, so a board
configured once (e.g. “RAW250 echo on channel 1”) comes back exactly that way when
replugged or repowered. Writes are skipped when the value is unchanged, to spare
the flash erase budget. Use !DEFAULTS to clear the saved record and fall back to
the compiled-in defaults on the next reset (§3.6).
Reset mechanics (verified on hardware): closing and reopening the port resets the board. Opening the host serial port toggles DTR, which resets the device. An in-place DTR toggle on an already-open port does not reset, so the host must fully close and reopen. After reopen, send
HELLOand wait for the banner to confirm the command plane — the boot banner emitted during the closed window is missed. Seescripts/relay_test.py(reset_to_command).
3. Serial command plane
Line-oriented, \n-terminated (a trailing \r is trimmed), valid only before
!GO. Baud is 115200.
3.1 Prefix characters
| Prefix | Direction | Meaning |
|---|---|---|
> |
host → relay | Send rest of line over radio (command plane only) |
< |
relay → host | A message received from radio |
! |
host → relay | Command (see 3.2) |
? |
host → relay | Query current config |
# |
relay → host | Comment / status / debug from the relay |
> in the command plane preserves single-line send for quick testing and for
interoperability checks (the radio is live before !GO). Bulk/transparent sending
is done in the data plane after !GO, with no prefix.
3.2 Commands
| Command | Description |
|---|---|
!C <ch> |
Set channel (0–35), forces group 10. Display shows the channel glyph. |
!CG <ch> <group> |
Set channel (0–83) and group (0–255). Display shows ?. |
!RC <ch> <group> |
Alias of !CG. |
!P <0-7> |
Set transmit power. |
!MODE MAKECODE |
Select 32-byte CODAL framing. |
!MODE RAW250 |
Select headerless ≤250-byte framing (default). RAW251 accepted as alias. |
!FRAG ON\|OFF |
MAKECODE over-length policy: fragment vs. truncate (default OFF). |
!ECHO [ON\|OFF] |
Transponder: bounce every received message back over radio. Bare !ECHO toggles. |
!DEFAULTS |
Clear the saved config; compiled-in defaults apply on next reset. |
!GO |
Leave command plane, enter data plane. Exit only via reset. |
!HELP |
Print protocol summary. |
HELLO |
Re-request the device announcement banner. |
Config changes (!C, !CG/!RC, !P, !MODE, !FRAG, !ECHO) are applied
immediately, persisted to flash (§2.1), and echoed back as a # comment.
Debug commands (compiled in by default; the whole facility is stripped when
the firmware is built with RELAY_DEBUG=0):
| Command | Description |
|---|---|
!DEBUG ON\|OFF |
Toggle # DBG ... radio TX/RX logging (off at boot, no reflash). |
!DEBUG? |
Report whether debug logging is on. |
!REGS |
Dump the live nRF RADIO registers as # comments (tuning/diagnostics). |
Every debug line is a #-prefixed comment, so a host parser that ignores #
lines is unaffected when debug is on.
3.3 Queries
| Query | Response (relay → host, #-prefixed) |
|---|---|
? |
# channel: <ch> group: <g> mode: <m> power: <p> |
!MODE? |
# mode: MAKECODE or # mode: RAW250 |
!DEBUG? |
# debug: ON or # debug: OFF (debug build only) |
Query support matters because the host cannot otherwise see relay state. Even though config persists across resets (§2.1), after opening the port the host should read config back rather than assume.
3.4 Boot announcement
DEVICE:RADIOBRIDGE:relay:<deviceName>:<serialNumber>
<deviceName> is the CODAL friendly name; <serialNumber> is the nRF serial.
Emitted on boot and on HELLO. The full field-by-field specification (including
the per-firmware serial encoding and a tolerant parsing regex) is in
docs/announce.md in the repository.
Earlier MakeCode/TypeScript relays announced as
DEVICE:RADIORELAY:relay:.... This C++ firmware usesRADIOBRIDGE; host tooling that auto-classifies boards keys off this difference.
3.5 Buttons
Buttons work without a host, in either plane.
A / B — channel control:
- A — channel down, B — channel up, wrapping within 0–35.
- Active only when
group == 10(a custom-group link is left undisturbed) and when the A+B menu is not open. - The new channel is applied immediately, persisted, shown on the display with the
glyph mapping below, and echoed to the host as
# channel: <ch> group: 10.
A+B — mode menu: the A+B chord opens a small menu. Each press advances to the next item; resting on an item for 3 seconds accepts it. Each item shows the opposite of the current state (i.e. the change you would make):
| Item | Display when chosen advances to | Action on accept |
|---|---|---|
| 0 | 3 (→ 32-byte MAKECODE) / 2 (→ 250-byte RAW250) |
toggle packet mode, persist |
| 1 | ghost icon (→ echo) / west-arrow (→ transmit/receive) | toggle echo mode, persist |
| 2 | X (cross icon) |
cancel — no change |
On accept, a check icon flashes briefly; on cancel the display just returns to
rest. The menu is the host-free equivalent of !MODE and !ECHO.
3.6 Defaults and display
On a fresh board (or after !DEFAULTS) the relay boots on channel 0 (0),
group 10, power 7, mode RAW250, FRAG off, echo off. A board that has been
configured restores its saved values instead (§2.1).
Channel 0 is intentional: the old MakeCode/TypeScript relays also boot on channel 0, so this firmware lands on the same default link as the hardware it replaces.
The 5×5 display reflects state:
- Resting: the channel glyph —
0–9for channels 0–9,A–Zfor 10–35,?for a custom-group config — or a ghost icon when echo mode is on. - Boot animation: echo-state icon (ghost / west-arrow) → flash the packet-size
mode (
32for MAKECODE,25for RAW250) → channel glyph → settle to the resting display. This reflects the persisted config restored at boot. - Entering the data plane: a single
..
4. Radio framing modes
4.1 RAW250 mode (headerless, ≤250 bytes) — default
No CODAL header. The payload handed to the radio is whatever the §5 framing layer
produces, up to 250 bytes — MICROBIT_RADIO_MAX_PACKET_SIZE, which CODAL caps at
- The peer must be C++/CODAL or MicroPython with a matching packet size. A stock MakeCode robot cannot participate in this mode.
4.2 MAKECODE mode (32-byte CODAL string packet)
The relay constructs the full CODAL/PXT header so a stock MakeCode robot fires
on received string. Layout of the radio payload the relay builds (CODAL prepends
its own 4-byte radio header — version/group/protocol — on the air, which is the
01 <grp> 01 the diagram shows ahead of the type byte):
[type:1=0x02] | TS TS TS TS | SN SN SN SN | LEN | <string bytes...>
typ \_timestamp_/ \_serial #_/ len up to 19 bytes
1 4 4 1 ≤19
- Type
02= string. (Number00and value01are not emitted in this mode — see decision below.) - Timestamp (4): left zero. A receiving MakeCode robot does not use it for
on received string. - Serial (4): left zero (serial-number sending disabled), conventional default.
- Length (1): number of string bytes that follow.
- String (≤19): UTF-8 bytes of the line.
Send trigger: a \n on the serial input cuts one packet (a \r is ignored);
the terminator itself is not transmitted.
Over-length lines: default truncate to 19 bytes with a # warning. With
!FRAG ON, the line is handed to the §5 streaming layer instead — but a stock
MakeCode robot cannot reassemble fragments, so !FRAG ON in MAKECODE mode is only
meaningful when both ends run this firmware.
Decision (string-only): MAKECODE mode emits type
02only. This keeps the serial side transparent text (a bashechois a string) and gives the robot a single entry point (on received string). Typed number/value sends, if ever needed, would be added as explicit!-commands, not in the data stream.
Reclaimable fields: when both ends run this firmware (not a stock robot), the 8 timestamp+serial bytes are free for your own use. In strict MAKECODE-compat they stay conventional so nothing downstream chokes.
5. Streaming / framing layer (data plane)
Raw radio is datagram, not stream: each send is one packet, lossy, unordered, no delivery guarantee. To carry arbitrary-length messages, the data plane wraps payload in a small frame. Identical logic in both modes; only the chunk-size constant differs.
5.1 Frame header
[SEQ:1][FLAGS:1][LEN:1][payload:n]
- SEQ — rolling sequence number; reassembly + duplicate/loss detection.
- FLAGS — bitfield:
- bit 0
START(0x01) — first fragment of a message - bit 1
MORE(0x02) — more fragments follow - bit 2
END(0x04) — last fragment - bit 3
ACK_REQ(0x08) — sender requests acknowledgment - bit 4
ACK(0x10) — this frame is an acknowledgment (SEQ = acked seq)
- bit 0
- LEN — payload byte count in this frame.
5.2 MTU per mode
| Mode | Radio payload | Frame header | Usable payload n |
|---|---|---|---|
| MAKECODE | 19 (in CODAL) | 3 | 16 |
| RAW250 | 250 | 3 | 247 |
In MAKECODE mode the frame header lives inside the 19 CODAL string bytes, so a fragmented stream is only decodable by this firmware, not a stock robot.
5.3 Reliability
The current sender is fire-and-forget (ACK_REQ clear) — the right default
for driving a stock MakeCode robot, and what keeps single-frame messages simple.
For reliability, a sender can set ACK_REQ; the receiver side is already
wired — on a frame with ACK_REQ set it waits one half-duplex turnaround
(kRadioTurnaroundMs) and replies with an ACK frame carrying the same SEQ. The
matching stop-and-wait sender (send, wait for the SEQ’d ACK or timeout +
retransmit) is the next increment; windowed ACK can layer on later using the same
SEQ/FLAGS fields.
Single-frame is most reliable. Because there is no retransmit yet, a multi-fragment message fails if any one fragment is lost. Keeping each message within one frame (≤16 bytes MAKECODE, ≤247 bytes RAW250) avoids that.
5.4 Data-plane host I/O
- RAW250 is a fully transparent byte stream. The relay accumulates up to one MTU and flushes on fill or after a short idle gap, so no byte is reserved and a partial chunk is not held indefinitely.
- MAKECODE is line-oriented: a
\ncuts one packet (the terminator is not sent); a\ris ignored. On receive, each decoded MakeCode packet is emitted to the host as one line (a\nis appended).
5.5 Echo / transponder mode
With echo on (!ECHO ON, the A+B menu, or restored from flash), every message the
relay receives over the radio is, in addition to being delivered to the host,
bounced back over the radio verbatim in the current framing — after a short delay
(kEchoDelayMs) so the original sender’s radio has flipped TX→RX and does not miss
the echo in the half-duplex gap. This lets a board run standalone (no host) as an
echo server for round-trip testing; it works in either plane. The resting display
shows the ghost icon while echo is on.
6. Quick start (host side)
The relay needs no special host software. At 115200 baud:
# open the port (DTR toggle resets the board); read the banner:
# DEVICE:RADIOBRIDGE:relay:<name>:<serial>
HELLO # re-request the banner if you missed it
? # read back channel/group/mode/power
!C 5 # channel 5, group 10
!MODE RAW250 # (default) headerless framing
!GO # enter the transparent data plane
<...bytes...> # everything after !GO is radio payload, both ways
# close + reopen the port to return to the command plane
A two-board end-to-end harness (discovery, reset, messaging, channel isolation,
throughput) lives at scripts/relay_test.py; standalone-peer echo/MAKECODE tests
are the other scripts/*_test.py files. See Using the Relay
for worked bash and Python examples.
7. Implementation status
- DTR reset behavior — Resolved. Close and reopen the port to reset; an in-place DTR toggle does not. (§2.1)
- Config persistence — Implemented. channel/group/power/mode/frag/echo
persist to flash across reset and power-cycle;
!DEFAULTSclears it. (§2.1) - MAKECODE over-length — Implemented: truncate by default,
!FRAG ONto fragment. (§4.2) - Line terminator — Implemented:
\ncuts a MAKECODE packet;\rignored. (§4.2) - Echo / transponder — Implemented:
!ECHO, A+B menu, persisted. (§5.5) - SEQ width — 1 byte (wraps at 256), sufficient for fire-and-forget / stop-and-wait; revisit if windowing is added. (§5.1)
- Radio packet size — Resolved:
MICROBIT_RADIO_MAX_PACKET_SIZE = 250(RAW250); both ends must match. - Reliability — fire-and-forget today; the
ACK_REQ/ACKresponder is wired, the stop-and-wait sender is the next step. (§5.3)