Skip to content

RF Protocol Details

OneSeventyFour edited this page May 14, 2026 · 1 revision

RF protocol details

The radio layer between the dongle and BYH receivers. This page collects the things that span both sides — addressing, channel mechanics, ACK payloads, scan algorithm — without re-explaining what's already covered in Receiver firmware and Dongle firmware.

PHY / MAC summary

Property Value
Chip Nordic nRF24L01+
Default data rate 250 kbps
OTA data rates 1 Mbps, 2 Mbps (host can pick at flash time)
Modulation GFSK
Frequency band 2.4 GHz ISM
Default RF channel 85 (2485 MHz)
Channel range 0–125
TX power RF24_PA_MAX
CRC 16-bit
Auto-ACK Enabled on pipe 0
ACK payloads Enabled
Dynamic payloads Enabled
Max payload size 32 bytes
Retries 5 retries × 250 µs delay (setRetries(15, 5))
Worst-case TX failure ~22 ms
Best-case successful TX ~3 ms (single try with ACK)

Topology

Star — one master (dongle), N slaves (receivers).

There is no mesh networking. An earlier prototype used a mesh layer, but the LNA-equipped radios easily reach 1000+ yards line-of-sight, which is more than sufficient for any backyard or club site. Removing the mesh saved a lot of latency and complexity.

There is no broadcast. Every command is unicast to one receiver's address. Commands intended for "everyone" (showstart, play, stop, etc.) are issued by the daemon as N separate unicasts, and the dongle's poll scheduler interleaves them at its TDMA cadence.

Addressing

Each receiver listens on a unique 64-bit address derived as:

receiver_address = ((rfSystemId << 32) | RECEIVER_BASE) + NODE_ID

With rfSystemId = 0 (default), RECEIVER_BASE = 1, and NODE_ID = 146 → address 0x000000093 (147 in low 32 bits).

The dongle has the inverse: receiverAddress(N) returns the same value. Because the address is deterministic from NODE_ID, the dongle doesn't need a pairing handshake — it just writes to the address corresponding to whichever receiver it wants to reach. The receiver finds the dongle's master address baked into its writing pipe (set up similarly).

rfSystemId is the only knob that distinguishes two independent firing systems on the same channel. Today it's always 0; future use cases (multiple shows in close geographic proximity) would assign different system IDs.

ACK payloads — why we use them

A normal nRF24 transaction is:

  1. Master TX command on receiver's address.
  2. Receiver auto-ACK with empty payload.
  3. Done.

With ACK payloads enabled:

  1. Master TX command.
  2. Receiver auto-ACK with up to 32 bytes of data piggybacked.
  3. Master reads ACK payload, gets receiver state for free.

This halves the round-trip count for "send command, get state" and matters enormously for telemetry latency and command queue throughput. The receiver's RECEIVER_STATUS (32 bytes — battery, show state, ident, continuity bits) lives entirely in the ACK payload of every command the dongle sends. So a periodic CLOCK_SYNC poll naturally also collects the receiver's full state.

Aggregated status vs. fast-path push

The dongle exposes per-receiver state to the host two ways:

  • Aggregate status JSON — emitted ~1 Hz, contains every receiver's state in one frame. Used by the UI to render the receiver grid steady-state.
  • Per-receiver rxupd JSON — emitted immediately when one receiver's state changes. The UI uses this for low-latency updates (battery edges down by 1, continuity bit flips, etc.) without waiting for the next aggregate.

The host distinguishes a successful poll (latency sample x present in rxupd) from a failed poll (no x) and only updates the receiver's "last contact" timestamp on success.

RF channel scan

Triggered from the UI (Settings → Debug → RF Scan) or directly via daemon command. The dongle:

  1. Stops the normal RF loop.
  2. For passes rounds (default 10, max 50), iterates each channel in the requested range (default 0–125).
  3. On each (pass, channel): switch to that channel, listen for ~180 µs, sample the RPD (Received Power Detector) bit. RPD = 1 if power above the nRF24's threshold during the listen window.
  4. Sums hits per channel.
  5. Restores RF config to whatever it was.
  6. Emits scan_result.

The host computes a recommended channel as:

score(ch) = hits(ch) + 0.5 × sum_{ch' in [ch-2, ch+2], ch' != ch} hits(ch')

I.e. the channel itself plus half-weighted contributions from each ±2 neighbor. The lowest score wins, with ties broken by higher channel (favoring less-used upper channels).

A 10-pass scan over 0–125 takes about 200–500 ms.

Setting the channel

The dongle's channel can be hot-swapped at runtime via {"rf_channel": N} JSON over serial (refused while a show is loaded or armed — mid-show channel changes would deafen all receivers).

Receiver firmware cannot hot-swap. To change the receiver fleet's channel, you'd reflash all receivers with a new rfChannel constant. So in practice: pick a quiet channel once based on a scan, set it on the dongle, leave it there. If you do need to change the fleet:

  1. Edit rfChannel = <new value> in os4_receiver.ino.
  2. devices/utils/build_receiver.sh.
  3. Reflash every receiver — over USB or, if they're still reachable on the old channel, OTA.
  4. Update the dongle to match: {"rf_channel": <new value>}.

Sharing a channel with WiFi

Channel 85 = 2485 MHz, which is above standard WiFi (2.412–2.484 GHz for 2.4 GHz channels 1–14). On most installs WiFi doesn't interfere meaningfully, even at high traffic. If you do see degradation, try channel 90+ (above all WiFi).

The RPD scan is a fine way to confirm: in a typical residential environment you'll see hits on channels 0–62 (WiFi) and very few hits on 70+.

OTA radio behavior

During an OTA flash, the radio:

  1. Switches to the host-requested rate (1 or 2 Mbps).
  2. Disables routine polling — the dongle exclusively talks to the OTA target.
  3. Strict ACK-gated chunk pacing: chunk N+1 isn't TX'd until chunk N is ACK'd.

The receiver's main loop also pauses runPlayLoop while OTA is active. Both ends return to 250 kbps after flash_end, abort, or 30 s of inactivity (at which point OTA tears down).

Fleet sizes

The dongle's poll list maxes out at 32 receivers. The TDMA cadence is clockSyncIntervalMs / numReceivers, so with 32 receivers and the default 2 s interval, each receiver gets polled every ~62 ms — still plenty fast for the periodic clock-sync requirement.

If you have more than 32 receivers, the architecture would need a larger array on the dongle and (probably) a longer poll interval to keep latency bounded. Nobody's hit this limit in practice.

Clone this wiki locally