signalk-cli 2.0.1__tar.gz → 2.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: signalk-cli
3
- Version: 2.0.1
3
+ Version: 2.2.0
4
4
  Summary: Query SignalK v2 APIs and export data as CSV, JSON or Apache Arrow Feather
5
5
  Keywords: signalk,sailing,marine,nmea,boating
6
6
  Author: jey burrows
@@ -71,6 +71,10 @@ Use `uv` to run without installing the module permanently, for example:
71
71
  uv run --with signalk-cli signalk_cli.history list-providers
72
72
  ```
73
73
 
74
+ ```bash
75
+ uv run --with signalk-cli signalk_cli.stream deltas navigation.position --follow
76
+ ```
77
+
74
78
  ## Running
75
79
 
76
80
  Run via `python -m signalk_cli.history <command>` or `python -m signalk_cli.stream <command>` or without installing the module with `uv run --with signalk-cli signalk_cli.history <command>`.
@@ -436,8 +440,10 @@ against the server's enumerated `/paths` list:
436
440
  Quote wildcarded paths (e.g. `'navigation.*'`) so the shell doesn't expand them
437
441
  against local filenames first.
438
442
 
439
- `deltas` always sends its own explicit subscribe message for `--context`,
440
- covering PATH arguments if given, otherwise every path (`*`). `--policy`,
443
+ `deltas` always sends its own explicit subscribe message for `--context`
444
+ (default `vessels.self`), covering PATH arguments if given, otherwise every
445
+ path (`*`). `--context` also accepts the SignalK wildcard: `'*'` or
446
+ `'vessels.*'` subscribes to every vessel, not just your own. `--policy`,
441
447
  `--period`, and `--min-period` control that subscription per the
442
448
  [Subscription Protocol](https://signalk.org/specification/1.8.2/doc/subscription_protocol.html)'s
443
449
  `policy`/`period`/`minPeriod` fields:
@@ -451,11 +457,23 @@ this CLI doesn't expose it: signalk-server rejects `full` outright ("Only
451
457
  delta format supported, using it") and always sends delta messages
452
458
  regardless, so the choice would be misleading.
453
459
 
454
- `--subscribe` is separate: it's the connection-level `subscribe` query
455
- parameter (`none`/`self`/`all`, default `none`), controlling only whether the
456
- server *additionally* auto-subscribes the connection at its own default
457
- policy/period — useful with `--subscribe all` to also receive other vessels'
458
- default-policy updates alongside your explicit subscription.
460
+ **`--subscribe` is a separate, easily-confused mechanism**: it's the
461
+ connection-level `subscribe` query parameter (`none`/`self`/`all`), which
462
+ controls whether the server *additionally* auto-subscribes the connection
463
+ at **its own** default policy/period — you don't get to choose the rate.
464
+ This CLI defaults it to `none`, diverging from the SignalK spec's own
465
+ default of `self`, because `deltas` always sends its own explicit `--context`
466
+ subscription anyway; leaving the connection-level default at `self` would
467
+ just double up on updates for your own vessel.
468
+
469
+ So there are two different ways to see every vessel, and they're not
470
+ interchangeable:
471
+
472
+ | Want | Use | Policy/period used |
473
+ |---|---|---|
474
+ | Just your own vessel | (defaults) | Yours (`--policy`/`--period`) |
475
+ | Every vessel, at your chosen rate | `--context '*'` | Yours (`--policy`/`--period`) |
476
+ | Every vessel, at whatever rate the server defaults to | `--subscribe all` | The server's own default |
459
477
 
460
478
  #### Options
461
479
 
@@ -468,8 +486,10 @@ default-policy updates alongside your explicit subscription.
468
486
  | `--policy [instant\|ideal\|fixed]` | `ideal` | Per-path subscribe `policy` field; see above |
469
487
  | `--period SECONDS` | `60` | Per-path subscribe `period` field, in seconds (converted to ms) |
470
488
  | `--min-period SECONDS` | — | Per-path subscribe `minPeriod` field, in seconds (converted to ms); only meaningful with `--policy instant` |
471
- | `--format [csv\|json\|raw\|feather]` | from extension, else csv | Output format. `json` is JSON Lines (one row object per line, suitable for a live stream). `raw` is the exact delta message text, one per line. `feather` requires `pip install 'signalk-cli[feather]'` and `--output` (cannot stream to stdout). |
489
+ | `--format [csv\|json\|raw\|values\|feather]` | from extension, else csv | Output format. `json` is JSON Lines (one row object per line, suitable for a live stream). `raw` is the exact delta message text, one per line. `values` is the bare value only, one per line — no other columns. `feather` requires `pip install 'signalk-cli[feather]'` and `--output` (cannot stream to stdout). |
472
490
  | `--no-header` | — | Suppress the CSV header row |
491
+ | `--include-meta` | — | Also emit rows for `meta` entries (units, description, zones, etc.), not just `values`. Adds a `kind` column (`value`/`meta`) to csv/json/feather output. Ignored for `--format raw`, which always includes meta as-is. |
492
+ | `--source PATTERN` | — | Only include updates whose `$source` matches `PATTERN`. Repeatable, OR'd together. Substring match unless `PATTERN` contains a glob metacharacter (`*`/`?`/`[`), e.g. `--source Teltonika` or `--source '*.GP'`. Client-side, applied after receipt: `csv`/`json`/`values`/`feather` filter per-update; `raw` (whole message, verbatim) passes a message through if *any* of its updates match. |
473
493
  | `-o, --output [FILE]` | stdout | Write to a file. Omit the filename (`--output` alone) to auto-name as `signalk-stream-<server>-<timestamp>.<ext>`. Required for `--format feather`. |
474
494
  | `-f, --follow` | — | Keep streaming until interrupted (Ctrl-C) or `--count` is reached. Without this, print the next message then exit. |
475
495
  | `-n, --count N` | 1 without `--follow`, unlimited with it | Number of delta messages to output |
@@ -477,10 +497,12 @@ default-policy updates alongside your explicit subscription.
477
497
 
478
498
  #### Output formats
479
499
 
480
- **csv** / **json**: `timestamp, context, source, path, value` — one row per `path`/`value` pair in each delta's updates, written incrementally as messages arrive. Structured values (e.g. `navigation.position`) are JSON-encoded in the `value` column.
500
+ **csv** / **json**: `timestamp, context, source, path, value` — one row per `path`/`value` pair in each delta's updates, written incrementally as messages arrive. Structured values (e.g. `navigation.position`) are JSON-encoded in the `value` column. With `--include-meta`, a `kind` column (`value`/`meta`) is inserted before `value`, and `meta` entries (units, description, zones, etc. — also JSON-encoded) are included as rows too; without it, `meta` entries are silently dropped from csv/json/feather (they're still present in `raw`).
481
501
 
482
502
  **raw**: the exact delta message JSON as received from the server, one message per line.
483
503
 
504
+ **values**: just the `value` column, one per line — no timestamp/context/source/path/kind. Best for piping a single path's readings straight into another tool or script. With `--include-meta`, meta values are interleaved in too, indistinguishable from data values (there's no `kind` column to tell them apart) — generally only useful combined with `--source`/PATH filtering down to one thing.
505
+
484
506
  **feather**: Apache Arrow Feather binary format, same columns as csv/json. Requires `pip install 'signalk-cli[feather]'`. Unlike the other formats, rows are buffered in memory across all received messages and written once the session ends (`--count` reached, or Ctrl-C with `--follow`) — cannot be streamed to stdout.
485
507
 
486
508
  #### Examples
@@ -517,6 +539,13 @@ python -m signalk_cli.stream deltas --host 10.36.10.21 --count 500 --format feat
517
539
 
518
540
  # Capture until Ctrl-C to a named Feather file
519
541
  python -m signalk_cli.stream deltas --host 10.36.10.21 --follow -o capture.feather 'navigation.*'
542
+
543
+ # Only updates from one sensor (substring match on $source)
544
+ python -m signalk_cli.stream deltas --host 10.36.10.21 --follow --source Teltonika 'navigation.*'
545
+
546
+ # Bare speed values from one sensor, piped straight into another tool
547
+ python -m signalk_cli.stream deltas --host 10.36.10.21 --follow --format values \
548
+ --source Teltonika --bare navigation.speedOverGround
520
549
  ```
521
550
 
522
551
  ---
@@ -43,6 +43,10 @@ Use `uv` to run without installing the module permanently, for example:
43
43
  uv run --with signalk-cli signalk_cli.history list-providers
44
44
  ```
45
45
 
46
+ ```bash
47
+ uv run --with signalk-cli signalk_cli.stream deltas navigation.position --follow
48
+ ```
49
+
46
50
  ## Running
47
51
 
48
52
  Run via `python -m signalk_cli.history <command>` or `python -m signalk_cli.stream <command>` or without installing the module with `uv run --with signalk-cli signalk_cli.history <command>`.
@@ -408,8 +412,10 @@ against the server's enumerated `/paths` list:
408
412
  Quote wildcarded paths (e.g. `'navigation.*'`) so the shell doesn't expand them
409
413
  against local filenames first.
410
414
 
411
- `deltas` always sends its own explicit subscribe message for `--context`,
412
- covering PATH arguments if given, otherwise every path (`*`). `--policy`,
415
+ `deltas` always sends its own explicit subscribe message for `--context`
416
+ (default `vessels.self`), covering PATH arguments if given, otherwise every
417
+ path (`*`). `--context` also accepts the SignalK wildcard: `'*'` or
418
+ `'vessels.*'` subscribes to every vessel, not just your own. `--policy`,
413
419
  `--period`, and `--min-period` control that subscription per the
414
420
  [Subscription Protocol](https://signalk.org/specification/1.8.2/doc/subscription_protocol.html)'s
415
421
  `policy`/`period`/`minPeriod` fields:
@@ -423,11 +429,23 @@ this CLI doesn't expose it: signalk-server rejects `full` outright ("Only
423
429
  delta format supported, using it") and always sends delta messages
424
430
  regardless, so the choice would be misleading.
425
431
 
426
- `--subscribe` is separate: it's the connection-level `subscribe` query
427
- parameter (`none`/`self`/`all`, default `none`), controlling only whether the
428
- server *additionally* auto-subscribes the connection at its own default
429
- policy/period — useful with `--subscribe all` to also receive other vessels'
430
- default-policy updates alongside your explicit subscription.
432
+ **`--subscribe` is a separate, easily-confused mechanism**: it's the
433
+ connection-level `subscribe` query parameter (`none`/`self`/`all`), which
434
+ controls whether the server *additionally* auto-subscribes the connection
435
+ at **its own** default policy/period — you don't get to choose the rate.
436
+ This CLI defaults it to `none`, diverging from the SignalK spec's own
437
+ default of `self`, because `deltas` always sends its own explicit `--context`
438
+ subscription anyway; leaving the connection-level default at `self` would
439
+ just double up on updates for your own vessel.
440
+
441
+ So there are two different ways to see every vessel, and they're not
442
+ interchangeable:
443
+
444
+ | Want | Use | Policy/period used |
445
+ |---|---|---|
446
+ | Just your own vessel | (defaults) | Yours (`--policy`/`--period`) |
447
+ | Every vessel, at your chosen rate | `--context '*'` | Yours (`--policy`/`--period`) |
448
+ | Every vessel, at whatever rate the server defaults to | `--subscribe all` | The server's own default |
431
449
 
432
450
  #### Options
433
451
 
@@ -440,8 +458,10 @@ default-policy updates alongside your explicit subscription.
440
458
  | `--policy [instant\|ideal\|fixed]` | `ideal` | Per-path subscribe `policy` field; see above |
441
459
  | `--period SECONDS` | `60` | Per-path subscribe `period` field, in seconds (converted to ms) |
442
460
  | `--min-period SECONDS` | — | Per-path subscribe `minPeriod` field, in seconds (converted to ms); only meaningful with `--policy instant` |
443
- | `--format [csv\|json\|raw\|feather]` | from extension, else csv | Output format. `json` is JSON Lines (one row object per line, suitable for a live stream). `raw` is the exact delta message text, one per line. `feather` requires `pip install 'signalk-cli[feather]'` and `--output` (cannot stream to stdout). |
461
+ | `--format [csv\|json\|raw\|values\|feather]` | from extension, else csv | Output format. `json` is JSON Lines (one row object per line, suitable for a live stream). `raw` is the exact delta message text, one per line. `values` is the bare value only, one per line — no other columns. `feather` requires `pip install 'signalk-cli[feather]'` and `--output` (cannot stream to stdout). |
444
462
  | `--no-header` | — | Suppress the CSV header row |
463
+ | `--include-meta` | — | Also emit rows for `meta` entries (units, description, zones, etc.), not just `values`. Adds a `kind` column (`value`/`meta`) to csv/json/feather output. Ignored for `--format raw`, which always includes meta as-is. |
464
+ | `--source PATTERN` | — | Only include updates whose `$source` matches `PATTERN`. Repeatable, OR'd together. Substring match unless `PATTERN` contains a glob metacharacter (`*`/`?`/`[`), e.g. `--source Teltonika` or `--source '*.GP'`. Client-side, applied after receipt: `csv`/`json`/`values`/`feather` filter per-update; `raw` (whole message, verbatim) passes a message through if *any* of its updates match. |
445
465
  | `-o, --output [FILE]` | stdout | Write to a file. Omit the filename (`--output` alone) to auto-name as `signalk-stream-<server>-<timestamp>.<ext>`. Required for `--format feather`. |
446
466
  | `-f, --follow` | — | Keep streaming until interrupted (Ctrl-C) or `--count` is reached. Without this, print the next message then exit. |
447
467
  | `-n, --count N` | 1 without `--follow`, unlimited with it | Number of delta messages to output |
@@ -449,10 +469,12 @@ default-policy updates alongside your explicit subscription.
449
469
 
450
470
  #### Output formats
451
471
 
452
- **csv** / **json**: `timestamp, context, source, path, value` — one row per `path`/`value` pair in each delta's updates, written incrementally as messages arrive. Structured values (e.g. `navigation.position`) are JSON-encoded in the `value` column.
472
+ **csv** / **json**: `timestamp, context, source, path, value` — one row per `path`/`value` pair in each delta's updates, written incrementally as messages arrive. Structured values (e.g. `navigation.position`) are JSON-encoded in the `value` column. With `--include-meta`, a `kind` column (`value`/`meta`) is inserted before `value`, and `meta` entries (units, description, zones, etc. — also JSON-encoded) are included as rows too; without it, `meta` entries are silently dropped from csv/json/feather (they're still present in `raw`).
453
473
 
454
474
  **raw**: the exact delta message JSON as received from the server, one message per line.
455
475
 
476
+ **values**: just the `value` column, one per line — no timestamp/context/source/path/kind. Best for piping a single path's readings straight into another tool or script. With `--include-meta`, meta values are interleaved in too, indistinguishable from data values (there's no `kind` column to tell them apart) — generally only useful combined with `--source`/PATH filtering down to one thing.
477
+
456
478
  **feather**: Apache Arrow Feather binary format, same columns as csv/json. Requires `pip install 'signalk-cli[feather]'`. Unlike the other formats, rows are buffered in memory across all received messages and written once the session ends (`--count` reached, or Ctrl-C with `--follow`) — cannot be streamed to stdout.
457
479
 
458
480
  #### Examples
@@ -489,6 +511,13 @@ python -m signalk_cli.stream deltas --host 10.36.10.21 --count 500 --format feat
489
511
 
490
512
  # Capture until Ctrl-C to a named Feather file
491
513
  python -m signalk_cli.stream deltas --host 10.36.10.21 --follow -o capture.feather 'navigation.*'
514
+
515
+ # Only updates from one sensor (substring match on $source)
516
+ python -m signalk_cli.stream deltas --host 10.36.10.21 --follow --source Teltonika 'navigation.*'
517
+
518
+ # Bare speed values from one sensor, piped straight into another tool
519
+ python -m signalk_cli.stream deltas --host 10.36.10.21 --follow --format values \
520
+ --source Teltonika --bare navigation.speedOverGround
492
521
  ```
493
522
 
494
523
  ---
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "signalk-cli"
3
- version = "2.0.1"
3
+ version = "2.2.0"
4
4
  description = "Query SignalK v2 APIs and export data as CSV, JSON or Apache Arrow Feather"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -102,13 +102,16 @@ mypy_path = ["./src"]
102
102
  [tool.uv]
103
103
  compile-bytecode = true
104
104
  managed = true
105
- exclude-newer = "5 days"
105
+ exclude-newer = "7 days"
106
106
  add-bounds = "major"
107
107
 
108
+ [tool.uv.exclude-newer-package]
109
+ uv_build = "2 days"
110
+
108
111
  [tool.uv.build-backend]
109
112
  module-root = "src"
110
113
  module-name = "signalk_cli"
111
114
 
112
115
  [build-system]
113
- requires = ["uv_build>=0.11.13,<0.12.0"]
116
+ requires = ["uv_build>=0.12.0,<0.13.0"]
114
117
  build-backend = "uv_build"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "signalk-cli"
3
- version = "2.0.1"
3
+ version = "2.2.0"
4
4
  description = "Query SignalK v2 APIs and export data as CSV, JSON or Apache Arrow Feather"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -101,11 +101,12 @@ mypy_path=["./src"]
101
101
  [tool.uv]
102
102
  compile-bytecode = true
103
103
  managed = true
104
- exclude-newer = "5 days"
104
+ exclude-newer = "7 days"
105
+ exclude-newer-package = {"uv_build"="2 days"}
105
106
  add-bounds = "major"
106
107
 
107
108
  [build-system]
108
- requires = ["uv_build>=0.11.13,<0.12.0"]
109
+ requires = ["uv_build>=0.12.0,<0.13.0"]
109
110
  build-backend = "uv_build"
110
111
 
111
112
 
@@ -13,11 +13,13 @@ import niquests
13
13
  from ..net import api_error, bare_option, host_option, resolve_host, stderr_ctx
14
14
  from .output import (
15
15
  FEATHER_EXTENSIONS,
16
+ delta_matches_source,
16
17
  extract_delta_rows,
17
18
  write_csv_delta,
18
19
  write_csv_header,
19
20
  write_feather_rows,
20
21
  write_json_delta,
22
+ write_values_delta,
21
23
  )
22
24
  from .stream_api import (
23
25
  SUBSCRIBE_POLICIES,
@@ -49,18 +51,26 @@ def cli():
49
51
  @host_option
50
52
  @click.option("--no-cache", is_flag=True, help="Ignore cached host")
51
53
  @click.option(
52
- "--context", "-c", default="vessels.self", show_default=True, help="SignalK context"
54
+ "--context",
55
+ "-c",
56
+ default="vessels.self",
57
+ show_default=True,
58
+ help="SignalK context for the explicit subscription this command "
59
+ "always sends. Also accepts the SignalK wildcard '*' (or 'vessels.*') "
60
+ "to subscribe to every vessel at your own --policy/--period — the "
61
+ "alternative to --subscribe all, which uses the server's default rate.",
53
62
  )
54
63
  @click.option(
55
64
  "--subscribe",
56
65
  type=click.Choice(SUBSCRIBE_POLICIES, case_sensitive=False),
57
66
  default="none",
58
67
  show_default=True,
59
- help="Connection-level subscribe policy (none/self/all): whether the "
60
- "server also auto-subscribes this connection using its own default "
61
- "policy/period, in addition to the explicit subscription this command "
62
- "always sends for --context. Use 'all' to also receive other vessels' "
63
- "default-policy updates.",
68
+ help="Connection-level auto-subscribe at the server's OWN default "
69
+ "policy/period — separate from, and in addition to, this command's "
70
+ "explicit --context subscription above. Defaults to 'none' (the "
71
+ "SignalK spec's own default is 'self') to avoid double-subscribing "
72
+ "your own context. 'all' adds other vessels at the server's rate; for "
73
+ "other vessels at your chosen rate, use --context '*' instead.",
64
74
  )
65
75
  @click.option(
66
76
  "--policy",
@@ -95,13 +105,36 @@ def cli():
95
105
  "--format",
96
106
  "fmt",
97
107
  default=None,
98
- type=click.Choice(["csv", "json", "raw", "feather"], case_sensitive=False),
108
+ type=click.Choice(
109
+ ["csv", "json", "raw", "feather", "values"], case_sensitive=False
110
+ ),
99
111
  help="Output format (default: inferred from --output extension, else csv). "
100
112
  "json is JSON Lines (one row object per line). raw is the exact delta "
101
- "message text, one per line. feather requires "
113
+ "message text, one per line. values is the bare value only, one per "
114
+ "line — no timestamp/context/source/path/kind columns. feather requires "
102
115
  "pip install 'signalk-cli[feather]' and --output (cannot stream to stdout).",
103
116
  )
104
117
  @click.option("--no-header", is_flag=True, help="Suppress header row (CSV only)")
118
+ @click.option(
119
+ "--include-meta",
120
+ is_flag=True,
121
+ help="Also emit rows for 'meta' entries (units, description, zones, etc.), "
122
+ "not just 'values'. Adds a 'kind' column (value/meta) to csv/json/feather "
123
+ "output. Ignored for --format raw, which always includes meta as-is.",
124
+ )
125
+ @click.option(
126
+ "--source",
127
+ "source",
128
+ multiple=True,
129
+ metavar="PATTERN",
130
+ help="Only include updates whose $source matches PATTERN. Repeatable "
131
+ "(OR'd together). PATTERN is a substring match unless it contains a "
132
+ "glob metacharacter (*/?/[), in which case it's matched as a glob, "
133
+ "e.g. --source Teltonika or --source '*.GP'. Filtering is client-side, "
134
+ "applied after receipt — for --format raw (whole message, verbatim) a "
135
+ "message passes if ANY of its updates match; other formats filter "
136
+ "per-update.",
137
+ )
105
138
  @click.option(
106
139
  "--output",
107
140
  "-o",
@@ -140,6 +173,8 @@ def deltas(
140
173
  min_period,
141
174
  fmt,
142
175
  no_header,
176
+ include_meta,
177
+ source,
143
178
  output,
144
179
  follow,
145
180
  count,
@@ -148,7 +183,8 @@ def deltas(
148
183
  """Stream live delta updates from the SignalK v1 Streaming API.
149
184
 
150
185
  Connects via WebSocket and prints delta messages as they arrive, in
151
- csv, json (JSON Lines), raw, or Feather format.
186
+ csv, json (JSON Lines), raw, values (bare value only), or Feather
187
+ format.
152
188
 
153
189
  Always sends an explicit subscribe message for --context, covering
154
190
  PATH arguments if given, otherwise every path ('*'). PATH arguments
@@ -180,6 +216,10 @@ def deltas(
180
216
 
181
217
  # Capture 100 messages to a Feather file (requires signalk-cli[feather])
182
218
  signalk_cli.stream deltas --host 10.36.10.21 --count 100 -o capture.feather
219
+
220
+ # Bare speed values from one sensor, piped straight into another tool
221
+ signalk_cli.stream deltas --host 10.36.10.21 --follow --format values \\
222
+ --source Teltonika --bare navigation.speedOverGround
183
223
  """
184
224
  with stderr_ctx(bare):
185
225
  host = resolve_host(host, no_cache)
@@ -208,6 +248,8 @@ def deltas(
208
248
  if fmt == "feather"
209
249
  else ".json"
210
250
  if fmt in ("json", "raw")
251
+ else ".txt"
252
+ if fmt == "values"
211
253
  else ".csv"
212
254
  )
213
255
  output = f"signalk-stream-{server_name}-{ts}{ext}"
@@ -233,7 +275,7 @@ def deltas(
233
275
  click.echo(f"Format: {fmt}", err=True)
234
276
 
235
277
  try:
236
- ws = open_stream(host, subscribe)
278
+ ws = open_stream(host, subscribe, timeout=None if follow else 30)
237
279
  except niquests.RequestException as e:
238
280
  click.echo(f"Error connecting to stream: {api_error(e)}", err=True)
239
281
  sys.exit(1)
@@ -252,7 +294,7 @@ def deltas(
252
294
  message_count = 0
253
295
  row_total = 0
254
296
  header_written = False
255
- feather_rows: list[tuple[str, str, str, str, str]] = []
297
+ feather_rows: list[tuple[str, ...]] = []
256
298
  fh = (
257
299
  open(output, "w", newline="") # noqa: SIM115
258
300
  if write_to_file and fmt != "feather"
@@ -263,26 +305,41 @@ def deltas(
263
305
  for raw, delta in iter_deltas(ws, effective_count):
264
306
  message_count += 1
265
307
  if fmt == "feather":
266
- feather_rows.extend(extract_delta_rows(delta))
308
+ feather_rows.extend(
309
+ extract_delta_rows(
310
+ delta, include_meta=include_meta, sources=source
311
+ )
312
+ )
267
313
  row_total = len(feather_rows)
268
314
  elif fmt == "raw":
269
- click.echo(raw, file=sink)
315
+ if delta_matches_source(delta, source):
316
+ click.echo(raw, file=sink)
270
317
  elif fmt == "json":
271
- row_total += write_json_delta(delta, sink)
318
+ row_total += write_json_delta(
319
+ delta, sink, include_meta=include_meta, sources=source
320
+ )
321
+ elif fmt == "values":
322
+ row_total += write_values_delta(
323
+ delta, sink, include_meta=include_meta, sources=source
324
+ )
272
325
  else:
273
326
  if not header_written and not no_header:
274
- write_csv_header(sink)
327
+ write_csv_header(sink, include_meta=include_meta)
275
328
  header_written = True
276
- row_total += write_csv_delta(delta, sink)
329
+ row_total += write_csv_delta(
330
+ delta, sink, include_meta=include_meta, sources=source
331
+ )
277
332
  except KeyboardInterrupt:
278
333
  pass
334
+ except niquests.RequestException as e:
335
+ click.echo(f"Stream connection lost: {api_error(e)}", err=True)
279
336
  finally:
280
337
  ws.close()
281
338
  if fh:
282
339
  fh.close()
283
340
 
284
341
  if fmt == "feather":
285
- write_feather_rows(feather_rows, output)
342
+ write_feather_rows(feather_rows, output, include_meta=include_meta)
286
343
 
287
344
  if write_to_file:
288
345
  click.echo(f"Wrote {output}", err=True)
@@ -0,0 +1,181 @@
1
+ """Row extraction and CSV/JSON/Feather writers for SignalK delta messages."""
2
+
3
+ import csv
4
+ import fnmatch
5
+ import json
6
+ from typing import IO
7
+
8
+ FEATHER_EXTENSIONS = {".feather", ".arrow", ".fea"}
9
+
10
+
11
+ def _normalize_value(value: object) -> str:
12
+ if isinstance(value, (dict, list)):
13
+ return json.dumps(value)
14
+ if value is None:
15
+ return ""
16
+ return str(value)
17
+
18
+
19
+ def _update_source(update: dict) -> str:
20
+ return update.get("$source") or json.dumps(update.get("source", {}))
21
+
22
+
23
+ def source_matches(source: str, patterns: tuple[str, ...]) -> bool:
24
+ """Match a `$source` string against `--source` filter patterns (OR'd).
25
+
26
+ No patterns means no filtering (always matches). A pattern containing
27
+ glob metacharacters (`*`/`?`/`[`) is matched as-is via `fnmatch`;
28
+ otherwise it's treated as a substring match, e.g. "Teltonika" matches
29
+ the source "Teltonika.GP".
30
+ """
31
+ if not patterns:
32
+ return True
33
+ return any(
34
+ fnmatch.fnmatch(source, p if any(c in p for c in "*?[") else f"*{p}*")
35
+ for p in patterns
36
+ )
37
+
38
+
39
+ def delta_matches_source(delta: dict, patterns: tuple[str, ...]) -> bool:
40
+ """True if any update in the delta has a `$source` matching `patterns`.
41
+
42
+ Used for `--format raw`, which echoes the whole message verbatim and so
43
+ can only filter at message granularity, not per-update.
44
+ """
45
+ if not patterns:
46
+ return True
47
+ return any(
48
+ source_matches(_update_source(update), patterns)
49
+ for update in delta.get("updates", [])
50
+ )
51
+
52
+
53
+ def extract_delta_rows(
54
+ delta: dict, *, include_meta: bool = False, sources: tuple[str, ...] = ()
55
+ ) -> list[tuple[str, ...]]:
56
+ """Flatten a single delta message into rows.
57
+
58
+ Without `include_meta`, rows are (timestamp, context, source, path,
59
+ value) from each update's "values" entries. With `include_meta`, a
60
+ "kind" column ("value"/"meta") is inserted before "value", and each
61
+ update's "meta" entries are included too — per the Streaming API spec,
62
+ "meta" entries have the same path/value shape but "value" is a metadata
63
+ object (units, description, zones, etc.), not a telemetry reading.
64
+
65
+ `sources`, if given, drops entire updates whose `$source` doesn't match
66
+ any pattern (see `source_matches`) — filtering is per-update, since
67
+ that's the granularity at which SignalK attaches a source.
68
+ """
69
+ context = delta.get("context", "")
70
+ rows: list[tuple[str, ...]] = []
71
+ for update in delta.get("updates", []):
72
+ source = _update_source(update)
73
+ if not source_matches(source, sources):
74
+ continue
75
+ timestamp = update.get("timestamp", "")
76
+ for entry in update.get("values", []):
77
+ path = entry.get("path", "")
78
+ value = _normalize_value(entry.get("value"))
79
+ if include_meta:
80
+ rows.append((timestamp, context, source, path, "value", value))
81
+ else:
82
+ rows.append((timestamp, context, source, path, value))
83
+ if include_meta:
84
+ for entry in update.get("meta", []):
85
+ path = entry.get("path", "")
86
+ value = _normalize_value(entry.get("value"))
87
+ rows.append((timestamp, context, source, path, "meta", value))
88
+ return rows
89
+
90
+
91
+ CSV_COLUMNS = ["timestamp", "context", "source", "path", "value"]
92
+ CSV_COLUMNS_WITH_KIND = ["timestamp", "context", "source", "path", "kind", "value"]
93
+
94
+
95
+ def _columns(include_meta: bool) -> list[str]:
96
+ return CSV_COLUMNS_WITH_KIND if include_meta else CSV_COLUMNS
97
+
98
+
99
+ def write_csv_header(sink: IO[str], *, include_meta: bool = False) -> None:
100
+ csv.writer(sink).writerow(_columns(include_meta))
101
+ sink.flush()
102
+
103
+
104
+ def write_csv_delta(
105
+ delta: dict,
106
+ sink: IO[str],
107
+ *,
108
+ include_meta: bool = False,
109
+ sources: tuple[str, ...] = (),
110
+ ) -> int:
111
+ """Write one delta's rows as CSV lines. Returns the number of rows written."""
112
+ rows = extract_delta_rows(delta, include_meta=include_meta, sources=sources)
113
+ writer = csv.writer(sink)
114
+ for row in rows:
115
+ writer.writerow(row)
116
+ sink.flush()
117
+ return len(rows)
118
+
119
+
120
+ def write_json_delta(
121
+ delta: dict,
122
+ sink: IO[str],
123
+ *,
124
+ include_meta: bool = False,
125
+ sources: tuple[str, ...] = (),
126
+ ) -> int:
127
+ """Write one delta's rows as JSON Lines (one row object per line). Returns row count."""
128
+ rows = extract_delta_rows(delta, include_meta=include_meta, sources=sources)
129
+ columns = _columns(include_meta)
130
+ for row in rows:
131
+ sink.write(json.dumps(dict(zip(columns, row))))
132
+ sink.write("\n")
133
+ sink.flush()
134
+ return len(rows)
135
+
136
+
137
+ def write_values_delta(
138
+ delta: dict,
139
+ sink: IO[str],
140
+ *,
141
+ include_meta: bool = False,
142
+ sources: tuple[str, ...] = (),
143
+ ) -> int:
144
+ """Write one delta's bare values, one per line — no other columns.
145
+
146
+ For `--format values`: useful for piping a single path's readings
147
+ straight into another tool/script. Returns the number of values written.
148
+ """
149
+ rows = extract_delta_rows(delta, include_meta=include_meta, sources=sources)
150
+ for row in rows:
151
+ sink.write(row[-1])
152
+ sink.write("\n")
153
+ sink.flush()
154
+ return len(rows)
155
+
156
+
157
+ def write_feather_rows(
158
+ rows: list[tuple[str, ...]], output: str, *, include_meta: bool = False
159
+ ) -> int:
160
+ """Write accumulated delta rows as Feather. Returns the number of rows written.
161
+
162
+ Unlike CSV/JSON, Feather cannot be appended to incrementally — callers must
163
+ buffer rows across messages and call this once at the end of the session.
164
+ """
165
+ try:
166
+ import pyarrow as pa
167
+ from pyarrow import feather
168
+ except ImportError:
169
+ raise ImportError(
170
+ "pyarrow is required for Feather output: pip install 'signalk-cli[feather]'"
171
+ ) from None
172
+ columns = _columns(include_meta)
173
+ row_columns = list(zip(*rows)) if rows else [()] * len(columns)
174
+ table = pa.table(
175
+ {
176
+ name: pa.array(values, type=pa.string())
177
+ for name, values in zip(columns, row_columns)
178
+ }
179
+ )
180
+ feather.write_feather(table, output)
181
+ return len(rows)
@@ -24,9 +24,15 @@ def to_ws_url(host: str) -> str:
24
24
  return urlunparse((scheme, parsed.netloc, STREAM_PATH, "", "", ""))
25
25
 
26
26
 
27
- def open_stream(host: str, subscribe: str, timeout: float = 30):
27
+ def open_stream(host: str, subscribe: str, timeout: float | None = 30):
28
28
  """Open a WebSocket connection to the SignalK streaming endpoint.
29
29
 
30
+ `timeout` sets the socket's read timeout for the life of the connection —
31
+ every subsequent `next_payload()` read reuses it, not just the initial
32
+ handshake. Pass `None` when the caller intends to block indefinitely
33
+ between messages (e.g. `--follow`), since deltas can legitimately go
34
+ quiet for longer than any fixed timeout depending on subscribe policy.
35
+
30
36
  Returns the underlying HTTP extension object, used to send/receive frames
31
37
  via `send_payload`/`next_payload`.
32
38
  """
@@ -1,89 +0,0 @@
1
- """Row extraction and CSV/JSON/Feather writers for SignalK delta messages."""
2
-
3
- import csv
4
- import json
5
- from typing import IO
6
-
7
- FEATHER_EXTENSIONS = {".feather", ".arrow", ".fea"}
8
-
9
-
10
- def extract_delta_rows(delta: dict) -> list[tuple[str, str, str, str, str]]:
11
- """Flatten a single delta message into (timestamp, context, source, path, value) rows."""
12
- context = delta.get("context", "")
13
- rows = []
14
- for update in delta.get("updates", []):
15
- timestamp = update.get("timestamp", "")
16
- source = update.get("$source") or json.dumps(update.get("source", {}))
17
- for entry in update.get("values", []):
18
- path = entry.get("path", "")
19
- value = entry.get("value")
20
- if isinstance(value, (dict, list)):
21
- value = json.dumps(value)
22
- elif value is None:
23
- value = ""
24
- else:
25
- value = str(value)
26
- rows.append((timestamp, context, source, path, value))
27
- return rows
28
-
29
-
30
- CSV_COLUMNS = ["timestamp", "context", "source", "path", "value"]
31
-
32
-
33
- def write_csv_header(sink: IO[str]) -> None:
34
- csv.writer(sink).writerow(CSV_COLUMNS)
35
- sink.flush()
36
-
37
-
38
- def write_csv_delta(delta: dict, sink: IO[str]) -> int:
39
- """Write one delta's rows as CSV lines. Returns the number of rows written."""
40
- rows = extract_delta_rows(delta)
41
- writer = csv.writer(sink)
42
- for row in rows:
43
- writer.writerow(row)
44
- sink.flush()
45
- return len(rows)
46
-
47
-
48
- def write_json_delta(delta: dict, sink: IO[str]) -> int:
49
- """Write one delta's rows as JSON Lines (one row object per line). Returns row count."""
50
- rows = extract_delta_rows(delta)
51
- for ts, context, source, path, value in rows:
52
- sink.write(
53
- json.dumps(
54
- {
55
- "timestamp": ts,
56
- "context": context,
57
- "source": source,
58
- "path": path,
59
- "value": value,
60
- }
61
- )
62
- )
63
- sink.write("\n")
64
- sink.flush()
65
- return len(rows)
66
-
67
-
68
- def write_feather_rows(rows: list[tuple[str, str, str, str, str]], output: str) -> int:
69
- """Write accumulated delta rows as Feather. Returns the number of rows written.
70
-
71
- Unlike CSV/JSON, Feather cannot be appended to incrementally — callers must
72
- buffer rows across messages and call this once at the end of the session.
73
- """
74
- try:
75
- import pyarrow as pa
76
- from pyarrow import feather
77
- except ImportError:
78
- raise ImportError(
79
- "pyarrow is required for Feather output: pip install 'signalk-cli[feather]'"
80
- ) from None
81
- columns = list(zip(*rows)) if rows else [()] * len(CSV_COLUMNS)
82
- table = pa.table(
83
- {
84
- name: pa.array(values, type=pa.string())
85
- for name, values in zip(CSV_COLUMNS, columns)
86
- }
87
- )
88
- feather.write_feather(table, output)
89
- return len(rows)