Skip to content

Receiver Firmware

OneSeventyFour edited this page May 14, 2026 · 1 revision

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.

Hardware target

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 map (board version ≥ 6)

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.

Boot sequence

  1. Configure the output shift chain (SHIFT_OUT_OE) and clear all bits — guarantees no cue is energized at power-on.
  2. Serial.begin(115200) with setTxTimeoutMs(50) so a slow USB host can't block the firmware.
  3. loadIdentityFromNVS() — read node_id and ident from NVS namespace byh_rx. If node_id == 0, the receiver is UNPROVISIONED — radio stays off, status strip slow-breathes magenta, only serial commands are accepted (SETID, GETID, WIPEID).
  4. loadFireDurationFromNVS() — read fire_dur (default 1000 ms if missing or out of range 50–5000).
  5. setBoardCount() — read the analog ladder, derive NUM_BOARDS (1–8) and set noBoardsDetected = true if the analog read came back as 0.
  6. statusLEDStartupSequence() — light a 3-LED visual that encodes the FW version (3 decimal digits), battery percent (bar of pixels), and NODE_ID (3 decimal digits) for ~1.5 s of "you can see it's been flashed and which version it is" indication.
  7. testLEDStrip() — sweep the cue strip green and blue.
  8. (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.
  9. 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 and isPlaying, walk the schedule.
  • handleInputMode (every 100 ms) — sample the input shift register for continuity, update LEDs.
  • handleSerial — accept SETID / GETID / WIPEID over USB-CDC at any time.
  • serviceOtaLoopIteration — if OTA is active, drive the chunk receive state machine.
  • Adaptive disconnect detection (see below).

RF protocol

Addressing (no pairing)

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_ID

RECEIVER_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.

Channel

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.

Wire frames

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.

ACK payloads (uplink)

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.

Cue module bus

Output (firing)

Each board has 8 outputs driven by a shift register. To fire cue i:

  1. Compute boardIndex = i / 8, bit = i % 8.
  2. Set the corresponding bit in shiftData[boardIndex].
  3. writeOutputShiftRegister: shift out one byte per board (MSB-first), starting from the highest-index board (the one furthest down the chain), latch, drive SHIFT_OUT_OE to 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.

Input (continuity)

Each board has 8 inputs that pull high when an e-match is connected. The input shift chain is read on demand:

  1. Latch the inputs.
  2. Clock 8 bits per board, MSB-first, into buffer[boardIdx].
  3. The receiver firmware translates this into the cont64_0 / cont64_1 masks in RECEIVER_STATUS.

Continuity is sampled:

  • Every time a status frame is built (i.e. before every ACK payload refresh).
  • In handleInputMode every 100 ms (drives the cue strip LEDs).

RECEIVER_USES_V1_CUES (currently false) inverts the polarity for older cue board revisions.

Continuity LED colors

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.

Show loading flow

  1. START_LOAD: resetSystem() (clear arrays, loadComplete=false); set expectedTargets, currentShowId.
  2. SHOW_LOAD / SHOW_LOADN: for each cue, set targetTimes[position] = time_ms and targetLoaded[position] = true. time_ms == 0 is treated as "ignore this slot" (sentinel for the legacy 2-cue SHOW_LOAD frame).
  3. loadComplete = true when the count of loaded slots ≥ expectedTargets.
  4. SHOW_START with matching showId: set startReady = true, store showStartTime (absolute synchronized time).
  5. GENERIC_PLAY: isPlaying = true. If unpausing, increment showPauseTimeAcc by the elapsed pause duration.
  6. Main loop: runPlayLoop computes elapsed = getSynchronizedTime() - showStartTime - showPauseTimeAcc. For each cue with targetLoaded[i] and not yet fired, if elapsed >= targetTimes[i], fire it.

Clock sync

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 compensation

The 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.

Adaptive host-loss detection

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 receive flow

  1. OTA_BEGIN: validate size/chunks, Update.begin(totalSize), allocate the OTA partition, transition to OTA_STATE_READY. After ACK, change RF data rate to the rate the host requested (1 Mbps or 2 Mbps for speed).
  2. OTA_DATA: enforce strict chunkIdx ordering (duplicate of last chunk is tolerated; out-of-order is rejected). Update.write(chunk). ACK with state in payload.
  3. OTA_END: ensure bytesReceived == totalSize; Update.end(true) (commits and switches the boot partition pointer); reboot.
  4. OTA_ABORT or 30 s of inactivity: Update.abort(), return to 250 kbps, return to OTA_STATE_IDLE.

While OTA is active, runPlayLoop does not execute — the firmware can't safely do both at once.

Persistent state (NVS)

Namespace byh_rx. Keys:

  • node_iduint8_t 1..254. 0 is the unprovisioned sentinel.
  • ident — string, up to 15 chars. Default "RX???".
  • fire_duruint16_t ms, 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.

Watchdog

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.

Versioning

#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.

Clone this wiki locally