Coverage for src/signalk_cli/net.py: 94%
49 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-06 00:17 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-06 00:17 +0000
1"""SignalK server discovery (mDNS) and the on-disk host cache."""
3import time
4from pathlib import Path
6try:
7 from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf
8except ImportError: # slim/Pyodide installs ship without zeroconf
9 Zeroconf = None # type: ignore[assignment,misc]
11CACHE_DIR = Path.home() / ".cache" / "signalk-cli"
12_SIGNALK_TYPE = "_signalk-ws._tcp.local."
13_HOST_CACHE_FILE = CACHE_DIR / "host.cache"
16def get_cached_host() -> str | None:
17 try:
18 if _HOST_CACHE_FILE.exists():
19 return _HOST_CACHE_FILE.read_text().strip() or None
20 except OSError:
21 pass
22 return None
25def save_cached_host(host: str) -> None:
26 try:
27 CACHE_DIR.mkdir(parents=True, exist_ok=True)
28 _HOST_CACHE_FILE.write_text(host)
29 except OSError:
30 pass
33def discover_host(timeout: float = 5.0) -> str | None:
34 """Browse mDNS for a SignalK server and return its base URL, or None."""
35 if Zeroconf is None:
36 return None
37 found: list[str] = []
39 def _on_change(
40 zeroconf: Zeroconf,
41 service_type: str,
42 name: str,
43 state_change: ServiceStateChange,
44 ) -> None:
45 if state_change is not ServiceStateChange.Added:
46 return
47 info = zeroconf.get_service_info(service_type, name)
48 if info is None:
49 return
50 addrs = info.parsed_addresses()
51 if not addrs:
52 return
53 host = f"http://{addrs[0]}:{info.port}"
54 found.append(host)
56 zc = Zeroconf()
57 try:
58 ServiceBrowser(zc, _SIGNALK_TYPE, handlers=[_on_change])
59 deadline = time.monotonic() + timeout
60 while not found and time.monotonic() < deadline:
61 time.sleep(0.1)
62 finally:
63 zc.close()
65 return found[0] if found else None
68def normalise_host(host: str) -> str:
69 """Prepend http:// if the host has no scheme."""
70 if "://" not in host:
71 return f"http://{host}"
72 return host