diff --git a/frontend/src/__tests__/issue-270-firmware-wifi-note.test.ts b/frontend/src/__tests__/issue-270-firmware-wifi-note.test.ts new file mode 100644 index 00000000..49cd711f --- /dev/null +++ b/frontend/src/__tests__/issue-270-firmware-wifi-note.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment jsdom +/** + * Issue #270 — a Visuino-built .bin boots and runs in Velxio and then reports + * `STA connect failed! 0x300a: ESP_ERR_WIFI_SSID`, which reads as the emulator + * being broken. It is not: the emulated radio broadcasts four networks and + * nothing else, and a binary built outside Velxio keeps whatever SSID it was + * built with, because the rewrite that saves compiled sketches happens in the + * compiler this file never went through. + * + * Verified against the real emulator while writing this: the same sketch built + * with SSID "MyHomeNet" fails to associate, and with "Velxio-GUEST" connects + * on 192.168.4.x. + */ +import { describe, it, expect } from 'vitest'; +import { wifiSsidNoteFor } from '../utils/firmwareWifiNote'; +import { EMULATED_WIFI_SSIDS } from '../types/board'; + +/** A fake image: some binary noise with the given ASCII strings buried in it. */ +function image(strings: string[]): File { + const parts: number[] = []; + for (let i = 0; i < 512; i++) parts.push(i & 0xff); + for (const s of strings) { + for (let i = 0; i < s.length; i++) parts.push(s.charCodeAt(i)); + parts.push(0); + for (let i = 0; i < 64; i++) parts.push(0xa5); + } + return new File([new Uint8Array(parts)], 'Generated.ino.merged.bin'); +} + +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'])); + 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(); + } + }); + + it('stays quiet for firmware that does not use WiFi', async () => { + // A note nobody needs is noise, and noise is how people learn to ignore + // the console. + expect(await wifiSsidNoteFor(image(['blink', 'digitalWrite']))).toBeNull(); + }); + + it('recognises the WiFi stack by more than one marker', async () => { + for (const marker of ['WiFiGeneric', 'STA.cpp', 'esp_wifi_connect', 'wifi_station']) { + expect(await wifiSsidNoteFor(image([marker, 'SomeHomeNet']))).toBeTruthy(); + } + }); + + 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)); + const note = await wifiSsidNoteFor(new File([new Uint8Array(bytes)], 'fw.bin')); + expect(note).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index 1303bc9e..111d1b6e 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -30,6 +30,7 @@ import { mergeSuggestedLibraries } from '../../utils/libraryManifest'; import { parseCompileResult, isNoiseBuildLine } from '../../utils/compilationLogger'; import type { CompilationLog, CompileTarget } from '../../utils/compilationLogger'; import { exportToWokwiZip, retargetBoardWires } from '../../utils/wokwiZip'; +import { wifiSsidNoteFor } from '../../utils/firmwareWifiNote'; import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject'; import { readFirmwareFile } from '../../utils/firmwareLoader'; import { @@ -1399,6 +1400,16 @@ export const EditorToolbar = ({ }); } + // A binary built elsewhere never passed through our compiler, so its + // WiFi SSID was never rewritten for the emulator — and the emulated + // radio only ever broadcasts EMULATED_WIFI_SSIDS. The firmware boots + // and runs perfectly and then sits there failing to associate, which + // reads as "the emulator is broken" (issue #270). Say it once, here, + // and only when the binary actually looks like it wants WiFi and names + // none of the networks it could reach. + const note = await wifiSsidNoteFor(file); + if (note) addLog({ timestamp: new Date(), type: 'info', message: note }); + if (activeBoardId) { compileBoardProgram(activeBoardId, result.program); markCompiled(); diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index a0049d27..4b8bc397 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -27,7 +27,7 @@ import type { I2CDevice } from '../simulation/I2CBusManager'; 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_SUPPORTS_ESPIDF, BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind, isStm32BoardKind } from '../types/board'; +import { BOARD_SUPPORTS_ESPIDF, BOARD_SUPPORTS_MICROPYTHON, EMULATED_WIFI_SSIDS, isPiBoardKind, isStm32BoardKind } from '../types/board'; import { boardGateDecision, proBoardFeatureName, triggerProUpgradePrompt } from '../lib/proBoardGate'; import { getSerialTxInterceptor } from '../lib/proHardwareSerial'; import { calculatePinPosition } from '../utils/pinPositionCalculator'; @@ -2118,7 +2118,7 @@ export const useSimulatorStore = create((set, get) => { const networkStub = wifiOn ? [ 'import network as _vlx_net', 'import time as _vlx_time', - '_VLX_SSIDS = ("Velxio-GUEST", "PICSimLabWifi", "Espressif", "MasseyWifi")', + `_VLX_SSIDS = (${EMULATED_WIFI_SSIDS.map((s) => JSON.stringify(s)).join(', ')})`, 'class _VlxWLAN:', ' def __init__(self, *a, **k):', ' self._w = _vlx_net.WLAN(*a, **k)', diff --git a/frontend/src/types/board.ts b/frontend/src/types/board.ts index 77d38fa4..a31585ba 100644 --- a/frontend/src/types/board.ts +++ b/frontend/src/types/board.ts @@ -194,6 +194,24 @@ export function isKnownBoardKind(kind: string): kind is BoardKind { ); } +/** + * The networks the emulated radio broadcasts — all open, no password. + * + * Both backends beacon the same four (the QEMU fork's esp32_wifi_ap.c and the + * in-browser engines' ACCESS_POINTS), and a station can only ever associate + * with one of them. Sketches COMPILED in Velxio never have to know: the + * compiler rewrites their SSID literal on the way to the emulator. Firmware + * that arrives already built does not pass through that step, so its own SSID + * is what it hunts for — which is why an otherwise-working binary sits there + * failing to connect (issue #270). + */ +export const EMULATED_WIFI_SSIDS = [ + 'Velxio-GUEST', + 'PICSimLabWifi', + 'Espressif', + 'MasseyWifi', +] as const; + export const BOARD_KIND_LABELS: Record = { 'arduino-uno': 'Arduino Uno', 'arduino-nano': 'Arduino Nano', diff --git a/frontend/src/utils/firmwareWifiNote.ts b/frontend/src/utils/firmwareWifiNote.ts new file mode 100644 index 00000000..02c57536 --- /dev/null +++ b/frontend/src/utils/firmwareWifiNote.ts @@ -0,0 +1,54 @@ +/** + * Tell someone their uploaded firmware will never find a network (issue #270). + * + * A sketch compiled inside Velxio has its WiFi SSID rewritten on the way to + * the emulator, because the emulated radio broadcasts a fixed set of networks + * and nothing else. Firmware that arrives already built — from the Arduino + * IDE, from Visuino, from anywhere — skips that step by construction: there is + * no compile to rewrite anything. So it boots, runs, and hunts for a network + * that does not exist here, which looks like the emulator failing rather than + * a configuration mismatch. The reporter of #270 lost an afternoon to exactly + * that with a binary that was, in every other respect, working. + */ +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. + */ +const WIFI_MARKERS = ['WiFiGeneric', 'STA.cpp', 'esp_wifi_connect', 'wifi_station']; + +/** Search a binary for an ASCII string, without decoding the whole image. */ +function containsAscii(bytes: Uint8Array, needle: string): boolean { + const pat = new Uint8Array(needle.length); + for (let i = 0; i < needle.length; i++) pat[i] = needle.charCodeAt(i); + outer: for (let i = 0; i + pat.length <= bytes.length; i++) { + for (let j = 0; j < pat.length; j++) if (bytes[i + j] !== pat[j]) continue outer; + return true; + } + return false; +} + +/** + * The line to print for this firmware, or null when there is nothing to say — + * either it does not use WiFi, or it already names a network the emulator + * broadcasts and will connect on its own. + */ +export async function wifiSsidNoteFor(file: File): Promise { + let bytes: Uint8Array; + try { + bytes = new Uint8Array(await file.arrayBuffer()); + } catch { + return null; // unreadable is the caller's problem, not ours to report twice + } + if (!WIFI_MARKERS.some((m) => containsAscii(bytes, m))) return null; + if (EMULATED_WIFI_SSIDS.some((s) => containsAscii(bytes, s))) return null; + return ( + 'This firmware uses WiFi but does not name any network the emulator has. ' + + `The emulated radio broadcasts only: ${EMULATED_WIFI_SSIDS.join(', ')} — all open, ` + + 'no password. Rebuild it with one of those as the SSID and it will connect. ' + + '(Sketches compiled here are rewritten automatically; an uploaded binary cannot be.)' + ); +}