fix(firmware): explain the blank-NVS WiFi failure, and detect WiFi in stripped images (#270)

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.
This commit is contained in:
David Montero Crespo 2026-08-26 04:51:16 +02:00
parent 45ac020a88
commit b35b5aead4
4 changed files with 119 additions and 11 deletions

View File

@ -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');
});
});

View File

@ -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 };
});

View File

@ -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 {

View File

@ -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;
}