-
Notifications
You must be signed in to change notification settings - Fork 3
Receiver Firmware
The custom 2.4 GHz receiver firmware lives at devices/os4_receiver/os4_receiver.ino. This page is a deep-dive on what it does, what it talks to, and how it talks.
For build and flash instructions see Flashing a receiver. For OTA flashing see OTA flashing.
| Component | Choice |
|---|---|
| MCU | ESP32-S2 (LOLIN S2 Mini family) |
| Radio | Nordic nRF24L01+ (with PA/LNA on production boards), 250 kbps default |
| Cue strip | NeoPixel chain (one cue indicator per cue, 8 per board, up to 16 boards) |
| Status strip | 3 NeoPixels (RX activity / sync / load state) |
| Cue bus | Bidirectional shift-register chain (output: fire one-hot bits; input: continuity sense bits) |
| Battery sense | Analog read on BATT_VOLTAGE_PIN
|
| Board count detect | Analog ladder on BOARD_CT_PIN (1–8 boards) |
| USB | USB-C, native USB-CDC for serial provisioning + USB-PD for charging (PD/charging is hardware, not firmware) |
| Persistent storage | NVS (Preferences, namespace byh_rx) |
| Pin | Use |
|---|---|
| 37 | RF24 CE |
| 36 | RF24 CSN |
| 35 / 33 / 34 | SPI MISO / MOSI / SCK |
| 11 | NeoPixel data — cue strip |
| 17 | NeoPixel data — status strip |
| 3 | Battery sense (ADC) |
| 2 | Board count detect (ADC ladder) |
| 10 / 9 / 13 / 7 | Output shift register: clock / latch / OE / data |
| 6 / 5 / 12 | Input shift register: clock / latch / data |
Older revisions (board version < 6) use different RF24 pins; defines fall back automatically.
- Configure the output shift chain (
SHIFT_OUT_OE) and clear all bits — guarantees no cue is energized at power-on. -
Serial.begin(115200)withsetTxTimeoutMs(50)so a slow USB host can't block the firmware. -
loadIdentityFromNVS()— readnode_idandidentfrom NVS namespacebyh_rx. Ifnode_id == 0, the receiver is UNPROVISIONED — radio stays off, status strip slow-breathes magenta, only serial commands are accepted (SETID,GETID,WIPEID). -
loadFireDurationFromNVS()— readfire_dur(default 1000 ms if missing or out of range 50–5000). -
setBoardCount()— read the analog ladder, deriveNUM_BOARDS(1–8) and setnoBoardsDetected = trueif the analog read came back as 0. -
statusLEDStartupSequence()— light a 3-LED visual that encodes the FW version (3 decimal digits), battery percent (bar of pixels), andNODE_ID(3 decimal digits) for ~1.5 s of "you can see it's been flashed and which version it is" indication. -
testLEDStrip()— sweep the cue strip green and blue. - (provisioned only) Radio init:
radio.begin()(loop until OK), 250 kbps, max PA level on board ≥ 6, channel 85,setRetries(15, 5)(5 retries, 4 ms apart), CRC16, auto-ACK with ACK payloads enabled, dynamic payloads. -
openReadingPipe(0, receiverReadAddress()),startListening(), prime the first ACK payload.
After boot, the main loop runs at full ESP32 speed and does:
- Drain the RF RX FIFO into
handleIncoming(one or more frames per iteration). - Refresh the ACK payload with the latest
RECEIVER_STATUS(so the next inbound command's ACK is accurate). -
runPlayLoop— if a show is loaded andisPlaying, walk the schedule. -
handleInputMode(every 100 ms) — sample the input shift register for continuity, update LEDs. -
handleSerial— acceptSETID/GETID/WIPEIDover USB-CDC at any time. -
serviceOtaLoopIteration— if OTA is active, drive the chunk receive state machine. - Adaptive disconnect detection (see below).
There's no over-the-air pairing handshake. Each receiver listens on a 64-bit address derived deterministically from its NODE_ID and a global rfSystemId:
receiverReadAddress() = ((uint64_t)rfSystemId << 32) + RECEIVER_BASE + NODE_IDRECEIVER_BASE = 1. rfSystemId = 0 by default. So with system ID 0, receiver RX146 listens on address 1 + 146 = 147 (in the low 32 bits).
The dongle has the inverse receiverAddress(nodeID) and will write to that address whenever it has a command for RX146. The dongle learns about each receiver from its host-side poll list (which the daemon populates from the Receivers SQL table).
rfSystemId exists so two independent firing systems on the same channel won't collide. Both sides have to agree on it. Today it's always 0; future multi-system installs would set it via the dongle's {"rf_system_id": N} JSON config and reflash receivers with the matching value.
Hard-coded rfChannel = 85 (the highest legal nRF24 channel — sits well above WiFi). The dongle can hot-swap its channel at runtime via {"rf_channel": N}; receivers cannot — they need a reflash to match. So if you change RF channel, plan to reflash the entire fleet.
All over-the-air payloads are packed C structs, max 32 bytes each. Each frame begins with a uint8_t type opcode. The receiver validates the message size before reading any other field.
| Opcode | Name | Body | Meaning |
|---|---|---|---|
| 1 | MANUAL_FIRE |
position: uint8 |
Fire one cue immediately. |
| 2 | CLOCK_SYNC |
timestamp: uint64 |
Update host time offset. |
| 3 | START_LOAD |
numTargetsToFire: u8, showId: u16
|
Reset and prepare to receive show schedule. |
| 4 | SHOW_LOAD |
up to 2 × (time_ms: u32, position: u8) |
Add up to 2 cues to schedule. (Legacy — replaced by SHOW_LOADN.) |
| 11 | SHOW_LOADN |
count: u8, then count × (time: u32, position: u8) |
Add up to 6 cues per frame. |
| 9 | SHOW_START |
targetStartTime: u64, numTargetsToFire: u8, showId: u16
|
Receiver acknowledges with startReady=true if loaded and showId matches. |
| 5 | GENERIC_PLAY |
(none) | Begin firing from the loaded schedule. |
| 6 | GENERIC_STOP |
(none) | Stop firing immediately. |
| 7 | GENERIC_RESET |
(none) | Clear the loaded schedule. |
| 8 | GENERIC_PAUSE |
(none) | Freeze schedule, accumulate pause offset. |
| 12 | RESET_DVC |
(none) | Same as GENERIC_RESET. |
| 13 | OTA_BEGIN |
totalSize: u32, totalChunks: u16, dataRate: u8, crc32: u32
|
Allocate Update partition. |
| 14 | OTA_DATA |
chunkIdx: u16, raw bytes |
Append chunk to OTA partition. |
| 15 | OTA_END |
(none) | Finalize and reboot. |
| 16 | OTA_ABORT |
(none) | Tear down OTA. |
| 18 | RECEIVER_CONFIG_QUERY |
flags: u8, fire_duration_ms: u16
|
Optionally write fire duration; load CONFIG_RESPONSE into next ACK payload. |
Every command from the dongle gets an auto-ACK. The receiver loads its uplink state into the ACK payload so it rides back inside the same packet. Two ACK payload variants:
RECEIVER_STATUS (default — refreshed before every TX wait):
| Field | Size | Meaning |
|---|---|---|
type |
u8 | 10 |
nodeID |
u8 | This receiver's NODE_ID |
batteryLevel |
u8 | Scaled 5..253 |
showState |
u16 | Lower 14 bits = currentShowId; bit 14 = loadComplete; bit 15 = startReady
|
ident |
char[10] | First 10 chars of RECEIVER_IDENT
|
cont64_0 |
u64 | Continuity bits, cues 0..63 |
cont64_1 |
u64 | Continuity bits, cues 64..127 |
RECEIVER_CONFIG_RESPONSE (queued after a RECEIVER_CONFIG_QUERY):
| Field | Size | Meaning |
|---|---|---|
type |
u8 | 19 |
nodeID |
u8 | |
fwVersion |
u8 | Currently 23 |
boardVersion |
u8 | Currently 9 |
numBoards |
u8 | Detected from analog ladder |
noBoardsDetected |
u8 | 1 if ladder reads ~0 |
cuesAvailable |
u16 | numBoards * 8 |
fireDurationMs |
u16 | Active fire duration |
reserved[8] |
u8[8] | For future use |
The dongle pulls these out of radio.read() after a successful TX with ACK and forwards them up to the host as rxupd / rxcfg JSON lines.
Each board has 8 outputs driven by a shift register. To fire cue i:
- Compute
boardIndex = i / 8,bit = i % 8. - Set the corresponding bit in
shiftData[boardIndex]. -
writeOutputShiftRegister: shift out one byte per board (MSB-first), starting from the highest-index board (the one furthest down the chain), latch, driveSHIFT_OUT_OEto enable outputs.
Multiple cues firing simultaneously OR their bits — all energized cues fire on the same latch.
The fireDurationMs config (NVS, default 1000 ms, range 50–5000) controls how long the bit stays high. After expiry, runPlayLoop (or a defensive timer in the main loop) clears the bit.
Each board has 8 inputs that pull high when an e-match is connected. The input shift chain is read on demand:
- Latch the inputs.
- Clock 8 bits per board, MSB-first, into
buffer[boardIdx]. - The receiver firmware translates this into the
cont64_0/cont64_1masks inRECEIVER_STATUS.
Continuity is sampled:
- Every time a status frame is built (i.e. before every ACK payload refresh).
- In
handleInputModeevery 100 ms (drives the cue strip LEDs).
RECEIVER_USES_V1_CUES (currently false) inverts the polarity for older cue board revisions.
The cue strip displays per-cue state:
| Color | Meaning |
|---|---|
| Red (180,0,0) | Continuity needed (cue is in loaded show) but not detected — bad e-match or no connection. |
| Green (0,175,0) | Continuity needed and detected — good. |
| Blue (0,0,175) | Continuity detected but cue isn't in the loaded show — informational. |
| Yellow (200,200,0) | Cue is currently firing. |
| Solid blue (0,0,255) | Cue has fired. |
The 3-LED status strip, separately:
- LED 0: white pulse on RX activity, fades over 1.5 s.
- LED 1: solid purple while playing, otherwise periodic battery-colored blink (slow if not loaded; faster if
startReady). - LED 2: orange while loading, cyan loaded waiting start, magenta playing pre-T0, white playing post-T0.
-
START_LOAD:resetSystem()(clear arrays,loadComplete=false); setexpectedTargets,currentShowId. -
SHOW_LOAD/SHOW_LOADN: for each cue, settargetTimes[position] = time_msandtargetLoaded[position] = true.time_ms == 0is treated as "ignore this slot" (sentinel for the legacy 2-cueSHOW_LOADframe). -
loadComplete = truewhen the count of loaded slots ≥expectedTargets. -
SHOW_STARTwith matchingshowId: setstartReady = true, storeshowStartTime(absolute synchronized time). -
GENERIC_PLAY:isPlaying = true. If unpausing, incrementshowPauseTimeAccby the elapsed pause duration. -
Main loop:
runPlayLoopcomputeselapsed = getSynchronizedTime() - showStartTime - showPauseTimeAcc. For each cue withtargetLoaded[i]and not yet fired, ifelapsed >= targetTimes[i], fire it.
The receiver maintains clock_offset = host_time_ms - local_millis(). getSynchronizedTime() returns millis() + clock_offset. On each CLOCK_SYNC:
clock_offset = msg->timestamp - millis() + ADDITIONAL_CLOCK_TX_OFFSET // +2 ms RTT compensationThe dongle sends CLOCK_SYNC at its poll cadence (default 2 s), which keeps the receivers' clocks within a few ms of the host.
SHOW_START's targetStartTime is in synchronized time units, so all receivers fire simultaneously regardless of their individual clock drift.
The receiver tracks how long it's been since it last got a command. The threshold isn't a fixed number — it's adaptive:
- Floor: 8 s.
- Typical: 6× the receiver's own measured median poll gap.
- Cap: 90 s.
When the threshold is exceeded:
- Flush + reopen the radio reading pipe (in case the radio got into a weird state).
- If
isPlaying, request a purple flash animation on the cue strip ("I lost the host but I'm still firing"). - Crucially: do not stop the show. Once preloaded and started, the show is committed. Operators stop a show by deliberate action (start switch up + arm switch up, or Abort button), not by losing radio.
-
OTA_BEGIN: validate size/chunks,Update.begin(totalSize), allocate the OTA partition, transition toOTA_STATE_READY. After ACK, change RF data rate to the rate the host requested (1 Mbps or 2 Mbps for speed). -
OTA_DATA: enforce strictchunkIdxordering (duplicate of last chunk is tolerated; out-of-order is rejected).Update.write(chunk). ACK with state in payload. -
OTA_END: ensurebytesReceived == totalSize;Update.end(true)(commits and switches the boot partition pointer); reboot. -
OTA_ABORTor 30 s of inactivity:Update.abort(), return to 250 kbps, return toOTA_STATE_IDLE.
While OTA is active, runPlayLoop does not execute — the firmware can't safely do both at once.
Namespace byh_rx. Keys:
-
node_id—uint8_t1..254. 0 is the unprovisioned sentinel. -
ident— string, up to 15 chars. Default "RX???". -
fire_dur—uint16_tms, range 50–5000.
Provisioning commands accepted at any time over USB-CDC serial:
SETID <node_id> <ident> # write both, then reboot
GETID # read current values
WIPEID # clear NVS, reboot back to UNPROVISIONED
SETID validates node_id is in 1..254 and ident is at most 15 chars. WIPEID is a one-way ticket — you'll need to SETID again before the receiver will join the network.
There is no ESP-IDF task watchdog enabled in receiver firmware (unlike the dongle, which has a 10 s WDT). The only watchdog-like behavior is the OTA inactivity timeout (30 s).
This is deliberate: the receiver's main loop touches several blocking peripheral APIs (NeoPixel update can take ~1 ms per pixel for long strips; SPI reads can momentarily spin) that could trip a tight WDT under bursty conditions. The cost of a hung receiver is "operator notices it's offline in the UI and power-cycles it" — a much smaller cost than losing a cue mid-show because of a spurious WDT panic.
#define FW_VERSION 23 at the top of the .ino. Encoded into the boot sequence's status LED display, the RECEIVER_CONFIG_RESPONSE.fwVersion field, and the build artifact filename (os4_receiver_v23.bin). Bump when shipping any change you want to be able to reference.
#define BOARD_VERISON 9 [sic, typo preserved] separately encodes the PCB hardware revision. Affects pin maps and PA level.
Getting started
- Overview
- Desktop installers (macOS / Windows)
- macOS
- Linux
- Windows
- Production vs Development
- Connecting the dongle
- Flash a receiver
- Flash a dongle
- OTA flashing
Raspberry Pi
System overview
Subsystems
Hardware
- Receiver firmware
- Dongle firmware
- RF protocol
- Contributor Portal — BOMs, schematics, and board resources
UI walkthrough
Reference
Downloads
- Firmware
- Installers
Module Build & User Guides
- Cue
- Receiver
- Dongle