From aeebe6b66bf331f6c87d985409b30de5af1aac57 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 13:14:39 +0000 Subject: [PATCH 1/5] Add network board discovery example Add examples/discover_boards.py, which finds every Vector board on the LAN via UDP discovery and then connects to each over HTTP to print its IP, name, firmware version, and game state. Boards are queried concurrently and unreachable boards are reported inline. Document it in docs/examples.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SFN2z3hJYtQbnRVmsJuRkd --- docs/examples.md | 44 +++++++++++++++++ examples/discover_boards.py | 98 +++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 examples/discover_boards.py diff --git a/docs/examples.md b/docs/examples.md index edf4c20..685c763 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -5,8 +5,52 @@ small, self-contained file you can copy, run, and tweak. | Example | What it shows | | --- | --- | +| [`discover_boards.py`](../examples/discover_boards.py) | Finding every board on the LAN, then connecting to each to print its IP, name, firmware, and game state | | [`elvira_hurryup.py`](../examples/elvira_hurryup.py) | Polling an SRAM byte in a loop and inferring game state to draw a live ELVIRA hurry-up display | +## Discover boards on the network + +[`examples/discover_boards.py`](../examples/discover_boards.py) lists every +Vector board on your LAN and prints what each one is. + +```bash +python examples/discover_boards.py +``` + +`warpedpinball.discover()` broadcasts on the network and returns a +`DiscoveredMachine` (an `ip` and a `name`) for every board that answers — no +password and no per-board request, so it works even on a busy network. That +inventory prints first: + +```python +boards = warpedpinball.discover(timeout=DISCOVERY_TIMEOUT) +for board in sorted(boards, key=lambda b: b.name.lower()): + print(f"{board.name} {board.ip}") +``` + +To show more than name and IP, the script then connects to each board by IP and +asks it a few read-only questions — `version()`, `game_name()`, and +`game_status()` — none of which need a password. Boards are contacted +concurrently with a `ThreadPoolExecutor` so one slow board doesn't hold up the +rest, and any board that fails to answer (mid-reboot, briefly unreachable) is +reported inline instead of aborting the whole run: + +```python +try: + with warpedpinball.connect(board.ip) as m: + facts["firmware"] = _version_string(m.version()) + facts["game"] = _scalar(m.game_name()) + ... +except (TransportError, VectorError, OSError) as error: + facts["error"] = str(error) +``` + +Connecting by IP (rather than by name) skips a second discovery round-trip — +you already have the address from the first broadcast. See +[connecting](machine.md) and the +[error handling](machine.md#connection-and-timeout-failures) reference for the +`TransportError` / `VectorError` families. + ## ELVIRA hurry-up display [`examples/elvira_hurryup.py`](../examples/elvira_hurryup.py) watches an diff --git a/examples/discover_boards.py b/examples/discover_boards.py new file mode 100644 index 0000000..bc43c21 --- /dev/null +++ b/examples/discover_boards.py @@ -0,0 +1,98 @@ +"""Find every Vector board on the LAN and print what each one is. + +Discovery (a UDP broadcast on port 37020) gives you the cheap facts for free -- +each board's IP and its LAN name -- with no password and no per-board HTTP +round-trip. That alone is enough for an inventory, so it prints first and always +works even on a locked-down or busy network. + +Then, for a richer listing, the script connects to each board over HTTP and asks +it a few read-only questions (firmware version, configured game name, whether a +game is in progress). None of these need a password. Boards are contacted +concurrently so one slow or unreachable board doesn't hold up the rest, and any +board that fails to answer is reported inline rather than aborting the run. + + python examples/discover_boards.py +""" + +from concurrent.futures import ThreadPoolExecutor + +import warpedpinball +from warpedpinball import TransportError, VectorError + +DISCOVERY_TIMEOUT = 20.0 # seconds to listen for boards (see discover() docs) + + +def describe(board): + """Connect to one discovered board and gather a few read-only details. + + Returns a dict of facts. Every field beyond ip/name is best-effort: a board + might be mid-reboot or briefly unreachable, so a failed lookup becomes an + ``error`` entry instead of raising and sinking the whole run. + """ + facts = {"name": board.name, "ip": board.ip} + try: + with warpedpinball.connect(board.ip) as m: + facts["firmware"] = _version_string(m.version()) + facts["game"] = _scalar(m.game_name()) + status = m.game_status() + facts["in_game"] = bool(_field(status, "GameActive", "game_active")) + except (TransportError, VectorError, OSError) as error: + facts["error"] = str(error) + return facts + + +def _version_string(payload): + """Pull a version out of /api/version, which may be a dict or a bare value.""" + if isinstance(payload, dict): + return str(payload.get("version") or payload.get("Version") or payload) + return str(payload) + + +def _scalar(payload): + """Unwrap a single-value API payload (often a 1-key dict) to something short.""" + if isinstance(payload, dict): + values = list(payload.values()) + if len(values) == 1: + return str(values[0]) + return str(payload) + + +def _field(payload, *keys): + """First present key from a dict payload (firmware casing varies), else None.""" + if isinstance(payload, dict): + for key in keys: + if key in payload: + return payload[key] + return None + + +def main(): + print(f"Listening for boards (up to {DISCOVERY_TIMEOUT:.0f}s)...") + boards = warpedpinball.discover(timeout=DISCOVERY_TIMEOUT) + if not boards: + print("No boards found. Are you on the same network as the machines?") + return + + boards = sorted(boards, key=lambda b: b.name.lower()) + print(f"\nFound {len(boards)} board(s):\n") + for board in boards: + print(f" {board.name:<20} {board.ip}") + + # Enrich each board over HTTP, all at once so slow boards overlap. + print("\nAsking each board about itself...\n") + with ThreadPoolExecutor(max_workers=min(8, len(boards))) as pool: + details = list(pool.map(describe, boards)) + + for facts in details: + print(f"{facts['name']} ({facts['ip']})") + if "error" in facts: + print(f" unreachable: {facts['error']}") + else: + print(f" firmware : {facts['firmware']}") + print(f" game : {facts['game']}") + print(f" in game : {'yes' if facts['in_game'] else 'no'}") + print() + + +if __name__ == "__main__": + main() From 9ce9087c2dacc4c5d8f6b99e0156edbff793a7ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 14:29:50 +0000 Subject: [PATCH 2/5] Add IP fallback to board discovery example When the UDP broadcast finds no boards -- common on phone hotspots, guest Wi-Fi, and some travel routers that block broadcast between clients -- the example now prompts for one board's IP and enumerates the rest from that board's /api/network/peers table over unicast HTTP. The peer payload is parsed defensively (its exact JSON shape can vary), and the supplied IP is always included so it works even if the peer table is empty. Document the fallback in docs/examples.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SFN2z3hJYtQbnRVmsJuRkd --- docs/examples.md | 24 ++++++++- examples/discover_boards.py | 97 ++++++++++++++++++++++++++++++++----- 2 files changed, 107 insertions(+), 14 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index 685c763..710d3f5 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -5,7 +5,7 @@ small, self-contained file you can copy, run, and tweak. | Example | What it shows | | --- | --- | -| [`discover_boards.py`](../examples/discover_boards.py) | Finding every board on the LAN, then connecting to each to print its IP, name, firmware, and game state | +| [`discover_boards.py`](../examples/discover_boards.py) | Finding every board on the LAN (with an IP fallback for networks that block broadcast), then connecting to each to print its IP, name, firmware, and game state | | [`elvira_hurryup.py`](../examples/elvira_hurryup.py) | Polling an SRAM byte in a loop and inferring game state to draw a live ELVIRA hurry-up display | ## Discover boards on the network @@ -51,6 +51,28 @@ you already have the address from the first broadcast. See [error handling](machine.md#connection-and-timeout-failures) reference for the `TransportError` / `VectorError` families. +### When broadcast finds nothing + +Some networks — phone hotspots, guest Wi-Fi, and some travel routers — isolate +clients and silently drop the broadcast traffic that `discover()` relies on. The +boards are still reachable by unicast HTTP; only the broadcast is blocked. So +when the broadcast turns up empty, the script asks for one board's IP and +enumerates the rest from that board's own peer table over HTTP — no broadcast +involved: + +```python +found = warpedpinball.discover(timeout=DISCOVERY_TIMEOUT) +if not found: + ip = input("Enter a board's IP address (blank to give up): ").strip() + with warpedpinball.connect(ip) as m: + peers = m.peers() # GET /api/network/peers +``` + +`peers()` returns the board's view of every board it knows about, so one known +IP is enough to list the whole network. The script parses that payload +defensively (the exact JSON shape can vary by firmware) and always includes the +IP you supplied, so it works even if the peer table comes back empty. + ## ELVIRA hurry-up display [`examples/elvira_hurryup.py`](../examples/elvira_hurryup.py) watches an diff --git a/examples/discover_boards.py b/examples/discover_boards.py index bc43c21..788f376 100644 --- a/examples/discover_boards.py +++ b/examples/discover_boards.py @@ -5,11 +5,19 @@ round-trip. That alone is enough for an inventory, so it prints first and always works even on a locked-down or busy network. -Then, for a richer listing, the script connects to each board over HTTP and asks -it a few read-only questions (firmware version, configured game name, whether a -game is in progress). None of these need a password. Boards are contacted -concurrently so one slow or unreachable board doesn't hold up the rest, and any -board that fails to answer is reported inline rather than aborting the run. +Some networks -- phone hotspots, guest Wi-Fi, some travel routers -- silently +drop the broadcast traffic discovery relies on (client isolation), so the +broadcast finds nothing even though the boards are reachable by unicast HTTP. +When that happens this script asks you for one board's IP and enumerates the +rest from that board's own peer table (``GET /api/network/peers``) -- no +broadcast required. + +Once it has a list of boards -- from discovery or from the IP you supply -- it +connects to each over HTTP and asks a few read-only questions (firmware version, +configured game name, whether a game is in progress). None of these need a +password. Boards are contacted concurrently so one slow or unreachable board +doesn't hold up the rest, and any board that fails to answer is reported inline +rather than aborting the run. python examples/discover_boards.py """ @@ -23,15 +31,16 @@ def describe(board): - """Connect to one discovered board and gather a few read-only details. + """Connect to one board (a ``(name, ip)`` pair) and gather read-only details. Returns a dict of facts. Every field beyond ip/name is best-effort: a board might be mid-reboot or briefly unreachable, so a failed lookup becomes an ``error`` entry instead of raising and sinking the whole run. """ - facts = {"name": board.name, "ip": board.ip} + name, ip = board + facts = {"name": name, "ip": ip} try: - with warpedpinball.connect(board.ip) as m: + with warpedpinball.connect(ip) as m: facts["firmware"] = _version_string(m.version()) facts["game"] = _scalar(m.game_name()) status = m.game_status() @@ -41,6 +50,69 @@ def describe(board): return facts +def find_boards(): + """Return a list of ``(name, ip)`` boards, via broadcast or an IP fallback.""" + print(f"Listening for boards (up to {DISCOVERY_TIMEOUT:.0f}s)...") + found = warpedpinball.discover(timeout=DISCOVERY_TIMEOUT) + if found: + return [(b.name, b.ip) for b in found] + + # Broadcast turned up nothing. On networks that block broadcast (hotspots, + # some travel routers) the boards are still reachable by IP, so ask for one + # and enumerate the rest from its peer table. + print("\nNo boards answered the broadcast. This network may be blocking it.") + ip = input("Enter a board's IP address (blank to give up): ").strip() + if not ip: + return [] + return boards_from_ip(ip) + + +def boards_from_ip(ip): + """Enumerate boards starting from one known IP, via its /api/network/peers.""" + boards = {ip: None} # ip -> name; the supplied board is always included + try: + with warpedpinball.connect(ip) as m: + for name, peer_ip in _parse_peers(m.peers()): + boards.setdefault(peer_ip, None) + if name: + boards[peer_ip] = name + except (TransportError, VectorError, OSError) as error: + # Couldn't reach that IP at all -- still return it so describe() can + # report the connection error to the user rather than silently dropping. + print(f"Could not read the peer table from {ip}: {error}") + return [(name or ip, ip) for ip, name in boards.items()] + + +def _parse_peers(payload): + """Yield ``(name, ip)`` from a peer payload, tolerant of its exact shape. + + The firmware's /api/network/peers JSON shape isn't guaranteed here, so accept + the common ones -- a list of dicts, a list of ip/name pairs, or an ip->name + mapping -- and skip anything without an IP-looking value. + """ + items = payload.items() if isinstance(payload, dict) else payload + if not isinstance(items, (list, tuple)) and not isinstance(payload, dict): + return + for item in items: + name = ip = None + if isinstance(item, dict): + ip = item.get("ip") or item.get("IP") or item.get("address") + name = item.get("name") or item.get("Name") + elif isinstance(item, (list, tuple)) and len(item) == 2: + first, second = item + # Either (ip, name) pairs or dict.items() -> (ip, name). + ip, name = (first, second) if _looks_like_ip(first) else (second, first) + if _looks_like_ip(ip): + yield (str(name) if name else None), str(ip) + + +def _looks_like_ip(value): + if not isinstance(value, str): + return False + parts = value.split(".") + return len(parts) == 4 and all(p.isdigit() and int(p) < 256 for p in parts) + + def _version_string(payload): """Pull a version out of /api/version, which may be a dict or a bare value.""" if isinstance(payload, dict): @@ -67,16 +139,15 @@ def _field(payload, *keys): def main(): - print(f"Listening for boards (up to {DISCOVERY_TIMEOUT:.0f}s)...") - boards = warpedpinball.discover(timeout=DISCOVERY_TIMEOUT) + boards = find_boards() if not boards: print("No boards found. Are you on the same network as the machines?") return - boards = sorted(boards, key=lambda b: b.name.lower()) + boards = sorted(boards, key=lambda b: b[0].lower()) print(f"\nFound {len(boards)} board(s):\n") - for board in boards: - print(f" {board.name:<20} {board.ip}") + for name, ip in boards: + print(f" {name:<20} {ip}") # Enrich each board over HTTP, all at once so slow boards overlap. print("\nAsking each board about itself...\n") From 0485da72f87c342c73ed5b63c43c0d6f62aaddbb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 17:39:19 +0000 Subject: [PATCH 3/5] Make discovery fast and reliable for single boards and filtered networks Root cause of unreliable single-board discovery, from the firmware's discovery service: a lone board never broadcasts FULL unprovoked (it only says HELLO every 15 s), so discovery depended entirely on our one OFFLINE probe reaching the board -- and 255.255.255.255 leaves only the default-route interface, which on a multi-homed Windows machine is often not the network the board is on. Two boards worked because their mutual chatter produces FULL broadcasts to overhear. Discovery now: - probes with PING as well as OFFLINE; any board hearing a PING replies with a unicast PONG to our port, which survives broadcast filtering toward us, and the registry still broadcasts FULL - sends probes from every local interface, to the limited broadcast and each subnet's /24 directed broadcast, so the right network is always reached - parses HELLO and PONG replies, not just FULL; if boards are heard but no FULL arrives within a short settle window, the complete list is fetched from a heard board over unicast HTTP (/api/network/peers) - re-probes every 1.5 s to match the boards' discovery service cadence, so results typically arrive within about two seconds Update tests for the new probe/reply/fallback logic and refresh the discovery notes in docs/machine.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SFN2z3hJYtQbnRVmsJuRkd --- docs/machine.md | 15 ++- examples/discover_boards.py | 3 +- tests/test_discovery.py | 185 +++++++++++++++++++++++++--- warpedpinball/discovery.py | 234 +++++++++++++++++++++++++++++++----- 4 files changed, 380 insertions(+), 57 deletions(-) diff --git a/docs/machine.md b/docs/machine.md index bec334c..844a926 100644 --- a/docs/machine.md +++ b/docs/machine.md @@ -34,11 +34,16 @@ with warpedpinball.connect("elvira") as m: `AmbiguousMachineError` (listing the candidates) when a name doesn't resolve to exactly one board. -Boards broadcast infrequently, so discovery listens for up to 20 s by default -(`connect(..., timeout=...)` / `discover(timeout=...)` to change it). It doesn't -usually wait that long, though: one board acts as a registry and answers with a -*complete* list of every board it knows about, so discovery returns the instant -that reply arrives. +Boards service discovery every 1.5 s, so results usually arrive within about +two seconds; the default 20 s cap (`connect(..., timeout=...)` / +`discover(timeout=...)` to change it) only matters on a genuinely quiet +network. Discovery probes every interface — the limited broadcast *and* each +subnet's directed broadcast, so multi-homed machines reach the right network — +and returns the instant one board answers with its *complete* list of every +board it knows about. Boards also answer probes with a unicast reply, so even +on networks that filter broadcasts toward your machine (some hotspots and +travel routers), discovery hears the board and fetches the full list from it +over plain HTTP instead. ## Authentication diff --git a/examples/discover_boards.py b/examples/discover_boards.py index 788f376..9e42a3a 100644 --- a/examples/discover_boards.py +++ b/examples/discover_boards.py @@ -55,7 +55,8 @@ def find_boards(): print(f"Listening for boards (up to {DISCOVERY_TIMEOUT:.0f}s)...") found = warpedpinball.discover(timeout=DISCOVERY_TIMEOUT) if found: - return [(b.name, b.ip) for b in found] + # A board heard only indirectly can come back nameless; show its IP. + return [(b.name or b.ip, b.ip) for b in found] # Broadcast turned up nothing. On networks that block broadcast (hotspots, # some travel routers) the boards are still reachable by IP, so ask for one diff --git a/tests/test_discovery.py b/tests/test_discovery.py index ec13846..95b6fb0 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -1,9 +1,24 @@ -"""FULL/OFFLINE frame encoding and tolerant decoding.""" +"""Frame encoding/decoding and the discover() probe/reply/fallback logic.""" import socket +import pytest + import warpedpinball.discovery as discovery -from warpedpinball.discovery import DiscoveredMachine, build_offline, parse_full +from warpedpinball.discovery import ( + DiscoveredMachine, + build_offline, + build_ping, + parse_full, + parse_hello, +) + + +@pytest.fixture(autouse=True) +def _hermetic(monkeypatch): + """Keep discover() off the real network and make settle waits fast.""" + monkeypatch.setattr(discovery, "_local_ips", lambda: []) + monkeypatch.setattr(discovery, "SETTLE_AFTER_SIGHTING", 0.05) def peer(ip: str, name: str) -> bytes: @@ -15,6 +30,11 @@ def full_frame(*peers: bytes) -> bytes: return bytes([2, len(peers)]) + b"".join(peers) +def hello_frame(name: str) -> bytes: + name_bytes = name.encode() + return bytes([1, len(name_bytes)]) + name_bytes + + def test_parse_full_two_peers(): frame = full_frame(peer("192.168.4.243", "Pinbot"), peer("192.168.4.7", "Elvira")) assert parse_full(frame) == [ @@ -58,6 +78,14 @@ def test_parse_full_bad_utf8_name_does_not_crash(): assert machines[0].ip == "10.0.0.9" +def test_parse_hello(): + assert parse_hello(hello_frame("Elvira")) == "Elvira" + assert parse_hello(b"") is None + assert parse_hello(bytes([2, 1, 65])) is None # FULL, not HELLO + assert parse_hello(bytes([1])) is None # truncated header + assert parse_hello(bytes([1, 2]) + b"\xff\xfe") == "��" # bad UTF-8 + + def test_build_offline(): frame = build_offline("192.168.4.7") assert frame[0] == 5 # MSG_OFFLINE @@ -65,6 +93,21 @@ def test_build_offline(): assert len(frame) == 5 +def test_build_ping(): + assert build_ping() == bytes([3]) + + +def test_directed_broadcasts(): + assert discovery._directed_broadcasts(["192.168.8.7", "10.1.2.3"]) == [ + "10.1.2.255", + "192.168.8.255", + ] + # Duplicate subnets collapse to one directed broadcast. + assert discovery._directed_broadcasts(["192.168.8.7", "192.168.8.9"]) == [ + "192.168.8.255" + ] + + def test_local_ip_falls_back_when_no_route(monkeypatch): class NoRouteSock: def connect(self, *a): @@ -80,22 +123,12 @@ def close(self): assert discovery._local_ip() == "0.0.0.0" -def test_discover_broadcasts_offline_frame(monkeypatch): - monkeypatch.setattr(discovery, "_local_ip", lambda: "192.168.4.7") - sent = [] - - class RecordingSocket(FakeSocket): - def sendto(self, data, addr): - sent.append(data) - super().sendto(data, addr) - - monkeypatch.setattr(discovery, "_open_socket", lambda: RecordingSocket([])) - discovery.discover(timeout=0.05) - assert sent and all(f == bytes([5, 192, 168, 4, 7]) for f in sent) - - class FakeSocket: - """Minimal socket stand-in: serves queued frames, then blocks (times out).""" + """Minimal socket stand-in: serves queued frames, then blocks (times out). + + Each queued item is either a frame (bytes) or a ``(frame, sender_ip)`` + pair; bare frames arrive "from" 10.0.0.1. + """ def __init__(self, frames): self._frames = list(frames) @@ -115,13 +148,39 @@ def sendto(self, *a): def recvfrom(self, _bufsize): if self._frames: - return self._frames.pop(0), ("10.0.0.1", 37020) + item = self._frames.pop(0) + if isinstance(item, tuple): + return item[0], (item[1], 37020) + return item, ("10.0.0.1", 37020) raise socket.timeout def close(self): pass +def test_discover_probes_with_ping_and_offline(monkeypatch): + monkeypatch.setattr(discovery, "_local_ip", lambda: "192.168.4.7") + monkeypatch.setattr(discovery, "_local_ips", lambda: ["192.168.4.7"]) + monkeypatch.setattr(discovery, "_probe_sockets", lambda ips: []) + sent = [] + + class RecordingSocket(FakeSocket): + def sendto(self, data, addr): + sent.append((data, addr)) + super().sendto(data, addr) + + monkeypatch.setattr(discovery, "_open_socket", lambda: RecordingSocket([])) + discovery.discover(timeout=0.05) + frames = {data for data, _addr in sent} + # Both probe types: PING (unicast PONG comes back even through broadcast + # filters) and OFFLINE (provokes the registry without registering us). + assert bytes([3]) in frames + assert bytes([5, 192, 168, 4, 7]) in frames + # Probes go to the limited broadcast and the /24 directed broadcast. + addrs = {addr[0] for _data, addr in sent} + assert addrs == {"255.255.255.255", "192.168.4.255"} + + def test_discover_returns_immediately_on_full_frame(monkeypatch): frame = full_frame(peer("10.0.0.1", "Elvira"), peer("10.0.0.2", "Pinbot")) monkeypatch.setattr(discovery, "_open_socket", lambda: FakeSocket([frame])) @@ -154,6 +213,62 @@ def test_discover_returns_early_when_named_board_appears(monkeypatch): assert DiscoveredMachine("10.0.0.2", "Pinbot") in result +def test_discover_hello_resolves_remaining_boards_over_http(monkeypatch): + # A lone board's HELLO is heard, but its FULL broadcast never arrives: + # after the settle window the full list comes from /api/network/peers. + monkeypatch.setattr( + discovery, "_open_socket", lambda: FakeSocket([(hello_frame("Elvira"), "10.0.0.9")]) + ) + queried = [] + + def fake_peers(ip): + queried.append(ip) + return [ + DiscoveredMachine("10.0.0.9", "Elvira"), + DiscoveredMachine("10.0.0.4", "Pinbot"), + ] + + monkeypatch.setattr(discovery, "_peers_over_http", fake_peers) + machines = discovery.discover(timeout=999) + assert queried == ["10.0.0.9"] # asked the board we actually heard + assert set(machines) == { + DiscoveredMachine("10.0.0.9", "Elvira"), + DiscoveredMachine("10.0.0.4", "Pinbot"), + } + + +def test_discover_hello_returns_early_when_named_board_heard(monkeypatch): + monkeypatch.setattr( + discovery, "_open_socket", lambda: FakeSocket([(hello_frame("Elvira"), "10.0.0.9")]) + ) + machines = discovery.discover(timeout=999, name="elvira") + assert machines == [DiscoveredMachine("10.0.0.9", "Elvira")] + + +def test_discover_pong_sighting_survives_http_failure(monkeypatch): + # A PONG proves a board exists at that IP even when its name is unknown + # and the HTTP fallback fails; the IP still comes back (empty name). + monkeypatch.setattr( + discovery, "_open_socket", lambda: FakeSocket([(bytes([4]), "10.0.0.9")]) + ) + + def failing_peers(ip): + raise OSError("unreachable") + + monkeypatch.setattr(discovery, "_peers_over_http", failing_peers) + assert discovery.discover(timeout=999) == [DiscoveredMachine("10.0.0.9", "")] + + +def test_discover_ignores_own_looped_back_probes(monkeypatch): + # Some stacks deliver our own broadcast back to us; frames from a local IP + # must not count as board sightings. + monkeypatch.setattr(discovery, "_local_ip", lambda: "192.168.4.7") + monkeypatch.setattr( + discovery, "_open_socket", lambda: FakeSocket([(bytes([3]), "192.168.4.7")]) + ) + assert discovery.discover(timeout=0.05) == [] + + def test_discover_times_out_with_no_replies(monkeypatch): # FakeSocket with no frames always raises socket.timeout; discover should # exhaust the (tiny) timeout and return whatever it has (nothing). @@ -180,6 +295,31 @@ def recvfrom(self, _bufsize): assert discovery.discover(timeout=999) == [] # OSError breaks the loop +def test_peers_over_http_parses_peer_map(monkeypatch): + class FakeTransport: + def __init__(self, host, timeout=None): + assert host == "10.0.0.9" + + def request(self, path): + assert path == "/api/network/peers" + return { + "10.0.0.9": {"name": "Elvira", "self": True}, + "10.0.0.4": {"name": "Pinbot", "self": False}, + } + + def close(self): + pass + + import warpedpinball.transports.http as http + + monkeypatch.setattr(http, "HttpTransport", FakeTransport) + machines = discovery._peers_over_http("10.0.0.9") + assert set(machines) == { + DiscoveredMachine("10.0.0.9", "Elvira"), + DiscoveredMachine("10.0.0.4", "Pinbot"), + } + + def test_open_socket_binds_and_is_datagram(): sock = discovery._open_socket() try: @@ -189,6 +329,15 @@ def test_open_socket_binds_and_is_datagram(): sock.close() +def test_probe_sockets_skips_unbindable_ips(): + # 203.0.113.1 (TEST-NET-3) is not a local address, so binding fails and + # the helper must skip it rather than raise. + socks = discovery._probe_sockets(["203.0.113.1"]) + for s in socks: # pragma: no cover - defensive cleanup + s.close() + assert socks == [] + + def test_open_socket_falls_back_to_ephemeral_when_port_taken(monkeypatch): binds = [] diff --git a/warpedpinball/discovery.py b/warpedpinball/discovery.py index a64ad45..1e7d4de 100644 --- a/warpedpinball/discovery.py +++ b/warpedpinball/discovery.py @@ -7,18 +7,39 @@ - ``FULL = 2``: ``bytes([2, peer_count])`` then, per peer: 4 raw IP bytes, 1 name-length byte, name bytes. The board with the lowest IP acts as the registry and broadcasts a FULL frame listing all known boards. +- ``PING = 3`` / ``PONG = 4``: liveness. A board answering a PING sends the + PONG *unicast* to the sender's IP on port 37020. - ``OFFLINE = 5``: ``bytes([5]) + 4 IP bytes``. Announces that the given IP - has left. A board hearing OFFLINE for *another* IP re-broadcasts the FULL - list (the registry does the actual broadcast), which is exactly the reply - we want. -- ``PING = 3`` / ``PONG = 4``: board-to-board liveness; clients just tolerate - (skip) them. - -Discovery strategy: rather than sending HELLO -- which would add this client -to the boards' peer registry (we only want *pinball machines* on that list) -- -we broadcast an OFFLINE frame naming our own IP. Declaring ourselves offline -provokes the registry into re-broadcasting the full peer list (within ~2 s; -boards service discovery about every 1.5 s) without ever registering us. + has left. + +How the boards behave (firmware ``discovery.py`` + scheduler): every board +services its discovery socket every **1.5 s**. On any frame from a sender it +does not know, the registry broadcasts FULL and non-registry boards broadcast +HELLO; a PING additionally earns a unicast PONG back to the sender. A *lone* +board never broadcasts FULL on its own -- unprovoked it only says HELLO every +15 s -- so finding a single board depends on our probe actually reaching it. + +Discovery strategy, built for awkward networks (Windows multi-homing, travel +routers, hotspots that filter broadcast one way but not the other): + +- **Probe with PING + OFFLINE(self).** Either one provokes the registry into + broadcasting FULL without registering us as a peer (only HELLO does that; + we never send HELLO, so no pinball machine ever lists this client). PING is + the important one: every board that hears it also PONGs us *unicast*, which + gets through even when broadcasts toward us are dropped. +- **Send probes out every interface, to the limited broadcast and to each + interface's /24 directed broadcast.** ``255.255.255.255`` leaves exactly one + interface (the default route's), which on a multi-homed machine is often not + the one the boards are on; a directed broadcast like ``192.168.8.255`` is + routed by subnet and reaches the right network. +- **Listen for FULL, HELLO, and PONG.** FULL is the registry's complete list + and ends discovery immediately. HELLO gives one board's IP and name straight + from the source. PONG gives an IP. If boards were *heard* but no FULL arrives + shortly after (its broadcast was filtered), the complete list is fetched from + a heard board over unicast HTTP (``/api/network/peers``) instead. + +Boards service discovery every 1.5 s, so the usual result is everything within +about two seconds; the (default 20 s) timeout is only a worst-case cap. """ from __future__ import annotations @@ -26,15 +47,25 @@ import socket import time from dataclasses import dataclass -from typing import List, Optional +from typing import Dict, List, Optional DISCOVERY_PORT = 37020 BROADCAST_ADDR = "255.255.255.255" MSG_HELLO = 1 MSG_FULL = 2 +MSG_PING = 3 +MSG_PONG = 4 MSG_OFFLINE = 5 MAX_NAME_LEN = 32 -REBROADCAST_INTERVAL = 2.0 +#: Boards service their discovery socket every 1.5 s; re-probing faster than +#: that only adds noise. +REBROADCAST_INTERVAL = 1.5 +#: A board answers PONG and (registry) FULL in the same 1.5 s service tick, so +#: if a board was heard but no FULL followed within this window, the FULL +#: broadcast isn't reaching us and it's time to ask over HTTP instead. +SETTLE_AFTER_SIGHTING = 0.5 +#: HTTP timeout for the /api/network/peers fallback query. +PEERS_HTTP_TIMEOUT = 5.0 @dataclass(frozen=True) @@ -66,11 +97,55 @@ def _local_ip() -> str: sock.close() +def _local_ips() -> List[str]: + """Every local IPv4 address worth probing from (all interfaces). + + The boards may sit on a different interface than the default route (e.g. a + travel router on Wi-Fi while Ethernet carries the internet), so probes are + sent from each. Best-effort: name resolution can fail on odd hosts, and the + default-route IP from :func:`_local_ip` is always included when available. + """ + ips = set() + try: + for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): + ips.add(info[4][0]) + except OSError: + pass + primary = _local_ip() + if primary != "0.0.0.0": + ips.add(primary) + return sorted(ip for ip in ips if not ip.startswith("127.")) + + +def _directed_broadcasts(ips: List[str]) -> List[str]: + """/24 directed broadcast for each local IP (e.g. 192.168.8.7 -> .8.255). + + ``255.255.255.255`` leaves only the default-route interface on most OSes; + a directed broadcast is routed by subnet and reaches the right network. + Home/travel-router LANs are /24 in practice; on a wider subnet this still + reaches the boards in the same /24 and the limited broadcast covers rest. + """ + return sorted({ip.rsplit(".", 1)[0] + ".255" for ip in ips}) + + def build_offline(ip: str) -> bytes: """Build an OFFLINE frame declaring ``ip`` (dotted-quad) as gone.""" return bytes([MSG_OFFLINE]) + _ip_to_bytes(ip) +def build_ping() -> bytes: + """Build a PING frame; any board hearing it PONGs us back unicast.""" + return bytes([MSG_PING]) + + +def parse_hello(data: bytes) -> Optional[str]: + """Name from a HELLO frame, or None for any other/garbled frame.""" + if len(data) < 2 or data[0] != MSG_HELLO: + return None + name_len = data[1] + return data[2 : 2 + name_len].decode("utf-8", errors="replace") + + def parse_full(data: bytes) -> List[DiscoveredMachine]: """Parse a FULL frame into machines; returns [] for anything else. @@ -106,30 +181,84 @@ def _open_socket() -> socket.socket: try: sock.bind(("0.0.0.0", DISCOVERY_PORT)) except OSError: - # Port taken (another client running). The FULL reply is addressed to - # the sender's source port, so an ephemeral port still works. + # Port taken (another client running). Broadcast FULL frames and + # unicast PONGs are both addressed to port 37020, so an ephemeral + # port is deaf to them -- but with SO_REUSEADDR the bind above rarely + # fails, and probing still provokes traffic other clients can use. sock.bind(("0.0.0.0", 0)) return sock +def _probe_sockets(ips: List[str]) -> List[socket.socket]: + """One broadcast-capable send socket bound to each local IP. + + Binding the source address forces the OS to send from that interface, + which is what makes the limited broadcast leave *every* interface rather + than just the default route's. Interfaces that refuse are skipped. + """ + socks = [] + for ip in ips: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.bind((ip, 0)) + socks.append(sock) + except OSError: + continue + return socks + + +def _peers_over_http(ip: str) -> List[DiscoveredMachine]: + """Fetch the complete board list from one board's ``/api/network/peers``. + + The payload maps IP -> ``{"name": ..., "self": ...}`` and includes the + queried board itself, so one reachable board yields the whole network over + plain unicast HTTP -- no broadcast involved. Raises TransportError family + on failure; the caller decides how much that matters. + """ + from .transports.http import HttpTransport + + transport = HttpTransport(ip, timeout=PEERS_HTTP_TIMEOUT) + try: + payload = transport.request("/api/network/peers") + finally: + transport.close() + + machines = [] + if isinstance(payload, dict): + for peer_ip, info in payload.items(): + name = info.get("name", "") if isinstance(info, dict) else str(info) + machines.append(DiscoveredMachine(ip=str(peer_ip), name=str(name))) + return machines + + def discover( timeout: float = 20.0, name: Optional[str] = None, ) -> List[DiscoveredMachine]: - """Broadcast OFFLINE (self) and collect boards from the registry's FULL reply. - - Declaring ourselves offline nudges the registry into re-broadcasting its - complete peer list without adding this client to it. Boards service - discovery infrequently, so getting a reply can take a while -- ``timeout`` - (default 20 s) is how long to wait, re-broadcasting every ~2 s. But a FULL - frame is the registry's *complete* list of known boards, so discovery - returns the moment one arrives rather than burning the whole timeout. - ``name`` lets it also return the instant that exact board appears in a - reply. Results are deduplicated by IP. + """Find Vector boards on the LAN; returns a list of :class:`DiscoveredMachine`. + + Probes (PING + OFFLINE) go out every interface each ~1.5 s -- matching how + often boards service discovery -- and the first FULL reply ends discovery + immediately, since it is the registry's complete list. Boards heard only + directly (a HELLO or a unicast PONG) mean broadcasts toward us are being + filtered; after a short settle the complete list is fetched from a heard + board over HTTP instead. The usual result is everything within about two + seconds; ``timeout`` (default 20 s) is the worst-case cap for genuinely + quiet networks. ``name`` makes discovery return the instant that exact + board appears. Results are deduplicated by IP. Never registers this client + in the boards' own peer lists (that would take a HELLO, which is not sent). """ sock = _open_socket() - offline = build_offline(_local_ip()) - found: dict = {} # ip -> DiscoveredMachine (dedup; registry may list itself) + local_ip = _local_ip() + local_ips = _local_ips() + probe_socks = _probe_sockets(local_ips) + targets = [BROADCAST_ADDR] + _directed_broadcasts(local_ips) + probes = [build_ping(), build_offline(local_ip)] + + found: Dict[str, DiscoveredMachine] = {} # via FULL frames (dedup by IP) + sightings: Dict[str, Optional[str]] = {} # boards heard directly: ip -> name? + first_sighting: Optional[float] = None target = name.lower() if name else None deadline = time.monotonic() + timeout next_broadcast = 0.0 @@ -138,19 +267,28 @@ def discover( now = time.monotonic() if now >= deadline: break + if first_sighting is not None and now - first_sighting >= SETTLE_AFTER_SIGHTING: + break # boards heard but their FULL isn't reaching us; go ask over HTTP if now >= next_broadcast: - try: - sock.sendto(offline, (BROADCAST_ADDR, DISCOVERY_PORT)) - except OSError: - pass # no broadcast route; FULL replies may still arrive + for frame in probes: + for out in [sock] + probe_socks: + for addr in targets: + try: + out.sendto(frame, (addr, DISCOVERY_PORT)) + except OSError: + pass # interface without a broadcast route next_broadcast = now + REBROADCAST_INTERVAL - sock.settimeout(min(0.5, deadline - now)) + wait = min(0.5, deadline - now) + if first_sighting is not None: + wait = min(wait, first_sighting + SETTLE_AFTER_SIGHTING - now) + sock.settimeout(max(wait, 0.01)) try: - data, _addr = sock.recvfrom(4096) + data, addr = sock.recvfrom(4096) except socket.timeout: continue except OSError: break + for machine in parse_full(data): found[machine.ip] = machine if target and machine.name.lower() == target: @@ -160,6 +298,36 @@ def discover( # instead of burning the rest of the (deliberately long) timeout. if len(data) >= 2 and data[0] == MSG_FULL: return list(found.values()) + + src = addr[0] + if src == local_ip or src in local_ips: + continue # our own probe looped back + hello_name = parse_hello(data) + if hello_name is not None: + sightings[src] = hello_name + if target and hello_name.lower() == target: + found[src] = DiscoveredMachine(ip=src, name=hello_name) + return list(found.values()) + elif data[:1] == bytes([MSG_PONG]): + sightings.setdefault(src, None) + else: + continue + if first_sighting is None: + first_sighting = time.monotonic() finally: sock.close() + for out in probe_socks: + out.close() + + # Boards were heard directly but no FULL broadcast made it through: get the + # complete list over unicast HTTP from the first heard board that answers. + for ip in sightings: + try: + for machine in _peers_over_http(ip): + found.setdefault(machine.ip, machine) + break # one board's peer table is the whole picture + except Exception: + continue # that board wouldn't talk HTTP; try the next sighting + for ip, seen_name in sightings.items(): + found.setdefault(ip, DiscoveredMachine(ip=ip, name=seen_name or "")) return list(found.values()) From 3b3dedb2cf12728861679e6be6b37380b5b080a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 19:23:04 +0000 Subject: [PATCH 4/5] Add end-to-end discovery tests and fix two Windows-specific failures Add tests/test_discovery_e2e.py: a fake board thread that speaks the firmware's discovery protocol over real UDP loopback sockets, so discover() is exercised with genuine network I/O on whatever OS runs the tests. Covers the fast FULL-reply path, named lookup, the PONG-only -> HTTP peers fallback, and the silent-network timeout. Running this file on a machine separates "library broken on this OS" from "network is blocking discovery". Two Windows fixes found while auditing for platform differences: - On Windows, recvfrom raises ConnectionResetError (WSAECONNRESET) when an earlier *sent* datagram bounced with ICMP port-unreachable; the receive loop treated any OSError as fatal and silently stopped listening. Now tolerated and listening continues. - requests honors system/environment proxy settings by default, so on machines with a proxy configured (corporate, VPN, WPAD auto-detect) HTTP to a board's private IP was routed to a proxy that cannot reach it. HttpTransport now disables proxy lookup for private, loopback, and link-local addresses; hostnames, public IPs, and caller-supplied sessions keep their behavior. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SFN2z3hJYtQbnRVmsJuRkd --- tests/test_discovery.py | 20 ++++ tests/test_discovery_e2e.py | 164 +++++++++++++++++++++++++++++++ tests/test_http.py | 18 ++++ warpedpinball/discovery.py | 5 + warpedpinball/transports/http.py | 21 +++- 5 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 tests/test_discovery_e2e.py diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 95b6fb0..d6913b4 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -286,6 +286,26 @@ def sendto(self, *a): assert discovery.discover(timeout=0.05) == [] +def test_discover_survives_windows_connection_reset(monkeypatch): + # Windows raises ConnectionResetError from recvfrom when an earlier *sent* + # datagram bounced (WSAECONNRESET). That must not end discovery -- the + # FULL frame queued behind it must still be received. + class WindowsySocket(FakeSocket): + def __init__(self, frames): + super().__init__(frames) + self._reset_once = True + + def recvfrom(self, bufsize): + if self._reset_once: + self._reset_once = False + raise ConnectionResetError("bounced datagram") + return super().recvfrom(bufsize) + + frame = full_frame(peer("10.0.0.7", "Elvira")) + monkeypatch.setattr(discovery, "_open_socket", lambda: WindowsySocket([frame])) + assert discovery.discover(timeout=999) == [DiscoveredMachine("10.0.0.7", "Elvira")] + + def test_discover_breaks_on_recvfrom_oserror(monkeypatch): class BrokenSocket(FakeSocket): def recvfrom(self, _bufsize): diff --git a/tests/test_discovery_e2e.py b/tests/test_discovery_e2e.py new file mode 100644 index 0000000..5663139 --- /dev/null +++ b/tests/test_discovery_e2e.py @@ -0,0 +1,164 @@ +"""End-to-end discovery tests against a fake board over real UDP sockets. + +The unit tests in test_discovery.py exercise the frame logic with fake +sockets; these tests run :func:`warpedpinball.discover` against a real +datagram socket served by a thread that behaves like the firmware's +``discovery.py`` (branch behavior: on any frame from an unknown sender the +registry replies FULL, and a PING additionally earns a unicast PONG to the +sender's IP on the discovery port). + +Everything runs on loopback: the fake board binds ``127.0.0.2:``, the +client binds the wildcard address on the same port, and the "broadcast" +address is pointed at the board. Loopback UDP behaves the same across +platforms and is not subject to firewalls or routing, so a pass here means +the library's socket handling, framing, timing, and fallback logic work on +*this* OS -- and a failure in the field is the network (firewall, client +isolation, wrong interface), not the library. Run just these with:: + + pytest tests/test_discovery_e2e.py -v +""" + +import socket +import threading +import time + +import pytest + +import warpedpinball.discovery as discovery +from warpedpinball.discovery import DiscoveredMachine + +BOARD_IP = "127.0.0.2" # a second loopback address; distinct from the client's +CLIENT_IP = "127.0.0.1" + + +def _free_udp_port() -> int: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +class FakeBoard(threading.Thread): + """A lone Vector board's discovery behavior, on a real UDP socket. + + Mirrors the firmware: a lone board is its own registry, so any frame from + an unknown sender provokes a FULL reply (sent to the sender's IP on the + discovery port, standing in for the firmware's broadcast), and a PING + additionally earns a unicast PONG. ``respond_full=False`` models a network + that eats the FULL broadcast but passes unicast -- the client then only + ever sees the PONG. + """ + + def __init__(self, port, name="Elvira", respond_full=True): + super().__init__(daemon=True) + self.port = port + self.name = name + self.respond_full = respond_full + self.frames_seen = [] + self._halt = threading.Event() + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.sock.bind((BOARD_IP, port)) # may raise; see fixture skip + self.sock.settimeout(0.05) + + def _full_frame(self) -> bytes: + name_bytes = self.name.encode() + ip_bytes = bytes(int(p) for p in BOARD_IP.split(".")) + return bytes([2, 1]) + ip_bytes + bytes([len(name_bytes)]) + name_bytes + + def run(self): + while not self._halt.is_set(): + try: + data, addr = self.sock.recvfrom(1024) + except socket.timeout: + continue + except OSError: + return + self.frames_seen.append(data) + reply_to = (addr[0], self.port) + if self.respond_full: + self.sock.sendto(self._full_frame(), reply_to) + if data[:1] == bytes([discovery.MSG_PING]): + self.sock.sendto(bytes([discovery.MSG_PONG]), reply_to) + + def stop(self): + self._halt.set() + self.join(timeout=2) + self.sock.close() + + +@pytest.fixture +def loopback_net(monkeypatch): + """Point discover() at a loopback 'network' with a free port.""" + port = _free_udp_port() + monkeypatch.setattr(discovery, "DISCOVERY_PORT", port) + monkeypatch.setattr(discovery, "BROADCAST_ADDR", BOARD_IP) + monkeypatch.setattr(discovery, "_local_ip", lambda: CLIENT_IP) + monkeypatch.setattr(discovery, "_local_ips", lambda: []) + return port + + +def _start_board(port, **kwargs) -> FakeBoard: + try: + board = FakeBoard(port, **kwargs) + except OSError: # pragma: no cover - host without a full 127/8 loopback + pytest.skip("host cannot bind a second loopback address (127.0.0.2)") + board.start() + return board + + +def test_e2e_lone_board_is_found_quickly(loopback_net): + board = _start_board(loopback_net) + try: + start = time.monotonic() + machines = discovery.discover(timeout=10.0) + elapsed = time.monotonic() - start + time.sleep(0.2) # let the board drain probes still in its buffer + finally: + board.stop() + assert machines == [DiscoveredMachine(BOARD_IP, "Elvira")] + # The board answers the first probe, so this must not burn the timeout. + assert elapsed < 3.0 + # The board actually received our probes over the wire. + types = {frame[0] for frame in board.frames_seen} + assert discovery.MSG_PING in types + assert discovery.MSG_OFFLINE in types + + +def test_e2e_named_lookup_matches(loopback_net): + board = _start_board(loopback_net) + try: + machines = discovery.discover(timeout=10.0, name="elvira") + finally: + board.stop() + assert DiscoveredMachine(BOARD_IP, "Elvira") in machines + + +def test_e2e_pong_only_board_found_via_http_fallback(loopback_net, monkeypatch): + # The network eats the FULL reply; only the unicast PONG gets through. + # The client must still find the board: PONG -> sighting -> HTTP peers. + queried = [] + + def fake_peers(ip): + queried.append(ip) + return [DiscoveredMachine(BOARD_IP, "Elvira")] + + monkeypatch.setattr(discovery, "_peers_over_http", fake_peers) + board = _start_board(loopback_net, respond_full=False) + try: + start = time.monotonic() + machines = discovery.discover(timeout=10.0) + elapsed = time.monotonic() - start + finally: + board.stop() + assert machines == [DiscoveredMachine(BOARD_IP, "Elvira")] + assert queried == [BOARD_IP] + assert elapsed < 4.0 # PONG + settle window + fallback, not the timeout + + +def test_e2e_silent_network_times_out_empty(loopback_net): + # Nothing answering: discover returns [] after (a small) timeout instead + # of hanging or crashing -- the "no boards" path over real sockets. + machines = discovery.discover(timeout=0.5) + assert machines == [] diff --git a/tests/test_http.py b/tests/test_http.py index af0ee85..7ed63b2 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -300,3 +300,21 @@ def _gen(): stream = make_transport(session).stream("/api/memory-snapshot") with pytest.raises(DeviceUnreachableError): list(stream) + + +def test_lan_addresses_bypass_system_proxies(): + # Boards live on private IPs; a configured system/env proxy (common on + # Windows: corporate, VPN, WPAD) cannot reach them, so the session must + # not consult proxy settings for LAN hosts. + for host in ("192.168.4.7", "10.0.0.9", "172.16.1.2", "127.0.0.1", "169.254.3.4"): + assert HttpTransport(host)._session.trust_env is False, host + + +def test_hostnames_and_public_ips_keep_proxy_behavior(): + for host in ("example.com", "8.8.8.8"): + assert HttpTransport(host)._session.trust_env is True, host + + +def test_caller_supplied_session_is_left_alone(): + session = requests.Session() + assert HttpTransport("192.168.4.7", session=session)._session.trust_env is True diff --git a/warpedpinball/discovery.py b/warpedpinball/discovery.py index 1e7d4de..9adc199 100644 --- a/warpedpinball/discovery.py +++ b/warpedpinball/discovery.py @@ -286,6 +286,11 @@ def discover( data, addr = sock.recvfrom(4096) except socket.timeout: continue + except ConnectionResetError: + # Windows only: a previously *sent* datagram bounced with ICMP + # port-unreachable, and Windows reports it on the next receive + # (WSAECONNRESET). Not a socket failure; keep listening. + continue except OSError: break diff --git a/warpedpinball/transports/http.py b/warpedpinball/transports/http.py index 7b6b0a9..beef20d 100644 --- a/warpedpinball/transports/http.py +++ b/warpedpinball/transports/http.py @@ -20,6 +20,17 @@ from . import Transport, parse_body, raise_for_status, serialize_body DEFAULT_TIMEOUT = 10.0 + + +def _is_lan_address(host: str) -> bool: + """True for private/link-local/loopback IPs (boards always live on these).""" + import ipaddress + + try: + ip = ipaddress.ip_address(host) + except ValueError: + return False # a hostname; leave proxy behavior alone + return ip.is_private or ip.is_link_local or ip.is_loopback #: Retries when the device says "429 Too many challenges" (expired challenges #: are purged on each challenge request, so a short sleep usually clears it). CHALLENGE_RETRIES = 3 @@ -46,7 +57,15 @@ def __init__( self._host_label = urlsplit(self.base_url).netloc or self.base_url self.password = password self.timeout = timeout - self._session = session or requests.Session() + if session is None: + session = requests.Session() + if _is_lan_address(urlsplit(self.base_url).hostname or ""): + # Never route LAN traffic through a system/environment proxy. + # Windows machines often have one configured (corporate, VPN, + # WPAD auto-detect), and requests would otherwise send this + # board's traffic to a proxy that cannot reach it. + session.trust_env = False + self._session = session @property def description(self) -> str: From b2efaae502ce86116813a565bfb38eb13e78c57d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 19:26:33 +0000 Subject: [PATCH 5/5] Restore 100% test coverage on discovery Cover the paths the discovery overhaul left untested: the real _local_ips interface merge and its getaddrinfo failure fallback, the _probe_sockets bind success path, probe sockets carrying probes and being closed by discover(), a second direct sighting reusing the first settle clock, and a non-dict /api/network/peers payload. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SFN2z3hJYtQbnRVmsJuRkd --- tests/test_discovery.py | 86 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index d6913b4..53deb78 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -13,6 +13,9 @@ parse_hello, ) +# The real function, captured before the autouse fixture below patches it. +_real_local_ips = discovery._local_ips + @pytest.fixture(autouse=True) def _hermetic(monkeypatch): @@ -108,6 +111,32 @@ def test_directed_broadcasts(): ] +def test_local_ips_merges_interfaces_and_filters(monkeypatch): + # getaddrinfo lists per-interface IPs; the default-route IP is merged in, + # loopback is dropped, and the result is sorted and de-duplicated. + infos = [ + (socket.AF_INET, socket.SOCK_DGRAM, 17, "", ("192.168.8.7", 0)), + (socket.AF_INET, socket.SOCK_DGRAM, 17, "", ("127.0.1.1", 0)), + (socket.AF_INET, socket.SOCK_DGRAM, 17, "", ("10.0.0.3", 0)), + ] + monkeypatch.setattr(discovery.socket, "getaddrinfo", lambda *a, **k: infos) + monkeypatch.setattr(discovery, "_local_ip", lambda: "192.168.8.7") + assert _real_local_ips() == ["10.0.0.3", "192.168.8.7"] + + +def test_local_ips_tolerates_getaddrinfo_failure(monkeypatch): + # Odd hosts can't resolve their own hostname; the default-route IP still + # comes back, and a routeless 0.0.0.0 fallback is not treated as an IP. + def boom(*a, **k): + raise OSError("resolution failed") + + monkeypatch.setattr(discovery.socket, "getaddrinfo", boom) + monkeypatch.setattr(discovery, "_local_ip", lambda: "192.168.8.7") + assert _real_local_ips() == ["192.168.8.7"] + monkeypatch.setattr(discovery, "_local_ip", lambda: "0.0.0.0") + assert _real_local_ips() == [] + + def test_local_ip_falls_back_when_no_route(monkeypatch): class NoRouteSock: def connect(self, *a): @@ -349,6 +378,63 @@ def test_open_socket_binds_and_is_datagram(): sock.close() +def test_probe_sockets_binds_local_ips(): + socks = discovery._probe_sockets(["127.0.0.1"]) + try: + assert len(socks) == 1 + assert socks[0].getsockname()[0] == "127.0.0.1" + finally: + for s in socks: + s.close() + + +def test_discover_uses_and_closes_probe_sockets(monkeypatch): + # Per-interface probe sockets must carry probes and be closed on exit. + monkeypatch.setattr(discovery, "_local_ip", lambda: "192.168.4.7") + monkeypatch.setattr(discovery, "_local_ips", lambda: ["192.168.4.7"]) + probe_sock = FakeSocket([]) + probe_sock.closed = False + probe_sock.close = lambda: setattr(probe_sock, "closed", True) + monkeypatch.setattr(discovery, "_probe_sockets", lambda ips: [probe_sock]) + monkeypatch.setattr(discovery, "_open_socket", lambda: FakeSocket([])) + discovery.discover(timeout=0.05) + assert probe_sock.sent > 0 + assert probe_sock.closed + + +def test_discover_second_sighting_keeps_first_settle_clock(monkeypatch): + # Two boards heard directly (PONGs from different IPs): both must come + # back, and the settle window runs from the *first* sighting. + monkeypatch.setattr( + discovery, + "_open_socket", + lambda: FakeSocket([(bytes([4]), "10.0.0.9"), (bytes([4]), "10.0.0.4")]), + ) + monkeypatch.setattr(discovery, "_peers_over_http", lambda ip: []) + machines = discovery.discover(timeout=999) + assert set(machines) == { + DiscoveredMachine("10.0.0.9", ""), + DiscoveredMachine("10.0.0.4", ""), + } + + +def test_peers_over_http_tolerates_non_dict_payload(monkeypatch): + class FakeTransport: + def __init__(self, host, timeout=None): + pass + + def request(self, path): + return ["not", "a", "peer", "map"] + + def close(self): + pass + + import warpedpinball.transports.http as http + + monkeypatch.setattr(http, "HttpTransport", FakeTransport) + assert discovery._peers_over_http("10.0.0.9") == [] + + def test_probe_sockets_skips_unbindable_ips(): # 203.0.113.1 (TEST-NET-3) is not a local address, so binding fails and # the helper must skip it rather than raise.