Repository navigation
protocol
The complete relay protocol — command grammar, radio framing modes, the data-plane streaming layer, and the device announcement.
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.
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.
- 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.
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)
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
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).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
HELLOgoes unanswered rather than trusting the reopen.mbrelaydoes exactly that (see Relay Server).
Line-oriented, \n-terminated (a trailing \r is trimmed), valid only before
!GO. Baud is 115200.
| 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.
| 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.
| 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.
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.
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.
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
..
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.
!Ncannot 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, usembrelay 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!CGis 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.
zuzuvis n = 1 (a reversed encoder saysvuzuz); palindromes likezuzuzorzavazcannot catch it.
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.
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.
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.
[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.
| 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.
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.
- 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).
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.
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.
- 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) -
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)