diff --git a/docs/examples.md b/docs/examples.md index edf4c20..710d3f5 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -5,8 +5,74 @@ 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 (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 + +[`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. + +### 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/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 new file mode 100644 index 0000000..9e42a3a --- /dev/null +++ b/examples/discover_boards.py @@ -0,0 +1,170 @@ +"""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. + +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 +""" + +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 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. + """ + name, ip = board + facts = {"name": name, "ip": ip} + try: + with warpedpinball.connect(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 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: + # 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 + # 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): + 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(): + 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[0].lower()) + print(f"\nFound {len(boards)} board(s):\n") + 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") + 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() diff --git a/tests/test_discovery.py b/tests/test_discovery.py index ec13846..53deb78 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -1,9 +1,27 @@ -"""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, +) + +# The real function, captured before the autouse fixture below patches it. +_real_local_ips = discovery._local_ips + + +@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 +33,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 +81,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 +96,47 @@ 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_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): @@ -80,22 +152,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 +177,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 +242,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). @@ -171,6 +315,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): @@ -180,6 +344,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 +378,72 @@ 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. + 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/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 a64ad45..9adc199 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,33 @@ 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 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 + for machine in parse_full(data): found[machine.ip] = machine if target and machine.name.lower() == target: @@ -160,6 +303,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()) 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: