2026-08-13 16:23:18 +04:00

347 lines
12 KiB
Python

"""
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