-
Notifications
You must be signed in to change notification settings - Fork 3
Dongle Firmware
The dongle firmware lives at devices/os4_dongle/os4_dongle.ino. It's the host's gateway to the rest of the system: it talks USB-CDC serial to the daemon and 2.4 GHz nRF24 + 433 MHz to the receivers.
For build/flash see Flashing a dongle.
| Component | Choice |
|---|---|
| MCU | ESP32-S2 (assumed LOLIN S2 Mini family — same as receivers) |
| 2.4 GHz radio | Nordic nRF24L01+ (with PA/LNA) |
| 433 MHz transmitter | GPIO bit-bang on RF_PIN (no separate chip; just timing-controlled pulses) |
| Status LEDs | 7 NeoPixels |
| Switches | 3 × hardware (start/stop, arm, manual fire) — INPUT_PULLUP, active LOW |
| USB | USB-C, native CDC at 115200 |
| Pin | Use |
|---|---|
| 37 | RF24 CE |
| 36 | RF24 CSN |
| 35 / 33 / 34 | SPI MISO / MOSI / SCK |
| 4 | 433 MHz TX (RF_PIN) |
| 5 | NeoPixel data |
| 9 | Start/stop switch |
| 8 | Arm switch |
| 7 | Manual fire switch |
-
Serial.setRxBufferSize(2048)— 2 KB USB-CDC RX buffer so an OTA host can pipeline multipleflash_datalines without the dongle losing bytes. -
Serial.begin(115200)withsetTxTimeoutMs(20)— caps how longSerial.writeblocks if the host is slow to drain (avoids a deadlock during OTA when the host is mid-state-write). -
setupLEDs()— boot pixel green. - SPI begin on the configured pins.
-
pinModethe 3 switches asINPUT_PULLUP. -
radio.begin()— loop until success, log "ERROR: Radio hardware not responding!" with red status pixel between attempts. -
radio.setDataRate(RF24_250KBPS),setPALevel(RF24_PA_MAX),setChannel(rfChannel=85),setRetries(15, 5), CRC16, auto-ACK with ACK payloads, dynamic payloads. -
openReadingPipe(0, masterReadAddress()),startListening(). -
Hardware task watchdog:
esp_task_wdt_initwith 10 s timeout,trigger_panic = false(soft restart, not panic — keeps USB enumeration intact). -
esp_task_wdt_add(NULL)— register the loop task with the watchdog. - Print boot banner:
M+ (RF24 Master Hub Online v4: ACK-payloads).
-
processSerialCommand— parse incoming serial lines. -
maybePollNextReceiver— issue a periodic CLOCK_SYNC (or queued CONFIG_QUERY) to one receiver perclockSyncIntervalMs / numReceiverswindow. -
pumpRfTx— send queued radio commands; collect ACK payloads. -
emitRxUpd/emitRxCfg— push fresh per-receiver state up to the host JSON-line by JSON-line. - Once per second: build and emit the aggregate
statusJSON. - Service
checkGpioStatus— debounce the 3 switches; emit agpio_statusJSON whenever any change. -
esp_task_wdt_reset()regularly so the WDT doesn't fire.
Star: one master (the dongle), N slaves (the receivers). The dongle writes to each receiver's address; receivers each listen on their unique pipe-0 address. ACK payloads carry receiver-side state back.
-
rfChannel = 85by default. Hot-swappable at runtime via host JSON{"rf_channel": N}(range 0–125). Receivers cannot hot-swap; their channel is hardcoded. -
rfSystemId = 0by default. Salts the high 32 bits of every receiver address so two independent systems on the same channel won't collide.
masterReadAddress() = MASTER_READ_BASE | systemSalt()
receiverAddress(N) = RECEIVER_BASE + N + systemSalt()- 250 kbps for normal operation. 1 / 2 Mbps during OTA at the host's request.
- PA_MAX.
- CRC16.
- Auto-ACK enabled on pipe 0.
-
setRetries(15, 5)— 15 × 250 µs delay, 5 retries. Worst-case TX failure latency: ~22 ms. - Dynamic payloads + ACK payloads enabled.
The dongle maintains an in-memory receivers[] array (max 32). Receivers are added when the host sends sync <ident> for a new identity; removed by forget <ident>. The maybePollNextReceiver function spreads polls across receivers at a cadence of clockSyncIntervalMs / numReceivers, with a floor of 5 ms per poll.
Each poll either:
- Sends
CLOCK_SYNC(default), so the receiver gets its time updated. - Sends
RECEIVER_CONFIG_QUERY(ifconfigQueryPending), so the dongle can update its cached config for that receiver.
In both cases, the receiver's ACK payload comes back with RECEIVER_STATUS (or RECEIVER_CONFIG_RESPONSE for the second case).
A single ring buffer of size MAX_COMMANDS_IN_QUEUE = 128. Reported to the host as:
-
q— current depth. -
qmax— capacity (128).
If the host floods commands faster than the dongle can transmit them, the queue fills and the dongle returns ERR: Command queue full. on the offending serial line. The daemon's protocol handler paces commands to stay well under qmax.
The 433 MHz transmitter is just digitalWrite on RF_PIN with carefully timed pulse widths. There's no separate transceiver IC; the firmware bit-bangs a Bilusocn-compatible OOK sequence directly on a GPIO connected to a small RF amplifier/antenna circuit.
The dongle accepts 433fire <bits> from the host. Format:
>>1010100110...:N<<
where the bit string is the binary encoding the Bilusocn firing system uses, and N is the repetition count (8 by default — 433 MHz is noisy and we want each command to land).
The firmware parses the envelope (isValidMessage), extracts the bit string and repetition count, and calls sendBinaryString(binaryString) N times back-to-back. Each call shifts out the bit pattern with sendOneMessage/sendZeroMessage timing.
There is no receive path on 433 MHz. Bilusocn receivers are TX-only.
The dongle is line-oriented: each \n/\r\n terminates a line. Lines starting with { are parsed as JSON; otherwise they're whitespace-separated text commands.
The OTA hot path (flash_data ..., flash_recover ..., flash_ping) is recognized before the general parser to avoid String allocations on the high-rate path.
Full text-command and JSON-config message catalog: Wire protocol reference.
Every ~1 second (when not mid-OTA), the dongle emits one big JSON line on serial:
{
"timestamp": 1715680000123,
"q": 12,
"qmax": 128,
"fw": 16,
"ch": 76,
"csim": 2000,
"l": 4,
"receivers": [
{
"i": "RX146", "n": 146, "b": 88, "s": 42,
"l": 1, "r": 1, "t": 1715680000050,
"x": 4, "sp": 99.5, "c": [18446744073709551615, 0],
"fw": 23, "bv": 9, "nb": 1, "nbd": false, "ca": 8, "fd": 1000
},
...
]
}Field meanings:
| Top-level | Meaning |
|---|---|
timestamp |
dongle-local ms |
q, qmax
|
command queue depth, capacity |
fw |
dongle FW_VERSION (currently 16) |
ch |
active RF channel |
csim |
post-clamp clock-sync interval in ms |
l |
aggregate average latency across recent samples |
receivers[] |
abbreviated per-receiver telemetry |
| Per-receiver | Meaning |
|---|---|
i |
ident (e.g. RX146) |
n |
nodeID |
b |
battery (5..253 scale) |
s |
currentShowId (0..16383) |
l |
loadComplete bool |
r |
startReady bool |
t |
last contact time (dongle ms) |
x |
last latency sample (ms) — only present if the contact was successful |
sp |
rolling success percentage |
c[2] |
continuity bitmask, 64-bit each (cues 0..63 / 64..127) |
fw/bv/nb/nbd/ca/fd
|
from CONFIG_RESPONSE: FW version, board version, num boards, no-boards-detected, cues available, fire duration ms |
Note fw appears at both top-level (dongle FW) and per-receiver — parsers must rely on context.
Whenever the dongle gets a successful (or failed) ACK from a receiver, it emits a rxupd JSON immediately — without waiting for the next aggregate status. Same key set as one receivers[] entry but with "type": "rxupd".
If x (latency sample) is absent, the contact failed (TX without ACK). The host uses this to avoid advancing the receiver's "last contact" timestamp on failed polls.
Triggered by scan <passes> <ch_start> <ch_end> (default scan 10 0 125). The dongle:
- Stops the normal RF loop.
- For each pass, for each channel in range, switches channel, listens for ~180 µs, samples
radio.testRPD()(Received Power Detector — 1 if power above threshold during the listen window). - Sums hits per channel across passes.
- Restores RF config.
- Emits a
scan_resultJSON withresults: [{ch, hits}, ...],current_ch,passes,duration_ms,ch_start,ch_end,fw.
The host computes score = hits + 0.5 * (neighbor hits within ±2) and recommends the channel with the lowest score, breaking ties by higher channel.
passes is hard-capped at 50 to keep the scan under ~1.2 s.
The dongle relays OTA chunks from the host to the target receiver:
| Host → dongle | Dongle → receiver | Dongle → host |
|---|---|---|
flash_begin <ident> <size> <chunks> <crc> <rate> |
OTA_BEGIN (struct) |
{"type":"ota","phase":"begin_ok"} (or error) |
flash_data <idx> <hex> |
OTA_DATA (struct) |
OA <idx> <state> <bytes> <attempts> (ack) or ON <idx> ... (nack) |
flash_recover <idx> <level> |
(replay or radio reset) |
OA / ON as above |
flash_ping |
(no-op) |
OP (pong) |
flash_end |
OTA_END (struct) |
{"phase":"end_sent"} then {"phase":"done"} after rejoin |
flash_abort |
OTA_ABORT (struct) |
{"phase":"aborted"} |
Compact OA / ON / OS / OP lines are used during the hot path because emitting JSON for thousands of chunks is expensive. The host's OtaFlashDriver parses both compact and JSON forms.
The dongle has its own per-chunk retry logic (OTA_PER_CHUNK_RETRIES = 4) and recovery rounds (OTA_RECOVERY_ROUNDS = 2 × OTA_RECOVERY_ATTEMPTS_PER_ROUND = 3). The host adds another 12 retry attempts on top with flash_recover escalation.
After flash_end, the dongle enters serviceOtaRejoin: pings the receiver with CLOCK_SYNC ~250 ms apart until a RECEIVER_STATUS from the new firmware comes back, or a 30 s timeout fires.
ESP-IDF esp_task_wdt configured with:
-
timeout_ms: 10000 (10 seconds). -
trigger_panic: false — soft restart on expiry, not panic dump. The panic path stalls the reboot and confuses some host USB stacks; soft restart isesp_restart()and the dongle re-enumerates on the same/dev/tty.usbmodem*path within ~2 s.
The loop task (loopTask) is registered with the WDT. A reset is issued every loop iteration. If the loop wedges (e.g. a malformed serial frame causes a parse loop), the WDT fires and reboots — the bridge's auto-reconnect then brings the link back automatically.
checkGpioStatus() reads each switch and, when state changes (or every 10 s as a heartbeat), emits:
{
"gpio": "gpio_status",
"start_stop": 1,
"armed": 0,
"man_fire": 1
}The values are the raw digitalRead results: with INPUT_PULLUP, HIGH (1) = open / off / disengaged, LOW (0) = closed / on / engaged. The daemon translates this into its is_armed / start_sw_active / man_fire_enabled flags.
Debouncing is a 200 ms minimum interval between gpio_status emits — short enough to feel responsive, long enough to filter out switch bounce on cheap toggle switches.
7 NeoPixels driven directly from per-LED state in ledStates[] and per-LED effect in ledEffects[]. Both arrays are written by the daemon's LEDHandler via the JSON config message that contains keys daemon_act, web_act_state, tx_active, show_load_state, show_run_state, error_state, arm_state.
Color mapping per ledStates[i]:
| Value | Color |
|---|---|
| 0 | off |
| 1 | green |
| 2 | yellow |
| 3 | red |
| 4 | blue |
| 5 | purple |
| 6 | white |
| 7 | cyan |
ledEffects[i]:
| Value | Effect |
|---|---|
| 0 | solid |
| 1 | blink |
| 2 | pulse |
So e.g. arm_state=1 (armed) → ledStates[6]=3 (red) + ledEffects[6]=2 (pulse) = pulsing red on the arm indicator.
A solid red on pixel 5 during boot indicates the radio failed to initialize.
#define FW_VERSION 16 at the top of the .ino. Reported in the aggregate status frame as the top-level fw key. Bump when shipping changes you want to reference.
The dongle and receiver firmware versions are independent — they don't have to match exactly. Receiver FW 23 talks fine to dongle FW 16. The wire format is forward/backward compatible within the constraints documented in Wire protocol reference.
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