V1.3 dual-MCU board
Open_FC_V1_3 REV A) adds four RS-485 VEX Smart ports next to the four legacy Competition-Switch ports. Two of the Smart ports live on a second MCU, so the board carries two firmware images and is flashed in two steps.bu_* bring-up protocol, not the certified match protocol, and are not certified for competition. The production serial protocol applies to hardware revision 1.1 boards.Architecture
| ESP32-S3 (host) | RP2350 (Serial Expander) | |
|---|---|---|
| Part | ESP32-S3-WROOM, 8 MB flash | RP2354A, flash-in-package |
| Talks to the PC | USB CDC via CH340C, JSON lines at 115200 | — (no USB wired) |
| Legacy V5 ports | 4 × RJ11, driven in unison | — |
| Smart ports | A, B (RS-485, direct) | C, D (RS-485, direct) |
| Link | SPI master, 1 MHz, mode 1 | SPI slave (PL022 on spi0) |
| Programmed by | esptool over the CH340 | The ESP32, over on-board SWD |
The PC only ever talks to the ESP32. Anything for ports C or D is proxied over the SPI link, and results come back tagged with the port letter so a host does not need to know which MCU owns a port. The field-control engine for C/D runs on the RP2350 itself; the ESP32 forwards the engage/state commands and polls status.
Inter-MCU pin map
| Signal | ESP32-S3 | RP2350 | Notes |
|---|---|---|---|
| SPI CS | IO10 | GPIO17 | Active low |
| SPI MOSI | IO11 | GPIO16 | ESP32 → RP2350 |
| SPI SCLK | IO12 | GPIO18 | Through R38 |
| SPI MISO | IO13 | GPIO19 | RP2350 → ESP32 |
| RP2350_IRQ | IO21 | GPIO8 | RP2350 has a result ready |
| RP2350_RUN | IO6 | RUN | Active-low reset, 10 K pull-up (R39) |
| BOOT_CTRL | IO7 | GPIO20 | Spare GPIO — not BOOTSEL |
| ESP_READY | IO40 | GPIO10 | ESP32 has booted |
| SWCLK | IO42 | SWCLK | 33 Ω series (R58) |
| SWDIO | IO41 | SWDIO | 33 Ω series (R59) |
| Smart port | MCU | TX | RX | DE |
|---|---|---|---|---|
| A | ESP32-S3 | IO17 | IO18 | IO4 |
| B | ESP32-S3 | IO15 | IO16 | IO5 |
| C | RP2350 | GPIO0 | GPIO1 | GPIO2 |
| D | RP2350 | GPIO4 | GPIO5 | GPIO6 |
Legacy ports use IO47 (ENABLE/DISABLE) and IO48 (DRIVER/AUT), shared across all four RJ11s, with the same polarity as revision 1.1. The RP2350's QSPI_SS/BOOTSEL and USB pins are unconnected, so its USB mass-storage bootloader is unreachable: SWD from the ESP32 is the only programming path.
Flashing from the browser
Open the V1.3 flasher in Chrome or Edge, connect the board over USB, and run the two steps in order.
- ESP32-S3. esptool-js writes the merged image (bootloader + partition table + app) at
0x0, exactly as on the standard flasher. The board reboots into the new firmware and answersget_infowith abu_helloevent. About 30 seconds. - RP2350. The browser never touches SWD: it streams the image to the ESP32 as base64 JSON commands over the live serial link, and the ESP32 does the programming. The flasher releases
RUN, probes SWD, erases, writes 384-byte chunks (each acknowledged before the next is sent), then reboots the RP2350 into the new image. About 10 seconds for a 53 KB image.
Afterwards, SPI ping on the flasher (or the RP2350 panel of the bring-up console) should report the RP2350 answering. Ports C and D do nothing until it does.
RUN low) until something releases it. The flasher and the CLI do this for you; if you drive the protocol yourself, send {"cmd":"rp2350_ctrl","run":true} before the SWD probe or it will report no DPIDR.Step 2 is only enabled once the connected board has identified itself with bu_hello. A board still running production firmware ignores the rp2350_* commands, which is why the ESP32 must be flashed first.
Flashing from the command line
The same sequence is available as a Python tool in the repository (needs pyserial). Build both images with PlatformIO first.
cd esp32 && pio run -t upload # ESP32-S3 over the CH340
cd ../rp2350 && pio run # produces .pio/build/rpipico2/firmware.bin
cd ..
py tools/bringup_cli.py --port COM3 flash-rp2350 rp2350/.pio/build/rpipico2/firmware.bin
py tools/bringup_cli.py --port COM3 cmd '{"cmd":"rp2350_spi","op":"ping"}'tools/publish_firmware.py merges the ESP32 image, copies the RP2350 image into public/firmware/ and prints the v13_releases manifest entry the web flasher reads.
RP2350 flash commands
Four bring-up commands carry the RP2350 image. All replies are bu_flash_status events with a stage field; wait for each reply before sending the next command.
| Command | Reply | Meaning |
|---|---|---|
{"cmd":"rp2350_swd_probe"} | bu_swd_probe | Connects and identifies. ok:true with dpidr:"4C013477" and bootrom_magic:"0402754D" means the core is alive. Safe at any time. |
{"cmd":"rp2350_flash_begin","size":N} | stage:"begin" | Reset-halts core0, runs the bootrom's flash init, and erases N bytes rounded up to 4 KB from flash offset 0. The RP2350 stays halted until end. |
{"cmd":"rp2350_flash_data","b64":"…"} | stage:"data" | Appends up to 384 raw bytes (base64-encoded). Larger chunks are truncated. received is the running byte count. |
{"cmd":"rp2350_flash_end"} | stage:"end" | Programs the last partial page, flushes the XIP cache, and pulses RUN so the new image boots. received should equal the image size. |
On failure ok is false and fail names the stage that failed (e.g. halt, reset_halt, no_session, bad_b64). The ESP32 bit-bangs SWD, staying under the ~250 kHz ceiling that applies while a blank RP2350 waits in its bootrom.
send({cmd:'rp2350_ctrl', run:true}); // out of reset
await reply('bu_swd_probe'); // expect ok:true
send({cmd:'rp2350_flash_begin', size: image.length});
await reply('bu_flash_status', 'begin');
for (let off = 0; off < image.length; off += 384) {
send({cmd:'rp2350_flash_data', b64: base64(image.subarray(off, off + 384))});
await reply('bu_flash_status', 'data'); // ok:true, received
}
send({cmd:'rp2350_flash_end'});
await reply('bu_flash_status', 'end'); // ok:true, received === image.lengthESP32 ↔ RP2350 SPI link
Fixed 37-byte frames in both directions (4-byte header, 32-byte payload, 8-bit sum checksum), 1 MHz, SPI mode 1 (CPOL=0, CPHA=1). The ESP32 is always master; because the slave cannot push a reply, every command is followed by a GET_RESULT poll, repeated while the status byte is PENDING (the RP2350_IRQ line rising means a result is ready).
| Byte | Request (ESP32 → RP2350) | Result (RP2350 → ESP32) |
|---|---|---|
| 0 | cmd | status (0 pending, 1 ok, 2 error, 0xFF idle) |
| 1 | port (‘C’ / ‘D’ / 0) | port (echoed) |
| 2 | flags (bit0 = DE for rs485_raw) | flags |
| 3 | payload length (0–32) | payload length |
| 4–35 | payload | payload |
| 36 | checksum | checksum |
FF FF FF… until both sides moved to CPHA=1. Keep the two openfc_spi_link.h copies identical.Commands over the link: PING, RS485_RAW (send bytes / passive listen on C or D), LINKTEST (Smart-port handshake), FIELD_SET and FIELD_STATUS (engage/state and per-port link/ack counters). From the PC these are reached through {"cmd":"rp2350_spi","op":…}; the field-control commands fan out to A/B on the ESP32 and C/D on the RP2350 automatically.
What is verified
| Subsystem | Status |
|---|---|
| Legacy port polarity | Confirmed against a live V5 (2026-09-01). |
| ESP32 → RP2350 flashing over SWD | Erase + write + boot of a 53 KB image in ~10 s, repeatable. |
| SPI link | 37/37 bytes per frame, valid checksums, zero re-inits after the mode-1 fix. |
| Smart ports C and D | Both held live sessions with VEX controllers in parallel for 45 s at ~50 acks/s, driven by the RP2350 and reported over SPI. |
| Smart ports A and B | Same handshake engine, on the ESP32 directly. |
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Step 2 disabled, "has not identified itself" | The board is not running V1.3 bring-up firmware, or the bu_hello was missed. Click Identify; if nothing arrives, run step 1. |
| SWD probe fails / no DPIDR | RP2350 held in reset. Release RUN (the flasher does this automatically; the bring-up console has a button). If it still fails, check the 33 Ω series resistors on SWCLK/SWDIO. |
fail:"reset_halt" at begin | The reset catch missed. Retry; the flasher pulses RUN itself at the start of every session. |
| SPI ping says no response after a successful flash | Give the RP2350 a moment after boot, then ping again. If it persists, confirm both firmwares share the same openfc_spi_link.h and SPI mode 1. |
| Port C or D shows no bytes with a controller attached | The controller has usually gone to sleep — power it back on. A controller also sulks after a disengage until power-cycled. |