diff --git a/backend/app/api/routes/iot_gateway.py b/backend/app/api/routes/iot_gateway.py index 3cbe83f7..fc63e8ab 100644 --- a/backend/app/api/routes/iot_gateway.py +++ b/backend/app/api/routes/iot_gateway.py @@ -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."}', diff --git a/frontend/src/components/simulator/SerialMonitor.tsx b/frontend/src/components/simulator/SerialMonitor.tsx index 103820c0..014a3d49 100644 --- a/frontend/src/components/simulator/SerialMonitor.tsx +++ b/frontend/src/components/simulator/SerialMonitor.tsx @@ -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( { 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); } diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 5e33a069..c6d86c35 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -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'); diff --git a/frontend/src/simulation/Esp32Bridge.ts b/frontend/src/simulation/Esp32Bridge.ts index d924f3ac..ac95d40c 100644 --- a/frontend/src/simulation/Esp32Bridge.ts +++ b/frontend/src/simulation/Esp32Bridge.ts @@ -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; diff --git a/frontend/src/types/board.ts b/frontend/src/types/board.ts index a2e14038..32dc7156 100644 --- a/frontend/src/types/board.ts +++ b/frontend/src/types/board.ts @@ -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 {