Skip to content

protocol

Eric Busboom edited this page Sep 13, 2026 · 4 revisions

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 string with 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 RAW251 is 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: > text sends 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 — and they are platform-specific.

On macOS, closing and reopening the port resets the board: the open toggles DTR, and DAPLink resets its target on that transition. An in-place DTR toggle on an already-open port does not reset, so the host must fully close and reopen. After reopen, send HELLO and wait for the banner to confirm the command plane — the boot banner emitted during the closed window is missed. See scripts/relay_test.py (reset_to_command).

On Linux this does not work at all. Measured on Ubuntu 24.04 against DAPLink v0257 on a micro:bit V2: close/reopen, an explicit DTR pulse, holding DTR low for two seconds, a 1200-baud touch, and an RTS pulse all leave the target running. The device never even re-enumerates. A board parked in the data plane therefore stays deaf indefinitely, and since the data plane has no in-band escape, nothing short of a reflash recovers it.

A break condition does reset it, reliably — verified on four boards, every attempt. So a host that needs to guarantee it can reach the command plane must send a break when HELLO goes unanswered rather than trusting the reopen. mbrelay does exactly that (see Relay Server).


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 ?. Persists.
!CGT <ch> <group> Same, for the current boot only — not saved to flash.
!RC <ch> <group> Alias of !CG.
!N <name> Set channel and group to a micro:bit name's default (§3.7). Display shows ?. Persists, with the name.
!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, !N, !P, !MODE, !FRAG, !ECHO) are applied immediately, persisted to flash (§2.1), and echoed back as a # comment. !CGT uses the same live retune and the same # channel: ... group: ... echo, but deliberately skips the flash write.

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> [name: <name>] caps: CGT N
!N? # name: <name>, or # name: - when a number chose the link (§3.7)
!MODE? # mode: MAKECODE or # mode: RAW250
!VER? # version: <release>, e.g. # version: 0.20260913.2
!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. The trailing caps: field is an extensible space-separated feature list; look for the tokens you understand (CGT for !CGT, N for !N) rather than exact-matching the line. The optional name: field sits before caps: for the same reason.

!VER? names the release the firmware was built from — the same string as the mbrelay server of that release. Firmware older than 0.20260913.2 answers # error: unknown command (try !HELP); treat that as "older", not as a fault.

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 uses RADIOBRIDGE; 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–9 for channels 0–9, A–Z for 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 (32 for MAKECODE, 25 for RAW250) → channel glyph → settle to the resting display. This reflects the persisted config restored at boot.
  • Entering the data plane: a single ..

3.7 Names and radio addresses

A micro:bit's five-letter name derives a radio address: the CODAL friendly name is NRF_FICR->DEVICEID[1] mod 3125 written in base 5, so anyone who knows the name computes the same (channel, group) with no coordination at all.

That pair is a default, not an address. The mapping spreads 3125 names over 73 channels, so about 43 names share each one, and two robots on one channel collide on the air. When they do you have to move one, and then its name no longer says where it is. Where a robot actually sits is the relay server's name registry, described in the server doc; this firmware knows nothing about it.

!N <name> tunes to a name's default, computed on the board:

!N tovez
# channel: 48 group: 29 mode: RAW250 power: 7 name: tovez
!N?
# name: tovez

The name is trimmed and lower-cased, then must be a well-formed micro:bit name; anything else answers # error: usage !N <name> and changes nothing. Like !CG, the link is saved to flash — with its name, so it survives a reset — and the display shows ?. !N? answers the name the link was chosen by, or - once !C, !CG, !CGT or a button picks a number instead (!CGT is transient, so the next reset brings the saved name back). name: follows power: on every config line and precedes ?'s caps: list, which carries N when the firmware has this command.

!N cannot see the registry. It always goes to the name's default, so for a robot that was moved off its default it tunes to the wrong place. To reach a robot as it actually is, use mbrelay connect tovez: it asks the relay host's registry, takes a relay from the pool, sends !CG <ch> <group>, enters the data plane and hands you a terminal on the robot — no channel, group, host or port. Because !CG is as old as this protocol, that works against every firmware version in the fleet, including boards without !N.

The mapping is specified by radio-robot-lib's Radio addressing page. It is implemented here twice — the firmware's source/relay/naming.h for !N, and the server's mbrelay.naming to compute a registry default — and the robot's own firmware implements it too. The server's tests check the entire 3125-name space against the spec's published sha256 rather than a sampled table, compile naming.h for the host and require it to match byte-for-byte, and just conformance runs every implementation it can find — this repo's two and any sibling repository exposing a tools/radio-address-dump — reporting the first name on which any two disagree.

positions 0, 2, 4   consonant   z v g p t   = 0 1 2 3 4
positions 1, 3      vowel       u o i e a   = 0 1 2 3 4

n       = base5(name)          # name[0] is the MOST significant digit, 0–3124
channel = 11 + (n mod 73)      # 11 … 83
group   = 15 + (n mod 241)     # 15 … 255

Every intermediate is at most 100,048, so MakeCode int32, C++ int and Python agree with no unsigned types or negative modulo. 73 and 241 are coprime and 3125 < 73 × 241, so every name gets its own pair: a pair is never shared, a channel is (43 names on each of channels 11–69, 42 on 70–83). It never emits channels 0–10 nor groups 0–14 — the legacy hand-allocated fleet (3/4/5), MakeCode's unconfigured default (band 7, group 0) and this relay's !C/button group 10 (§3.5). So a hand-dialled !C can never land on a derived link, and !C cannot reach one at all; only !N and !CG can.

Note that a registry override is free of those guarantees — it can put a robot anywhere !CG accepts, including group 10. That is the point: the reserved values protect the derived space, and an override exists precisely because two robots in it collided.

name n channel group board
zeguz 425 71 199 robot
zetuv 476 49 250 robot
vevov 1031 20 82 robot
tovez 2665 48 29 robot
togov 2681 64 45 robot
vevav 1046 35 97 robot
gopiv 1461 12 30 bench rig
getez 1740 72 68 relay
zavaz 545 45 78 relay

Those are the defaults; mbrelay names on the relay host is what says where each of them is today. No two of those robots share a channel, but tovez (48) and zetuv (49) are one apart: channels are 1 MHz apart and the radio's 1 Mbit mode is about 1 MHz wide, so adjacent channels leak a little.

A relay has no address of its own — it adopts the robot's, so getez serving tovez tunes to 48/29 and its own pair never goes on air. Two robots on one channel contend for airtime even though neither parses the other's traffic; the group is only an address filter.

Endianness. Base-5 conversion naturally emits the least significant digit first, but the name is big-endian. A reversed encoder still yields 3125 well-formed, distinct names — silently wrong. zuzuv is n = 1 (a reversed encoder says vuzuz); palindromes like zuzuz or zavaz cannot catch it.


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 250. 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. (Number 00 and value 01 are 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 02 only. This keeps the serial side transparent text (a bash echo is 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)
  • 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 \n cuts one packet (the terminator is not sent); a \r is ignored. On receive, each decoded MakeCode packet is emitted to the host as one line (a \n is 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

  1. DTR reset behavior — Resolved. Close and reopen the port to reset; an in-place DTR toggle does not. (§2.1)
  2. Config persistence — Implemented. channel/group/power/mode/frag/echo persist to flash across reset and power-cycle; !DEFAULTS clears it. (§2.1)
  3. MAKECODE over-length — Implemented: truncate by default, !FRAG ON to fragment. (§4.2)
  4. Line terminator — Implemented: \n cuts a MAKECODE packet; \r ignored. (§4.2)
  5. Echo / transponder — Implemented: !ECHO, A+B menu, persisted. (§5.5)
  6. SEQ width — 1 byte (wraps at 256), sufficient for fire-and-forget / stop-and-wait; revisit if windowing is added. (§5.1)
  7. Radio packet size — Resolved: MICROBIT_RADIO_MAX_PACKET_SIZE = 250 (RAW250); both ends must match.
  8. Reliability — fire-and-forget today; the ACK_REQ/ACK responder is wired, the stop-and-wait sender is the next step. (§5.3)
  9. Names and radio addresses — Implemented (0.20260913.2): a name derives a default address (channel = 11 + n mod 73, group = 15 + n mod 241), and the relay server's name registry says where a robot actually is. !N <name> / !N? tune to and report a name's default on the board (flash record v4 holds the name); mbrelay connect <robot> consults the registry and tunes with !CG. (§3.7)