From b35b5aead440ba24303a3b0797d53c501877a997 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Wed, 26 Aug 2026 04:51:16 +0200 Subject: [PATCH] fix(firmware): explain the blank-NVS WiFi failure, and detect WiFi in stripped images (#270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reporter sent his three Visuino binaries, and running them settles what his error was. It was never the network name, and it was not his delay() theory either. His first binary reproduces his exact report on the in-browser engine — `[40][E][STA.cpp:357] connect(): STA connect failed! 0x300a: ESP_ERR_WIFI_SSID`, timestamps and all — and his latest binary connects and prints its lease, the same `NAN WiFi stop` line and IP his real-hardware screenshot shows. Same emulator, same day. The difference is inside the firmware: the failing build reaches esp_wifi_connect with no usable SSID, and a sketch calling WiFi.begin() with NO arguments on this simulator produces his error verbatim — verified. On his real DevKit that same call works, because begin() with no credentials reuses whatever some earlier firmware saved in NVS, and his chip's flash had a network in it. The simulator's flash starts blank. Nothing in the output said any of that, and it cost him days. So the serial monitor now says it: when `0x300a: ESP_ERR_WIFI_SSID` scrolls past, one [velxio] line explains the NVS mechanism and gives the literal fix (`WiFi.begin("Velxio-GUEST", "")`). Wired at the serial batcher, which both backends feed; it fires once per run, re-arming naturally because a fresh run starts a fresh log. The delay() theory was measured and does not hold — the same sketch with and without delay(1) both associate, 48s vs 52s on the engine, 8.0s vs 8.2s on QEMU. His binaries also exposed d64b6796's detection as built on sand: it keyed on Arduino's log strings ('WiFiGeneric', 'STA.cpp'), which vanish when a build lowers its debug level — his latest image contains neither, so the note would have stayed silent on precisely the firmware it was written for. The markers are now IDF driver names (net80211, phy_init, esp_wifi, wifi_init), chosen by measuring a blink build against a WiFi build: each is absent from the former and present in the latter. WIFI_INIT looked obvious and is in BOTH, so it is not on the list. Checked against all three of his images: WiFi detected, his correct SSID found, note rightly silent. Separate finding, out of scope here: his merged images reboot-loop on the QEMU machine (flash-init assert). His build carries CONFIG_SPIRAM=y — the core 3.x precompiled libs enable it regardless of the PSRAM=disabled menu choice — and our own build with PSRAM enabled fails the same way on QEMU, which models no PSRAM chip ('PSRAM ID read error: 0x00000000'). velxio.dev routes ESP32 to the in-browser engine, where his images run, so this affects only the self-hosted QEMU path. --- .../issue-270-firmware-wifi-note.test.ts | 65 +++++++++++++++++-- frontend/src/store/useSimulatorStore.ts | 8 ++- frontend/src/utils/firmwareWifiNote.ts | 18 +++-- frontend/src/utils/serialDiagnostics.ts | 39 +++++++++++ 4 files changed, 119 insertions(+), 11 deletions(-) create mode 100644 frontend/src/utils/serialDiagnostics.ts diff --git a/frontend/src/__tests__/issue-270-firmware-wifi-note.test.ts b/frontend/src/__tests__/issue-270-firmware-wifi-note.test.ts index 49cd711f..4ba1b8c0 100644 --- a/frontend/src/__tests__/issue-270-firmware-wifi-note.test.ts +++ b/frontend/src/__tests__/issue-270-firmware-wifi-note.test.ts @@ -29,14 +29,14 @@ function image(strings: string[]): File { describe('issue #270 — uploaded firmware that cannot reach any network', () => { it('warns when the image uses WiFi and names no reachable network', async () => { - const note = await wifiSsidNoteFor(image(['WiFiGeneric', 'MyHomeNet'])); + const note = await wifiSsidNoteFor(image(['net80211', 'esp_wifi', 'MyHomeNet'])); expect(note).toBeTruthy(); for (const ssid of EMULATED_WIFI_SSIDS) expect(note).toContain(ssid); }); it('stays quiet when the image already names one', async () => { for (const ssid of EMULATED_WIFI_SSIDS) { - expect(await wifiSsidNoteFor(image(['WiFiGeneric', ssid]))).toBeNull(); + expect(await wifiSsidNoteFor(image(['net80211', 'esp_wifi', ssid]))).toBeNull(); } }); @@ -46,17 +46,74 @@ describe('issue #270 — uploaded firmware that cannot reach any network', () => expect(await wifiSsidNoteFor(image(['blink', 'digitalWrite']))).toBeNull(); }); + it('stays quiet for the reporter\'s own binary, which names the right network', async () => { + // His SSID was correct all along — the note must not accuse him of a + // problem he does not have. + expect(await wifiSsidNoteFor(image(['net80211', 'esp_wifi', 'Velxio-GUEST']))).toBeNull(); + }); + it('recognises the WiFi stack by more than one marker', async () => { - for (const marker of ['WiFiGeneric', 'STA.cpp', 'esp_wifi_connect', 'wifi_station']) { + for (const marker of ['net80211', 'phy_init', 'esp_wifi', 'wifi_init']) { expect(await wifiSsidNoteFor(image([marker, 'SomeHomeNet']))).toBeTruthy(); } }); + it('still recognises it when the build stripped its log strings', async () => { + // The reporter's second binary has no 'STA.cpp' and no 'WiFiGeneric' at + // all — a lower debug level drops them — while the driver names remain. + // Keying on the log strings meant the note stayed silent on exactly the + // firmware it exists for. + const stripped = image(['net80211', 'phy_init', 'esp_wifi', 'MyHomeNet']); + expect(await wifiSsidNoteFor(stripped)).toBeTruthy(); + }); + + it('does not fire on a name that a blink image also carries', async () => { + // WIFI_INIT is in both a blink build and a WiFi build, measured. + expect(await wifiSsidNoteFor(image(['WIFI_INIT_CONFIG_DEFAULT', 'blink']))).toBeNull(); + }); + it('finds a marker that straddles the very end of the image', async () => { const bytes: number[] = []; for (let i = 0; i < 100; i++) bytes.push(0xff); - for (const ch of 'WiFiGeneric') bytes.push(ch.charCodeAt(0)); + for (const ch of 'net80211') bytes.push(ch.charCodeAt(0)); const note = await wifiSsidNoteFor(new File([new Uint8Array(bytes)], 'fw.bin')); expect(note).toBeTruthy(); }); }); + +describe('issue #270 — the serial note for a blank-NVS connect', () => { + // Verified against the emulator: WiFi.begin() with no arguments on the + // simulator's blank flash produces exactly `STA.cpp:357 ... 0x300a: + // ESP_ERR_WIFI_SSID` — the reporter's error, timestamps and all. On his + // real DevKit the same binary worked because NVS still held credentials + // some earlier firmware had saved. + const MARKER = '0x300a: ESP_ERR_WIFI_SSID'; + + it('explains the error once', async () => { + const { annotateSerialChunk } = await import('../utils/serialDiagnostics'); + const chunk = `[ 40][E][STA.cpp:357] connect(): STA connect failed! ${MARKER}\r\n`; + const out = annotateSerialChunk('', chunk); + expect(out).toContain('NVS'); + expect(out).toContain('Velxio-GUEST'); + }); + + it('does not repeat itself while the same run keeps failing', async () => { + const { annotateSerialChunk } = await import('../utils/serialDiagnostics'); + const chunk = `STA connect failed! ${MARKER}\r\n`; + const first = annotateSerialChunk('', chunk); + const second = annotateSerialChunk(first, chunk); + expect(second).not.toContain('NVS'); + }); + + it('re-arms on a fresh run, whose log starts empty', async () => { + const { annotateSerialChunk } = await import('../utils/serialDiagnostics'); + const chunk = `STA connect failed! ${MARKER}\r\n`; + annotateSerialChunk('', chunk); + expect(annotateSerialChunk('', chunk)).toContain('NVS'); + }); + + it('leaves ordinary output alone', async () => { + const { annotateSerialChunk } = await import('../utils/serialDiagnostics'); + expect(annotateSerialChunk('', 'TICK 1\r\n')).toBe('TICK 1\r\n'); + }); +}); diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 030f9657..6244a5a5 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -28,6 +28,7 @@ import type { RP2040I2CDevice } from '../simulation/RP2040Simulator'; import type { Wire, WireInProgress, WireEndpoint } from '../types/wire'; import type { BoardKind, BoardInstance, LanguageMode, WifiStatus } from '../types/board'; import { BOARD_KIND_FQBN, BOARD_SUPPORTS_ESPIDF, BOARD_SUPPORTS_MICROPYTHON, EMULATED_WIFI_SSIDS, isPiBoardKind, isStm32BoardKind } from '../types/board'; +import { annotateSerialChunk } from '../utils/serialDiagnostics'; import { boardGateDecision, proBoardFeatureName, triggerProUpgradePrompt } from '../lib/proBoardGate'; import { getSerialTxInterceptor } from '../lib/proHardwareSerial'; import { calculatePinPosition } from '../utils/pinPositionCalculator'; @@ -1312,8 +1313,11 @@ const { append: appendSerial } = createSerialBatcher((perBoard) => { useSimulatorStore.setState((s) => { let globalOut = s.serialOutput; const boards = s.boards.map((b) => { - const chunk = perBoard.get(b.id); - if (!chunk) return b; + const raw = perBoard.get(b.id); + if (!raw) return b; + // Attach the one-line explanation when a known cryptic firmware error + // scrolls past (issue #270); a no-op for ordinary output. + const chunk = annotateSerialChunk(b.serialOutput, raw); if (s.activeBoardId === b.id) globalOut += chunk; return { ...b, serialOutput: b.serialOutput + chunk }; }); diff --git a/frontend/src/utils/firmwareWifiNote.ts b/frontend/src/utils/firmwareWifiNote.ts index 02c57536..f7852895 100644 --- a/frontend/src/utils/firmwareWifiNote.ts +++ b/frontend/src/utils/firmwareWifiNote.ts @@ -13,12 +13,20 @@ import { EMULATED_WIFI_SSIDS } from '../types/board'; /** - * Markers that the image contains the Arduino WiFi stack. Kept narrow on - * purpose: `esp_wifi` alone appears in images that never touch the radio, and - * a note shown to someone who does not need it is noise that teaches people to - * ignore the console. + * Markers that the image links the WiFi stack. + * + * Chosen by measurement, against two real builds of the same board: a blink + * sketch and a WiFi one. Every name here is absent from the blink image and + * present in the WiFi one. `WIFI_INIT` looked obvious and is in BOTH, so it is + * not here. + * + * They are IDF-level names on purpose. The first version of this list used the + * Arduino log strings ('WiFiGeneric', 'STA.cpp'), which vanish when the build + * lowers its debug level — the reporter's own second binary has neither, and + * the note would have stayed silent on the exact firmware it was written for. + * These survive, because they are the driver, not a message about it. */ -const WIFI_MARKERS = ['WiFiGeneric', 'STA.cpp', 'esp_wifi_connect', 'wifi_station']; +const WIFI_MARKERS = ['net80211', 'phy_init', 'esp_wifi', 'wifi_init']; /** Search a binary for an ASCII string, without decoding the whole image. */ function containsAscii(bytes: Uint8Array, needle: string): boolean { diff --git a/frontend/src/utils/serialDiagnostics.ts b/frontend/src/utils/serialDiagnostics.ts new file mode 100644 index 00000000..1f430ae1 --- /dev/null +++ b/frontend/src/utils/serialDiagnostics.ts @@ -0,0 +1,39 @@ +/** + * One-line explanations for serial errors that read as emulator bugs. + * + * The firmware's own message is written for someone holding real hardware. + * `ESP_ERR_WIFI_SSID` cost the reporter of issue #270 days: his Visuino build + * called WiFi.begin() with no credentials, which on a real DevKit quietly + * reuses the network saved in the chip's flash (NVS) by some earlier firmware + * — and the simulator's flash starts blank, so the same binary fails at boot + * with a hex code. Nothing was wrong with the emulation; nothing in the output + * said so. + * + * Each entry appends its note once per run: the note re-arms when the serial + * log is cleared (every Run starts a fresh log), because the presence of the + * note in the existing output is itself the marker. + */ +const NOTES: ReadonlyArray<{ marker: string; note: string }> = [ + { + marker: '0x300a: ESP_ERR_WIFI_SSID', + note: + '[velxio] This firmware asked to connect with no SSID (or an invalid one). ' + + 'WiFi.begin() without arguments reuses credentials saved in the chip’s flash (NVS) ' + + 'by a previous firmware — the simulator’s flash starts blank, so there is nothing ' + + 'to reuse. Pass them explicitly: WiFi.begin("Velxio-GUEST", "").', + }, +]; + +/** + * The chunk to actually append, with any due explanation attached. Pure so it + * can be tested without the frame batcher around it. + */ +export function annotateSerialChunk(existingOutput: string, chunk: string): string { + let out = chunk; + for (const { marker, note } of NOTES) { + if (out.includes(marker) && !existingOutput.includes(note) && !out.includes(note)) { + out += `\r\n${note}\r\n`; + } + } + return out; +}