fix(micropython): stop shadowing the real WiFi driver when a NIC exists

Reported in #262: on an emulated ESP32, MicroPython's WLAN.scan() always
returned [], ifconfig() reported 10.0.2.15/10.0.2.2/10.0.2.3, and
outbound sockets raised EHOSTUNREACH — while Arduino WiFi.h on the same
board and container scanned, connected on 192.168.4.x and completed real
HTTP requests. Two independent faults, both needed for the failure.

1. The MicroPython prelude injected a stub `network` module into
   sys.modules unconditionally, so user code never reached the firmware
   driver. The stub's hardcoded ifconfig() happens to be QEMU's SLIRP
   defaults, which is why the symptom read so convincingly as a routing
   bug.

2. hasWifi survived a language switch. It is a property of the compiled
   sketch, set only by arduino-cli, but setBoardLanguageMode kept it — so
   a board compiled once as Arduino carried that verdict into a later
   MicroPython run. The source-scanning fallback only fires when hasWifi
   is undefined, so it never got a chance, and the backend attaches the
   `-nic` device only when wifi_enabled is true.

The stub is NOT removed, which is where this departs from the patch in
the issue. It exists for a reason the report didn't have: without a NIC,
`network.WLAN(STA_IF)` hangs forever waiting on peripheral status bits
QEMU never sets, tripping the FreeRTOS watchdog after ~26s. Deleting it
would trade a fake-but-working network for a hung board. It is now
conditional — real driver when a NIC is attached, stub when none is —
so both paths are honest.

Also kept: the ntptime and urequests stubs, which the issue's diff would
have dropped along with `network` (it removed the whole prelude block).
Those back 29 call sites in the 100-days gallery examples, which have no
emulated internet to reach regardless of the NIC.

The detector is now one shared function instead of two copies that could
disagree — the stub decision and the -nic decision MUST match. Folded in
`from network import WLAN`, which matched neither previous pattern: such
a sketch was judged WiFi-less and hung on the missing peripheral. The
existing tests re-implemented the detection inline, so they only ever
proved that copy self-consistent; the new ones call the shipped function.
This commit is contained in:
David Montero Crespo 2026-08-10 21:40:37 +02:00
parent 2908726007
commit 1800f57225
2 changed files with 128 additions and 29 deletions

View File

@ -9,6 +9,7 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { sketchUsesWifi } from '../store/useSimulatorStore';
// ── Mocks ────────────────────────────────────────────────────────────────────
@ -357,6 +358,54 @@ void loop() { delay(1000); }
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3b. The REAL detector (issue #262)
//
// The block above re-implements the detection inline, so it only ever
// proves the copy in this file is self-consistent — which is how the
// MicroPython gap below survived. These call the shipped function.
//
// Getting this wrong is not cosmetic: a false verdict means QEMU is
// started without `-nic`, and MicroPython's `network.WLAN(STA_IF)` then
// hangs ~26s on a peripheral that was never attached until the FreeRTOS
// watchdog resets the board.
// ─────────────────────────────────────────────────────────────────────────────
describe('sketchUsesWifi — the shipped detector', () => {
const uses = (content: string) => sketchUsesWifi([{ content }]);
it('detects the Arduino forms', () => {
expect(uses('#include <WiFi.h>')).toBe(true);
expect(uses('#include "WiFi.h"')).toBe(true);
expect(uses('#include <esp_wifi.h>')).toBe(true);
expect(uses('void setup(){ WiFi.begin("s",""); }')).toBe(true);
});
it('detects the MicroPython import forms', () => {
expect(uses('import network')).toBe(true);
expect(uses('import network as net')).toBe(true);
expect(uses('wlan = network.WLAN(network.STA_IF)')).toBe(true);
});
it('detects `from network import WLAN` (the #262 gap)', () => {
// Reported as MicroPython WiFi being "broken": this form matched
// neither /import\s+network\b/ nor /network\.WLAN/, so the sketch was
// judged WiFi-less and never got a NIC.
expect(uses('from network import WLAN\nw = WLAN(0)')).toBe(true);
});
it('stays false for sketches that touch no network', () => {
expect(uses('void setup(){ Serial.begin(115200); }')).toBe(false);
expect(uses('from machine import Pin\nPin(2).on()')).toBe(false);
});
it('scans every file, not just the first', () => {
expect(
sketchUsesWifi([{ content: 'from machine import Pin' }, { content: 'import network' }]),
).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. BLE detection from sketch files
// ─────────────────────────────────────────────────────────────────────────────

View File

@ -963,6 +963,34 @@ function isEsp32Kind(kind: BoardKind): boolean {
return getProBoard(kind)?.esp32Family !== undefined;
}
/**
* Does this sketch need the emulated WiFi NIC?
*
* Single source of truth for two callers that must agree: startBoard(),
* which decides whether QEMU gets `-nic` at all, and
* loadMicroPythonProgram(), which decides whether to shadow `network`
* with the offline stub. Disagreement between them is precisely the
* failure reported in #262 — a real driver with no device behind it, or
* a stub in front of a device that works.
*
* Arduino is detected from its includes; MicroPython from its import
* forms. `from network import WLAN` is listed because omitting it left
* hasWifi false for a sketch that genuinely uses WiFi, and the board
* then hung for ~26s on a peripheral that was never attached.
*/
export function sketchUsesWifi(files: Array<{ content: string }>): boolean {
return files.some(
(f) =>
f.content.includes('#include <WiFi.h>') ||
f.content.includes('#include <esp_wifi.h>') ||
f.content.includes('#include "WiFi.h"') ||
f.content.includes('WiFi.begin(') ||
/import\s+network\b/.test(f.content) ||
/from\s+network\s+import\b/.test(f.content) ||
/network\.WLAN/.test(f.content),
);
}
function isRiscVEsp32Kind(kind: BoardKind): boolean {
const fam = getProBoard(kind)?.esp32Family;
return ESP32_RISCV_KINDS.has(kind) || fam === 'esp32-c3' || fam === 'esp32-c6';
@ -1944,13 +1972,33 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
return `with open(${path},'w') as _f:\n _f.write(${lit})`;
});
// Does this run get a real NIC? Must match what startBoard()
// will decide, because that is what actually attaches the
// device — see sketchUsesWifi().
const wifiOn = board.hasWifi ?? sketchUsesWifi(files);
// WiFi compat shim: replace `network`, `ntptime`, `urequests`
// with smart stubs BEFORE user main.py imports them. The
// picsimlab QEMU fork's esp32_wifi NIC emulation is sufficient
// for Arduino's lightweight WiFi.h but not for MicroPython's
// full esp_wifi_init path — calling `network.WLAN(STA_IF)`
// hangs forever waiting for peripheral status bits QEMU never
// sets, tripping the FreeRTOS task watchdog after ~26s.
// with smart stubs BEFORE user main.py imports them.
//
// `network` is stubbed ONLY when no NIC is attached. With one
// attached the real driver works (DHCP on 192.168.4.x, DNS,
// outbound TCP), and shadowing it made MicroPython WiFi look
// permanently broken while Arduino worked on the same board:
// scan() returned [], ifconfig() reported QEMU's SLIRP
// defaults, and sockets raised EHOSTUNREACH — issue #262.
//
// Without a NIC the stub still earns its place. The picsimlab
// QEMU fork's esp32_wifi emulation is enough for Arduino's
// lightweight WiFi.h but not for MicroPython's full
// esp_wifi_init path: `network.WLAN(STA_IF)` then hangs
// forever waiting for peripheral status bits QEMU never sets,
// tripping the FreeRTOS task watchdog after ~26s. Deleting the
// stub outright would trade a working-but-fake network for a
// hung board, so it stays as the offline fallback.
//
// ntptime and urequests are stubbed unconditionally: they back
// the gallery examples' clocks and weather panels, which have
// no emulated internet to reach either way.
//
// Smart stub behaviour (so examples like smart-ui-eyes WORK
// end-to-end, not just degrade gracefully):
@ -1967,14 +2015,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
// useful data instead of "API Error".
const now = new Date();
const fakeWeatherCity = 'Simulator City';
const wifiStub = [
'import sys',
'import json as _json',
'try:',
' import machine as _machine',
'except ImportError:',
' _machine = None',
'',
const networkStub = wifiOn ? [] : [
'class _StubWLAN:',
' def __init__(self, *a, **k):',
' self._calls = 0',
@ -1993,6 +2034,17 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
' AP_IF = 1',
' WLAN = _StubWLAN',
'sys.modules["network"] = _StubNetwork()',
];
const wifiStub = [
'import sys',
'import json as _json',
'try:',
' import machine as _machine',
'except ImportError:',
' _machine = None',
'',
...networkStub,
'',
'# ntptime: pre-load RTC with host UTC so localtime() works.',
`_VLX_BOOT_UTC = (${now.getUTCFullYear()}, ${now.getUTCMonth() + 1}, ${now.getUTCDate()}, ${now.getUTCDay() || 7}, ${now.getUTCHours()}, ${now.getUTCMinutes()}, ${now.getUTCSeconds()}, 0)`,
@ -2089,10 +2141,18 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
// Stop any running simulation
if (board.running) get().stopBoard(boardId);
// Clear compiled program since language changed
// Clear compiled program since language changed. hasWifi goes with
// it: it is a property of the compiled sketch, not of the board, and
// only arduino-cli sets it. Keeping the old value meant a board that
// had been compiled as Arduino carried that verdict into a later
// MicroPython run, where the source-scanning fallback never fires
// (it only runs when hasWifi is undefined) — so a MicroPython WiFi
// sketch on a reused board silently got no NIC at all (#262).
set((s) => ({
boards: s.boards.map((b) =>
b.id === boardId ? { ...b, languageMode: mode, compiledProgram: null } : b,
b.id === boardId
? { ...b, languageMode: mode, compiledProgram: null, hasWifi: undefined }
: b,
),
}));
@ -2282,19 +2342,9 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
const editorState = useEditorStore.getState();
const rawFiles = editorState.fileGroups[board.activeFileGroupId];
const boardFiles = rawFiles && rawFiles.length > 0 ? rawFiles : editorState.files;
hasWifi = boardFiles.some(
(f) =>
f.content.includes('#include <WiFi.h>') ||
f.content.includes('#include <esp_wifi.h>') ||
f.content.includes('#include "WiFi.h"') ||
f.content.includes('WiFi.begin(') ||
// MicroPython patterns — without these the WiFi NIC is never
// passed to QEMU, and `network.WLAN(STA_IF)` hangs forever
// trying to init a peripheral that doesn't exist, eventually
// tripping the FreeRTOS task watchdog (TG1WDT_SYS_RESET).
/import\s+network\b/.test(f.content) ||
/network\.WLAN/.test(f.content),
);
// Shared with loadMicroPythonProgram's stub decision — the two
// must not disagree about whether a NIC exists.
hasWifi = sketchUsesWifi(boardFiles);
}
esp32Bridge.wifiEnabled = hasWifi;