diff --git a/backend/app/api/routes/iot_gateway.py b/backend/app/api/routes/iot_gateway.py index fc63e8ab..07816ab1 100644 --- a/backend/app/api/routes/iot_gateway.py +++ b/backend/app/api/routes/iot_gateway.py @@ -87,8 +87,26 @@ async def gateway_proxy(client_id: str, path: str, request: Request) -> Response if overlay_resp is not None: return overlay_resp + # Wording matters here. The old text said "make sure your sketch connected + # to WiFi", which reads as an accusation to the one sketch that most often + # lands on this branch: a captive-portal / provisioning sketch that called + # WiFi.softAP() and deliberately did NOT join a network. Such a board is + # the access point, so it never gets a DHCP lease, never reaches 'got_ip', + # and never registers the bridge this proxy looks up — there is nothing to + # reach and nothing the user did wrong. Name that case first. 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."}', + content=json.dumps({ + 'error': 'no_reachable_board', + 'message': ( + 'Nothing is registered for this client, so there is no server ' + 'to reach. If your sketch calls WiFi.softAP(), that is why: ' + 'the simulator can reach a board that JOINED a network, not a ' + 'board that created one. Join a simulated network instead ' + '(WiFi.begin("Velxio-GUEST") — open, no password) and start ' + 'your server on port 80. If the sketch does join a network, ' + 'wait for it to print its IP before opening the gateway.' + ), + }), status_code=404, media_type='application/json', ) diff --git a/frontend/src/components/simulator/SerialMonitor.tsx b/frontend/src/components/simulator/SerialMonitor.tsx index 014a3d49..01490bb3 100644 --- a/frontend/src/components/simulator/SerialMonitor.tsx +++ b/frontend/src/components/simulator/SerialMonitor.tsx @@ -293,7 +293,20 @@ export const SerialMonitor: React.FC = () => { // the emulated network. The trailing dot-check keeps a longer // address like 192.168.4.15.99 from half-matching. const ipRegex = /(?:http:\/\/)?(?:192\.168\.4|10\.13\.37)\.(\d{1,3})(?!\d|\.\d)(\/[^\s]*)?/g; - const matches = [...text.matchAll(ipRegex)]; + // ...but ONLY once the board actually joined a network. A sketch + // that calls WiFi.softAP() becomes the access point instead of a + // client, and the ESP32's SoftAP default address is 192.168.4.1 + // — the very subnet above. The address alone cannot tell the two + // apart, so a captive-portal sketch used to get a gateway link + // that could never resolve: the bridge the proxy looks up is + // registered on 'got_ip', which only a STATION ever reaches, so + // the click landed on a 404 telling the user to connect to WiFi + // when the sketch had deliberately chosen not to. Gate on the + // status every family reports: QEMU (wifi_status_parser), + // the in-browser JS engines, and the Pico W's cyw43 peripheral + // all emit 'got_ip'. Without it the IP stays plain text. + const reachable = activeBoard.wifiStatus?.status === 'got_ip'; + const matches = reachable ? [...text.matchAll(ipRegex)] : []; if (matches.length > 0) { const parts: (string | React.ReactNode)[] = []; diff --git a/test/backend/unit/test_esp32_wifi_webserver.py b/test/backend/unit/test_esp32_wifi_webserver.py index e5e01da6..b35de0cb 100644 --- a/test/backend/unit/test_esp32_wifi_webserver.py +++ b/test/backend/unit/test_esp32_wifi_webserver.py @@ -246,6 +246,26 @@ class TestHttpServerDetection(unittest.TestCase): class TestIoTGatewayUrl(unittest.TestCase): """Test IoT Gateway URL construction for the WebServer sketch.""" + def test_unreachable_message_names_softap_instead_of_blaming_the_sketch(self): + """The 404 a captive-portal sketch hits must explain WHY, not accuse. + + A sketch that calls WiFi.softAP() is the access point: it never gets a + DHCP lease, never reaches 'got_ip', and so never registers the bridge + the proxy looks up. The old copy said "make sure your sketch connected + to WiFi", which is exactly what such a sketch deliberately did not do. + """ + import re + from pathlib import Path + + route = Path(__file__).resolve().parents[3] / 'backend/app/api/routes/iot_gateway.py' + src = route.read_text(encoding='utf-8') + # The accusatory sentence must be gone... + self.assertNotIn('Make sure your sketch connected to WiFi and started', src) + # ...and the replacement must name softAP and point at a real network. + tail = src[src.index('no_reachable_board'):] + self.assertIn('WiFi.softAP()', tail) + self.assertIn('Velxio-GUEST', tail) + def test_root_gateway_url(self): client_id = 'board-1' url = f'http://localhost:8001/api/gateway/{client_id}/'