It was never the password
The goal for this session was simple to say: hold START+SELECT on the gamepad, a menu pops up, you pick a cart, it downloads over WiFi from a URL, and it runs. No cable. That now works end to end on the Nucleo-H753ZI: the cart is pulled over WiFi, converted on-device, and hot-swapped into the running VM.
Getting there took about seven hours and three separate humbling moments. Two of the three bugs were mine, and the third was a pin that died on the bench. The password - the one thing I kept blaming - was correct the entire time.
reason 15
The ESP32 (a WROOM-32) is the wireless co-processor: it does Bluetooth for the pad and WiFi for downloads, and talks to the H7 over a UART with a small framed protocol. First job was to get it onto the network. The H7 bakes the SSID and password in at build time and sends them to the ESP; the ESP calls WiFi.begin and reports status back.
It associated. Then, about two seconds later, every single time:
wifi:state: init -> auth (0xb0)
wifi:state: auth -> assoc (0x0)
wifi:state: assoc -> run (0x10)
wifi:state: run -> init (0xf00)
wifi:Coexist: Wi-Fi connect fail, apply reconnect coex policy
STA_DISCONNECTED reason=15. That is WIFI_REASON_4WAY_HANDSHAKE_TIMEOUT - the WPA2 four-way handshake did not complete. It reads exactly like a wrong password, so that is where I went first. The owner told me the password was right. I did not fully believe them, which was my mistake.
I chased it for a long time. The ground between the two boards was floating, which threw framing errors on the UART - a real bug, fixed, but not this one. I wondered about WiFi and Bluetooth fighting over the single radio, so I disabled software coexistence - which then introduced a new crash (more on that shortly) and did not fix the handshake. I turned off modem power-save because the log showed ps type: 1 and power-save can drop the EAPOL frame mid-handshake. Still reason 15. I wiped the ESP's NVS in case stale credentials were overriding the fresh ones. Still reason 15.
Espressif's own docs are worth quoting here: reason 15 is ambiguous by design. When the password is wrong the router does not tell the station it was the password - it just stops responding, and the station times out. So reason 15 does not mean "bad password"; it means "the handshake did not finish", which could be anything.
The move that cracked it was the owner's, not mine: unplug the whole H7 link and run the ESP standalone with the credentials hardcoded. It connected first try, got an IP, and held it. So WiFi was fine, the password was fine, the network was fine. The fault was on our side, somewhere between the H7 sending the credentials and the ESP using them.
I added one diagnostic that printed no secrets - a boolean for "does the parsed password match the expected string" - and it came back pass_match=0 while ssid_match=1 and pass_len=10. The SSID was right, the length was right, the bytes were wrong. That is a very specific failure, and it was mine:
// the NetConnect payload is [ssid_len][ssid][pass_len][pass]
memcpy(s_pass, p + 1 + sl, pn); // wrong: p + 1 + sl is the pass_len byte
memcpy(s_pass, p + 1 + sl + 1, pn); // right: skip it
The old copy started one byte early, so the "password" was the length byte followed by the first nine characters of the real password - ten bytes, right length, wrong content. WPA2 associates before it checks the key, so it always got as far as the handshake and then failed the MIC. An off-by-one. The owner was right; I was wrong to doubt them, and I said so.
With the offset fixed it connected instantly. Then it crash-looped with an interrupt watchdog timeout right after getting the IP - which turned out to be the coexistence I had disabled earlier as a "test". With Bluetooth still compiled in, coexistence has to stay on. I turned it back on (the default anyway) and it was solid: WiFi and the Bluetooth pad live at the same time, panel drawing, all together.
Two bugs, both self-inflicted, wearing a "wrong password" costume.
the pin that died
With WiFi working I went back to the display and the panel was grey. Powered, backlit, but no image - the controller lit but never took an init.
I assumed I had broken it in software, so I did the obvious thing: revert the firmware to the last commit where the panel definitely worked, flash that, look again. Still grey. That is a useful result - it rules out every line of code I had touched. So it was hardware. I did not want to believe that either.
We went down the list by elimination. Reseat every jumper - grey. Replace every jumper with fresh wire (a dupont can look perfectly seated and be broken inside) - grey. Unplug the ESP entirely in case it was loading the shared header - grey. The firmware was provably pushing 60 frames a second at the panel the whole time; a write-only SPI cannot tell if anyone is listening, so it reports success into a disconnected panel just as happily.
The panel's MOSI was on PC3, which HARDWARE.md has always flagged as the sketchy one - it is the A2 analog-switch pin. So the test was: move MOSI to the other SPI2 MOSI pin, PB15, and change nothing else.
// PC3 died on this board during the CN9 hot-plugging - confirmed by elimination.
Spi::new_txonly(p.SPI2, p.PD3, p.PB15, p.DMA1_CH1, Irqs, spicfg)
The panel came back instantly. PC3 is dead - it stopped driving during all the plugging and unplugging on that header while I was wiring the ESP in next to it. I own that too: my own decision to crowd the ESP link onto the same connector as the panel's SPI pins is what put a delicate pin one hole away from hours of hot-plugging. The panel and the rest of the board are fine; one pin is gone, and MOSI lives on PB15 now.
downloading a cart over WiFi
With WiFi up and the panel back, the actual feature was almost anticlimactic. The ESP does an HTTP GET of a .p8.png at whatever URL it is given and streams the body straight off the socket in 1000-byte chunks - it never buffers the whole file - framed as begin/chunk/end with an incremental CRC-32 in the end frame. The H7 reassembles the chunks, checks the CRC, and then reuses the exact path a USB-sideloaded cart already takes: a raw .p8.png is converted to the internal bundle format on-device and hot-swapped into the VM.
esp-net cart: begin 35484 bytes
esp-net cart: complete 35484 bytes, crc ok -> hot-swap
That is a 35484-byte cart, pulled over HTTPS and running on the panel. The one gotcha: the download blocks the ESP's main loop for a few seconds, and during that window the Bluetooth stack is not serviced, so the paired pad drops. The fix is to pump the BT stack every 15ms inside the fetch loop. Downloads are seconds, not milliseconds - you cannot just go dark on Bluetooth for the duration.
the launcher
The last piece was the front end. The state machine is a small no_std crate, host-tested: it eats the 16-bit gamepad mask and returns actions (open the library, fetch cart N, resume, back). It does no rendering and no IO - the firmware owns those. Alongside it, a tiny byte-span JSON parser turns a manifest into cart entries without allocating.
START+SELECT (both held at once - the log showed me it was 0x3000 the moment the owner held them together instead of tapping them in turn) opens a LIBRARY overlay drawn straight into the PICO-8 framebuffer. The d-pad moves the selection, A downloads and runs the highlighted cart, B backs out, and over a running cart the same chord opens a Resume / Restart / Library pause menu. While the overlay is up the running cart is frozen, so navigating the library does not also drive it.
The library is manifest-driven: a small built-in fallback list so it always has something, and an optional manifest.json of your own cart URLs fetched over WiFi so the list is editable without a reflash. Browse, pick one, watch it download and boot. That is the whole thing working: a bare-metal STM32 with a Bluetooth pad, pulling a cart from a URL and running it.
what it cost
Three lessons, none of them new, all of them re-learned the hard way:
- When someone who owns the hardware tells you the password is right, believe them and go look at your own code first. Reason 15 is not a wrong-password error, whatever it feels like.
- When reverting to a known-good commit does not fix it, it is not the code. Stop editing and start unplugging.
- Do not route a signal you care about through a pin the docs already warned you about, and do not crowd a delicate bus next to something you will be hot-plugging for hours.
Still to come: box art in the grid, and settling where the manifest lives. But the spine is done, and it works.