""" Network discovery and host addressing. Nothing in this dashboard assumes a fixed IP. Three separate problems are solved here: 1. Which addresses is this dashboard reachable on? -> local_addresses() The server binds 0.0.0.0, so it answers on every interface. We enumerate them so the operator can open the dashboard from a phone on the same Wi-Fi. 2. What subnets are we on? -> local_networks() Derived from the live interface list, so moving between networks just works. 3. Where is the robot right now? -> scan_subnet() / probe_host() A bounded TCP sweep of the current subnet, matching hosts that answer on the ports an X2 exposes. """ from __future__ import annotations import asyncio import ipaddress import socket import subprocess import sys import time from typing import Iterable from . import x2_spec # -------------------------------------------------------------------------- # Local interface enumeration # -------------------------------------------------------------------------- def _addresses_from_os() -> set[str]: """ Every IPv4 the OS reports, by asking the OS directly. getaddrinfo() and the UDP-connect trick below both under-report: the first depends on how the hostname resolves, the second only ever reveals the default-route interface. On a machine with both Wi-Fi and Ethernet that means the dashboard advertised only one of its addresses. """ found: set[str] = set() try: if sys.platform == "win32": out = subprocess.run( ["ipconfig"], capture_output=True, text=True, timeout=8, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ).stdout for line in out.splitlines(): line = line.strip() # Localised Windows uses a translated label, so match on the # "IPv4" token rather than the full English phrase. if "IPv4" in line and ":" in line: found.add(line.split(":")[-1].strip().replace("(Preferred)", "").strip()) else: out = subprocess.run( ["ip", "-o", "-4", "addr", "show"], capture_output=True, text=True, timeout=8, ).stdout for line in out.splitlines(): for token in line.split(): if "/" in token and token[0].isdigit(): found.add(token.split("/")[0]) break except (OSError, subprocess.SubprocessError, ValueError): pass return found def _addresses_via_socket() -> set[str]: """Addresses this host answers on, without third-party dependencies.""" found: set[str] = set(_addresses_from_os()) try: for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): found.add(info[4][0]) except socket.gaierror: pass # Opening a UDP socket toward a public address makes the kernel pick the # outbound interface and tell us its address - no packet is actually sent. for probe in ("8.8.8.8", "1.1.1.1"): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: sock.connect((probe, 80)) found.add(sock.getsockname()[0]) except OSError: pass finally: sock.close() cleaned = set() for addr in found: try: ipaddress.IPv4Address(addr) cleaned.add(addr) except ipaddress.AddressValueError: continue return cleaned def _netmask_for(addr: str) -> str | None: """Best-effort netmask lookup. Falls back to a /24 assumption.""" try: if sys.platform == "win32": out = subprocess.run( ["ipconfig"], capture_output=True, text=True, timeout=5, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ).stdout block: list[str] = [] for line in out.splitlines(): block.append(line) if addr in line: # The mask is printed on the line after the address. idx = block.index(line) for follow in out.splitlines()[idx + 1: idx + 4]: if "Mask" in follow or "掩码" in follow: return follow.split(":")[-1].strip() else: out = subprocess.run( ["ip", "-o", "-f", "inet", "addr", "show"], capture_output=True, text=True, timeout=5, ).stdout for line in out.splitlines(): if f"{addr}/" in line: for tok in line.split(): if tok.startswith(f"{addr}/"): return tok.split("/")[1] except (OSError, subprocess.SubprocessError, ValueError): pass return None def local_addresses() -> list[dict]: """Every usable IPv4 this machine holds, loopback last.""" result = [] for addr in sorted(_addresses_via_socket()): try: ip = ipaddress.IPv4Address(addr) except ipaddress.AddressValueError: continue result.append({ "address": addr, "loopback": ip.is_loopback, "private": ip.is_private, "link_local": ip.is_link_local, }) result.sort(key=lambda r: (r["loopback"], r["link_local"], not r["private"], r["address"])) return result def local_networks() -> list[str]: """CIDR networks this host sits on, excluding loopback and link-local.""" nets: list[str] = [] for entry in local_addresses(): if entry["loopback"] or entry["link_local"]: continue addr = entry["address"] mask = _netmask_for(addr) try: iface = ipaddress.IPv4Interface(f"{addr}/{mask}" if mask else f"{addr}/24") except (ipaddress.AddressValueError, ipaddress.NetmaskValueError, ValueError): iface = ipaddress.IPv4Interface(f"{addr}/24") cidr = str(iface.network) if cidr not in nets: nets.append(cidr) return nets def primary_address() -> str | None: """ The address other devices on the LAN would actually reach us on. Delegates to nic.detect(), which ranks real adapters and refuses to return a stale one. Enumerating "the first non-loopback address" is not good enough: a disconnected adapter keeps its address, answers local connections through loopback, and would be published as a link nobody else can open. """ from . import nic return nic.primary_address() def address_detail() -> dict: """Full detection result - which adapter won, what was rejected and why.""" from . import nic found = nic.detect() primary = found["primary"] return { "ip": primary["ip"] if primary else None, "adapter": primary["adapter"] if primary else None, "kind": ("wifi" if primary and primary.get("iftype") == nic.IF_TYPE_WIFI else "wired" if primary else None), "gateway": primary.get("gateway") if primary else None, "reason": found["reason"], "reason_text": nic.REASON_TEXT.get(found["reason"] or "", ""), "alternates": [ {"ip": a["ip"], "adapter": a["adapter"], "kind": "wifi" if a.get("iftype") == nic.IF_TYPE_WIFI else "wired"} for a in found["alternates"] ], "rejected": [ {"ip": r["ip"], "adapter": r["adapter"], "why": r["rejected"]} for r in found["rejected"] if not str(r["ip"]).startswith("127.") ], "checked_at": found["checked_at"], } def hostname_urls(port: int, advertised: str | None = None) -> list[str]: """ Name-based URLs for the dashboard. Only the name the dashboard advertises for itself is offered. The PC's own computer name is deliberately not published: this is a robot dashboard, and the link should say so. The bare single-label form is left out too - it resolves on Windows from local configuration without a packet reaching the network, and no phone resolves it at all. """ if not advertised: return [] return [f"http://{advertised.strip().strip('.').lower()}.local:{port}"] def dashboard_urls(port: int, advertised: str | None = None) -> list[str]: """ URLs for the dashboard, best first. The live Wi-Fi address leads: it is what a phone can always reach, needing no mDNS, no multicast and no DNS. Names come after it, because on many access points `.local` resolution is blocked between wireless clients. """ urls: list[str] = [] detail = address_detail() if detail["ip"]: urls.append(f"http://{detail['ip']}:{port}") for alternate in detail["alternates"]: urls.append(f"http://{alternate['ip']}:{port}") for url in hostname_urls(port, advertised): if url not in urls: urls.append(url) urls.append(f"http://localhost:{port}") return urls # -------------------------------------------------------------------------- # Robot probing # -------------------------------------------------------------------------- async def _tcp_open(host: str, port: int, timeout: float) -> bool: try: fut = asyncio.open_connection(host, port) reader, writer = await asyncio.wait_for(fut, timeout=timeout) writer.close() try: await writer.wait_closed() except (ConnectionError, OSError): pass return True except (asyncio.TimeoutError, OSError): return False async def probe_host(host: str, ports: Iterable[int] | None = None, timeout: float = 0.6) -> dict: """Check which of the X2's known ports a host answers on.""" ports = list(ports or x2_spec.DISCOVERY_PORTS) started = time.monotonic() results = await asyncio.gather(*[_tcp_open(host, p, timeout) for p in ports]) open_ports = [p for p, ok in zip(ports, results) if ok] hostname = None if open_ports: try: hostname = await asyncio.get_running_loop().run_in_executor( None, lambda: socket.gethostbyaddr(host)[0] ) except (OSError, socket.herror): hostname = None return { "host": host, "reachable": bool(open_ports), "open_ports": open_ports, "hostname": hostname, "latency_ms": round((time.monotonic() - started) * 1000, 1), "is_pc1": host == x2_spec.PC1_MOTION_CONTROL_IP, } async def scan_subnet(cidr: str, ports: Iterable[int] | None = None, timeout: float = 0.4, concurrency: int = 128, progress=None) -> list[dict]: """ Sweep a subnet for hosts answering on X2 ports. Refuses networks larger than /22 - a /16 sweep is 65k hosts and would hang for minutes rather than seconds. """ network = ipaddress.IPv4Network(cidr, strict=False) if network.num_addresses > 1024: raise ValueError( f"{cidr} has {network.num_addresses} addresses; " "narrow the range to /22 or smaller before scanning." ) hosts = list(network.hosts()) semaphore = asyncio.Semaphore(concurrency) done = 0 total = len(hosts) async def one(ip): nonlocal done async with semaphore: res = await probe_host(str(ip), ports, timeout) done += 1 if progress and done % 16 == 0: await progress(done, total) return res results = await asyncio.gather(*[one(ip) for ip in hosts]) if progress: await progress(total, total) return [r for r in results if r["reachable"]] async def resolve(host: str) -> str | None: """Resolve a hostname to an IPv4 address, or return it unchanged if already one.""" try: ipaddress.IPv4Address(host) return host except ipaddress.AddressValueError: pass try: loop = asyncio.get_running_loop() info = await loop.getaddrinfo(host, None, family=socket.AF_INET) return info[0][4][0] except (socket.gaierror, OSError, IndexError): return None