347 lines
12 KiB
Python
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
|