fix(picow): open the IoT gateway in an in-app iframe, not a new tab

The Pico W emulation runs in THIS browser tab via requestAnimationFrame.
Opening the gateway with target=_blank / window.open backgrounds the
emulation tab; the browser then pauses its rAF, the simulated chip
freezes, and the gateway can no longer reach the server on it — the
request times out (502) and toggles do nothing.

Render the served page in a same-tab iframe panel (openDeviceGateway) for
the Pico W so the emulation stays in the foreground and keeps answering.
The ESP32 is unchanged (its server runs in QEMU on the backend, immune to
tab visibility), so it keeps opening in a new tab.

Also: the async-led page now shows the LED state (the board's onboard LED
isn't drawn on the canvas) so the toggle has visible feedback.
This commit is contained in:
David Montero 2026-06-14 04:19:48 +02:00
parent 3b8266a75f
commit 80d2086907
4 changed files with 118 additions and 13 deletions

View File

@ -7,6 +7,7 @@ import React, { useRef, useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import { getTabSessionId } from '../../simulation/Esp32Bridge';
import { openDeviceGateway } from '../../lib/openDeviceGateway';
import type { BoardKind } from '../../types/board';
import { boardDisplayName } from '../../types/board';
@ -283,12 +284,23 @@ export const SerialMonitor: React.FC = () => {
const gatewayUrl = `${backendBase}/gateway/${clientId}${path}`;
parts.push(text.slice(lastIdx, start));
const isPicoW = activeBoard.boardKind === 'pi-pico-w';
parts.push(
<a
key={i}
href={gatewayUrl}
target="_blank"
rel="noreferrer"
onClick={
isPicoW
? (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);
}
: undefined
}
style={{
color: '#4fc3f7',
textDecoration: 'underline',

View File

@ -1,5 +1,6 @@
import { useSimulatorStore, getEsp32Bridge } from '../../store/useSimulatorStore';
import { useElectricalStore } from '../../store/useElectricalStore';
import { openDeviceGateway } from '../../lib/openDeviceGateway';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Undo2, Redo2 } from 'lucide-react';
@ -2202,7 +2203,13 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
__velxio_iot_gateway_open_gate__?: () => boolean;
}).__velxio_iot_gateway_open_gate__;
if (gate && gate()) return;
window.open(gatewayUrl, '_blank');
// 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') {
openDeviceGateway(gatewayUrl);
} else {
window.open(gatewayUrl, '_blank');
}
};
return (
<span

View File

@ -70,24 +70,27 @@ async def wifi_connect():
print("IP:", ip)
print("Open browser: http://%s/" % ip)
HTML = """<!DOCTYPE html>
<html><body><h2>Pico W Async LED</h2>
<button onclick="fetch('on')">ON</button>
<button onclick="fetch('off')">OFF</button>
</body></html>"""
led_on = False
def page():
# The board's onboard LED isn't drawn on the canvas, so show the state
# here; the buttons reload the page so you can see it flip.
state = "ON" if led_on else "OFF"
return ("<!DOCTYPE html><html><body><h2>Pico W LED: %s</h2>"
"<button onclick=\\"fetch('on').then(()=>location.reload())\\">ON</button> "
"<button onclick=\\"fetch('off').then(()=>location.reload())\\">OFF</button>"
"</body></html>") % state
async def handle_client(reader, writer):
request_line = await reader.readline()
request = request_line.decode()
global led_on
request = (await reader.readline()).decode()
while await reader.readline() != b"\\r\\n":
pass
if "GET /on" in request:
led.on(); body = "ON"
led.on(); led_on = True
elif "GET /off" in request:
led.off(); body = "OFF"
else:
body = HTML
writer.write(("HTTP/1.1 200 OK\\r\\nContent-Type: text/html\\r\\nConnection: close\\r\\n\\r\\n" + body).encode())
led.off(); led_on = False
writer.write(("HTTP/1.1 200 OK\\r\\nContent-Type: text/html\\r\\nConnection: close\\r\\n\\r\\n" + page()).encode())
await writer.drain()
await writer.wait_closed()

View File

@ -0,0 +1,83 @@
/**
* openDeviceGateway
*
* Opens an emulated board's IoT-gateway page inside an in-app iframe panel
* (same tab) instead of a new browser tab.
*
* Why: the Raspberry Pi Pico W emulation runs in THIS browser tab, driven by
* requestAnimationFrame. Opening the gateway in a new tab backgrounds the
* emulation tab, the browser pauses its rAF, the simulated chip freezes, and
* the gateway can no longer reach the server running on it (the request times
* out / returns 502). Rendering the served page in an iframe keeps the
* emulation tab in the foreground, so the chip keeps running and answers.
*
* (The ESP32 doesn't need this its server runs in QEMU on the backend, which
* is unaffected by browser tab visibility.)
*
* Plain DOM, no React state, so it can be invoked from anywhere (serial-monitor
* link, canvas WiFi badge) without threading props.
*/
const OVERLAY_ID = 'velxio-device-gateway-overlay';
export function openDeviceGateway(url: string): void {
if (typeof document === 'undefined') return;
document.getElementById(OVERLAY_ID)?.remove();
const overlay = document.createElement('div');
overlay.id = OVERLAY_ID;
overlay.style.cssText =
'position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,0.55);' +
'display:flex;align-items:center;justify-content:center;';
const panel = document.createElement('div');
panel.style.cssText =
'background:#1e1e1e;border:1px solid #3a3a3a;border-radius:10px;' +
'width:min(440px,94vw);height:min(640px,90vh);display:flex;' +
'flex-direction:column;overflow:hidden;box-shadow:0 12px 48px rgba(0,0,0,0.5);';
const bar = document.createElement('div');
bar.style.cssText =
'display:flex;align-items:center;justify-content:space-between;gap:12px;' +
'padding:8px 12px;background:#252526;color:#ddd;' +
'font:13px -apple-system,BlinkMacSystemFont,sans-serif;border-bottom:1px solid #3a3a3a;';
const title = document.createElement('span');
title.textContent = 'Device web page (IoT Gateway)';
title.style.cssText = 'font-weight:600;';
const right = document.createElement('div');
right.style.cssText = 'display:flex;align-items:center;gap:14px;';
const reload = document.createElement('button');
reload.type = 'button';
reload.textContent = 'Reload';
reload.style.cssText = 'background:none;border:none;color:#4fc3f7;cursor:pointer;font-size:13px;padding:0;';
const openTab = document.createElement('a');
openTab.textContent = 'Open in tab';
openTab.href = url;
openTab.target = '_blank';
openTab.rel = 'noreferrer';
openTab.style.cssText = 'color:#4fc3f7;text-decoration:none;font-size:13px;';
const close = document.createElement('button');
close.type = 'button';
close.textContent = 'Close';
close.style.cssText = 'background:#3a3a3a;border:none;color:#fff;cursor:pointer;font-size:13px;border-radius:5px;padding:4px 10px;';
const iframe = document.createElement('iframe');
iframe.src = url;
iframe.style.cssText = 'flex:1;border:none;width:100%;background:#fff;';
reload.onclick = () => { iframe.src = url; };
const dismiss = () => overlay.remove();
close.onclick = dismiss;
overlay.onclick = (e) => { if (e.target === overlay) dismiss(); };
right.append(reload, openTab, close);
bar.append(title, right);
panel.append(bar, iframe);
overlay.append(panel);
document.body.append(overlay);
}