fix(gateway): keep in-browser WiFi boards reachable through the IoT gateway

The ESP32 examples now default to the in-browser JS engines, whose lwIP
serves the sketch's HTTP server from the simulation tab itself. Opening
the IoT gateway in a new tab backgrounds that tab, the browser throttles
its timers, and the in-chip server can't answer before the proxy's
timeout — the exact failure the Pico W iframe path already solved.

- WifiStatus grows an inBrowser flag (set by the overlay's JS bridges);
  the serial-monitor link and the WiFi badge now open the in-app iframe
  for any board that carries it, not just pi-pico-w.
- /api/gateway polls ~1s before answering 404: the net bridge dials on
  'got_ip', which races the sketch printing its URL, so a fast click
  used to land before the bridge registered.
This commit is contained in:
David Montero Crespo 2026-08-14 19:45:39 +02:00
parent efabac0a05
commit 8391cc7fa8
5 changed files with 49 additions and 17 deletions

View File

@ -10,6 +10,7 @@ URL pattern:
/api/gateway/{client_id}/{path}
→ http://127.0.0.1:{hostfwd_port}/{path}
"""
import asyncio
import json
import logging
@ -66,17 +67,25 @@ async def gateway_proxy(client_id: str, path: str, request: Request) -> Response
media_type='application/json',
)
# ── ESP32: the server runs in QEMU, reachable via slirp hostfwd. ──
inst = esp_lib_manager.get_instance(client_id)
if inst and inst.wifi_enabled and inst.wifi_hostfwd_port != 0:
return await _proxy_esp32(inst, path, request)
# Registration races the sketch printing its URL: the in-browser engines
# dial their net bridge only on 'got_ip', which lands at the same moment
# "Server started at: ..." appears in the serial monitor — a fast click
# reaches here before the bridge exists. Poll briefly before giving up.
for attempt in range(5):
if attempt:
await asyncio.sleep(0.25)
# ── Pico W (and any other overlay-provided board): the server runs in the
# browser-side lwIP, reachable only by the overlay proxying TCP into the
# chip over the WS bridge. OSS has no resolver -> falls through to 404. ──
overlay_resp = await dispatch_gateway_proxy(client_id, path, request)
if overlay_resp is not None:
return overlay_resp
# ── ESP32: the server runs in QEMU, reachable via slirp hostfwd. ──
inst = esp_lib_manager.get_instance(client_id)
if inst and inst.wifi_enabled and inst.wifi_hostfwd_port != 0:
return await _proxy_esp32(inst, path, request)
# ── Pico W (and any other overlay-provided board): the server runs in
# the browser-side lwIP, reachable only by the overlay proxying TCP
# into the chip over the WS bridge. OSS has no resolver -> None. ──
overlay_resp = await dispatch_gateway_proxy(client_id, path, request)
if overlay_resp is not None:
return overlay_resp
return Response(
content='{"error":"No WiFi-enabled board found for this client. Make sure your sketch connected to WiFi and started a server on port 80."}',

View File

@ -311,7 +311,15 @@ export const SerialMonitor: React.FC = () => {
const gatewayUrl = `${backendBase}/gateway/${clientId}${path}`;
parts.push(text.slice(lastIdx, start));
const isPicoW = activeBoard.boardKind === 'pi-pico-w';
// Boards whose network stack lives in THIS tab (Pico W, and
// any ESP32 on the in-browser JS engine — wifiStatus carries
// inBrowser) must open in the in-app iframe: a new tab
// backgrounds this one, the emulation gets timer-throttled,
// and the in-chip server times out. QEMU boards run
// backend-side, so a new tab is fine there.
const servesInTab =
activeBoard.boardKind === 'pi-pico-w' ||
activeBoard.wifiStatus?.inBrowser === true;
parts.push(
<a
key={i}
@ -319,10 +327,8 @@ export const SerialMonitor: React.FC = () => {
target="_blank"
rel="noreferrer"
onClick={
isPicoW
servesInTab
? (e) => {
// Pico W runs in this tab; a new tab freezes the
// emulation. Show the page in an in-app iframe.
e.preventDefault();
openDeviceGateway(gatewayUrl);
}

View File

@ -2806,9 +2806,16 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
}
).__velxio_iot_gateway_open_gate__;
if (gate && gate()) return;
// Pico W runs in THIS tab — a new tab would background and
// freeze the emulation. Show the page in an in-app iframe.
if (activeBoard.boardKind === 'pi-pico-w') {
// Boards whose network stack lives in THIS tab (Pico W,
// and any ESP32 on the in-browser JS engine — wifiStatus
// carries inBrowser) open in the in-app iframe: a new tab
// backgrounds this one, the emulation gets timer-throttled,
// and the in-chip server times out. QEMU boards run
// backend-side, so a new tab is fine there.
if (
activeBoard.boardKind === 'pi-pico-w' ||
activeBoard.wifiStatus?.inBrowser === true
) {
openDeviceGateway(gatewayUrl);
} else {
window.open(gatewayUrl, '_blank');

View File

@ -108,6 +108,11 @@ export interface WifiStatus {
status: string;
ssid?: string;
ip?: string;
/** True when the board's network stack lives in THIS browser tab (in-browser
* JS engine). The IoT gateway must then open in the in-app iframe: a new
* tab backgrounds this one, the emulation gets timer-throttled, and the
* in-chip HTTP server can't answer. Unset = server runs backend-side. */
inBrowser?: boolean;
}
export interface BleStatus {
status: string;

View File

@ -100,6 +100,11 @@ export interface WifiStatus {
status: string; // 'initializing' | 'connected' | 'got_ip' | 'disconnected'
ssid?: string;
ip?: string;
/** True when the board's network stack lives in THIS browser tab (in-browser
* JS engine). The IoT gateway must then open in the in-app iframe: a new
* tab backgrounds this one, the emulation gets timer-throttled, and the
* in-chip HTTP server can't answer. Unset = server runs backend-side. */
inBrowser?: boolean;
}
export interface BleStatus {