Skip to content

Flashing a Receiver

OneSeventyFour edited this page Jun 19, 2026 · 3 revisions

Flashing a receiver

This is the manual (USB-cable) flashing procedure. If your receiver is already powered on and online in the UI, you can update it without touching it — see OTA flashing instead.

Background — why this is harder than arduino-cli upload

Each receiver needs to know its NODE_ID (1–254, e.g. 146) and its RECEIVER_IDENT (e.g. RX146). These two values get combined into the receiver's nRF24 listening address — that's how the dongle finds it without a pairing handshake.

In firmware versions before v14, every receiver needed its own custom build with its NODE_ID baked in as a #define. From v14 forward, identity lives in the chip's NVS (non-volatile storage) instead. One firmware binary serves the entire fleet, and each receiver gets provisioned (assigned its NODE_ID / ident) over USB serial after flashing. Routine firmware updates preserve NVS, so a receiver keeps its identity through every future update.

What you need

  • A working USB-C cable (data, not charging-only).
  • The receiver, with at least one cue module attached so you can see boot indication.
  • A Mac, Linux box, or Windows machine with arduino-cli installed (for building) and Python 3 (for flashing).
  • Once: install arduino-cli and the ESP32 core. See Getting Started for your OS.

TL;DR

# Build (one-time, or whenever you bump firmware):
devices/utils/build_receiver.sh

# Flash a brand-new chip and provision it as RX146:
devices/utils/flash_receiver.py --full --node 146

# Routinely update an already-provisioned receiver (preserves its identity):
devices/utils/flash_receiver.py

# Re-number a receiver in the field (no flash, just update its identity):
devices/utils/set_node_id.py --node 200 --ident RX200

That's the whole interface. The rest of this page explains what's actually happening.

Step 1 — Build the firmware

devices/utils/build_receiver.sh

This script:

  • Reads the current FW_VERSION from devices/os4_receiver/os4_receiver.ino (currently 23 at time of writing).
  • Runs arduino-cli compile --fqbn esp32:esp32:lolin_s2_mini.
  • Copies four .bin files into devices/os4_receiver/bin/:
    • os4_receiver_v<N>.bin — the app partition (~340 KB), flashed at 0x10000.
    • os4_receiver_v<N>.bootloader.bin — chip bootloader, flashed at 0x1000 during --full.
    • os4_receiver_v<N>.partitions.bin — partition table, flashed at 0x8000 during --full.
    • os4_receiver_v<N>.boot_app0.bin — OTA "next-app" pointer, flashed at 0xe000 on every flash.
  • Updates bin/latest.* symlinks pointing at the newest version.

If arduino-cli complains about the ESP32 core, follow the bootstrap commands the script prints (one-time setup):

arduino-cli config init --overwrite
arduino-cli config set directories.data ~/Library/Arduino15
arduino-cli config set board_manager.additional_urls https://espressif.github.io/arduino-esp32/package_esp32_index.json
arduino-cli core update-index
arduino-cli core install esp32:esp32

Important: the receiver firmware uses additional Arduino libraries (RF24, Adafruit_NeoPixel). If you've ever built the sketch in the Arduino IDE on this machine they're already installed. Otherwise install them via the Library Manager in the Arduino IDE once.

Step 2 — Flash with flash_receiver.py

flash_receiver.py is a Python wrapper around esptool that picks the right binaries, sends the right commands, and provisions the receiver afterwards. It self-bootstraps a Python venv at devices/utils/.venv on first run — no manual pip install needed.

It has three modes:

flash_receiver.py (default)

App-only flash to a chip that's already been flashed before. Writes the app partition at 0x10000 and re-stamps boot_app0.bin at 0xe000. Preserves NVS — the receiver's NODE_ID and ident come back unchanged.

Use this for routine firmware bumps.

flash_receiver.py --full

Full flash: bootloader + partition table + boot_app0 + app, in one esptool invocation. Use this:

  • For a brand-new chip that has never been flashed.
  • For recovery after a corrupt partition table.
  • Whenever the partition layout itself changes.

After the flash completes, the script prompts for a NODE_ID (1–254) and sends SETID <node_id> RX<node_id> over serial. The default ident is RX<node_id>; pass --ident MYNAME to override.

flash_receiver.py --set-id

App-only flash, then interactive SETID. Use this to renumber a receiver without wiping anything else in NVS. Equivalent to flash_receiver.py followed by set_node_id.py --node N --ident IDENT.

Skip the prompts

devices/utils/flash_receiver.py --full --port /dev/tty.usbmodem01 --node 146
# Use the default ident "RX146", or pass --ident MYNAME to override.

Step 3 — Manual ROM bootloader entry (rare)

The lolin_s2_mini's ESP32-S2 runs the user firmware over USB-CDC, so its ability to respond to esptool's auto-reset signals depends on what the user app is doing the moment esptool tries to talk. flash_receiver.py handles this transparently with a three-attempt cascade:

  1. --before default_reset — the normal auto-reset path.

  2. --before no_reset (silent retry) — automatic, no operator interaction. On the lolin_s2_mini the chip is often already in a download-ready state after a "failed" default_reset, so this catches the common case without a prompt.

  3. --before no_reset after manual BOOT+RESET — only reached if steps 1 and 2 both fail. The script prompts you to:

    1. Press and HOLD the BOOT button on the receiver.
    2. While still holding BOOT, press and release RESET.
    3. Release BOOT.
    4. Press Enter at the prompt.

You'll usually only see step 1 succeed, or step 2 succeed silently. The manual prompt is uncommon in practice — on a healthy receiver, every flash should complete with no operator input.

If the manual retry also fails:

  • Unplug and replug the receiver to recycle the USB endpoint.
  • Make sure nothing else is holding the serial port — Arduino IDE Serial Monitor, screen, another terminal session.
  • Try a different USB-C cable (charging-only cables look identical and break flashing).

How NVS is preserved

The default ESP32-S2 partition table puts NVS at 0x90000xdfff, in the gap between the partition table (0x80000x8fff) and boot_app0 (0xe0000xffff). The flasher never writes to that gap, so NVS is left intact even on --full.

This is why a receiver flashed with flash_receiver.py --full --node 146 keeps NODE_ID = 146 through every subsequent flash_receiver.py (no flag) and through every OTA flash.

Renumber an already-provisioned receiver

You don't need to reflash to change a receiver's NODE_ID. The dedicated helper:

devices/utils/set_node_id.py                    # prompts for everything
devices/utils/set_node_id.py --node 146         # ident defaults to RX146
devices/utils/set_node_id.py --get              # just read the current ID
devices/utils/set_node_id.py --wipe             # back to UNPROVISIONED

Under the hood this opens the receiver's USB serial port at 115200 8N1 and sends one of these commands (which the firmware accepts at any time):

GETID
SETID <node_id> <ident>
WIPEID

SETID and WIPEID reboot the chip after writing.

What "UNPROVISIONED" means

A brand-new ESP32-S2 with zero NVS boots into UNPROVISIONED mode — radio disabled, slow magenta breathe on the three status LEDs. SETID fixes it. WIPEID returns it to that state.

You'll see this:

  • After a full flash on a chip that's never been provisioned.
  • After deliberately wiping with WIPEID.
  • If NVS is somehow corrupted (rare).

Troubleshooting

"Could not configure port: (6, 'Device not configured')" hangs at "Connecting..."

USB-CDC quirks. See Step 3 above.

"esptool not found" or "pyserial not installed"

flash_receiver.py self-bootstraps a venv at devices/utils/.venv (POSIX bin/, Windows Scripts\). If something's wrong with it, delete it and re-run:

# macOS / Linux:
rm -rf devices/utils/.venv
devices/utils/flash_receiver.py        # re-run; will rebuild the venv
# Windows (PowerShell):
Remove-Item -Recurse -Force devices\utils\.venv
python devices\utils\flash_receiver.py

If the venv still won't build (locked-down Python), install the deps onto your PATH instead — the script will detect an importable pyserial and skip the venv: python -m pip install "pyserial>=3.5" "esptool>=4.9".

Receiver shows up as RX??? in the boot banner

It's UNPROVISIONED. Run:

devices/utils/set_node_id.py --node <pick a number 1-254>

Receiver flashed fine but never appears online in the UI

Either:

  • The receiver's NODE_ID doesn't match what you typed in the Receivers page in the UI. They have to match.
  • The dongle and receiver are on different RF channels. The dongle's current channel is shown in the Settings → Debug → RF Scan panel; the receiver firmware ships with channel 85 hard-coded but can be reflashed if you need to change it.
  • Out of RF range (very unlikely with these radios — 1000+ yard line-of-sight is normal).

Bin directory layout

After a successful build:

devices/os4_receiver/bin/
├── os4_receiver_v23.bin
├── os4_receiver_v23.bootloader.bin
├── os4_receiver_v23.partitions.bin
├── os4_receiver_v23.boot_app0.bin
├── latest.bin               -> os4_receiver_v23.bin
├── latest.bootloader.bin    -> os4_receiver_v23.bootloader.bin
├── latest.partitions.bin    -> os4_receiver_v23.partitions.bin
└── latest.boot_app0.bin     -> os4_receiver_v23.boot_app0.bin

flash_receiver.py always picks the highest-versioned matching bin. To force a specific binary, use --bin. Older versions are kept on disk so you can roll back.

See also

Clone this wiki locally