Coverage for src/signalk_cli/net.py: 38%
77 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-14 12:47 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-14 12:47 +0000
1"""Shared host discovery/caching and CLI option helpers for SignalK API clients."""
3import contextlib
4import io
5import time
6from pathlib import Path
8import click
9import niquests
10from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf
12CACHE_DIR = Path.home() / ".cache" / "signalk-cli"
13_SIGNALK_TYPE = "_signalk-ws._tcp.local."
14_HOST_CACHE_FILE = CACHE_DIR / "host.cache"
17def get_cached_host() -> str | None:
18 try:
19 if _HOST_CACHE_FILE.exists():
20 return _HOST_CACHE_FILE.read_text().strip() or None
21 except OSError:
22 pass
23 return None
26def save_cached_host(host: str) -> None:
27 try:
28 CACHE_DIR.mkdir(parents=True, exist_ok=True)
29 _HOST_CACHE_FILE.write_text(host)
30 except OSError:
31 pass
34def discover_host(timeout: float = 5.0) -> str | None:
35 """Browse mDNS for a SignalK server and return its base URL, or None."""
36 found: list[str] = []
38 def _on_change(
39 zeroconf: Zeroconf,
40 service_type: str,
41 name: str,
42 state_change: ServiceStateChange,
43 ) -> None:
44 if state_change is not ServiceStateChange.Added:
45 return
46 info = zeroconf.get_service_info(service_type, name)
47 if info is None:
48 return
49 addrs = info.parsed_addresses()
50 if not addrs:
51 return
52 host = f"http://{addrs[0]}:{info.port}"
53 found.append(host)
55 zc = Zeroconf()
56 try:
57 ServiceBrowser(zc, _SIGNALK_TYPE, handlers=[_on_change])
58 deadline = time.monotonic() + timeout
59 while not found and time.monotonic() < deadline:
60 time.sleep(0.1)
61 finally:
62 zc.close()
64 return found[0] if found else None
67def normalise_host(host: str) -> str:
68 """Prepend http:// if the host has no scheme."""
69 if "://" not in host:
70 return f"http://{host}"
71 return host
74def api_error(exc: niquests.RequestException) -> str:
75 """Return the most informative message from an API error response."""
76 resp = getattr(exc, "response", None)
77 if resp is not None:
78 with contextlib.suppress(Exception):
79 body = resp.json()
80 return body.get("error") or body.get("message") or str(exc)
81 return str(exc)
84# ---------------------------------------------------------------------------
85# Shared Click option decorators
86# ---------------------------------------------------------------------------
89def host_option(f):
90 return click.option(
91 "--host",
92 default=None,
93 envvar="SIGNALK_HOST",
94 help="SignalK server base URL. http:// added if scheme omitted. "
95 "Discovered via mDNS if omitted.",
96 )(f)
99def bare_option(f):
100 return click.option(
101 "--bare",
102 is_flag=True,
103 help="Suppress all informational messages, outputting data only.",
104 )(f)
107def stderr_ctx(bare: bool) -> contextlib.AbstractContextManager:
108 return (
109 contextlib.redirect_stderr(io.StringIO()) if bare else contextlib.nullcontext()
110 )
113def resolve_host(host: str | None, no_cache: bool = False) -> str:
114 """Return a normalised host URL, discovering via mDNS if none provided."""
115 if host: 115 ↛ 117line 115 didn't jump to line 117 because the condition on line 115 was always true
116 return normalise_host(host)
117 if not no_cache:
118 cached = get_cached_host()
119 if cached:
120 click.echo(f"Using cached host: {cached}", err=True)
121 return cached
122 click.echo("No host specified — searching for SignalK via mDNS...", err=True)
123 discovered = discover_host()
124 if not discovered:
125 raise click.UsageError(
126 "No SignalK server found via mDNS. Use --host or set SIGNALK_HOST."
127 )
128 click.echo(f"Discovered: {discovered}", err=True)
129 if not no_cache:
130 save_cached_host(discovered)
131 return discovered