RaceLink Wire Protocol Reference¶
The single source of truth for the wire format is
racelink_proto.h — that file is duplicated
byte-identically in the Gateway and WLED firmware repos and pinned by
tests/test_proto_header_drift.py. This document is the human-friendly
reading of the same content, oriented at:
- engineers reading a wire trace and trying to identify what they see;
- contributors adding a new opcode (cross-reference with
docs/DEVELOPER_GUIDE.md); - anyone debugging a host ↔ gateway ↔ node interaction.
If this document and the header disagree, the header wins. File a bug.
Layers¶
+------------------+
| Host (Python) | racelink/services/* + racelink/transport/*
+------------------+
| USB CDC, 921600 baud
+------------------+
| Gateway | ESP32 + SX1262
+------------------+
| LoRa SX1262, SF7/250 kHz/CR4:5 default
+------------------+
| WLED node(s) |
+------------------+
Two distinct wire formats are in play:
- Host ↔ Gateway — USB CDC, 921600 baud, byte-stuffed framing (sentinel + length + payload). Carries the LoRa traffic plus USB-only signal frames (TX done, RX window state, errors).
- Gateway ↔ Node — LoRa, packets are the same
Header7 + Bodystructure that travels over USB but without the USB framing wrapper.
The structure below covers both — every packet on USB that is not a USB-signal frame is exactly the LoRa packet that went on (or came off) the radio.
USB framing¶
Each frame on the host ↔ gateway link is:
0x00— frame sentinel.LEN— total bytes after0x00 LEN, i.e.1 + len(DATA).TYPE— first payload byte. Either:- an LP type from
racelink_proto.h::LP::OPC_*combined with a direction bit (top bit), or - one of the USB-signal type bytes (
EV_*).
Direction byte¶
The high bit of TYPE distinguishes host→node and node→host:
| Constant | Value | Meaning |
|---|---|---|
DIR_M2N |
0x00 |
Master → Node (host outgoing) |
DIR_N2M |
0x80 |
Node → Master (node reply) |
make_type(dir, opc7) is just dir | opc7 (opc7 = the 7-bit opcode
constant below).
Header7¶
Every LoRa-bearing frame's body starts with a 7-byte header:
sender3/receiver3are the last 3 bytes of the MAC address.b"\xFF\xFF\xFF"is broadcast (and indeed every node's mask matches it).type_full = make_type(direction, opcode7). The receiver checks the direction first; the wrong direction silently drops.
The ASCII-only constants are below; on USB they appear after the
0x00 LEN TYPE framing, on LoRa they are the entire payload.
Opcodes (M2N + replies)¶
| Opcode | Hex | Direction | Reply | Body | Notes |
|---|---|---|---|---|---|
OPC_DEVICES |
0x01 |
M2N | IDENTIFY_REPLY |
get_devices body | Discovery broadcast |
OPC_SET_GROUP |
0x02 |
M2N | OPC_ACK |
set_group body |
Move a node into a group |
OPC_STATUS |
0x03 |
M2N | STATUS_REPLY |
status body |
Poll device state |
OPC_PRESET |
0x04 |
M2N | none | P_Preset (4 B) |
Apply a numeric WLED preset |
OPC_CONFIG |
0x05 |
M2N | OPC_ACK |
P_Config (5 B) |
Configuration change (option + data) |
OPC_SYNC |
0x06 |
M2N | RESP_NONE | P_Sync (4 B) |
Fire armed effects at ts24 |
OPC_STREAM |
0x07 |
M2N | OPC_ACK |
up to 128 B logical | Gateway fragments + reassembles |
OPC_CONTROL |
0x08 |
M2N | OPC_ACK |
variable (3..21 B) | Direct effect parameters |
OPC_OFFSET |
0x09 |
M2N | RESP_NONE | variable (2..7 B) | Configure offset for ARM_ON_SYNC / OFFSET_MODE |
OPC_GET_CONFIG |
0x0A |
M2N | same opcode N→M | P_GetConfig (1 B) → P_Config (5 B) |
Read-back of an OPC_CONFIG-style property |
OPC_HEADLESS |
0x0B |
M2N | RESP_NONE | P_Headless (2 B) |
Headless-Mode catalog trigger; receivers expand via the shared catalog in racelink_headless.h. Broadcast-shaped. (Renamed 2026-05-17 — see §"Naming history" appendix.) |
OPC_INDICATE |
0x0C |
M2N | RESP_NONE | P_Indicate (2 B) |
Short-lived status-indicator overlay; receivers expand via the shared catalog in racelink_indicators.h. Broadcast or unicast. |
OPC_RF_CONFIG |
0x0D |
M2N | OPC_ACK |
P_RfConfig (12 B) |
Push new LoRa RF settings to a node. Unicast-only — broadcast is firmware-forbidden (would brick every reachable node). Node validates, persists to NVS, ACKs, then reboots ~50 ms later onto the new settings. |
OPC_GET_RF_CONFIG |
0x0E |
M2N | same opcode N→M | [reserved (1)] → P_RfConfig (12 B) |
Read-back of a node's current NVS RF settings. The reserved byte MUST be 0; non-zero is a structurally invalid request (no reply, mirrors the OPC_GET_CONFIG unknown-option path). |
OPC_ACK |
0x7E |
both | — | ack body (4 B) |
Used as a reply only |
BODY_MAX is 22 bytes; OPC_CONTROL is the first opcode that
pushes that bound (its largest body is 21 B). The receiver
enforces BODY_MAX and rejects oversize bodies as malformed.
For historical opcode renames (the same wire bytes carrying different identifiers over time), see the §"Naming history" appendix at the end of this document. Wire-format readers should treat the byte values as canonical; the C identifiers are the naming layer on top of them.
Body layouts¶
P_Preset — apply a WLED preset (OPC_PRESET, 4 B fixed)¶
groupId—0xFF(255) for broadcast; otherwise a specific group.flags— see the flags byte section below.presetId— WLED preset slot to apply.brightness—0..255.
Reply: none for both unicast and broadcast — OPC_PRESET is
RESP_NONE in racelink_proto_auto.py::RULES and the firmware
dispatcher (case OPC_PRESET in racelink_wled.cpp) does not
emit sendAckTo. The host therefore treats the wire send as
fire-and-forget; the operator sees the new state via the next
OPC_STATUS poll, the host's own optimistic local mirror in
send_device_preset / _update_group_preset_cache, or both.
OPC_CONTROL — direct effect parameters (variable, 3..21 B)¶
The first variable-length packet in the protocol. Layout:
groupId (1) | flags (1) | fieldMask (1)
[ tail_main bytes per fieldMask bits 0..6, in fixed order ]
[ extMask (1) | tail_ext bytes per extMask bits, if fieldMask bit 7 set ]
fieldMask bits (LSB first):
| Bit | Constant | Adds | Meaning |
|---|---|---|---|
| 0 | RL_CTRL_F_BRIGHTNESS |
+1 B u8 | Brightness 0–255 |
| 1 | RL_CTRL_F_MODE |
+1 B u8 | WLED effect-mode index |
| 2 | RL_CTRL_F_SPEED |
+1 B u8 | Effect speed 0–255 |
| 3 | RL_CTRL_F_INTENSITY |
+1 B u8 | Effect intensity 0–255 |
| 4 | RL_CTRL_F_CUSTOM1 |
+1 B u8 | Effect custom 1 |
| 5 | RL_CTRL_F_CUSTOM2 |
+1 B u8 | Effect custom 2 |
| 6 | RL_CTRL_F_CUSTOM3_CHECKS |
+1 B packed | bits 0–4 = custom3 (0–31), bits ⅚/7 = check1 / check2 / check3 |
| 7 | RL_CTRL_F_EXT |
extMask byte + extended payload | Has extended block |
extMask bits (LSB first):
| Bit | Constant | Adds | Meaning |
|---|---|---|---|
| 0 | RL_CTRL_E_PALETTE |
+1 B u8 | Palette index |
| 1 | RL_CTRL_E_COLOR1 |
+3 B RGB | Slot 1 color |
| 2 | RL_CTRL_E_COLOR2 |
+3 B RGB | Slot 2 color |
| 3 | RL_CTRL_E_COLOR3 |
+3 B RGB | Slot 3 color |
Fields are emitted only if their mask bit is set. The receiver keeps existing values for fields whose bit is 0 — this is what makes "send only the changed bits" wire-efficient.
Worst case: all 7 main bits + extMask + palette + 3 colours = 2 (group/flags) + 1 (fieldMask) + 7 (main fields, one of which is the custom3_checks byte) + 1 (extMask) + 1 + 9 (palette + 3×RGB) = 21 B.
OPC_OFFSET — configure offset (variable, 2..7 B)¶
Tagged-union body; the second byte selects the variant:
| Mode | Hex | Adds | Layout |
|---|---|---|---|
OFFSET_MODE_NONE |
0x00 |
— | Just groupId, mode. Clears stored offset config; effective offset = 0 |
OFFSET_MODE_EXPLICIT |
0x01 |
+2 B | offset_ms (uint16 LE, clamped 0..65535) |
OFFSET_MODE_LINEAR |
0x02 |
+4 B | base_ms (int16 LE) + step_ms (int16 LE). Each device computes base + groupId * step |
OFFSET_MODE_VSHAPE |
0x03 |
+5 B | base_ms + step_ms + center (uint8 0..254). Computes base + abs(groupId − center) * step |
OFFSET_MODE_MODULO |
0x04 |
+5 B | base_ms + step_ms + cycle (uint8 1..255). Computes base + (groupId % cycle) * step |
groupId == 255 broadcasts the formula to every device; combined
with the formula modes (LINEAR / VSHAPE / MODULO) this is the
"strategy A" wire path the optimizer chooses for all-groups
participation — one packet configures the whole fleet.
Receivers store the offset as a pending change that materialises
on the next accepted OPC_PRESET (immediate-apply path) or on the
OPC_SYNC that fires a queued arm-on-sync effect (deferred-apply
path). See the OPC_OFFSET comment block in racelink_proto.h
for the full state machine.
Acceptance gate (strict symmetric, 2026-04-30)¶
Every OPC_CONTROL and OPC_PRESET packet is filtered by a gate
that compares the packet's OFFSET_MODE flag against the
receiver's effective offset state (pendingChange if valid,
else active). The gate is strict in both directions:
Packet OFFSET_MODE |
Receiver eff.mode |
Gate result |
|---|---|---|
1 |
!= NONE |
accept (apply with stored offset) |
0 |
NONE |
accept (normal immediate apply) |
1 |
NONE |
drop (use-offset request without configured offset is a no-op) |
0 |
!= NONE |
drop (device stays in offset mode; OPC_OFFSET(NONE) is the only exit) |
The two accept rows fire when the sender's intent matches the
receiver's stored state. The two drop rows fire on mismatches —
they are features, not bugs:
- The
F=1 + E=NONEdrop gives Strategy A (broadcastOPC_CONTROLwithF=1) its scope filter — a single broadcast lands on exactly the offset-configured devices. - The
F=0 + E=non-NONEdrop is the state-stickiness rule. Once a device has been transitioned into "offset mode" viaOPC_OFFSET(formula)+ materialisation, it stays there until it receivesOPC_OFFSET(NONE)+ materialisation. RandomF=0packets do not implicitly transition the device out — they're silently dropped. State transitions are explicit, not implicit.
Leaving offset mode (the only valid sequence):
- Send
OPC_OFFSET(NONE)to the target. SetspendingChange.mode = NONE,pendingChangeValid = true. Effective config is now NONE;F=0packets will match the gate. - Send a packet that materialises pending into active. Two options:
- An
OPC_PRESETwithF=0(the dispatch case callsmaterialisePendingChange()after the gate accepts). - An
ARM_ON_SYNCOPC_CONTROLwithF=0, followed byOPC_SYNC(the SYNC handler materialises pending then fires the queued effect). - After step 2,
active.mode = NONE,pendingChangeValid = false. The device is fully out of offset mode.
The host-side scene_runner's offset_group(mode=none) container
performs steps 1 + 2 in one operator action: Phase-1 sends
OPC_OFFSET(NONE) to the participants, Phase-2 sends each child
with F=0 (mode-conditional, see scene_runner_service.py).
Operator discipline: a normal (non-offset_group) scene's
children fly with F=0. If the targeted devices are still in
offset mode (i.e., the operator didn't clear first), the strict
gate drops every child silently. Visible symptom: the masterbar
shows TX activity but the devices don't react. Fix: run an
offset_group(mode=none, children=[…]) scene first to transition
the devices out of offset mode.
P_Sync — fire armed effects (OPC_SYNC, 4 B fixed)¶
ts24— gateway-relative 24-bit timestamp at which the receiver should fire its queuedarm_on_synceffect. The gateway'sts24clock is exposed viaEV_TX_DONEevents; the host treats it as opaque and just echoes back numbers it has seen.brightness— overrides any per-device brightness for this fire.0means "use stored brightness".
P_Config — configuration body (OPC_CONFIG + OPC_GET_CONFIG, 5 B fixed)¶
P_Config is the body for both the OPC_CONFIG write request
(M2N) and the OPC_GET_CONFIG reply (N2M). The reply mirrors the
write layout per option, so the host's existing OPC_CONFIG codec
parses both directions unchanged.
OPC_CONFIG is unicast-only — the device rejects broadcast
receivers. Reply is always OPC_ACK (sent before the option is
applied, since some options take time).
Multi-byte values are little-endian.
Properties vs Methods¶
The option codes split into two semantic categories. The split is not visible on the wire (every option uses the same 5-byte body), but it drives both the host UX and the read-back support:
- Properties are persistent values stored on the device. The
host can read them back via
OPC_GET_CONFIG(see below). The Device Options dialog renders them as input rows with a Save button and a divergence badge that compares the host-stored intent against the live device value. - Methods are one-shot side-effecting commands. There is no meaningful "current value" to read; once invoked, the device performs the action and ACKs. The dialog renders them as action buttons (with a confirm prompt for destructive ones).
- Hybrid options (
0x01,0x03,0x04) are persistent like properties but their state is exposed viaSTATUS_REPLY'sconfigByterather thanOPC_GET_CONFIG. From the operator UX they behave like methods (toggle commands).
| Hex | Name | Category | Read path |
|---|---|---|---|
0x01 |
MAC filter enable | Method (toggle) | STATUS_REPLY.configByte bit 0 |
0x02 |
Clear master MAC | Method (one-shot) | — |
0x03 |
MAC filter persist | Method (toggle) | STATUS_REPLY.configByte bit 1 |
0x04 |
WLAN AP open/closed | Method (toggle) | STATUS_REPLY.configByte bit 2 |
0x05 |
Target FPS | Property | OPC_GET_CONFIG |
0x06 |
Segment 0 geometry | Property (uint16 pair) | OPC_GET_CONFIG |
0x07 |
Segment 1 geometry | Property (uint16 pair) | OPC_GET_CONFIG |
0x08 |
ABL max mA | Property | OPC_GET_CONFIG |
0x09 |
Default brightness briS |
Property | OPC_GET_CONFIG |
0x0A |
Transition duration | Property | OPC_GET_CONFIG |
0x0F |
Clear all overrides | Method (destructive — "Reset to RaceLink defaults") | — |
0x80 |
Forget master MAC | Method (destructive) | — |
0x81 |
Reboot node | Method (destructive) | — |
0x8C |
STARTBLOCK number of slots (DEV_TYPE 50/51) | Property | OPC_GET_CONFIG |
0x8D |
STARTBLOCK first slot (DEV_TYPE 50/51) | Property | OPC_GET_CONFIG |
Identity / wireless config (0x01..0x04)¶
| Option | Hex | Wire | Meaning |
|---|---|---|---|
| MAC filter enable | 0x01 |
data0: 0 disable / 1 enable |
Toggle the per-node sender MAC allowlist. |
| Clear learned master MAC | 0x02 |
data0: any (typically 1) |
Forget the bound master, return the node to "unconfigured" so it accepts the next discovery. |
| MAC filter persist | 0x03 |
data0: 0 disable / 1 enable |
Whether the bound master MAC survives reboots. |
| WLAN AP open/closed | 0x04 |
data0: 0 closed / 1 open |
Open or close the node's WiFi AP on demand. |
RaceLink-authorised LED-config overrides (0x05..0x0A, 0x0F)¶
These option codes set persistent host-authorised overrides of the
device's compile-time RaceLink defaults. See
opcodes.md §"OPC_CONFIG — device configuration"
for the override semantics, persistence, and Policy A vs Policy B
distinction.
| Option | Hex | Wire | Meaning |
|---|---|---|---|
| Set FPS override | 0x05 |
data0: uint8 fps (0–250) |
Policy A: override RACELINK_DEFAULT_FPS. Persisted in cfg.json as RaceLink.overrides.fps. |
| Set Segment 0 geometry | 0x06 |
data0..1: uint16 LE start; data2..3: uint16 LE stop |
Policy B: set seg[0] start/stop. Persisted as RaceLink.overrides.seg0={start,stop}. |
| Set Segment 1 geometry | 0x07 |
data0..1: uint16 LE start; data2..3: uint16 LE stop |
Policy B: set seg[1] start/stop. Device appends seg[1] if missing. Persisted as RaceLink.overrides.seg1={start,stop}. |
| Set ABL max mA override | 0x08 |
data0..1: uint16 LE mA (0 = ABL disabled) |
Policy A: override RACELINK_DEFAULT_ABL_MAX_MA. Persisted as RaceLink.overrides.abl. |
| Set default brightness override | 0x09 |
data0: uint8 briS (0–255) |
Policy B: override the persisted boot brightness. Persisted as RaceLink.overrides.bri. |
| Set transition duration override | 0x0A |
data0..1: uint16 LE ms |
Policy B: override transitionDelayDefault. Persisted as RaceLink.overrides.tt. |
| Clear all RaceLink overrides | 0x0F |
data0..3: 0 |
Method (destructive) — surfaced in the Device Options dialog as "Reset to RaceLink defaults". Resets every *Set flag to false. Compile-time defaults re-take effect on next boot (Policy A) or operator-saved values are honoured again (Policy B). |
System commands (0x80..0x8F)¶
| Option | Hex | Wire | Meaning |
|---|---|---|---|
| Reboot node | 0x81 |
data0: 1 to reboot |
Sets doReboot = true; reboot happens in main loop. |
| Number of slots (startblock, DEV_TYPE 50/51) | 0x8C |
data0: 1–8 |
ePaper layout slot count. |
| First slot (startblock, DEV_TYPE 50/51) | 0x8D |
data0: 1–8 |
ePaper layout starting slot. |
Unknown option codes hit the else branch silently (no NACK) — the
ACK was already sent. Senders should not rely on unknown-option
detection; verify support out-of-band.
P_GetConfig — read-back request (OPC_GET_CONFIG, 1 B fixed)¶
OPC_GET_CONFIG (opcode 0x0A) requests the device's live value
for one property-class option (see Properties vs Methods
above). Reply uses the same opcode with the N→M direction bit and a
P_Config-shaped 5-byte body — the data bytes carry the live value
packed per-option in the same little-endian layout as the matching
OPC_CONFIG write command.
- Unicast-only. Different device classes interpret options
differently, so a broadcast read would be ambiguous. Senders MUST
use a concrete
recv3; the firmware drops broadcast receivers. - Property-only. Method codes (
0x01–0x04,0x0F,0x80–0x81) are write-only; sendingOPC_GET_CONFIGfor them produces no reply and the host'ssend_and_matchwaiter times out gracefully. The dialog renders the row's device-side value asdevice: ?with a Retry button. - Reply timing. The firmware reads the property's live runtime value (not the override slot). For Policy A overrides this equals "override-or-compile-default"; for Policy B it equals "override-or-operator-cfg". Either way the host gets the answer to "what is the device using right now", which is what the Device Options dialog's divergence check needs.
- Codec note. The host's
parse_reply_eventdecodes a 5-byte reply for opcode0x0Ainto aGET_CONFIG_REPLYevent carryingoption,data0..3. ThePendingMatchermatches on(sender, expected_opcode, discriminator_field="option")so two simultaneous reads for different options on the same device cannot wake each other's waiter (the secondary-discriminator field preserves the iteration-3 fix under the unified matcher).
P_Headless — Headless-Mode catalog trigger (OPC_HEADLESS, 2 B fixed)¶
Renamed from OPC_SCENE / P_Scene on 2026-05-17; see the
§"Naming history" appendix for the wire-vs-identifier guarantee.
The receiver-side helper / enum names keep "scene" terminology
because each catalog row is internally still called a "Headless
scene".
sceneId— wire-stable identifier from the shared Headless catalog inracelink_headless.h::HeadlessSceneId(SCENE_OFFSET_BREATHE = 0,SCENE_SOLID_RED = 1,SCENE_SOLID_GREEN = 2,SCENE_ALL_OFF = 3,SCENE_RESTORE_BOOT_COLOR = 4). Unknown ids on a receiver that pre-dates the catalog row are silently dropped viafindSceneById() == nullptr— forward-compatible.brightness— desired strip brightness while the scene runs.
Receivers expand the row locally (fxMode, speed, intensity, color1,
plus a per-group phase offset base + groupId * step for rows that
declare SCENE_FLAG_USE_OFFSET). There is no separate OPC_OFFSET
on the wire for Headless catalog triggers — the offset formula is
part of the catalog row and applied receiver-side, which keeps the
wire to a single packet per Headless trigger and avoids the
single-slot TX queue race the naïve pre-emit would trigger.
Reply: none (RESP_NONE). Always broadcast (recv3 = FFFFFF);
group filtering does not apply — every receiver expands the row.
OPC_HEADLESS is emitted exclusively by a device running in Headless
Master mode (or by external Gateway-side software that includes
racelink_headless.h). The wire body is identical regardless of
which side built it.
P_Indicate — status-indicator overlay (OPC_INDICATE, 2 B fixed)¶
type— wire-stable identifier from the shared indicator catalog inracelink_indicators.h::IndicatorType(IND_PAIR_CONFIRMED = 0,IND_PROBE_REJECTED = 1,IND_HEADLESS_ENTER = 2,IND_HEADLESS_EXIT = 3,IND_IDENTIFY = 4). Unknown types are silently dropped — forward-compatible.durationSec— how long the overlay runs, in seconds (0..255).durationSec == 0is a cancel signal: the receiver clears any active indicator without showing a new one. Use it to abort a long-running indicator from the Host without waiting for it to expire.
Receivers render the indicator as a frame-buffer overlay via
WLED's Usermod::handleOverlayDraw() callback (fires after every
segment effect has rendered and blended, immediately before
strip.show()). The underlying effect's segment mode, palette,
colour slots, and SEGENV runtime state stay untouched — the
strobe is purely a pixel-level overwrite for the duration of the
overlay. Consequences:
- Fleet phase sync is preserved automatically. Time-driven effects (Traffic Light, etc.) continue advancing their phase during the overlay; on indicator expiry the device is in the exact phase its fleet-mates are in, with zero catch-up cycling.
- No snapshot / restore. The pre-indicator visual reappears
the instant
activeflips back tofalse— there's nothing saved that needs restoring. - New wire commands during the overlay (
OPC_HEADLESS,OPC_CONTROL,OPC_PRESET) take over the segment normally; the host can also preempt by re-issuingOPC_INDICATEwith a new catalog row or withdurationSec == 0to cancel.
The catalog is STROBE-only (fxMode 23) by design and pinned to
the WLED-effective speed range 235..252 on a 3-tier urgency
code: 235 = slow / positive, 245 = medium / informational /
operator action, 250 = fast / error. Colour encodes the event
category via channel dominance (green = success, blue =
promotion, red = error, red+blue = operator-locate, mixed warm =
demotion). Pure red / green / blue / white are deliberately
avoided so an indicator cannot be confused with a normal scene
colour. See
RaceLink_WLED/indicators.md
for the full catalog table and rendering details.
Reply: none (RESP_NONE). Wire shape is identical for broadcast
(recv3 = FFFFFF, e.g. fleet-wide IDENTIFY pulse) and unicast (a
Host pinging one specific device); the firmware does not distinguish.
P_RfConfig — runtime RF settings (12 B fixed)¶
Carried by three different opcodes/commands depending on which radio gets re-configured:
OPC_RF_CONFIG— LoRa, host → node. The node validates the body, persists to NVS, ACKs (ACK_OKorACK_BAD_*), then reboots ~50 ms later onto the new settings. The reboot drops the link by design — the operator-facing outcome is the ACK, not a post-reboot heartbeat. Applying a new RF config also moves the node to a different radio network, so before rebooting the node disables master persistence: it comes back up open to pairing and re-binds to whichever gateway owns the destination network. The stored master slot is left intact. See WLED master pairing. Note the gateway only forwards this opcode if it has a forwarder case — see Gateway opcode forwarding.GW_CMD_SET_RF_CONFIG— USB-CDC, host → gateway. The gateway either live-reconfigures (persist=false) or writes NVS + reboots (persist=true). EmitsEV_RF_CHANGED~100 ms before the reboot so the host still catches it.OPC_GET_RF_CONFIG(LoRa) andGW_CMD_GET_RF_CONFIG(USB-CDC) — the read-back forms. The node's reply is the sameP_RfConfigbody inside anOPC_GET_RF_CONFIGreply; the gateway's reply is anEV_RF_CHANGEDwithreason=OKand the current NVS contents.
Wire layout (little-endian throughout, 12 bytes fixed):
freq_hz (4) uint32 — carrier frequency in Hz, e.g. 867_700_000
bw_khz_x10 (2) uint16 — bandwidth × 10 (1250 = 125.0 kHz)
sf (1) uint8 — spreading factor, 5..12
cr_den (1) uint8 — coding rate denominator, 5..8 ⇒ 4/CR
sync_word (1) uint8 — LoRa SyncWord (PHY discriminator)
tx_power_dbm (1) int8 — TX power, -9..22, SIGNED
preamble (2) uint16 — preamble symbols
tx_power_dbm is signed — host-side codecs use
struct.pack("<IHBBBbH", …). The single-byte sync_word is the
LoRa PHY's discriminator: two transmitters on the same frequency
but different SyncWords look like noise to each other's receiver.
OPC_RF_CONFIG is broadcast-forbidden — broadcasting a new RF
config would brick every reachable node. The firmware rejects
recv3 == FFFFFF; the host's
gateway_service.set_node_rf_config(...) defence-in-depth check
refuses to even hit the wire with a broadcast.
NVS is the single source of truth on every device. The firmware
writes a CRC + boot-counter alongside the saved config and falls
back to the compile-default after three failed boots — corruption
recovery without operator action. See
RaceLink_Host/multi-network.md
for the operator workflow that drives OPC_RF_CONFIG (Migration)
and GW_CMD_*_RF_CONFIG (Network Manager + bind wizard).
The EV_RF_CHANGED USB-signal frame carries a reason byte in
front of the P_RfConfig:
reason |
Name | Meaning |
|---|---|---|
0x00 |
RF_CHANGE_OK |
Apply succeeded; body carries the now-active config |
0x01 |
RF_CHANGE_REJECTED_RANGE |
One of the fields was out of legal range |
0x02 |
RF_CHANGE_REJECTED_NVS |
NVS write failed; gateway stays on the previous config |
0x03 |
RF_CHANGE_REJECTED_CRC |
Persisted-record CRC mismatch on read-back |
0xFF |
unknown | Forward-compat fallback |
On RF_CHANGE_REJECTED_* the body carries the still-active
config — the host can use that to render "we tried X, gateway
stayed on Y".
The shipped channel table in
docs/reference/channels.md is what
the Network Manager dialog binds to; an Advanced-mode operator
can also POST raw rf_config dicts to the host's
PUT /api/networks/{id} endpoint to bypass the table.
Other bodies¶
get_devices, set_group, status, stream, ack body layouts
are documented inline in
racelink/protocol/packets.py
via build_*_body and the matching parsers in
racelink/protocol/codec.py.
Flags byte¶
Six user-intent flags share the same byte across OPC_PRESET and
OPC_CONTROL (and the persisted form on RL presets):
| Bit | Constant | Meaning |
|---|---|---|
| 0 | RL_FLAG_POWER_ON |
Brightness > 0 (auto-derived) |
| 1 | RL_FLAG_ARM_ON_SYNC |
Defer apply until next OPC_SYNC |
| 2 | RL_FLAG_HAS_BRI |
Brightness field is meaningful |
| 3 | RL_FLAG_FORCE_TT0 |
Force transition time 0 (no fade) |
| 4 | RL_FLAG_FORCE_REAPPLY |
Re-apply even if state hasn't changed |
| 5 | RL_FLAG_OFFSET_MODE |
Use the device's stored offset (gates participation) |
Construction is always via
racelink/domain/flags.py::build_flags_byte
on the host side — never hand-assemble the byte.
USB-signal frames (gateway → host only)¶
These frames carry no LoRa payload; they are gateway-internal
notifications. Batch B (2026-04-28) consolidated the pre-existing
EV_RX_WINDOW_OPEN/CLOSED pair into EV_STATE_CHANGED and added
two new events for the synchronous-send contract:
| Constant | Hex | Body | Meaning |
|---|---|---|---|
EV_ERROR |
0xF0 |
UTF-8 reason / reason byte(s) | The gateway hit a fault |
EV_STATE_CHANGED |
0xF1 |
[state_byte, [metadata]] |
Gateway's internal state machine transitioned (see Gateway state machine below) |
EV_TX_DONE |
0xF3 |
last_len (uint8) |
Outcome event: a host-initiated frame completed transmission |
EV_TX_REJECTED |
0xF4 |
[type_full, reason_byte] |
Outcome event: the gateway refused a host-initiated send (see Reason codes below) |
EV_STATE_REPORT |
0xF5 |
[state_byte, [metadata]] |
Reply to GW_CMD_STATE_REQUEST; same body shape as EV_STATE_CHANGED |
EV_RF_CHANGED |
0xF6 |
[reason, P_RfConfig (12)] |
Reply to GW_CMD_SET_RF_CONFIG / GW_CMD_GET_RF_CONFIG. The gateway also emits this ~100 ms BEFORE rebooting on a persist-mode SET so the host still catches it. See P_RfConfig body section above. |
Retired (do not use, byte values reused):
EV_RX_WINDOW_OPEN(was0xF1) — replaced byEV_STATE_CHANGED.EV_RX_WINDOW_CLOSED(was0xF2) — subsumed byEV_STATE_CHANGED(IDLE)/EV_STATE_CHANGED(RX)depending on default RX mode.EV_IDLE(was0xF4) — the byte was repurposed asEV_TX_REJECTED.
EV_TX_DONE / EV_TX_REJECTED are outcome events — paired 1:1
with the host's _send_m2n synchronous wait. EV_STATE_CHANGED is
the transition event for the gateway's state machine; it drives
the master pill verbatim with no host-side derivation.
Gateway state machine¶
The gateway runs a finite state machine (see
RaceLink_Gateway/src/main.cpp's setGatewayState). The state set
depends on the gateway's setDefault* mode at boot:
| State | Byte | Meaning (default mode) |
|---|---|---|
IDLE |
0x00 |
setDefaultRxContinuous: in continuous RX, ready for next host TX |
TX |
0x01 |
Transmitting (between scheduling and tx-done) |
RX_WINDOW |
0x02 |
Bounded RX window open. Metadata = min_ms (uint16 LE) |
RX |
0x03 |
setDefaultRxNone only: actively receiving |
ERROR |
0xFE |
Gateway hit a fault. Metadata = reason byte(s) or empty |
UNKNOWN (0xFF) is a host-only sentinel used between USB connect
and the first EV_STATE_REPORT reply.
Typical transitions under setDefaultRxContinuous (the gateway's
current setup):
[IDLE] ──host TX accepted──► [TX]
[TX] ──TX completes──► [IDLE] (also emits EV_TX_DONE)
[IDLE] ──host TX rejected──► [IDLE] (also emits EV_TX_REJECTED; state byte unchanged)
[IDLE] ──open bounded RX window──► [RX_WINDOW]
[RX_WINDOW] ──window expires / close──► [IDLE]
[any] ──fault──► [ERROR] (also emits EV_ERROR)
[ERROR] ──recovery──► [IDLE]
The gateway emits exactly one EV_STATE_CHANGED per actual
transition (idempotent sets are deduplicated). Outcome events
(EV_TX_DONE, EV_TX_REJECTED) are emitted in addition to the
state transition they cause.
EV_TX_REJECTED reason codes¶
Body byte 1 of EV_TX_REJECTED. Body byte 0 echoes the rejected
packet's type_full so the host can match the NACK to the offending
send.
| Constant | Hex | Meaning |
|---|---|---|
TX_REJECT_TXPENDING |
0x01 |
Gateway already transmitting (single-slot scheduler busy) |
TX_REJECT_OVERSIZE |
0x02 |
Body exceeded sizeof(rl.txBuf) |
TX_REJECT_ZEROLEN |
0x03 |
Body empty / zero-length (host-side framing bug) |
TX_REJECT_UNKNOWN |
0xFF |
Defence-in-depth fallback |
Host → Gateway commands (USB-only)¶
Sent as the TYPE byte in [0x00][LEN][TYPE][DATA] framing; never
travel on the LoRa wire.
| Command | Hex | Payload | Meaning |
|---|---|---|---|
GW_CMD_IDENTIFY |
0x01 |
(none, 1-byte payload [0x01]) |
Port-discovery ping; gateway replies with its identity string |
GW_CMD_SET_RF_CONFIG |
0x02 |
[P_RfConfig (12), flags (1)] |
Reconfigure the gateway's RF settings. flags bit 0 = persist-to-NVS (1 writes NVS + reboots, 0 is volatile / no reboot). Reply: EV_RF_CHANGED. |
GW_CMD_GET_RF_CONFIG |
0x03 |
(none, 1-byte payload [0x03]) |
Read the gateway's current NVS RF settings. Reply: EV_RF_CHANGED with reason=OK. |
GW_CMD_STATE_REQUEST |
0x7F |
(none, 1-byte payload [0x7F]) |
Ask gateway for current state; reply is EV_STATE_REPORT |
Host ↔ Gateway flow control¶
The gateway's TX scheduler (RaceLinkTransport::scheduleSend in
the gateway firmware) is still single-slot, no queue, but Batch
B (2026-04-28) added a typed NACK on every rejection:
bool ok = RaceLinkTransport::scheduleSend(rl, buf, len, jitterMaxMs);
if (!ok) {
usb_send_tx_rejected(type_full, /* reason byte */);
}
The wrapper try_schedule_or_nack(...) in
RaceLink_Gateway/src/main.cpp pre-checks the same conditions
(txPending, oversize, zero-length) and emits the matching reason
code so the host always gets either a TX_DONE (success) or a
TX_REJECTED (refusal) for every host-initiated frame.
The host's send path is now synchronous — see
racelink/transport/gateway_serial.py:
def _send_m2n(...) -> SendOutcome:
"""SendOutcome ∈ { SUCCESS, REJECTED(reason), TIMEOUT, USB_ERROR }.
Synchronous: writes the frame, blocks on a Condition until the
matching outcome arrives or the 2 s deadlock guard fires."""
Guarantees:
- Exactly one outcome per call. No "did my packet make it?"
guesswork — the gateway either sent it (
TX_DONE) or refused it (TX_REJECTEDwith reason). - Bounded latency. Capped at
SEND_OUTCOME_TIMEOUT_S = 2.0s, typically <500 ms with a healthy gateway under SF7. - One in flight at a time. The host's
_tx_lock/_tx_outcome_cvpair enforces 1-in-flight; pipelining isn't supported.
This collapses the pre-Batch-B body-length-scaled TX barrier
(TX_BARRIER_FLOOR_S etc.) into a single 2 s deadlock guard — the
barrier was a workaround for the silent-drop hazard that
EV_TX_REJECTED now eliminates.
State queries (GW_CMD_STATE_REQUEST → EV_STATE_REPORT)¶
The host can ask the gateway for its current state at any time via
gateway_service.query_state() (which writes [0x00][0x01][0x7F]
and waits for the matching EV_STATE_REPORT, bounded to 500 ms).
Used at:
- Startup: pill seeds from the reply (otherwise it sits in
UNKNOWNuntil the next spontaneous transition). - After USB reconnect: re-syncs the host mirror.
- Operator request: the master-pill
↻button callsPOST /api/gateway/query-state. - Internal recovery: after a
_send_m2nTIMEOUToutcome, a follow-up state query is a useful diagnostic to verify the gateway is alive before the next send.
Open future work (firmware)¶
- Buffered burst tolerance. A small queue (e.g. 4 entries)
inside
scheduleSendwould let the gateway absorb LBT-window bursts without surfacingTX_REJECTEDto the host. Trade-off: introduces ordering + fairness concerns and a small memory cost; keep deferred until a workload actually needs it. The per-rejection NACK already prevents silent drops. - Host-driven auto-sync. Today's gateway emits a periodic
auto-sync TX every 30 s when idle; if it fires while a host
send is staged, the host sees an unexpected
EV_TX_REJECTED. Migrating auto-sync to host-scheduled would close that small race. Currently mitigated by the host retrying onREJECTED(txPending)outcomes. The protocol-level race that blocked an earlier migration attempt — interval autosync materialising armed effects ahead of the scene's deliberate sync — is now resolved by theSYNC_FLAG_TRIGGER_ARMEDsemantics described below; only the timer itself remains as follow-up work.
OPC_SYNC variants¶
OPC_SYNC is variable-length (4 or 5 bytes) and serves two
distinct roles, distinguished by the optional flags byte:
- 4 B (legacy / clock-tick form).
ts24_0..2+brightness. The device unwraps the 24-bit master timestamp and adjusts itsstrip.timebase. Pending arm-on-sync state stays armed —pending.validis not materialised. This is the form used by autosync (today gateway-driven, eventually host-driven). - 5 B (flag-bearing / deliberate-fire form). Same first four
bytes plus a trailing
flagsbyte. Bit 0 (SYNC_FLAG_TRIGGER_ARMED = 0x01) tells the device to materialise any pending arm-on-sync state in addition to the unconditional timebase adjustment. Used by the scene runner's_run_syncand any operator-driven manual fire.
Bits 1-7 are reserved. Bit 1 is earmarked for a future
HAS_GROUP_MASK extension carrying a per-group selector that
would let one SYNC fire armed effects on a subset of groups
without disturbing the rest.
The device-side RULES table has req_len = 0 for OPC_SYNC
so both lengths pass the dispatch length check; the body is
validated inline (bodyLen >= 4 && bodyLen <= sizeof(P_Sync)).
The gateway also accepts both lengths from the host and passes
the flags byte through its re-stamp so the trigger bit reaches
the nodes end-to-end. Synchronised rollout is required: an
old-firmware node has req_len = 4 strict and rejects the 5 B
deliberate-fire packet; flash every node before deploying a new
host. The gateway-side WAIT_HOST_TRIGGER inhibit stays as
defence-in-depth.
USB latency tuning (host)¶
The USB-CDC bridge chip on the gateway (CP210x / FTDI / CH340) defaults to a 16 ms latency_timer that buffers small RX bursts before flushing to the host. For RaceLink's interactive small-frame traffic this dominates per-packet wall-clock — empirical baseline was ~25 ms per send before tuning.
The host transport
(gateway_serial.py)
mitigates this in three ways automatically on every open:
set_low_latency_mode(True)— pyserial >= 3.4, Linux only. WritesASYNC_LOW_LATENCYviaTIOCSSERIALso the kernel USB-serial driver shrinks the bridge's effective poll interval to ~1 ms. Largest single impact (~8-16 ms saved per send).ser.flush()after every write — forces the OS USB-serial buffer onto the wire instead of waiting for it to coalesce with a follow-up write. ~1-3 ms saved.- Chunked
read(in_waiting)in_reader— pulls every byte already buffered in one syscall instead ofread(1)per byte. ~2-5 ms saved at the next syscall boundary.
The gateway firmware also
(main.cpp::usb_send_frame)
coalesces the 4-part USB event (SOF + LEN + TYPE + DATA) into
a single buffered Serial.write(buf, len) so the bridge sees one
USB transaction per event instead of four. ~2-5 ms saved on the
gateway → host path.
Operational fallback (e.g. running on Windows where
set_low_latency_mode is a no-op, or where the user can't
upgrade pyserial): on Linux, the latency_timer can be tuned
manually via sysfs (requires root or a udev rule):
# One-shot:
echo 1 | sudo tee /sys/bus/usb-serial/devices/ttyUSB0/latency_timer
# Persistent via udev (replace 10c4:ea60 with your bridge's VID:PID
# from `lsusb`):
sudo tee /etc/udev/rules.d/99-racelink-low-latency.rules <<'EOF'
SUBSYSTEM=="usb-serial", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", \
ATTR{latency_timer}="1"
EOF
sudo udevadm control --reload-rules && sudo udevadm trigger
The host's set_low_latency_mode call achieves the same effect
without sudo; the manual sysfs route is the fallback when that
call is unavailable.
Direction + response policies¶
Every opcode has a (direction, response) rule in
racelink/protocol/rules.py::RULES.
The host reads this rule to decide whether send_*_and_wait is
applicable. Possible response policies:
RESP_NONE— fire and forget (broadcasts;OPC_SYNC;OPC_OFFSET).RESP_ACK— receiver replies withOPC_ACK(most M2N unicasts).RESP_SPECIFIC— receiver replies with a specific opcode (e.g.OPC_DEVICES→IDENTIFY_REPLY).
The host's send_and_wait_with_retries / send_and_match use this
rule to populate the right PendingMatcher field
(expected_ack_of for RESP_ACK, expected_opcode for
RESP_SPECIFIC) — see
Reply Matching (PendingMatcher).
Versioning¶
Defined in racelink_proto.h. Bump MINOR for backward-compatible
additions (new opcodes, new optional flag bits); bump MAJOR for
breaking changes (struct reshapes, opcode reuses). The proto-drift
test (tests/test_proto_header_drift.py) catches accidental
divergence; intentional changes still need a coordinated commit
across all three repos (Host, Gateway, WLED).
Where things live in code¶
| Layer | Path |
|---|---|
| C header (source of truth) | racelink_proto.h |
| Auto-generated Python mirror | racelink/racelink_proto_auto.py |
| Generator | gen_racelink_proto_py.py |
| Body builders | racelink/protocol/packets.py |
| Reply parsers | racelink/protocol/codec.py |
| Per-opcode rules | racelink/protocol/rules.py |
| USB framing | racelink/transport/framing.py |
| Transport sender / reader | racelink/transport/gateway_serial.py |
| Drift regression test | tests/test_proto_header_drift.py |
Naming history¶
Identifier renames over the wire's lifetime. The wire byte values never changed in any of these renames — receivers parse by byte value, so a binary built before the rename interoperates byte-for- byte with one built after. Only the C identifiers (and a few host / WebUI labels) moved.
| Date | Old name | New name | Wire byte | Rationale |
|---|---|---|---|---|
| 2026-04-25 ("Phase D") | OPC_CONTROL |
OPC_PRESET |
0x04 |
The legacy OPC_CONTROL was actually "apply a WLED preset slot". Renamed for clarity against the new direct-effect-parameter opcode below. |
| 2026-04-25 ("Phase D") | OPC_CONTROL_ADV |
OPC_CONTROL |
0x08 |
Promoted to the canonical name now that the slot-apply opcode is OPC_PRESET. |
| 2026-05-04 (host-side cleanup) | wled_control Specials function |
rl_preset |
n/a (host symbol) | The Specials picker was actually choosing an RL preset and emitting OPC_CONTROL. Label "WLED Control" → "RaceLink Preset". |
| 2026-05-04 (host-side cleanup) | wled_control scene action kind |
rl_effect |
n/a (host symbol) | The scene action was inline effect parameters with no preset id, emitting OPC_CONTROL. Field set realigned to the 14-field RL-preset editor schema. |
| 2026-05-17 | OPC_SCENE / P_Scene |
OPC_HEADLESS / P_Headless |
0x0B |
Keeps the bare term "Scene" free for a future host-level RaceLink-Scene opcode (today's host-side RaceLink Scenes travel as OPC_CONTROL; the host-level rename is a separate, later step). Field name sceneId and the receiver-side helper / enum names keep "scene" terminology because each catalog row is internally still called a "Headless scene". |
| 2026-05-17 (firmware-internal) | IND_TX_BLIP |
IND_PAIRING_TX |
catalog ID 5 (never on the wire) |
The indicator fired for routine SYNC keepalive, drowning out the user-relevant "I'm pairing a device" cue. The narrowed semantics are reflected in the new name; the catalog ID is append-only and wire-stable. |