From 173cc3ea3690348d973a446781347f724da08cd2 Mon Sep 17 00:00:00 2001 From: David Montero Date: Sun, 14 Jun 2026 02:15:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(picow):=20IoT=20gateway=20=E2=80=94=20prox?= =?UTF-8?q?y=20browser=20HTTP=20into=20the=20chip's=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ESP32 web-server examples are reachable from the browser via /api/gateway// (QEMU slirp hostfwd). The Pico W server lives in the browser-side lwIP, so there was no inbound path: visiting the chip's IP did nothing. Add the mirror of tcp_nat.py: tcp_inbound.TcpInbound originates a TCP connection INTO the chip over the WebSocket bridge (SYN -> SYN+ACK -> ACK -> request -> response -> FIN), so the backend can fetch a page the sketch serves on 10.13.37.42:80 and hand it back to the browser. - bridge.py routes chip TCP segments addressed to a gateway-opened connection to TcpInbound (before the chip-initiated NAT, which would RST them); exposes http_into_chip() + ensure_chip_mac() (primes the chip's gateway ARP). - iot_gateway.py: same /api/gateway// route now falls through to the Pico W bridge when there's no ESP32 instance, builds a raw HTTP/1.1 request, and parses the chip's response. Same plan gate, same URL shape — the browser sees no difference between ESP32 and Pico W. Validated end to end (real RP2040 emulator serving an HTTP page -> gateway returns it) plus 6 unit tests for the TCP state machine, response parsing and ARP priming. --- backend/app/api/routes/iot_gateway.py | 111 ++++++- backend/app/services/picow_net/bridge.py | 45 ++- backend/app/services/picow_net/tcp_inbound.py | 283 ++++++++++++++++++ backend/app/services/picow_net_bridge.py | 3 + .../unit/test_picow_inbound_gateway.py | 189 ++++++++++++ 5 files changed, 624 insertions(+), 7 deletions(-) create mode 100644 backend/app/services/picow_net/tcp_inbound.py create mode 100644 test/backend/unit/test_picow_inbound_gateway.py diff --git a/backend/app/api/routes/iot_gateway.py b/backend/app/api/routes/iot_gateway.py index 3059468a..ca5cc9fc 100644 --- a/backend/app/api/routes/iot_gateway.py +++ b/backend/app/api/routes/iot_gateway.py @@ -18,6 +18,8 @@ from fastapi import APIRouter, Request, Response from app.core.hooks import iot_gateway_gate from app.services.esp32_lib_manager import esp_lib_manager +from app.services.picow_net.consts import STA_IP +from app.services.picow_net_bridge import picow_net_manager router = APIRouter() logger = logging.getLogger(__name__) @@ -66,14 +68,26 @@ 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 not inst or not inst.wifi_enabled or inst.wifi_hostfwd_port == 0: - return Response( - content='{"error":"No WiFi-enabled ESP32 instance found for this client"}', - status_code=404, - media_type='application/json', - ) + if inst and inst.wifi_enabled and inst.wifi_hostfwd_port != 0: + return await _proxy_esp32(inst, path, request) + # ── Pico W: the server runs in the browser-side lwIP, reachable only + # by injecting TCP frames over the WebSocket bridge into the chip. ── + picow = picow_net_manager.get_instance(client_id) + if picow is not None and picow.wifi_enabled: + return await _proxy_picow(picow, path, request) + + 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."}', + status_code=404, + media_type='application/json', + ) + + +async def _proxy_esp32(inst, path: str, request: Request) -> Response: + """Reverse-proxy to an ESP32 web server via QEMU slirp hostfwd.""" target_url = f'http://127.0.0.1:{inst.wifi_hostfwd_port}/{path}' body = await request.body() @@ -117,3 +131,88 @@ async def gateway_proxy(client_id: str, path: str, request: Request) -> Response headers=resp_headers, media_type=resp.headers.get('content-type'), ) + + +# Hop-by-hop / per-connection headers we never forward verbatim. +_HOP_BY_HOP = {'host', 'transfer-encoding', 'connection', 'content-encoding', + 'keep-alive', 'proxy-connection', 'upgrade'} + + +async def _proxy_picow(bridge, path: str, request: Request) -> Response: + """Reverse-proxy to a Pico W web server living in the browser-side lwIP. + + There is no host-side socket to connect to — the server only exists + inside the simulated chip — so we hand-build a raw HTTP/1.1 request and + have the picow_net stack open a TCP connection INTO the chip over the + WebSocket bridge, then parse the raw response back out.""" + body = await request.body() + query = request.url.query + target = '/' + path + (('?' + query) if query else '') + + headers = { + k: v for k, v in request.headers.items() + if k.lower() not in _HOP_BY_HOP + } + headers['Host'] = STA_IP + headers['Connection'] = 'close' # make the chip's server FIN when done + if body and 'content-length' not in {k.lower() for k in headers}: + headers['Content-Length'] = str(len(body)) + + req_line = f'{request.method} {target} HTTP/1.1\r\n' + header_block = ''.join(f'{k}: {v}\r\n' for k, v in headers.items()) + raw_request = (req_line + header_block + '\r\n').encode('latin-1') + body + + try: + raw_response = await bridge.http_into_chip(raw_request, timeout=12.0) + except Exception: + logger.exception('[picow-gateway] request into chip failed') + raw_response = None + + if not raw_response: + return Response( + content='{"error":"Pico W HTTP server did not respond. Make sure your sketch connected to WiFi and is listening on port 80."}', + status_code=502, + media_type='application/json', + ) + + status, resp_headers, resp_body = _parse_http_response(raw_response) + for h in ('transfer-encoding', 'connection', 'content-encoding', + 'content-length', 'keep-alive'): + resp_headers.pop(h, None) + media_type = resp_headers.pop('content-type', None) or 'text/html' + + return Response( + content=resp_body, + status_code=status, + headers=resp_headers, + media_type=media_type, + ) + + +def _parse_http_response(raw: bytes) -> tuple[int, dict, bytes]: + """Split a raw HTTP/1.x response into (status, headers, body). Headers + are returned with lower-cased keys (so callers can pop reliably).""" + sep = raw.find(b'\r\n\r\n') + sep_len = 4 + if sep < 0: + sep = raw.find(b'\n\n') + sep_len = 2 + if sep < 0: + # No header terminator — treat the whole thing as a body. + return 200, {}, raw + + head = raw[:sep].decode('latin-1', 'replace') + resp_body = raw[sep + sep_len:] + lines = head.replace('\r\n', '\n').split('\n') + + status = 200 + parts = lines[0].split(' ', 2) + if len(parts) >= 2 and parts[1].isdigit(): + status = int(parts[1]) + + headers: dict = {} + for line in lines[1:]: + if ':' in line: + k, v = line.split(':', 1) + headers[k.strip().lower()] = v.strip() + return status, headers, resp_body diff --git a/backend/app/services/picow_net/bridge.py b/backend/app/services/picow_net/bridge.py index a450c4a1..07c5e481 100644 --- a/backend/app/services/picow_net/bridge.py +++ b/backend/app/services/picow_net/bridge.py @@ -18,9 +18,12 @@ from typing import Awaitable, Callable from .arp import ArpResponder from .consts import ( + ARP_REQUEST, + BROADCAST_MAC, ETHERTYPE_ARP, ETHERTYPE_IPV4, GATEWAY_IP, + GATEWAY_MAC, IPPROTO_ICMP, IPPROTO_TCP, IPPROTO_UDP, @@ -35,7 +38,8 @@ from .dhcp import ( ) from .dns import DnsResolver, is_dns_traffic, make_dns_frame from .icmp import IcmpResponder -from .protocols import Ethernet, IPv4, TCP, UDP +from .protocols import Arp, Ethernet, IPv4, TCP, UDP, make_frame_arp +from .tcp_inbound import TcpInbound from .tcp_nat import TcpNat from .udp_nat import UdpNat @@ -59,6 +63,7 @@ class PicowNetBridge: self._dns = DnsResolver() self._icmp = IcmpResponder() self._tcp = TcpNat(self._inject) + self._tcp_in = TcpInbound(self._inject, lambda: self._chip_mac) self._udp = UdpNat(self._inject) # ── lifecycle ────────────────────────────────────────────────── @@ -74,6 +79,7 @@ class PicowNetBridge: self.running = False await asyncio.gather( self._tcp.shutdown(), + self._tcp_in.shutdown(), self._udp.shutdown(), return_exceptions=True, ) @@ -126,6 +132,12 @@ class PicowNetBridge: tcp = TCP.parse(ip.payload) except ValueError: return + # A reply to a connection WE opened into the chip's server (the + # IoT gateway) takes priority over the chip-initiated NAT, which + # would otherwise RST it as a stray segment. + if self._tcp_in.matches(ip, tcp): + await self._tcp_in.handle_chip_segment(ip, tcp) + return await self._tcp.handle_chip_segment(self._chip_mac, ip, tcp) return @@ -152,6 +164,37 @@ class PicowNetBridge: # Anything else — generic UDP NAT. await self._udp.handle_chip_datagram(chip_mac, ip, udp) + # ── host → chip: inbound HTTP (IoT gateway) ──────────────────── + + async def ensure_chip_mac(self) -> bytes: + """Prime the chip's ARP cache for the gateway before we open a + connection into it, so its SYN+ACK doesn't stall on a lookup. + + The chip's on-wire MAC is deterministically DEFAULT_STA_MAC + (frontend virtual-ap.ts), which equals our STA_MAC, so the default + ``_chip_mac`` is already the right destination — we don't need to + learn it. If the chip ever sent an outbound frame, the learned MAC + is used instead. We still emit one gratuitous ARP for the STA so the + chip resolves the gateway promptly, then return without blocking.""" + req = Arp( + opcode=ARP_REQUEST, + sha=GATEWAY_MAC, + spa=ip_to_bytes(GATEWAY_IP), + tha=b'\x00' * 6, + tpa=ip_to_bytes(STA_IP), + ) + await self._inject(make_frame_arp(BROADCAST_MAC, GATEWAY_MAC, req)) + await asyncio.sleep(0.05) + return self._chip_mac + + async def http_into_chip(self, raw_http: bytes, timeout: float = 12.0) -> bytes | None: + """Open a TCP connection to the chip's :80 server, send a raw HTTP + request, and return the raw HTTP response bytes (or None).""" + if not self.running or not self.wifi_enabled: + return None + await self.ensure_chip_mac() + return await self._tcp_in.request(raw_http, timeout=timeout) + # ── host → chip ──────────────────────────────────────────────── async def _inject(self, frame: bytes) -> None: diff --git a/backend/app/services/picow_net/tcp_inbound.py b/backend/app/services/picow_net/tcp_inbound.py new file mode 100644 index 00000000..a1e0b26d --- /dev/null +++ b/backend/app/services/picow_net/tcp_inbound.py @@ -0,0 +1,283 @@ +""" +TCP inbound — host-initiated connections INTO the chip's listening server. + +The mirror image of ``tcp_nat.py``. Where TcpNat plays the *server* for +connections the chip opens outward, TcpInbound plays the *client* for +connections we open inward — so a browser can reach an HTTP server the +Pico W sketch is running on ``10.13.37.42:80``. + +This is what makes Pico W web-server examples as useful as the ESP32 +ones: the ESP32 server lives in QEMU and is reachable via slirp hostfwd, +but the Pico W server lives in the browser-side lwIP, reachable only by +injecting frames over the WebSocket bridge. We synthesize a TCP client +sourced from the gateway (``10.13.37.1``) and drive a one-shot HTTP +request/response, exactly the per-request shape the IoT-gateway proxy +already uses for the ESP32. + + CLOSED + │ we send SYN + ▼ + SYN_SENT ── await chip SYN+ACK + │ chip SYN+ACK; we send ACK + request + ▼ + ESTABLISHED ── pump response bytes chip → us, ACK them + │ chip FIN (or Content-Length satisfied) + ▼ + we ACK + FIN ──► CLOSED + +Sequence arithmetic is modular-2³² and mirrors tcp_nat.py. +""" + +from __future__ import annotations + +import asyncio +import logging +import random +import struct +from dataclasses import dataclass, field +from typing import Awaitable, Callable, Dict, Optional, Tuple + +from .consts import ( + GATEWAY_IP, + GATEWAY_MAC, + IPPROTO_TCP, + STA_IP, + TCP_ACK, + TCP_FIN, + TCP_MSS, + TCP_PSH, + TCP_RST, + TCP_SYN, + TCP_WINDOW, + ip_to_bytes, +) +from .protocols import IPv4, TCP, make_frame_ipv4, parse_tcp_options + +logger = logging.getLogger(__name__) + +InjectFn = Callable[[bytes], Awaitable[None]] +ChipMacFn = Callable[[], bytes] + +_GW_IP = ip_to_bytes(GATEWAY_IP) +_STA_IP = ip_to_bytes(STA_IP) + + +def _seq_add(a: int, b: int) -> int: + return (a + b) & 0xffffffff + + +class _State: + SYN_SENT = 'SYN_SENT' + ESTABLISHED = 'ESTABLISHED' + CLOSED = 'CLOSED' + + +@dataclass +class _InboundConn: + chip_port: int # = 80 (server port on the chip) + our_port: int # ephemeral gateway-side port + state: str = _State.SYN_SENT + our_isn: int = 0 + our_seq: int = 0 # next seq we put on the wire chipward + chip_seq: int = 0 # next seq we expect from the chip + rx: bytearray = field(default_factory=bytearray) + established: asyncio.Event = field(default_factory=asyncio.Event) + finished: asyncio.Event = field(default_factory=asyncio.Event) + data_event: asyncio.Event = field(default_factory=asyncio.Event) + reset: bool = False + + +class TcpInbound: + """One-shot host→chip TCP client used by the IoT gateway.""" + + def __init__(self, inject: InjectFn, chip_mac: ChipMacFn) -> None: + self._inject = inject + self._chip_mac = chip_mac + self._conns: Dict[int, _InboundConn] = {} # keyed by our ephemeral port + + # ── routing predicate (called by the bridge before the outbound NAT) ── + + def matches(self, ip: IPv4, tcp: TCP) -> bool: + return ( + tcp.dst_port in self._conns + and tcp.src_port == self._conns[tcp.dst_port].chip_port + and bytes(ip.src) == _STA_IP + and bytes(ip.dst) == _GW_IP + ) + + # ── chip → us (segments from the chip's server) ──────────────────── + + async def handle_chip_segment(self, ip: IPv4, tcp: TCP) -> None: + conn = self._conns.get(tcp.dst_port) + if conn is None: + return + + if tcp.flags & TCP_RST: + conn.reset = True + conn.state = _State.CLOSED + conn.established.set() + conn.finished.set() + return + + if conn.state == _State.SYN_SENT: + if (tcp.flags & TCP_SYN) and (tcp.flags & TCP_ACK): + conn.chip_seq = _seq_add(tcp.seq, 1) # SYN consumes one seq + conn.state = _State.ESTABLISHED + await self._send(conn, TCP_ACK) # complete the handshake + conn.established.set() + return + + if conn.state != _State.ESTABLISHED: + return + + # In-order data only; re-ACK and drop anything out of order so the + # chip retransmits (these servers send tiny, in-order responses). + if tcp.payload: + if tcp.seq == conn.chip_seq: + conn.rx.extend(tcp.payload) + conn.chip_seq = _seq_add(conn.chip_seq, len(tcp.payload)) + await self._send(conn, TCP_ACK) + conn.data_event.set() + else: + await self._send(conn, TCP_ACK) # force retransmit + return + + if tcp.flags & TCP_FIN: + conn.chip_seq = _seq_add(conn.chip_seq, 1) + # ACK the FIN, then send our own FIN to close cleanly. + await self._send(conn, TCP_ACK) + await self._send(conn, TCP_FIN | TCP_ACK) + conn.our_seq = _seq_add(conn.our_seq, 1) + conn.state = _State.CLOSED + conn.finished.set() + + # ── public one-shot request ──────────────────────────────────────── + + async def request(self, raw_http: bytes, timeout: float = 12.0) -> Optional[bytes]: + """Open a connection to the chip's :80 server, send ``raw_http``, + return the raw HTTP response bytes (or None on failure).""" + our_port = self._alloc_port() + our_isn = random.randint(0, 0xffffffff) + conn = _InboundConn( + chip_port=80, + our_port=our_port, + our_isn=our_isn, + our_seq=_seq_add(our_isn, 1), # our SYN consumes one seq + ) + self._conns[our_port] = conn + try: + # SYN (advertise MSS, like the chip does). + await self._send(conn, TCP_SYN, seq=our_isn, + options=b'\x02\x04' + struct.pack('!H', TCP_MSS)) + try: + await asyncio.wait_for(conn.established.wait(), timeout=4.0) + except asyncio.TimeoutError: + logger.info('[picow-tcp-in] SYN to chip:80 timed out') + return None + if conn.reset or conn.state != _State.ESTABLISHED: + return None + + # Send the HTTP request. + await self._send(conn, TCP_PSH | TCP_ACK, payload=raw_http) + conn.our_seq = _seq_add(conn.our_seq, len(raw_http)) + + # Collect the response until the chip FINs, the body is complete + # per Content-Length, or we go idle. + deadline = asyncio.get_event_loop().time() + timeout + while not conn.finished.is_set(): + if _http_response_complete(conn.rx): + break + remaining = deadline - asyncio.get_event_loop().time() + if remaining <= 0: + break + conn.data_event.clear() + try: + # Wake on new data; also poll so the idle/length checks run. + await asyncio.wait_for(conn.data_event.wait(), timeout=min(remaining, 1.5)) + except asyncio.TimeoutError: + if conn.rx and _http_headers_complete(conn.rx): + break # got a full header block and went idle — good enough + return bytes(conn.rx) if conn.rx else None + finally: + self._conns.pop(our_port, None) + + # ── frame emission ───────────────────────────────────────────────── + + async def _send( + self, + conn: _InboundConn, + flags: int, + seq: Optional[int] = None, + options: bytes = b'', + payload: bytes = b'', + ) -> None: + tcp = TCP( + src_port=conn.our_port, + dst_port=conn.chip_port, + seq=(conn.our_seq if seq is None else seq) & 0xffffffff, + ack=conn.chip_seq, + flags=flags, + window=TCP_WINDOW, + options=options, + payload=payload, + ) + l4 = tcp.to_bytes(_GW_IP, _STA_IP) + frame = make_frame_ipv4( + dst_mac=self._chip_mac(), + src_mac=GATEWAY_MAC, + src_ip=_GW_IP, + dst_ip=_STA_IP, + protocol=IPPROTO_TCP, + l4_payload=l4, + ) + await self._inject(frame) + + def _alloc_port(self) -> int: + for _ in range(64): + port = random.randint(49152, 65535) + if port not in self._conns: + return port + # Extremely unlikely; fall back to a linear scan. + for port in range(49152, 65536): + if port not in self._conns: + return port + raise RuntimeError('no free ephemeral port') + + async def shutdown(self) -> None: + for conn in list(self._conns.values()): + conn.reset = True + conn.finished.set() + conn.established.set() + self._conns.clear() + + +# ─── HTTP framing helpers (just enough to know when a reply is done) ──── + +def _http_headers_complete(buf: bytearray) -> bool: + return b'\r\n\r\n' in buf or b'\n\n' in buf + + +def _http_response_complete(buf: bytearray) -> bool: + """True once we have a full header block plus a body matching + Content-Length (if any). Without a length we wait for FIN/idle.""" + sep = buf.find(b'\r\n\r\n') + sep_len = 4 + if sep < 0: + sep = buf.find(b'\n\n') + sep_len = 2 + if sep < 0: + return False + header_blob = bytes(buf[:sep]).lower() + idx = header_blob.find(b'content-length:') + if idx < 0: + return False # no declared length — rely on FIN / idle + try: + line = header_blob[idx:].split(b'\n', 1)[0] + length = int(line.split(b':', 1)[1].strip()) + except (ValueError, IndexError): + return False + body_len = len(buf) - (sep + sep_len) + return body_len >= length + + +__all__ = ['TcpInbound'] diff --git a/backend/app/services/picow_net_bridge.py b/backend/app/services/picow_net_bridge.py index a3ed435f..13d9f859 100644 --- a/backend/app/services/picow_net_bridge.py +++ b/backend/app/services/picow_net_bridge.py @@ -74,6 +74,9 @@ class PicowNetManager: def has_instance(self, client_id: str) -> bool: return client_id in self._instances + def get_instance(self, client_id: str) -> PicowNetBridge | None: + return self._instances.get(client_id) + # ── Outbound traffic — chip → host ───────────────────────────── async def deliver_packet_out(self, client_id: str, ether_b64: str) -> None: diff --git a/test/backend/unit/test_picow_inbound_gateway.py b/test/backend/unit/test_picow_inbound_gateway.py new file mode 100644 index 00000000..a6476b49 --- /dev/null +++ b/test/backend/unit/test_picow_inbound_gateway.py @@ -0,0 +1,189 @@ +""" +Unit tests for the Pico W inbound IoT-gateway path. + +The gateway lets a browser reach an HTTP server running inside the +browser-side lwIP of a simulated Pico W. The backend opens a TCP +connection INTO the chip (``tcp_inbound.TcpInbound``) over the WebSocket +bridge, sends a raw HTTP request, and parses the response back out. + +These tests import the real modules (no re-implemented framing) so a +refactor of the stack surfaces here immediately: + + - the inbound TCP client drives a correct SYN → SYN+ACK → ACK → + request → response → FIN exchange and returns the response bytes; + - the gateway response parser splits status/headers/body; + - the bridge learns the chip's real MAC via ARP before originating. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from app.services.picow_net.bridge import PicowNetBridge +from app.services.picow_net.consts import ( + GATEWAY_IP, + STA_IP, + STA_MAC, + TCP_ACK, + TCP_FIN, + TCP_PSH, + TCP_SYN, + ip_to_bytes, +) +from app.services.picow_net.protocols import Ethernet, IPv4, TCP, Arp, make_frame_arp +from app.services.picow_net.tcp_inbound import ( + TcpInbound, + _http_response_complete, + _seq_add, +) +from app.api.routes.iot_gateway import _parse_http_response + +_STA = ip_to_bytes(STA_IP) +_GW = ip_to_bytes(GATEWAY_IP) +CHIP_MAC = bytes.fromhex('0242da0000aa') + + +def _parse_injected_tcp(frame: bytes) -> TCP: + eth = Ethernet.parse(frame) + ip = IPv4.parse(eth.payload) + return TCP.parse(ip.payload) + + +def _chip_seg(src_port: int, dst_port: int, seq: int, ack: int, + flags: int, payload: bytes = b'') -> tuple[IPv4, TCP]: + """A segment as if sent by the chip's server (STA:80 → gateway).""" + ip = IPv4(protocol=6, src=_STA, dst=_GW) + tcp = TCP(src_port=src_port, dst_port=dst_port, seq=seq, ack=ack, + flags=flags, window=64240, payload=payload) + return ip, tcp + + +async def _wait_until(pred, timeout=1.0): + loop = asyncio.get_event_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + if pred(): + return True + await asyncio.sleep(0.005) + return False + + +@pytest.mark.asyncio +async def test_inbound_tcp_http_roundtrip(): + injected: list[bytes] = [] + + async def inject(frame: bytes) -> None: + injected.append(frame) + + tin = TcpInbound(inject, lambda: CHIP_MAC) + + request_bytes = b'GET /on HTTP/1.1\r\nHost: 10.13.37.42\r\nConnection: close\r\n\r\n' + task = asyncio.create_task(tin.request(request_bytes, timeout=2.0)) + + # 1. The client should inject a SYN to the chip's :80. + assert await _wait_until(lambda: len(injected) >= 1) + syn = _parse_injected_tcp(injected[0]) + assert syn.flags & TCP_SYN and not (syn.flags & TCP_ACK) + assert syn.dst_port == 80 + ephport = syn.src_port + our_isn = syn.seq + + # 2. Reply with SYN+ACK; expect the client to ACK and then send the request. + chip_isn = 7000 + ip, tcp = _chip_seg(80, ephport, seq=chip_isn, ack=_seq_add(our_isn, 1), + flags=TCP_SYN | TCP_ACK) + await tin.handle_chip_segment(ip, tcp) + + assert await _wait_until( + lambda: any(_parse_injected_tcp(f).payload == request_bytes for f in injected)) + # The handshake ACK must have gone out before the request. + assert any(_parse_injected_tcp(f).flags & TCP_ACK for f in injected) + + # 3. Send the HTTP response, then FIN (a length-less, close-delimited body — + # the common MicroPython "socket then conn.close()" shape). + response = (b'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n' + b'LED ON') + ip, tcp = _chip_seg(80, ephport, seq=_seq_add(chip_isn, 1), + ack=0, flags=TCP_PSH | TCP_ACK, payload=response) + await tin.handle_chip_segment(ip, tcp) + ip, tcp = _chip_seg(80, ephport, seq=_seq_add(chip_isn, 1 + len(response)), + ack=0, flags=TCP_FIN | TCP_ACK) + await tin.handle_chip_segment(ip, tcp) + + result = await asyncio.wait_for(task, timeout=2.0) + assert result == response + # The client must close cleanly: a FIN should have been injected. + assert any(_parse_injected_tcp(f).flags & TCP_FIN for f in injected) + + +@pytest.mark.asyncio +async def test_inbound_returns_none_when_chip_never_answers(): + async def inject(frame: bytes) -> None: + pass + + tin = TcpInbound(inject, lambda: CHIP_MAC) + # No SYN+ACK ever arrives → request gives up (short SYN timeout path). + result = await tin.request(b'GET / HTTP/1.1\r\n\r\n', timeout=0.5) + assert result is None + + +def test_http_response_complete_by_content_length(): + full = b'HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello' + assert _http_response_complete(bytearray(full)) + # One byte short → not complete. + assert not _http_response_complete(bytearray(full[:-1])) + # No declared length → relies on FIN/idle, never "complete" here. + assert not _http_response_complete(bytearray(b'HTTP/1.1 200 OK\r\n\r\nhi')) + + +def test_parse_http_response_splits_status_headers_body(): + raw = (b'HTTP/1.1 404 Not Found\r\n' + b'Content-Type: application/json\r\n' + b'X-Foo: bar\r\n\r\n' + b'{"missing":true}') + status, headers, body = _parse_http_response(raw) + assert status == 404 + assert headers['content-type'] == 'application/json' + assert headers['x-foo'] == 'bar' + assert body == b'{"missing":true}' + + +@pytest.mark.asyncio +async def test_ensure_chip_mac_primes_gateway_arp(): + """ensure_chip_mac emits an ARP for the STA (priming the chip's gateway + lookup) and returns the chip MAC. The chip's on-wire MAC is + deterministically STA_MAC, so the default is already the right target.""" + sent: list[tuple[str, dict]] = [] + + async def emit(event: str, data: dict) -> None: + sent.append((event, data)) + + bridge = PicowNetBridge('sess::pico', emit, wifi_enabled=True) + mac = await bridge.ensure_chip_mac() + assert mac == STA_MAC + + # An ARP request for the STA, sourced from the gateway, must have gone out. + injected = [d['ether_b64'] for e, d in sent if e == 'picow_packet_in'] + assert injected, 'ensure_chip_mac should inject an ARP' + import base64 + eth = Ethernet.parse(base64.b64decode(injected[0])) + assert eth.ethertype == 0x0806 + arp = Arp.parse(eth.payload) + assert arp.opcode == 1 and bytes(arp.tpa) == _STA + + +@pytest.mark.asyncio +async def test_bridge_tracks_chip_mac_from_outbound_frame(): + """If the chip ever sends an outbound frame, the bridge adopts its src + MAC (used as the destination for injected replies).""" + async def emit(event: str, data: dict) -> None: + pass + + bridge = PicowNetBridge('sess::pico', emit, wifi_enabled=True) + assert bridge._chip_mac == STA_MAC + # A gratuitous ARP from a chip that happens to use a different MAC. + reply = Arp(opcode=2, sha=CHIP_MAC, spa=_STA, tha=STA_MAC, tpa=_GW) + await bridge.deliver_packet_out(make_frame_arp(STA_MAC, CHIP_MAC, reply)) + assert bridge._chip_mac == CHIP_MAC