Skip to content

Dongle Firmware

OneSeventyFour edited this page May 14, 2026 · 1 revision

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.

Hardware target

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 map

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

Boot sequence

  1. Serial.setRxBufferSize(2048) — 2 KB USB-CDC RX buffer so an OTA host can pipeline multiple flash_data lines without the dongle losing bytes.
  2. Serial.begin(115200) with setTxTimeoutMs(20) — caps how long Serial.write blocks if the host is slow to drain (avoids a deadlock during OTA when the host is mid-state-write).
  3. setupLEDs() — boot pixel green.
  4. SPI begin on the configured pins.
  5. pinMode the 3 switches as INPUT_PULLUP.
  6. radio.begin() — loop until success, log "ERROR: Radio hardware not responding!" with red status pixel between attempts.
  7. radio.setDataRate(RF24_250KBPS), setPALevel(RF24_PA_MAX), setChannel(rfChannel=85), setRetries(15, 5), CRC16, auto-ACK with ACK payloads, dynamic payloads.
  8. openReadingPipe(0, masterReadAddress()), startListening().
  9. Hardware task watchdog: esp_task_wdt_init with 10 s timeout, trigger_panic = false (soft restart, not panic — keeps USB enumeration intact).
  10. esp_task_wdt_add(NULL) — register the loop task with the watchdog.
  11. Print boot banner: M+ (RF24 Master Hub Online v4: ACK-payloads).

Main loop responsibilities

  • processSerialCommand — parse incoming serial lines.
  • maybePollNextReceiver — issue a periodic CLOCK_SYNC (or queued CONFIG_QUERY) to one receiver per clockSyncIntervalMs / numReceivers window.
  • 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 status JSON.
  • Service checkGpioStatus — debounce the 3 switches; emit a gpio_status JSON whenever any change.
  • esp_task_wdt_reset() regularly so the WDT doesn't fire.

RF protocol details (2.4 GHz)

Topology

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.

Channel and addressing

  • rfChannel = 85 by default. Hot-swappable at runtime via host JSON {"rf_channel": N} (range 0–125). Receivers cannot hot-swap; their channel is hardcoded.
  • rfSystemId = 0 by 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()

MAC parameters

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

Receiver poll list

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 (if configQueryPending), 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).

Command queue

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.

433 MHz frontend (Bilusocn)

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.

Host serial protocol (overview)

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.

Aggregate status frame

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.

Per-receiver fast-path push (rxupd)

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.

RF channel scan

Triggered by scan <passes> <ch_start> <ch_end> (default scan 10 0 125). The dongle:

  1. Stops the normal RF loop.
  2. 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).
  3. Sums hits per channel across passes.
  4. Restores RF config.
  5. Emits a scan_result JSON with results: [{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.

OTA "transparent forwarder" flow

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.

Hardware task watchdog

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 is esp_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.

Switches

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.

LEDs

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.

Versioning

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

Clone this wiki locally