signalk-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- signalk_cli/__init__.py +1 -0
- signalk_cli/__main__.py +7 -0
- signalk_cli/history/__init__.py +31 -0
- signalk_cli/history/__main__.py +3 -0
- signalk_cli/history/cli.py +499 -0
- signalk_cli/history/history_api.py +231 -0
- signalk_cli/history/output.py +190 -0
- signalk_cli-1.0.0.dist-info/METADATA +281 -0
- signalk_cli-1.0.0.dist-info/RECORD +11 -0
- signalk_cli-1.0.0.dist-info/WHEEL +4 -0
- signalk_cli-1.0.0.dist-info/entry_points.txt +3 -0
signalk_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""signalk_cli — Python clients and CLI for SignalK APIs."""
|
signalk_cli/__main__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Entry point for `python -m signalk` — lists supported API modules."""
|
|
2
|
+
|
|
3
|
+
print("Supported SignalK APIs:")
|
|
4
|
+
print(" signalk_cli.history — SignalK v2 History API")
|
|
5
|
+
print()
|
|
6
|
+
print("Usage: python -m signalk_cli.<api> [COMMAND] [OPTIONS]")
|
|
7
|
+
print(" python -m signalk_cli.history --help")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""signalk_cli.history — Python client and CLI for the SignalK v2 History API."""
|
|
2
|
+
|
|
3
|
+
from .history_api import (
|
|
4
|
+
HISTORY_BASE,
|
|
5
|
+
api_error,
|
|
6
|
+
apply_time_default,
|
|
7
|
+
expand_paths,
|
|
8
|
+
fetch_default_provider,
|
|
9
|
+
fetch_server_paths,
|
|
10
|
+
get_cached_provider,
|
|
11
|
+
normalise_host,
|
|
12
|
+
resolve_provider,
|
|
13
|
+
save_cached_provider,
|
|
14
|
+
)
|
|
15
|
+
from .output import extract_rows, write_csv, write_feather
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"HISTORY_BASE",
|
|
19
|
+
"api_error",
|
|
20
|
+
"apply_time_default",
|
|
21
|
+
"expand_paths",
|
|
22
|
+
"extract_rows",
|
|
23
|
+
"fetch_default_provider",
|
|
24
|
+
"fetch_server_paths",
|
|
25
|
+
"get_cached_provider",
|
|
26
|
+
"normalise_host",
|
|
27
|
+
"resolve_provider",
|
|
28
|
+
"save_cached_provider",
|
|
29
|
+
"write_csv",
|
|
30
|
+
"write_feather",
|
|
31
|
+
]
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""Click CLI for the SignalK v2 History API."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import io
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import niquests
|
|
13
|
+
|
|
14
|
+
from .history_api import (
|
|
15
|
+
HISTORY_BASE,
|
|
16
|
+
api_error,
|
|
17
|
+
apply_time_default,
|
|
18
|
+
discover_host,
|
|
19
|
+
expand_paths,
|
|
20
|
+
fetch_server_paths,
|
|
21
|
+
get_cached_host,
|
|
22
|
+
normalise_host,
|
|
23
|
+
resolve_provider,
|
|
24
|
+
save_cached_host,
|
|
25
|
+
)
|
|
26
|
+
from .output import (
|
|
27
|
+
FEATHER_EXTENSIONS,
|
|
28
|
+
csv_sink,
|
|
29
|
+
write_csv,
|
|
30
|
+
write_csv_wide,
|
|
31
|
+
write_feather,
|
|
32
|
+
write_feather_wide,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
AGGREGATION_METHODS = [
|
|
36
|
+
"average",
|
|
37
|
+
"min",
|
|
38
|
+
"max",
|
|
39
|
+
"first",
|
|
40
|
+
"last",
|
|
41
|
+
"mid",
|
|
42
|
+
"middle_index",
|
|
43
|
+
"sma",
|
|
44
|
+
"ema",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Shared option decorators
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _host_option(f):
|
|
53
|
+
return click.option(
|
|
54
|
+
"--host",
|
|
55
|
+
default=None,
|
|
56
|
+
envvar="SIGNALK_HOST",
|
|
57
|
+
help="SignalK server base URL. http:// added if scheme omitted. "
|
|
58
|
+
"Discovered via mDNS if omitted.",
|
|
59
|
+
)(f)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_host(host: str | None, no_cache: bool = False) -> str:
|
|
63
|
+
"""Return a normalised host URL, discovering via mDNS if none provided."""
|
|
64
|
+
if host:
|
|
65
|
+
return normalise_host(host)
|
|
66
|
+
if not no_cache:
|
|
67
|
+
cached = get_cached_host()
|
|
68
|
+
if cached:
|
|
69
|
+
click.echo(f"Using cached host: {cached}", err=True)
|
|
70
|
+
return cached
|
|
71
|
+
click.echo("No host specified — searching for SignalK via mDNS...", err=True)
|
|
72
|
+
discovered = discover_host()
|
|
73
|
+
if not discovered:
|
|
74
|
+
raise click.UsageError(
|
|
75
|
+
"No SignalK server found via mDNS. Use --host or set SIGNALK_HOST."
|
|
76
|
+
)
|
|
77
|
+
click.echo(f"Discovered: {discovered}", err=True)
|
|
78
|
+
if not no_cache:
|
|
79
|
+
save_cached_host(discovered)
|
|
80
|
+
return discovered
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _provider_options(f):
|
|
84
|
+
f = click.option("--no-cache", is_flag=True, help="Ignore cached default provider")(
|
|
85
|
+
f
|
|
86
|
+
)
|
|
87
|
+
f = click.option(
|
|
88
|
+
"--provider",
|
|
89
|
+
help="History provider plugin id (default fetched and cached automatically)",
|
|
90
|
+
)(f)
|
|
91
|
+
return f
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _time_options(f):
|
|
95
|
+
f = click.option(
|
|
96
|
+
"--duration",
|
|
97
|
+
metavar="DURATION",
|
|
98
|
+
help="Duration: integer seconds or ISO 8601 (e.g. PT15M, 3600)",
|
|
99
|
+
)(f)
|
|
100
|
+
f = click.option("--to", metavar="DATETIME", help="End of range (ISO 8601)")(f)
|
|
101
|
+
f = click.option(
|
|
102
|
+
"--from", "from_", metavar="DATETIME", help="Start of range (ISO 8601)"
|
|
103
|
+
)(f)
|
|
104
|
+
return f
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _bare_option(f):
|
|
108
|
+
return click.option(
|
|
109
|
+
"--bare",
|
|
110
|
+
is_flag=True,
|
|
111
|
+
help="Suppress all informational messages, outputting data only.",
|
|
112
|
+
)(f)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _stderr_ctx(bare: bool) -> contextlib.AbstractContextManager:
|
|
116
|
+
return (
|
|
117
|
+
contextlib.redirect_stderr(io.StringIO()) if bare else contextlib.nullcontext()
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _build_time_params(from_: str | None, to: str | None, duration: str | None) -> dict:
|
|
122
|
+
p: dict = {}
|
|
123
|
+
if from_:
|
|
124
|
+
p["from"] = from_
|
|
125
|
+
if to:
|
|
126
|
+
p["to"] = to
|
|
127
|
+
if duration:
|
|
128
|
+
p["duration"] = duration
|
|
129
|
+
return p
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _build_path_specs(
|
|
133
|
+
paths: list[str],
|
|
134
|
+
aggregation: str | None,
|
|
135
|
+
samples: int | None,
|
|
136
|
+
alpha: float | None,
|
|
137
|
+
) -> tuple[str, bool]:
|
|
138
|
+
"""Build the comma-separated paths query param with aggregation suffixes.
|
|
139
|
+
|
|
140
|
+
Returns (query_string, wide_mode). wide_mode is True when no aggregation
|
|
141
|
+
is given and no path contains an inline ':method' suffix — in that case
|
|
142
|
+
min/max/average are requested and the output uses wide columns.
|
|
143
|
+
"""
|
|
144
|
+
has_inline = any(":" in p for p in paths)
|
|
145
|
+
|
|
146
|
+
if aggregation:
|
|
147
|
+
specs = []
|
|
148
|
+
for path in paths:
|
|
149
|
+
if ":" in path:
|
|
150
|
+
specs.append(path) # inline spec passes through unchanged
|
|
151
|
+
else:
|
|
152
|
+
spec = f"{path}:{aggregation}"
|
|
153
|
+
if aggregation == "sma" and samples is not None:
|
|
154
|
+
spec += f":{samples}"
|
|
155
|
+
elif aggregation == "ema" and alpha is not None:
|
|
156
|
+
spec += f":{alpha}"
|
|
157
|
+
specs.append(spec)
|
|
158
|
+
return ",".join(specs), False
|
|
159
|
+
|
|
160
|
+
if has_inline:
|
|
161
|
+
return ",".join(paths), False
|
|
162
|
+
|
|
163
|
+
# Default: wide mode — fetch min, max and average for each path
|
|
164
|
+
specs = [f"{p}:{m}" for p in paths for m in ("min", "average", "max")]
|
|
165
|
+
return ",".join(specs), True
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# CLI group
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
174
|
+
def cli():
|
|
175
|
+
"""SignalK v2 history CLI."""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# query
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@cli.command()
|
|
184
|
+
@click.argument("paths", nargs=-1, required=True, metavar="PATH...")
|
|
185
|
+
@_host_option
|
|
186
|
+
@_time_options
|
|
187
|
+
@click.option(
|
|
188
|
+
"--resolution",
|
|
189
|
+
metavar="RESOLUTION",
|
|
190
|
+
help="Sample window: integer seconds or time expression (1s, 1m, 1h, 1d)",
|
|
191
|
+
)
|
|
192
|
+
@click.option(
|
|
193
|
+
"--context", "-c", default="vessels.self", show_default=True, help="SignalK context"
|
|
194
|
+
)
|
|
195
|
+
@_provider_options
|
|
196
|
+
@click.option(
|
|
197
|
+
"--aggregation",
|
|
198
|
+
"--agg",
|
|
199
|
+
"aggregation",
|
|
200
|
+
type=click.Choice(AGGREGATION_METHODS, case_sensitive=False),
|
|
201
|
+
default=None,
|
|
202
|
+
help=(
|
|
203
|
+
"Aggregation method applied to all paths. "
|
|
204
|
+
"Omit for wide mode (min/max/average columns). "
|
|
205
|
+
"Paths may also carry an inline ':method[:param]' suffix."
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
@click.option(
|
|
209
|
+
"--samples",
|
|
210
|
+
type=int,
|
|
211
|
+
default=None,
|
|
212
|
+
metavar="N",
|
|
213
|
+
help="Sample count for --aggregation sma",
|
|
214
|
+
)
|
|
215
|
+
@click.option(
|
|
216
|
+
"--alpha",
|
|
217
|
+
type=float,
|
|
218
|
+
default=None,
|
|
219
|
+
metavar="FLOAT",
|
|
220
|
+
help="Alpha value (0-1) for --aggregation ema",
|
|
221
|
+
)
|
|
222
|
+
@click.option(
|
|
223
|
+
"--format",
|
|
224
|
+
"fmt",
|
|
225
|
+
default=None,
|
|
226
|
+
type=click.Choice(["csv", "feather"], case_sensitive=False),
|
|
227
|
+
help="Output format (default: feather if output extension is .feather/.arrow/.fea, else csv)",
|
|
228
|
+
)
|
|
229
|
+
@click.option("--no-header", is_flag=True, help="Suppress header row (CSV only)")
|
|
230
|
+
@click.option(
|
|
231
|
+
"--output",
|
|
232
|
+
"-o",
|
|
233
|
+
default=None,
|
|
234
|
+
metavar="FILE",
|
|
235
|
+
help="Output file (default: signalk-history-<server>-<timestamp>.<ext>, use - for stdout)",
|
|
236
|
+
)
|
|
237
|
+
@click.option(
|
|
238
|
+
"--stdout",
|
|
239
|
+
is_flag=True,
|
|
240
|
+
help="Also print to stdout; when no --output given, stdout only (CSV only)",
|
|
241
|
+
)
|
|
242
|
+
@_bare_option
|
|
243
|
+
def query(
|
|
244
|
+
paths,
|
|
245
|
+
host,
|
|
246
|
+
from_,
|
|
247
|
+
to,
|
|
248
|
+
duration,
|
|
249
|
+
resolution,
|
|
250
|
+
context,
|
|
251
|
+
provider,
|
|
252
|
+
no_cache,
|
|
253
|
+
aggregation,
|
|
254
|
+
samples,
|
|
255
|
+
alpha,
|
|
256
|
+
fmt,
|
|
257
|
+
no_header,
|
|
258
|
+
output,
|
|
259
|
+
stdout,
|
|
260
|
+
bare,
|
|
261
|
+
):
|
|
262
|
+
"""Query history and write results as CSV or Feather.
|
|
263
|
+
|
|
264
|
+
PATH arguments may be literal SignalK paths, Python regex/glob patterns,
|
|
265
|
+
or inline path specs with aggregation (e.g. navigation.speedOverGround:sma:5).
|
|
266
|
+
|
|
267
|
+
Without --aggregation and without inline specs, the default is wide mode:
|
|
268
|
+
min/max/average are fetched per path and written as separate columns.
|
|
269
|
+
|
|
270
|
+
\b
|
|
271
|
+
Examples:
|
|
272
|
+
signalk-history query --host 10.36.10.21 --duration PT1H navigation.speedOverGround
|
|
273
|
+
signalk-history query --host 10.36.10.21 --duration PT1H --agg sma --samples 5 '*'
|
|
274
|
+
signalk-history query --host 10.36.10.21 --duration PT1H navigation.speedOverGround:ema:0.2
|
|
275
|
+
signalk-history query --host 10.36.10.21 --from 2026-05-26T00:00:00Z --to 2026-05-27T00:00:00Z '*'
|
|
276
|
+
"""
|
|
277
|
+
# --bare: stdout-only with all informational messages suppressed
|
|
278
|
+
if bare and output is None:
|
|
279
|
+
output = "-"
|
|
280
|
+
|
|
281
|
+
with _stderr_ctx(bare):
|
|
282
|
+
host = _resolve_host(host, no_cache)
|
|
283
|
+
base_url = host.rstrip("/") + HISTORY_BASE
|
|
284
|
+
provider = resolve_provider(host, base_url, provider, no_cache)
|
|
285
|
+
time_params = apply_time_default(_build_time_params(from_, to, duration))
|
|
286
|
+
|
|
287
|
+
# Resolve output path early so we can infer format from extension
|
|
288
|
+
explicit_file = bool(output and output != "-")
|
|
289
|
+
if output is None:
|
|
290
|
+
server_name = urlparse(host).hostname or re.sub(r"[^\w.-]", "_", host)
|
|
291
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
292
|
+
# Extension determined after format is known; placeholder for now
|
|
293
|
+
output = f"signalk-history-{server_name}-{ts}"
|
|
294
|
+
explicit_file = True
|
|
295
|
+
defer_ext = True
|
|
296
|
+
else:
|
|
297
|
+
defer_ext = False
|
|
298
|
+
|
|
299
|
+
# Auto-detect format from extension when not explicitly set
|
|
300
|
+
if fmt is None:
|
|
301
|
+
if Path(output).suffix.lower() in FEATHER_EXTENSIONS:
|
|
302
|
+
fmt = "feather"
|
|
303
|
+
else:
|
|
304
|
+
fmt = "csv"
|
|
305
|
+
|
|
306
|
+
# Now append extension to auto-named file
|
|
307
|
+
if defer_ext:
|
|
308
|
+
output += ".feather" if fmt == "feather" else ".csv"
|
|
309
|
+
|
|
310
|
+
if fmt == "feather" and (stdout or bare):
|
|
311
|
+
raise click.UsageError(
|
|
312
|
+
"--stdout is not supported for feather output (binary format)"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
click.echo(f"Server: {host}", err=True)
|
|
316
|
+
click.echo(f"Provider: {provider or '(none)'}", err=True)
|
|
317
|
+
click.echo(f"Context: {context}", err=True)
|
|
318
|
+
click.echo(
|
|
319
|
+
f"From: {time_params.get('from', '(server default)')}", err=True
|
|
320
|
+
)
|
|
321
|
+
click.echo(
|
|
322
|
+
f"To: {time_params.get('to', '(server default)')}", err=True
|
|
323
|
+
)
|
|
324
|
+
click.echo(
|
|
325
|
+
f"Duration: {time_params.get('duration', '(not specified)')}", err=True
|
|
326
|
+
)
|
|
327
|
+
click.echo(f"Resolution: {resolution or '(server default)'}", err=True)
|
|
328
|
+
click.echo(f"Format: {fmt}", err=True)
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
resolved = expand_paths(list(paths), base_url, time_params, provider)
|
|
332
|
+
except niquests.RequestException as e:
|
|
333
|
+
click.echo(f"Error resolving paths: {api_error(e)}", err=True)
|
|
334
|
+
sys.exit(1)
|
|
335
|
+
|
|
336
|
+
if not resolved:
|
|
337
|
+
click.echo("No paths matched — nothing to query.", err=True)
|
|
338
|
+
sys.exit(1)
|
|
339
|
+
|
|
340
|
+
path_query, wide_mode = _build_path_specs(resolved, aggregation, samples, alpha)
|
|
341
|
+
|
|
342
|
+
agg_label = aggregation or ("wide (min/max/average)" if wide_mode else "inline")
|
|
343
|
+
click.echo(f"Aggregation: {agg_label}", err=True)
|
|
344
|
+
|
|
345
|
+
params: dict = {**time_params, "paths": path_query, "context": context}
|
|
346
|
+
if resolution:
|
|
347
|
+
params["resolution"] = resolution
|
|
348
|
+
if provider:
|
|
349
|
+
params["provider"] = provider
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
resp = niquests.get(f"{base_url}/values", params=params, timeout=60)
|
|
353
|
+
resp.raise_for_status()
|
|
354
|
+
except niquests.RequestException as e:
|
|
355
|
+
click.echo(f"Error fetching history: {api_error(e)}", err=True)
|
|
356
|
+
sys.exit(1)
|
|
357
|
+
|
|
358
|
+
result = resp.json()
|
|
359
|
+
|
|
360
|
+
write_to_stdout = stdout or bare or (output == "-")
|
|
361
|
+
write_to_file = explicit_file
|
|
362
|
+
|
|
363
|
+
if fmt == "feather":
|
|
364
|
+
if wide_mode:
|
|
365
|
+
row_count, unique_paths = write_feather_wide(result, output)
|
|
366
|
+
else:
|
|
367
|
+
row_count, unique_paths = write_feather(result, output)
|
|
368
|
+
else:
|
|
369
|
+
file_fh, sink = csv_sink(output, write_to_file, write_to_stdout)
|
|
370
|
+
try:
|
|
371
|
+
if wide_mode:
|
|
372
|
+
row_count, unique_paths = write_csv_wide(result, sink, no_header)
|
|
373
|
+
else:
|
|
374
|
+
row_count, unique_paths = write_csv(result, sink, no_header)
|
|
375
|
+
finally:
|
|
376
|
+
if file_fh:
|
|
377
|
+
file_fh.close()
|
|
378
|
+
|
|
379
|
+
if write_to_file:
|
|
380
|
+
click.echo(f"Wrote {output}", err=True)
|
|
381
|
+
click.echo(
|
|
382
|
+
f"{row_count} rows, {len(unique_paths)} unique path(s): {', '.join(sorted(unique_paths))}",
|
|
383
|
+
err=True,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
# list-paths
|
|
389
|
+
# ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@cli.command("list-paths")
|
|
393
|
+
@_host_option
|
|
394
|
+
@_time_options
|
|
395
|
+
@_provider_options
|
|
396
|
+
@click.option(
|
|
397
|
+
"--context", "-c", default="vessels.self", show_default=True, help="SignalK context"
|
|
398
|
+
)
|
|
399
|
+
@_bare_option
|
|
400
|
+
def list_paths(host, from_, to, duration, provider, no_cache, context, bare):
|
|
401
|
+
"""List paths that have data for the given time range."""
|
|
402
|
+
with _stderr_ctx(bare):
|
|
403
|
+
host = _resolve_host(host, no_cache)
|
|
404
|
+
base_url = host.rstrip("/") + HISTORY_BASE
|
|
405
|
+
provider = resolve_provider(host, base_url, provider, no_cache)
|
|
406
|
+
time_params = apply_time_default(_build_time_params(from_, to, duration))
|
|
407
|
+
|
|
408
|
+
click.echo(f"Server: {host}", err=True)
|
|
409
|
+
click.echo(f"Provider: {provider or '(none)'}", err=True)
|
|
410
|
+
click.echo(f"From: {time_params.get('from', '(server default)')}", err=True)
|
|
411
|
+
click.echo(f"To: {time_params.get('to', '(server default)')}", err=True)
|
|
412
|
+
click.echo(
|
|
413
|
+
f"Duration: {time_params.get('duration', '(not specified)')}", err=True
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
paths = fetch_server_paths(base_url, time_params, provider)
|
|
418
|
+
except niquests.RequestException as e:
|
|
419
|
+
click.echo(f"Error fetching paths: {api_error(e)}", err=True)
|
|
420
|
+
sys.exit(1)
|
|
421
|
+
|
|
422
|
+
for path in sorted(paths):
|
|
423
|
+
click.echo(path)
|
|
424
|
+
|
|
425
|
+
click.echo(f"{len(paths)} path(s)", err=True)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ---------------------------------------------------------------------------
|
|
429
|
+
# list-providers
|
|
430
|
+
# ---------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@cli.command("list-providers")
|
|
434
|
+
@_host_option
|
|
435
|
+
@_bare_option
|
|
436
|
+
def list_providers(host, bare):
|
|
437
|
+
"""List registered history providers."""
|
|
438
|
+
with _stderr_ctx(bare):
|
|
439
|
+
host = _resolve_host(host)
|
|
440
|
+
base_url = host.rstrip("/") + HISTORY_BASE
|
|
441
|
+
click.echo(f"Server: {host}", err=True)
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
resp = niquests.get(f"{base_url}/_providers", timeout=10)
|
|
445
|
+
resp.raise_for_status()
|
|
446
|
+
except niquests.RequestException as e:
|
|
447
|
+
click.echo(f"Error fetching providers: {api_error(e)}", err=True)
|
|
448
|
+
sys.exit(1)
|
|
449
|
+
|
|
450
|
+
providers: dict = resp.json()
|
|
451
|
+
for pid, info in sorted(providers.items()):
|
|
452
|
+
marker = " (default)" if info.get("isDefault") else ""
|
|
453
|
+
click.echo(f"{pid}{marker}")
|
|
454
|
+
|
|
455
|
+
click.echo(f"{len(providers)} provider(s)", err=True)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
# list-contexts
|
|
460
|
+
# ---------------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@cli.command("list-contexts")
|
|
464
|
+
@_host_option
|
|
465
|
+
@_time_options
|
|
466
|
+
@_provider_options
|
|
467
|
+
@_bare_option
|
|
468
|
+
def list_contexts(host, from_, to, duration, provider, no_cache, bare):
|
|
469
|
+
"""List contexts that have historical data for the given time range."""
|
|
470
|
+
with _stderr_ctx(bare):
|
|
471
|
+
host = _resolve_host(host, no_cache)
|
|
472
|
+
base_url = host.rstrip("/") + HISTORY_BASE
|
|
473
|
+
provider = resolve_provider(host, base_url, provider, no_cache)
|
|
474
|
+
time_params = apply_time_default(_build_time_params(from_, to, duration))
|
|
475
|
+
|
|
476
|
+
click.echo(f"Server: {host}", err=True)
|
|
477
|
+
click.echo(f"Provider: {provider or '(none)'}", err=True)
|
|
478
|
+
click.echo(f"From: {time_params.get('from', '(server default)')}", err=True)
|
|
479
|
+
click.echo(f"To: {time_params.get('to', '(server default)')}", err=True)
|
|
480
|
+
click.echo(
|
|
481
|
+
f"Duration: {time_params.get('duration', '(not specified)')}", err=True
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
params = {**time_params}
|
|
485
|
+
if provider:
|
|
486
|
+
params["provider"] = provider
|
|
487
|
+
|
|
488
|
+
try:
|
|
489
|
+
resp = niquests.get(f"{base_url}/contexts", params=params, timeout=30)
|
|
490
|
+
resp.raise_for_status()
|
|
491
|
+
except niquests.RequestException as e:
|
|
492
|
+
click.echo(f"Error fetching contexts: {api_error(e)}", err=True)
|
|
493
|
+
sys.exit(1)
|
|
494
|
+
|
|
495
|
+
contexts: list = resp.json()
|
|
496
|
+
for ctx in sorted(contexts):
|
|
497
|
+
click.echo(ctx)
|
|
498
|
+
|
|
499
|
+
click.echo(f"{len(contexts)} context(s)", err=True)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""SignalK v2 History API client."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import niquests
|
|
11
|
+
from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf
|
|
12
|
+
|
|
13
|
+
HISTORY_BASE = "/signalk/v2/api/history"
|
|
14
|
+
CACHE_DIR = Path.home() / ".cache" / "signalk-cli"
|
|
15
|
+
_SIGNALK_TYPE = "_signalk-ws._tcp.local."
|
|
16
|
+
_HOST_CACHE_FILE = CACHE_DIR / "host.cache"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_cached_host() -> str | None:
|
|
20
|
+
try:
|
|
21
|
+
if _HOST_CACHE_FILE.exists():
|
|
22
|
+
return _HOST_CACHE_FILE.read_text().strip() or None
|
|
23
|
+
except OSError:
|
|
24
|
+
pass
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def save_cached_host(host: str) -> None:
|
|
29
|
+
try:
|
|
30
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
_HOST_CACHE_FILE.write_text(host)
|
|
32
|
+
except OSError:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def discover_host(timeout: float = 5.0) -> str | None:
|
|
37
|
+
"""Browse mDNS for a SignalK server and return its base URL, or None."""
|
|
38
|
+
found: list[str] = []
|
|
39
|
+
|
|
40
|
+
def _on_change(
|
|
41
|
+
zeroconf: Zeroconf,
|
|
42
|
+
service_type: str,
|
|
43
|
+
name: str,
|
|
44
|
+
state_change: ServiceStateChange,
|
|
45
|
+
) -> None:
|
|
46
|
+
if state_change is not ServiceStateChange.Added:
|
|
47
|
+
return
|
|
48
|
+
info = zeroconf.get_service_info(service_type, name)
|
|
49
|
+
if info is None:
|
|
50
|
+
return
|
|
51
|
+
addrs = info.parsed_addresses()
|
|
52
|
+
if not addrs:
|
|
53
|
+
return
|
|
54
|
+
host = f"http://{addrs[0]}:{info.port}"
|
|
55
|
+
found.append(host)
|
|
56
|
+
|
|
57
|
+
zc = Zeroconf()
|
|
58
|
+
try:
|
|
59
|
+
ServiceBrowser(zc, _SIGNALK_TYPE, handlers=[_on_change])
|
|
60
|
+
import time
|
|
61
|
+
|
|
62
|
+
deadline = time.monotonic() + timeout
|
|
63
|
+
while not found and time.monotonic() < deadline:
|
|
64
|
+
time.sleep(0.1)
|
|
65
|
+
finally:
|
|
66
|
+
zc.close()
|
|
67
|
+
|
|
68
|
+
return found[0] if found else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def normalise_host(host: str) -> str:
|
|
72
|
+
"""Prepend http:// if the host has no scheme."""
|
|
73
|
+
if "://" not in host:
|
|
74
|
+
return f"http://{host}"
|
|
75
|
+
return host
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def apply_time_default(time_params: dict) -> dict:
|
|
79
|
+
"""If neither 'from' nor 'duration' is set, default to the hour ending at 'to' (or now)."""
|
|
80
|
+
if "from" in time_params or "duration" in time_params:
|
|
81
|
+
return time_params
|
|
82
|
+
if "to" in time_params:
|
|
83
|
+
try:
|
|
84
|
+
to_dt = datetime.fromisoformat(time_params["to"].replace("Z", "+00:00"))
|
|
85
|
+
except ValueError:
|
|
86
|
+
to_dt = datetime.now(timezone.utc)
|
|
87
|
+
else:
|
|
88
|
+
to_dt = datetime.now(timezone.utc)
|
|
89
|
+
from_dt = to_dt - timedelta(hours=1)
|
|
90
|
+
result = {
|
|
91
|
+
**time_params,
|
|
92
|
+
"from": from_dt.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
93
|
+
"to": to_dt.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
94
|
+
}
|
|
95
|
+
click.echo(
|
|
96
|
+
f"No time range specified — defaulting to from={result['from']} to={result['to']}",
|
|
97
|
+
err=True,
|
|
98
|
+
)
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def api_error(exc: niquests.RequestException) -> str:
|
|
103
|
+
"""Return the most informative message from an API error response."""
|
|
104
|
+
resp = getattr(exc, "response", None)
|
|
105
|
+
if resp is not None:
|
|
106
|
+
try:
|
|
107
|
+
body = resp.json()
|
|
108
|
+
return body.get("error") or body.get("message") or str(exc)
|
|
109
|
+
except Exception as e:
|
|
110
|
+
logging.debug("Error handling API error: %s", e)
|
|
111
|
+
return str(exc)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Provider cache (on-disk, per host)
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _cache_key(host: str) -> Path:
|
|
120
|
+
safe = re.sub(r"[^\w.-]", "_", host)
|
|
121
|
+
return CACHE_DIR / f"{safe}.provider"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_cached_provider(host: str) -> str | None:
|
|
125
|
+
try:
|
|
126
|
+
f = _cache_key(host)
|
|
127
|
+
if f.exists():
|
|
128
|
+
return f.read_text().strip() or None
|
|
129
|
+
except OSError:
|
|
130
|
+
pass
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def save_cached_provider(host: str, provider_id: str) -> None:
|
|
135
|
+
try:
|
|
136
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
_cache_key(host).write_text(provider_id)
|
|
138
|
+
except OSError:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def fetch_default_provider(base_url: str) -> str:
|
|
143
|
+
resp = niquests.get(f"{base_url}/_providers/_default", timeout=10)
|
|
144
|
+
resp.raise_for_status()
|
|
145
|
+
return resp.json()["id"]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def resolve_provider(
|
|
149
|
+
host: str, base_url: str, provider: str | None, no_cache: bool
|
|
150
|
+
) -> str | None:
|
|
151
|
+
"""Return the effective provider id, fetching and caching the default if needed."""
|
|
152
|
+
if provider:
|
|
153
|
+
return provider
|
|
154
|
+
if not no_cache:
|
|
155
|
+
provider = get_cached_provider(host)
|
|
156
|
+
if not provider:
|
|
157
|
+
try:
|
|
158
|
+
provider = fetch_default_provider(base_url)
|
|
159
|
+
if not no_cache:
|
|
160
|
+
save_cached_provider(host, provider)
|
|
161
|
+
except niquests.HTTPError as e:
|
|
162
|
+
click.echo(
|
|
163
|
+
f"Warning: could not fetch default provider: {api_error(e)}", err=True
|
|
164
|
+
)
|
|
165
|
+
return provider
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Path resolution
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def fetch_server_paths(
|
|
174
|
+
base_url: str, time_params: dict, provider: str | None
|
|
175
|
+
) -> list[str]:
|
|
176
|
+
"""Fetch all paths that have data for the given time range."""
|
|
177
|
+
params = {k: v for k, v in time_params.items() if v is not None}
|
|
178
|
+
if provider:
|
|
179
|
+
params["provider"] = provider
|
|
180
|
+
resp = niquests.get(f"{base_url}/paths", params=params, timeout=30)
|
|
181
|
+
resp.raise_for_status()
|
|
182
|
+
return resp.json()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def expand_paths(
|
|
186
|
+
patterns: list[str],
|
|
187
|
+
base_url: str,
|
|
188
|
+
time_params: dict,
|
|
189
|
+
provider: str | None,
|
|
190
|
+
) -> list[str]:
|
|
191
|
+
"""Expand path patterns to concrete paths.
|
|
192
|
+
|
|
193
|
+
Literal paths pass through unchanged. Patterns containing regex
|
|
194
|
+
metacharacters are matched against the server's /paths endpoint;
|
|
195
|
+
invalid regex is retried as a glob pattern.
|
|
196
|
+
"""
|
|
197
|
+
regex_chars = set(r".*+?[](){}|^$\\")
|
|
198
|
+
literals: list[str] = []
|
|
199
|
+
regexps: list[str] = []
|
|
200
|
+
|
|
201
|
+
for p in patterns:
|
|
202
|
+
if ":" in p:
|
|
203
|
+
literals.append(p) # inline spec — pass through unchanged
|
|
204
|
+
elif any(c in p for c in regex_chars):
|
|
205
|
+
regexps.append(p)
|
|
206
|
+
else:
|
|
207
|
+
literals.append(p)
|
|
208
|
+
|
|
209
|
+
if not regexps:
|
|
210
|
+
return literals
|
|
211
|
+
|
|
212
|
+
click.echo("Resolving patterns against server paths...", err=True)
|
|
213
|
+
available = fetch_server_paths(base_url, apply_time_default(time_params), provider)
|
|
214
|
+
|
|
215
|
+
def _compile(pattern: str) -> re.Pattern:
|
|
216
|
+
try:
|
|
217
|
+
return re.compile(pattern)
|
|
218
|
+
except re.PatternError:
|
|
219
|
+
click.echo(
|
|
220
|
+
f"Note: '{pattern}' is not valid regex, treating as glob", err=True
|
|
221
|
+
)
|
|
222
|
+
return re.compile(fnmatch.translate(pattern))
|
|
223
|
+
|
|
224
|
+
matched: set[str] = set()
|
|
225
|
+
for pattern, rx in [(p, _compile(p)) for p in regexps]:
|
|
226
|
+
hits = {path for path in available if rx.search(path)}
|
|
227
|
+
if not hits:
|
|
228
|
+
click.echo(f"Warning: '{pattern}' matched no paths", err=True)
|
|
229
|
+
matched |= hits
|
|
230
|
+
|
|
231
|
+
return literals + sorted(matched)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""CSV and Feather output writers for SignalK data."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from typing import IO, cast
|
|
7
|
+
|
|
8
|
+
import pyarrow as pa
|
|
9
|
+
import pyarrow.feather as feather
|
|
10
|
+
|
|
11
|
+
FEATHER_EXTENSIONS = {".feather", ".arrow", ".fea"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _MultiWriter:
|
|
15
|
+
"""Fans writes out to multiple underlying streams."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, *fhs):
|
|
18
|
+
self._fhs = fhs
|
|
19
|
+
|
|
20
|
+
def write(self, s):
|
|
21
|
+
for fh in self._fhs:
|
|
22
|
+
fh.write(s)
|
|
23
|
+
|
|
24
|
+
def flush(self):
|
|
25
|
+
for fh in self._fhs:
|
|
26
|
+
fh.flush()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Narrow mode (single value column)
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def extract_rows(result: dict) -> tuple[list, list, list, set[str]]:
|
|
35
|
+
"""Flatten an API result into parallel (timestamps, paths, values, unique_paths) lists."""
|
|
36
|
+
value_columns = result.get("values", [])
|
|
37
|
+
data_rows = result.get("data", [])
|
|
38
|
+
timestamps, paths, values = [], [], []
|
|
39
|
+
unique_paths: set[str] = set()
|
|
40
|
+
|
|
41
|
+
for row in data_rows:
|
|
42
|
+
if not row:
|
|
43
|
+
continue
|
|
44
|
+
timestamp = row[0]
|
|
45
|
+
for i, col in enumerate(value_columns):
|
|
46
|
+
path_name = col.get("path", f"col_{i}")
|
|
47
|
+
value = row[i + 1] if i + 1 < len(row) else None
|
|
48
|
+
if value is None:
|
|
49
|
+
continue
|
|
50
|
+
if isinstance(value, (dict, list)):
|
|
51
|
+
value = json.dumps(value)
|
|
52
|
+
elif not isinstance(value, str):
|
|
53
|
+
value = str(value)
|
|
54
|
+
timestamps.append(timestamp)
|
|
55
|
+
paths.append(path_name)
|
|
56
|
+
values.append(value)
|
|
57
|
+
unique_paths.add(path_name)
|
|
58
|
+
|
|
59
|
+
return timestamps, paths, values, unique_paths
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def write_csv(result: dict, sink, no_header: bool) -> tuple[int, set[str]]:
|
|
63
|
+
"""Write result as CSV rows (timestamp, path, value). Returns (row_count, unique_paths)."""
|
|
64
|
+
timestamps, paths, values, unique_paths = extract_rows(result)
|
|
65
|
+
writer = csv.writer(sink)
|
|
66
|
+
if not no_header:
|
|
67
|
+
writer.writerow(["timestamp", "path", "value"])
|
|
68
|
+
for ts, path, val in zip(timestamps, paths, values):
|
|
69
|
+
writer.writerow([ts, path, val])
|
|
70
|
+
return len(timestamps), unique_paths
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def write_feather(result: dict, output: str) -> tuple[int, set[str]]:
|
|
74
|
+
"""Write result as Feather (timestamp, path, value). Returns (row_count, unique_paths)."""
|
|
75
|
+
timestamps, paths, values, unique_paths = extract_rows(result)
|
|
76
|
+
table = pa.table(
|
|
77
|
+
{
|
|
78
|
+
"timestamp": pa.array(timestamps, type=pa.string()),
|
|
79
|
+
"path": pa.array(paths, type=pa.string()),
|
|
80
|
+
"value": pa.array(values, type=pa.string()),
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
feather.write_feather(table, output)
|
|
84
|
+
return len(timestamps), unique_paths
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Wide mode (min_value / max_value / avg_value columns)
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _cell(row: list, col_idx: int) -> str:
|
|
93
|
+
"""Get a string cell value from a data row by 0-based column index (row[0] is timestamp)."""
|
|
94
|
+
i = col_idx + 1
|
|
95
|
+
if i >= len(row):
|
|
96
|
+
return ""
|
|
97
|
+
v = row[i]
|
|
98
|
+
if v is None:
|
|
99
|
+
return ""
|
|
100
|
+
if isinstance(v, (dict, list)):
|
|
101
|
+
return json.dumps(v)
|
|
102
|
+
return str(v)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def extract_rows_wide(result: dict) -> tuple[list, list, list, list, list, set[str]]:
|
|
106
|
+
"""Extract rows as (timestamps, paths, min_vals, max_vals, avg_vals, unique_paths)."""
|
|
107
|
+
value_columns = result.get("values", [])
|
|
108
|
+
data_rows = result.get("data", [])
|
|
109
|
+
|
|
110
|
+
# Build {path: {method: col_idx}} preserving path order
|
|
111
|
+
path_method_idx: dict[str, dict[str, int]] = {}
|
|
112
|
+
for i, col in enumerate(value_columns):
|
|
113
|
+
path = col.get("path", f"col_{i}")
|
|
114
|
+
method = col.get("method", "")
|
|
115
|
+
path_method_idx.setdefault(path, {})[method] = i
|
|
116
|
+
|
|
117
|
+
ordered_paths = list(path_method_idx)
|
|
118
|
+
timestamps, paths_out, min_vals, max_vals, avg_vals = [], [], [], [], []
|
|
119
|
+
unique_paths: set[str] = set()
|
|
120
|
+
|
|
121
|
+
for row in data_rows:
|
|
122
|
+
if not row:
|
|
123
|
+
continue
|
|
124
|
+
ts = row[0]
|
|
125
|
+
for path in ordered_paths:
|
|
126
|
+
methods = path_method_idx[path]
|
|
127
|
+
mn = _cell(row, methods["min"]) if "min" in methods else ""
|
|
128
|
+
av = _cell(row, methods["average"]) if "average" in methods else ""
|
|
129
|
+
mx = _cell(row, methods["max"]) if "max" in methods else ""
|
|
130
|
+
if not mn and not av and not mx:
|
|
131
|
+
continue
|
|
132
|
+
timestamps.append(ts)
|
|
133
|
+
paths_out.append(path)
|
|
134
|
+
min_vals.append(mn)
|
|
135
|
+
avg_vals.append(av)
|
|
136
|
+
max_vals.append(mx)
|
|
137
|
+
unique_paths.add(path)
|
|
138
|
+
|
|
139
|
+
return timestamps, paths_out, min_vals, max_vals, avg_vals, unique_paths
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def write_csv_wide(result: dict, sink, no_header: bool) -> tuple[int, set[str]]:
|
|
143
|
+
"""Write result as CSV (timestamp, path, min_value, max_value, avg_value)."""
|
|
144
|
+
timestamps, paths, min_vals, max_vals, avg_vals, unique_paths = extract_rows_wide(
|
|
145
|
+
result
|
|
146
|
+
)
|
|
147
|
+
writer = csv.writer(sink)
|
|
148
|
+
if not no_header:
|
|
149
|
+
writer.writerow(["timestamp", "path", "min_value", "avg_value", "max_value"])
|
|
150
|
+
for row in zip(timestamps, paths, min_vals, avg_vals, max_vals):
|
|
151
|
+
writer.writerow(row)
|
|
152
|
+
return len(timestamps), unique_paths
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def write_feather_wide(result: dict, output: str) -> tuple[int, set[str]]:
|
|
156
|
+
"""Write result as Feather (timestamp, path, min_value, max_value, avg_value)."""
|
|
157
|
+
timestamps, paths, min_vals, max_vals, avg_vals, unique_paths = extract_rows_wide(
|
|
158
|
+
result
|
|
159
|
+
)
|
|
160
|
+
table = pa.table(
|
|
161
|
+
{
|
|
162
|
+
"timestamp": pa.array(timestamps, type=pa.string()),
|
|
163
|
+
"path": pa.array(paths, type=pa.string()),
|
|
164
|
+
"min_value": pa.array(min_vals, type=pa.string()),
|
|
165
|
+
"avg_value": pa.array(avg_vals, type=pa.string()),
|
|
166
|
+
"max_value": pa.array(max_vals, type=pa.string()),
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
feather.write_feather(table, output)
|
|
170
|
+
return len(timestamps), unique_paths
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Sink helper
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def csv_sink(
|
|
179
|
+
output: str, write_to_file: bool, write_to_stdout: bool
|
|
180
|
+
) -> tuple[IO[str] | None, _MultiWriter | IO[str]]:
|
|
181
|
+
"""Return (file_handle_or_None, sink) for CSV writing. Caller must close file_handle."""
|
|
182
|
+
file_fh: IO[str] | None = open(output, "w", newline="") if write_to_file else None
|
|
183
|
+
sink: _MultiWriter | IO[str]
|
|
184
|
+
if write_to_file and write_to_stdout:
|
|
185
|
+
sink = _MultiWriter(cast(IO[str], file_fh), sys.stdout)
|
|
186
|
+
elif write_to_file:
|
|
187
|
+
sink = cast(IO[str], file_fh)
|
|
188
|
+
else:
|
|
189
|
+
sink = sys.stdout
|
|
190
|
+
return file_fh, sink
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: signalk-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Query the SignalK v2 History API and export data as CSV or Apache Arrow Feather
|
|
5
|
+
Keywords: signalk,sailing,marine,nmea,boating
|
|
6
|
+
Author: jey burrows
|
|
7
|
+
Author-email: jey burrows <jrb@rhizomatics.org.uk>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Natural Language :: English
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Requires-Dist: click>=8.3.3
|
|
18
|
+
Requires-Dist: niquests>=2.28
|
|
19
|
+
Requires-Dist: pyarrow>=17.0
|
|
20
|
+
Requires-Dist: zeroconf>=0.149.16,<0.150.0
|
|
21
|
+
Requires-Python: >=3.13
|
|
22
|
+
Project-URL: Homepage, https://github.com/rhizomatics/signalk-cli
|
|
23
|
+
Project-URL: Repository, https://github.com/rhizomatics/signalk-cli
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# SignalK CLI
|
|
27
|
+
|
|
28
|
+
Query and explore SignalK APIs from the command line, and export data as CSV or Apache Arrow Feather.
|
|
29
|
+
|
|
30
|
+
Presently the [SignalK v2 History API](https://signalk.org/https://demo.signalk.org/documentation/Developing/REST_APIs/History_API.html) is supported.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
### PyPi
|
|
35
|
+
|
|
36
|
+
```pip install signalk-cli``` or ```uv pip install signalk-cli```
|
|
37
|
+
|
|
38
|
+
### Local Copy
|
|
39
|
+
|
|
40
|
+
Requires Python 3.13+ and [uv](https://docs.astral.sh/uv/).
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git clone https://github.com/rhizomatics/signalk-cli
|
|
44
|
+
cd signalk-cli
|
|
45
|
+
uv sync
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Running
|
|
49
|
+
|
|
50
|
+
Run via `python -m signalk_cli.history <command>`.
|
|
51
|
+
|
|
52
|
+
## Determining SignalK host name
|
|
53
|
+
|
|
54
|
+
If not host name set as an argument, the CLI will look for a `SIGNALK_HOST` environment variable, and failing that attempt to automatically discover
|
|
55
|
+
the host using mDNS (aka Bonjour).
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
export SIGNALK_HOST=192.168.6.99 # http:// is added automatically if omitted
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Help
|
|
62
|
+
|
|
63
|
+
Run with no arguments to list available commands:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
$ python -m signalk_cli.history
|
|
67
|
+
Usage: signak_cli.history [OPTIONS] COMMAND [ARGS]...
|
|
68
|
+
|
|
69
|
+
SignalK v2 history CLI.
|
|
70
|
+
|
|
71
|
+
Commands:
|
|
72
|
+
list-contexts List contexts that have historical data for the given time range.
|
|
73
|
+
list-paths List paths that have data for the given time range.
|
|
74
|
+
list-providers List registered history providers.
|
|
75
|
+
query Query history and write results as CSV or Feather.
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Commands
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
### `query`
|
|
82
|
+
|
|
83
|
+
Fetch historical values for one or more paths and write to a file (default) or stdout. Aggregation can be controlled in the same way as the History API itself
|
|
84
|
+
or a default form where `min_value`,`avg_value` and `max_value` are returned for every period.
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
python -m signalk_cli.history query [OPTIONS] PATH...
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**PATH** arguments may be:
|
|
91
|
+
- **Literal paths** — e.g. `navigation.speedOverGround`
|
|
92
|
+
- **Regex / glob patterns** — any argument containing metacharacters (`*`, `.`, `[`, `(`, etc.) is matched against the server's `/paths` endpoint. Bare `*` is treated as a glob wildcard.
|
|
93
|
+
- **Inline path specs** — `path:method` or `path:method:param`, e.g. `navigation.speedOverGround:sma:5`. These pass through to the server unchanged.
|
|
94
|
+
|
|
95
|
+
#### Options
|
|
96
|
+
|
|
97
|
+
| Option | Default | Description |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| `--host` | `$SIGNALK_HOST` | Server base URL. `http://` added if scheme omitted. |
|
|
100
|
+
| `--from DATETIME` | — | Start of range (ISO 8601, e.g. `2026-05-26T00:00:00Z`) |
|
|
101
|
+
| `--to DATETIME` | now | End of range (ISO 8601) |
|
|
102
|
+
| `--duration DURATION` | — | Duration as seconds (`3600`) or ISO 8601 (`PT1H`, `PT15M`). Combined with `--from` or `--to`, or alone for a window ending now. |
|
|
103
|
+
| `--resolution RESOLUTION` | server default | Sample window size: seconds or time expression (`1s`, `1m`, `1h`, `1d`). |
|
|
104
|
+
| `-c, --context TEXT` | `vessels.self` | SignalK context |
|
|
105
|
+
| `--provider TEXT` | fetched & cached | History provider plugin id |
|
|
106
|
+
| `--no-cache` | — | Ignore the cached default provider |
|
|
107
|
+
| `--aggregation / --agg` | — | Aggregation method: `average`, `min`, `max`, `first`, `last`, `mid`, `middle_index`, `sma`, `ema`. Omit for wide mode (see below). |
|
|
108
|
+
| `--samples N` | server default | Sample count for `--agg sma` |
|
|
109
|
+
| `--alpha FLOAT` | server default | Alpha (0–1) for `--agg ema` |
|
|
110
|
+
| `--format [csv\|feather]` | from extension, else csv | Output format. Auto-detected from `.feather`, `.arrow`, `.fea` extensions. |
|
|
111
|
+
| `--no-header` | — | Suppress the CSV header row |
|
|
112
|
+
| `-o / --output FILE` | auto-named | Output file. Use `-` to write CSV to stdout. |
|
|
113
|
+
| `--stdout` | — | Print CSV to stdout. If `--output` is also given, writes to both. Not supported with feather. |
|
|
114
|
+
| `--bare` | — | Print CSV to stdout with **no informational messages** (server, provider, progress, row count). Ideal for piping to other tools. Implies `--stdout`. Not supported with feather. |
|
|
115
|
+
|
|
116
|
+
Output files are auto-named `signalk-history-<server>-<timestamp>.<ext>` in the current directory.
|
|
117
|
+
|
|
118
|
+
If no time range is given, the tool defaults to the hour ending now.
|
|
119
|
+
|
|
120
|
+
#### Output columns
|
|
121
|
+
|
|
122
|
+
**Wide mode** (default — no `--aggregation` and no inline specs): fetches `min`, `average`, and `max` for each path and writes them as separate columns:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
timestamp, path, min_value, avg_value, max_value
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Narrow mode** (explicit `--aggregation` or inline path specs): single value column:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
timestamp, path, value
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Structured values (positions, arrays) are JSON-encoded in the value column.
|
|
135
|
+
|
|
136
|
+
Feather output produces the same columns as Apache Arrow Feather, readable with pandas, Polars, R, pyarrow, etc.
|
|
137
|
+
|
|
138
|
+
#### Examples
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
# Last hour of speed — wide mode (min/max/avg columns), auto-named CSV
|
|
142
|
+
python -m signalk_cli.history query --host 10.36.10.21 --duration PT1H \
|
|
143
|
+
navigation.speedOverGround
|
|
144
|
+
|
|
145
|
+
# Wide mode printed to stdout
|
|
146
|
+
python -m signalk_cli.history query --host 10.36.10.21 --duration PT1H --stdout \
|
|
147
|
+
navigation.speedOverGround
|
|
148
|
+
|
|
149
|
+
# Simple moving average (5 samples), narrowed to one value column
|
|
150
|
+
python -m signalk_cli.history query --host 10.36.10.21 --duration PT1H \
|
|
151
|
+
--agg sma --samples 5 \
|
|
152
|
+
navigation.speedOverGround
|
|
153
|
+
|
|
154
|
+
# EMA with alpha 0.2
|
|
155
|
+
python -m signalk_cli.history query --host 10.36.10.21 --duration PT1H \
|
|
156
|
+
--agg ema --alpha 0.2 \
|
|
157
|
+
navigation.speedOverGround
|
|
158
|
+
|
|
159
|
+
# Inline spec — path:method, multiple paths with different methods
|
|
160
|
+
python -m signalk_cli.history query --host 10.36.10.21 --duration PT1H --stdout \
|
|
161
|
+
'navigation.speedOverGround:max' 'navigation.courseOverGroundTrue:average'
|
|
162
|
+
|
|
163
|
+
# Multiple literal paths, 1-minute resolution
|
|
164
|
+
python -m signalk_cli.history query --host 10.36.10.21 --duration PT1H --resolution 1m \
|
|
165
|
+
navigation.speedOverGround navigation.courseOverGroundTrue
|
|
166
|
+
|
|
167
|
+
# All navigation paths, specific date range, write to named file
|
|
168
|
+
python -m signalk_cli.history query --host 10.36.10.21 \
|
|
169
|
+
--from 2026-05-26T00:00:00Z --to 2026-05-27T00:00:00Z \
|
|
170
|
+
--output may26.csv \
|
|
171
|
+
'navigation\..*'
|
|
172
|
+
|
|
173
|
+
# Glob wildcard — all paths for the last 30 minutes as Feather
|
|
174
|
+
python -m signalk_cli.history query --host 10.36.10.21 --duration PT30M --format feather '*'
|
|
175
|
+
|
|
176
|
+
# Extension auto-selects feather format
|
|
177
|
+
python -m signalk_cli.history query --host 10.36.10.21 --duration PT1H \
|
|
178
|
+
-o out.feather navigation.speedOverGround
|
|
179
|
+
|
|
180
|
+
# Write to both a file and stdout
|
|
181
|
+
python -m signalk_cli.historyquery --host 10.36.10.21 --duration PT1H \
|
|
182
|
+
--output out.csv --stdout navigation.speedOverGround
|
|
183
|
+
|
|
184
|
+
# Duration in seconds, suppress header, pipe to another tool
|
|
185
|
+
python -m signalk_cli.history query --host 10.36.10.21 --duration 3600 --no-header --stdout \
|
|
186
|
+
navigation.speedOverGround | cut -d, -f1,3
|
|
187
|
+
|
|
188
|
+
# --bare: pure CSV output, no informational noise — pipe-friendly
|
|
189
|
+
python -m signalk_cli.history query --host 10.36.10.21 --duration PT1H --bare \
|
|
190
|
+
navigation.speedOverGround | awk -F, 'NR>1 {print $2, $3}'
|
|
191
|
+
|
|
192
|
+
# Different context
|
|
193
|
+
python -m signalk_cli.history query --host 10.36.10.21 --duration PT1H -c vessels.urn:mrn:imo:mmsi:123456789 \
|
|
194
|
+
navigation.speedOverGround
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
### `list-paths`
|
|
200
|
+
|
|
201
|
+
List all SignalK paths that have recorded data in a given time range.
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
python -m signalk_cli.history list-paths [OPTIONS]
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Outputs one path per line to stdout. Defaults to the last hour if no time range is given. Accepts the same `--from`/`--to`/`--duration`, `--provider`/`--no-cache`, `-c/--context`, and `--bare` options as `query`.
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
# Paths recorded in the last hour
|
|
211
|
+
python -m signalk_cli.history list-paths --host 10.36.10.21
|
|
212
|
+
|
|
213
|
+
# Paths available on a specific day
|
|
214
|
+
python -m signalk_cli.historylist-paths --host 10.36.10.21 \
|
|
215
|
+
--from 2026-05-26T00:00:00Z --to 2026-05-27T00:00:00Z
|
|
216
|
+
|
|
217
|
+
# Pipe into grep
|
|
218
|
+
python -m signalk_cli.history list-paths --host 10.36.10.21 --duration PT24H | grep navigation
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
### `list-providers`
|
|
224
|
+
|
|
225
|
+
List all registered history provider plugins and identify the default. Supports `--bare` to suppress the "Server:" and provider-count lines.
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
python -m signalk_cli.history list-providers --host 10.36.10.21
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Example output:
|
|
232
|
+
|
|
233
|
+
```
|
|
234
|
+
kip (default)
|
|
235
|
+
signalk-parquet
|
|
236
|
+
2 provider(s)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The default provider is used automatically when `--provider` is not specified on other commands. It is fetched once and cached in `~/.cache/signalk-cli/`.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
### `list-contexts`
|
|
244
|
+
|
|
245
|
+
List SignalK contexts (vessels, aircraft, etc.) that have recorded data in a given time range.
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
python -m signalk_cli.history list-contexts [OPTIONS]
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Defaults to the last hour if no time range is given.
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
python -m signalk_cli.history list-contexts --host 10.36.10.21
|
|
255
|
+
|
|
256
|
+
python -m signalk_cli.history list-contexts --host 10.36.10.21 \
|
|
257
|
+
--from 2026-05-26T00:00:00Z --to 2026-05-27T00:00:00Z
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## Time range
|
|
263
|
+
|
|
264
|
+
All commands that query data accept the same three time parameters. At least one of `--from` or `--duration` must be provided (or the tool supplies a one-hour default).
|
|
265
|
+
|
|
266
|
+
| Parameter | Format | Examples |
|
|
267
|
+
|---|---|---|
|
|
268
|
+
| `--from` | ISO 8601 timestamp | `2026-05-26T00:00:00Z` |
|
|
269
|
+
| `--to` | ISO 8601 timestamp | `2026-05-27T00:00:00Z` |
|
|
270
|
+
| `--duration` | ISO 8601 duration or integer seconds | `PT1H`, `PT15M`, `3600` |
|
|
271
|
+
|
|
272
|
+
Typical combinations:
|
|
273
|
+
|
|
274
|
+
- `--duration PT1H` — last hour ending now
|
|
275
|
+
- `--from T --duration PT1H` — hour starting at T
|
|
276
|
+
- `--from T1 --to T2` — explicit range
|
|
277
|
+
- `--duration PT1H --to T` — hour ending at T
|
|
278
|
+
|
|
279
|
+
## Provider caching
|
|
280
|
+
|
|
281
|
+
The default history provider is fetched from the server once and cached per host in `~/.cache/signalk-history-cli/`. Pass `--no-cache` to force a fresh lookup, or `--provider <id>` to target a specific provider explicitly.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
signalk_cli/__init__.py,sha256=Vl5fsJmHWc1XK1_vlWW6XU1xYHKygFrOn9PpkX-_J9w,63
|
|
2
|
+
signalk_cli/__main__.py,sha256=zIQ0UJ9uUpidMQODg_Soc8MNk2iNRwvFw0ezROBr2fs,293
|
|
3
|
+
signalk_cli/history/__init__.py,sha256=FalcH0mCbX5weJl3QGPAZQSUpmlegEratn2KeRoLmlY,706
|
|
4
|
+
signalk_cli/history/__main__.py,sha256=fHF09yAi8r1luo8JRKHSU6juXvtxF9pedLgDQ_c_x2w,47
|
|
5
|
+
signalk_cli/history/cli.py,sha256=Rrk0Xfb7F0lLMo39yiJkb-yUcE9khTZwDWi0LBGCZ3k,16000
|
|
6
|
+
signalk_cli/history/history_api.py,sha256=7_iD1kZe6ESB0GPxFDN5xsb8h0yHTR4foTLN-CMtq7A,6905
|
|
7
|
+
signalk_cli/history/output.py,sha256=ecBaEJ_OfCoX-Vrsso4APAgNbyibhPr7AGqz3o-ebd8,6753
|
|
8
|
+
signalk_cli-1.0.0.dist-info/WHEEL,sha256=f5fWSvWsg5Knq5GWa6t1nJIug0Tqo69GqAWD_9LbBKw,81
|
|
9
|
+
signalk_cli-1.0.0.dist-info/entry_points.txt,sha256=OeMyoA0iRGQTJcvimkMXnP8Z7iVuFqPKGJK5lGJn9e0,52
|
|
10
|
+
signalk_cli-1.0.0.dist-info/METADATA,sha256=u1_2mDWAgBDpIFqOUyNSrxOHXminzRNS-puuDlhheRU,10453
|
|
11
|
+
signalk_cli-1.0.0.dist-info/RECORD,,
|