diff --git a/.github/tui.svg b/.github/tui.svg
index a85d0f9..d351b16 100644
--- a/.github/tui.svg
+++ b/.github/tui.svg
@@ -14,27 +14,27 @@
- Sniff decoded traffic
+ 1 Sniff decoded traffic
Stream typed JSON or physical-value text from CAN, USB, gateway, or capture
- Record raw observations
+ 2 Record raw observations
Capture replayable candump or context-rich observation JSON lines
- Replay a capture
+ 3 Replay a capture
Decode a candump file with optional source timing and CEL filtering
- Validate a source
+ 4 Validate a source
Summarize typed and undecodable messages; optionally enforce strict mode
- Inventory network devices
+ 5 Inventory network devices
Actively discover a live bus or inspect device identities captured in a file
- Explore the PGN schema
- Autocomplete known PGNs and inspect fields, ranges, units, and confidence
+ 6 Explore the PGN schema
+ Search PGNs by name or number and inspect fields, ranges, units, and confidence
- Update n2k
+ 7 Update n2k
Check GitHub releases and update through Homebrew, Go, or a verified binary
- 1–7 jump • / filter • enter configure • ? help • q quit
+ 1–7 configure • / filter • enter configure • ? help • q quit
diff --git a/README.md b/README.md
index 67d06a6..dbfce3d 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
[](https://github.com/open-ships/n2k-cli/releases)
-Decode, record, replay, validate, filter, and discover devices - all from a nicely packaged TUI.
+Decode, record, replay, validate, filter, and discover devices - with guided terminal workflows and scriptable commands.
Powered by the [`open-ships/n2k`](https://github.com/open-ships/n2k) Go library.
@@ -63,16 +63,34 @@ n2k
The command center provides:
-- Fuzzy command search with `/`, keyboard navigation, and `1`–`7` shortcuts.
+- Fuzzy command search with `/`, keyboard navigation, and numbered `1`–`7` shortcuts. Escape clears a palette filter before leaving.
- Guided source selection for SocketCAN, USB-CAN, TCP/UDP gateways, and files.
- Inline validation for paths, durations, addresses, formats, and PGNs.
-- Tab-completable capture paths, common interfaces, gateway addresses, CEL
- filters, durations, and every known PGN number.
-- A reviewable, copyable command preview before anything runs.
-- Terminal-aware colors, contextual key help, cancellation, and a full-screen
- alternate buffer that leaves the shell clean.
+- Tab completion for local capture paths, interfaces, gateway addresses, CEL
+ filters, and durations; PGN search by name or number. Enter advances input fields.
+- A reviewable, copyable command preview before anything runs, plus explicit
+ confirmation before replacing an existing recording.
+- Terminal-aware colors, compact layouts for small terminals, contextual key help,
+ and a full-screen command palette that leaves the shell clean.
+- Readable results, progress counts, saved-file confirmation, and retained settings
+ for editing or retrying. Ctrl+C stops the running operation and saves captured data;
+ the next-action menu lets you continue or quit.
+Use `n2k tui --accessible` (or `N2K_ACCESSIBLE=1`) for screen-reader-friendly
+prompts. In forms, Escape or Ctrl+C cancels configuration and returns to workflow
+selection; Escape finishes or clears an active search first. Shift+Tab goes to
+the previous field. In accessible mode, follow the numbered prompts and use
+Ctrl+C to exit.
+
+Try an offline inspection from a source checkout, without connecting hardware:
+
+```bash
+n2k sniff --file testdata/sample.log --output text --filter 'pgn == 128267'
+n2k devices --file testdata/sample.log --output text
+n2k pgn heading --output text
+```
+
### Scriptable commands
The same workflows retain deterministic stdout and exit behavior:
@@ -96,6 +114,8 @@ n2k sniff -i can0 -f 'pgn == 127250' --unknown | jq .
# Record, replay, validate, discover, and inspect schema support
n2k record -i can0 --out capture.log
+# Existing files are protected; replace only when intended:
+n2k record -i can0 --out capture.log --overwrite
n2k record --tcp 192.168.4.1:1457 --out observations.jsonl --output-format jsonl
n2k replay --timing=false capture.log
n2k validate --file capture.log --strict
@@ -103,6 +123,8 @@ n2k devices --tcp 192.168.4.1:1457 --wait 5s
n2k devices --file capture.log.gz
n2k devices list --file capture.log.gz # "list" is an optional, readable alias
n2k pgn 127250
+n2k pgn 127250 --output text
+n2k pgn heading --output text
n2k pgn list | jq 'select(.complete == true)'
```
@@ -184,9 +206,21 @@ Completion is dynamic rather than a static command list. It understands:
`sniff` and `replay` default to JSON lines containing typed structs and their
exact wire values. The demo projects the metadata envelope down to its PGN for
readability. Set `--output text` for the concrete `pgn.`, source address,
-scaled physical values, SI units, and lookup type names.
+scaled physical values, SI units, and labels for common marine enumerations
+(with numeric fallbacks for other lookups).
+
+`record` writes replayable candump by default. Existing destinations require
+`--overwrite`; an input capture cannot also be the output, including through
+symlinks or hard links. The wizard suggests a fresh filename and asks before
+replacement. JSONL is a detailed export and cannot currently be replayed by n2k.
+Empty files and unsupported capture formats produce actionable errors.
+
+`devices`, `validate`, and `pgn` accept `--output text` for readable tables and
+summaries. The guided workflows default to readable output; scriptable commands
+keep JSON as their default. Validation JSON includes `undecodableByPgn`, and its
+text summary identifies the failing PGNs with an inspection command.
-`record` writes replayable candump by default. JSON-lines mode retains each
+In JSON-lines mode, `record` retains each
owned source observation, including adapter and network identity, source and
receipt timestamps, gateway-relative time, direction, and frame bytes. The Go
library's observation stream additionally exposes assembled messages and
diff --git a/cmd/n2k/capture_safety.go b/cmd/n2k/capture_safety.go
new file mode 100644
index 0000000..e1384e0
--- /dev/null
+++ b/cmd/n2k/capture_safety.go
@@ -0,0 +1,124 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/open-ships/n2k"
+)
+
+func expandPath(path string) string {
+ if path == "~" || strings.HasPrefix(path, "~/") {
+ if home, err := os.UserHomeDir(); err == nil {
+ if path == "~" {
+ return home
+ }
+ return filepath.Join(home, strings.TrimPrefix(path, "~/"))
+ }
+ }
+ return path
+}
+
+// Probe using the library's parser so accepted candump syntax stays consistent
+// with replay. Stop at the first observation; never pace the probe by timestamps.
+func validateCapture(ctx context.Context, path, displayPath string) error {
+ file, err := os.Open(path) // #nosec G304 -- operator-selected capture.
+ if err != nil {
+ return fmt.Errorf("opening capture %q: %w", displayPath, err)
+ }
+ defer func() { _ = file.Close() }()
+ info, err := file.Stat()
+ if err != nil {
+ return err
+ }
+ if !info.Mode().IsRegular() {
+ return fmt.Errorf("capture %q must be a regular candump file", displayPath)
+ }
+ if info.Size() == 0 {
+ return fmt.Errorf("capture %q is empty; record some traffic before inspecting it", displayPath)
+ }
+ prefix := make([]byte, 4096)
+ n, err := file.Read(prefix)
+ if err != nil && !errors.Is(err, io.EOF) {
+ return err
+ }
+ prefix = bytes.TrimSpace(prefix[:n])
+ probeCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ for observation, err := range n2k.Observe(probeCtx, n2k.File(path)) {
+ if err != nil {
+ return fmt.Errorf("reading capture %q: %w", displayPath, err)
+ }
+ if observation.Frame != nil {
+ return nil
+ }
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if bytes.HasPrefix(prefix, []byte("{")) || bytes.HasPrefix(prefix, []byte("[")) {
+ return fmt.Errorf("capture %q contains JSON; replay requires candump text (record with --output-format candump)", displayPath)
+ }
+ return fmt.Errorf("capture %q contains no readable CAN frames; use candump -L/-l text or a gzip capture", displayPath)
+}
+
+func checkRecordPaths(inputPath, outputPath string) error {
+ if inputPath == "" || outputPath == "" || outputPath == "-" {
+ return nil
+ }
+ input, err := os.Stat(expandPath(inputPath))
+ if err != nil {
+ return fmt.Errorf("opening capture %q: %w", inputPath, err)
+ }
+ output, err := os.Stat(expandPath(outputPath))
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("checking output %q: %w", outputPath, err)
+ }
+ if os.SameFile(input, output) {
+ return errors.New("input and output refer to the same capture; choose a different output path")
+ }
+ return nil
+}
+
+// Recheck the opened destination before truncation, including symlinks and
+// hard links that resolve to the source capture.
+func checkOutputFile(inputPath string, output *os.File) error {
+ info, err := output.Stat()
+ if err != nil {
+ return err
+ }
+ if !info.Mode().IsRegular() {
+ return errors.New("output must be a regular file; use --out - to stream to stdout")
+ }
+ if inputPath != "" {
+ input, err := os.Stat(expandPath(inputPath))
+ if err != nil {
+ return err
+ }
+ if os.SameFile(input, info) {
+ return errors.New("input and output refer to the same capture; choose a different output path")
+ }
+ }
+ return nil
+}
+
+type contextReader struct {
+ ctx context.Context
+ reader io.Reader
+}
+
+func (reader contextReader) Read(data []byte) (int, error) {
+ if err := reader.ctx.Err(); err != nil {
+ return 0, err
+ }
+ return reader.reader.Read(data)
+}
diff --git a/cmd/n2k/cli.go b/cmd/n2k/cli.go
index ff8f990..00c5cd2 100644
--- a/cmd/n2k/cli.go
+++ b/cmd/n2k/cli.go
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"os"
+ "os/signal"
"strconv"
"strings"
"text/tabwriter"
@@ -70,6 +71,9 @@ func (parsed parsedCommand) durationValue(name string) (time.Duration, error) {
if err != nil {
return 0, fmt.Errorf("invalid --%s duration %q: %w", name, value, err)
}
+ if duration <= 0 {
+ return 0, fmt.Errorf("--%s must be greater than zero", name)
+ }
return duration, nil
}
@@ -137,11 +141,22 @@ func (app *cli) ExecuteContext(ctx context.Context, args []string) error {
}
func (app *cli) runParsed(ctx context.Context, parsed parsedCommand) error {
+ if ctx.Err() != nil {
+ return nil
+ }
+ if parsed.spec.name != "tui" {
+ var stop context.CancelFunc
+ ctx, stop = signal.NotifyContext(ctx, os.Interrupt)
+ defer stop()
+ }
+ if err := validateFilter(parsed.stringValue("filter")); err != nil {
+ return err
+ }
switch parsed.spec.name {
case "tui":
return app.runInteractive(ctx, parsed.boolValue("accessible"))
case "sniff":
- source, _, cleanup, err := preparedSource(parsed, true)
+ source, _, cleanup, err := preparedSource(ctx, parsed, true)
if err != nil {
return err
}
@@ -155,7 +170,10 @@ func (app *cli) runParsed(ctx context.Context, parsed parsedCommand) error {
parsed.stringValue("output"),
)
case "record":
- source, _, cleanup, err := preparedSource(parsed, true)
+ if err := checkRecordPaths(parsed.stringValue("file"), parsed.stringValue("out")); err != nil {
+ return err
+ }
+ source, _, cleanup, err := preparedSource(ctx, parsed, true)
if err != nil {
return err
}
@@ -164,8 +182,10 @@ func (app *cli) runParsed(ctx context.Context, parsed parsedCommand) error {
ctx,
app.out,
source,
+ parsed.stringValue("file"),
parsed.stringValue("out"),
parsed.stringValue("output-format"),
+ parsed.boolValue("overwrite"),
)
case "replay":
file := parsed.stringValue("file")
@@ -178,7 +198,7 @@ func (app *cli) runParsed(ctx context.Context, parsed parsedCommand) error {
if file == "" {
return errors.New("a candump capture path is required")
}
- file, cleanup, err := prepareCapture(file)
+ file, cleanup, err := prepareCapture(ctx, file)
if err != nil {
return err
}
@@ -193,17 +213,17 @@ func (app *cli) runParsed(ctx context.Context, parsed parsedCommand) error {
parsed.stringValue("output"),
)
case "validate":
- source, _, cleanup, err := preparedSource(parsed, true)
+ source, _, cleanup, err := preparedSource(ctx, parsed, true)
if err != nil {
return err
}
defer cleanup()
- return runValidate(ctx, app.out, source, parsed.boolValue("strict"))
+ return runValidate(ctx, app.out, source, parsed.boolValue("strict"), parsed.stringValue("output"))
case "devices":
if len(parsed.positionals) == 1 && parsed.positionals[0] != "list" {
return fmt.Errorf("unknown devices action %q: use \"list\"", parsed.positionals[0])
}
- source, values, cleanup, err := preparedSource(parsed, true)
+ source, values, cleanup, err := preparedSource(ctx, parsed, true)
if err != nil {
return err
}
@@ -217,11 +237,11 @@ func (app *cli) runParsed(ctx context.Context, parsed parsedCommand) error {
return err
}
if values.file != "" || values.udp != "" {
- return runPassiveDevices(ctx, app.out, source, wait, values.udp != "")
+ return runPassiveDevices(ctx, app.out, source, wait, values.udp != "", parsed.stringValue("output"))
}
- return runActiveDevices(ctx, app.out, source, values.tcp != "", wait, claimTimeout)
+ return runActiveDevices(ctx, app.out, source, values.tcp != "", wait, claimTimeout, parsed.stringValue("output"))
case "pgn":
- return runPGN(app.out, parsed.positionals[0])
+ return runPGN(app.out, parsed.positionals[0], parsed.stringValue("output"))
case "completion":
return writeShellCompletion(app.out, parsed.positionals[0])
case "version":
@@ -235,12 +255,12 @@ func (app *cli) runParsed(ctx context.Context, parsed parsedCommand) error {
}
}
-func preparedSource(parsed parsedCommand, allowReadOnly bool) (n2k.Option, sourceFlagValues, func(), error) {
+func preparedSource(ctx context.Context, parsed parsedCommand, allowReadOnly bool) (n2k.Option, sourceFlagValues, func(), error) {
values := sourceValues(parsed, allowReadOnly)
if _, err := values.option(); err != nil {
return nil, values, func() {}, err
}
- cleanup, err := values.prepareCapture()
+ cleanup, err := values.prepareCapture(ctx)
if err != nil {
return nil, values, func() {}, err
}
@@ -348,7 +368,7 @@ func parseCommand(spec commandSpec, args []string) (parsedCommand, error) {
value := inline
if !hasValue {
- if index+1 >= len(args) {
+ if index+1 >= len(args) || (strings.HasPrefix(args[index+1], "-") && args[index+1] != "-") {
return parsedCommand{}, fmt.Errorf("--%s requires <%s>", flag.name, flag.valueName)
}
index++
@@ -422,7 +442,7 @@ func commandSpecs() []commandSpec {
},
{
name: "record",
- summary: "Capture owned raw observations",
+ summary: "Record a replayable capture or detailed JSON export",
usage: "record [source flags] [options]",
examples: []string{
"n2k record -i can0 --out capture.log",
@@ -430,6 +450,7 @@ func commandSpecs() []commandSpec {
},
flags: append(
readSourceFlags(),
+ flagSpec{name: "overwrite", kind: boolFlag, defaultVal: "false", description: "replace an existing output file (never the input capture)"},
flagSpec{name: "out", short: "o", valueName: "path", defaultVal: "-", description: "output path, or - for stdout", file: true},
flagSpec{
name: "output-format",
@@ -438,7 +459,7 @@ func commandSpecs() []commandSpec {
description: "capture format",
choices: []completionItem{
{value: "candump", description: "replayable candump text"},
- {value: "jsonl", description: "owned observation JSON lines"},
+ {value: "jsonl", description: "detailed JSON export (cannot replay)"},
},
},
),
@@ -471,6 +492,7 @@ func commandSpecs() []commandSpec {
},
flags: append(
readSourceFlags(),
+ summaryOutputFlag(),
flagSpec{name: "strict", kind: boolFlag, defaultVal: "false", description: "fail when undecodable messages are found"},
),
},
@@ -488,6 +510,7 @@ func commandSpecs() []commandSpec {
},
flags: append(
readSourceFlags(),
+ summaryOutputFlag(),
flagSpec{name: "wait", valueName: "duration", defaultVal: "3s", kind: durationFlag, description: "live/UDP observation window"},
flagSpec{name: "claim-timeout", valueName: "duration", defaultVal: "2s", kind: durationFlag, description: "writable-source address-claim timeout"},
),
@@ -495,12 +518,14 @@ func commandSpecs() []commandSpec {
{
name: "pgn",
summary: "Describe or list typed PGN support",
- usage: "pgn ",
+ usage: "pgn [--output json|text]",
+ flags: []flagSpec{summaryOutputFlag()},
minArgs: 1,
maxArgs: 1,
examples: []string{
"n2k pgn 127250",
"n2k pgn list",
+ "n2k pgn heading --output text",
},
},
{
diff --git a/cmd/n2k/commands.go b/cmd/n2k/commands.go
index d2d6a75..7d75794 100644
--- a/cmd/n2k/commands.go
+++ b/cmd/n2k/commands.go
@@ -35,11 +35,11 @@ func (values sourceFlagValues) option() (n2k.Option, error) {
return sourceOption(values.iface, values.usb, values.file, values.tcp, values.udp, values.format, values.timing)
}
-func (values *sourceFlagValues) prepareCapture() (func(), error) {
+func (values *sourceFlagValues) prepareCapture(ctx context.Context) (func(), error) {
if values.file == "" {
return func() {}, nil
}
- path, cleanup, err := prepareCapture(values.file)
+ path, cleanup, err := prepareCapture(ctx, values.file)
if err != nil {
return func() {}, err
}
@@ -51,7 +51,8 @@ func (values *sourceFlagValues) prepareCapture() (func(), error) {
// file. n2k.File owns candump parsing, so keeping decompression at this
// boundary avoids duplicating the library's parser and preserves all file
// source behavior.
-func prepareCapture(path string) (string, func(), error) {
+func prepareCapture(ctx context.Context, path string) (string, func(), error) {
+ path = expandPath(path)
file, err := os.Open(path) // #nosec G304 -- the CLI reads the operator-selected capture path.
if err != nil {
return "", func() {}, fmt.Errorf("opening capture %q: %w", path, err)
@@ -64,7 +65,7 @@ func prepareCapture(path string) (string, func(), error) {
return "", func() {}, fmt.Errorf("inspecting capture %q: %w", path, peekErr)
}
if len(header) < 2 || header[0] != 0x1f || header[1] != 0x8b {
- return path, func() {}, nil
+ return path, func() {}, validateCapture(ctx, path, path)
}
compressed, err := gzip.NewReader(buffered)
@@ -80,7 +81,7 @@ func prepareCapture(path string) (string, func(), error) {
expandedPath := expanded.Name()
removeExpanded := func() { _ = os.Remove(expandedPath) }
limited := io.LimitReader(compressed, maxExpandedCaptureBytes+1)
- written, err := io.Copy(expanded, limited)
+ written, err := io.Copy(expanded, contextReader{ctx: ctx, reader: limited})
if err != nil {
_ = expanded.Close()
removeExpanded()
@@ -99,6 +100,10 @@ func prepareCapture(path string) (string, func(), error) {
removeExpanded()
return "", func() {}, fmt.Errorf("finishing gzip capture %q: %w", path, err)
}
+ if err := validateCapture(ctx, expandedPath, path); err != nil {
+ removeExpanded()
+ return "", func() {}, err
+ }
return expandedPath, removeExpanded, nil
}
@@ -125,22 +130,23 @@ func runSniff(ctx context.Context, out io.Writer, source n2k.Option, expression
if err := writer.Write(message); err != nil {
return err
}
+ countProgress(ctx)
}
return nil
}
-func runRecord(ctx context.Context, stdout io.Writer, source n2k.Option, outputPath, outputFormat string) error {
+func runRecord(ctx context.Context, stdout io.Writer, source n2k.Option, inputPath, outputPath, outputFormat string, overwrite bool) (resultErr error) {
if outputFormat != "candump" && outputFormat != "jsonl" {
return fmt.Errorf("unknown output format %q: use candump or jsonl", outputFormat)
}
- writer, closeWriter, err := outputWriter(outputPath, stdout)
+ writer, closeWriter, err := outputWriter(inputPath, outputPath, stdout, overwrite)
if err != nil {
return err
}
- defer closeWriter()
+ defer func() { resultErr = errors.Join(resultErr, closeWriter()) }()
buffered := bufio.NewWriter(writer)
- defer func() { _ = buffered.Flush() }()
+ defer func() { resultErr = errors.Join(resultErr, buffered.Flush()) }()
encoder := json.NewEncoder(buffered)
for observation, observeErr := range n2k.Observe(ctx, source) {
if observeErr != nil {
@@ -153,6 +159,7 @@ func runRecord(ctx context.Context, stdout io.Writer, source n2k.Option, outputP
if err := encoder.Encode(observation); err != nil {
return fmt.Errorf("encoding observation: %w", err)
}
+ countProgress(ctx)
continue
}
if observation.Frame == nil {
@@ -161,19 +168,38 @@ func runRecord(ctx context.Context, stdout io.Writer, source n2k.Option, outputP
if _, err := fmt.Fprintln(buffered, formatCandump(observation)); err != nil {
return fmt.Errorf("writing capture: %w", err)
}
+ countProgress(ctx)
}
- return buffered.Flush()
+ return nil // Deferred flush and close report errors, including after cancellation.
}
-func outputWriter(path string, stdout io.Writer) (io.Writer, func(), error) {
+func outputWriter(inputPath, path string, stdout io.Writer, overwrite bool) (io.Writer, func() error, error) {
if path == "" || path == "-" {
- return stdout, func() {}, nil
+ return stdout, func() error { return nil }, nil
+ }
+ path = expandPath(path)
+ flags := os.O_CREATE | os.O_WRONLY | os.O_EXCL
+ if overwrite {
+ flags = os.O_CREATE | os.O_WRONLY
}
- file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) // #nosec G304 -- the CLI writes the operator-selected capture path.
+ file, err := os.OpenFile(path, flags, 0o600) // #nosec G304 -- the CLI writes the operator-selected capture path.
if err != nil {
- return nil, func() {}, fmt.Errorf("opening output: %w", err)
+ if errors.Is(err, os.ErrExist) {
+ return nil, nil, fmt.Errorf("output %q already exists; choose a new path or use --overwrite to replace it", path)
+ }
+ return nil, nil, fmt.Errorf("opening output: %w", err)
+ }
+ if err := checkOutputFile(inputPath, file); err != nil {
+ _ = file.Close()
+ return nil, nil, err
+ }
+ if overwrite {
+ if err := file.Truncate(0); err != nil {
+ _ = file.Close()
+ return nil, nil, fmt.Errorf("replacing output: %w", err)
+ }
}
- return file, func() { _ = file.Close() }, nil
+ return file, file.Close, nil
}
func formatCandump(observation n2k.Observation) string {
@@ -216,19 +242,21 @@ func runReplay(ctx context.Context, out io.Writer, file string, timing bool, exp
if err := writer.Write(message); err != nil {
return err
}
+ countProgress(ctx)
}
return nil
}
type validationSummary struct {
- Messages int `json:"messages"`
- Typed int `json:"typed"`
- Undecodable int `json:"undecodable"`
- ByPGN map[uint32]int `json:"byPgn"`
+ Messages int `json:"messages"`
+ Typed int `json:"typed"`
+ Undecodable int `json:"undecodable"`
+ ByPGN map[uint32]int `json:"byPgn"`
+ UndecodableByPGN map[uint32]int `json:"undecodableByPgn"`
}
-func runValidate(ctx context.Context, out io.Writer, source n2k.Option, strict bool) error {
- summary := validationSummary{ByPGN: make(map[uint32]int)}
+func runValidate(ctx context.Context, out io.Writer, source n2k.Option, strict bool, outputFormat string) error {
+ summary := validationSummary{ByPGN: make(map[uint32]int), UndecodableByPGN: make(map[uint32]int)}
for message, receiveErr := range n2k.Receive(ctx, source, n2k.IncludeUnknown()) {
if receiveErr != nil {
if ctx.Err() != nil {
@@ -237,8 +265,10 @@ func runValidate(ctx context.Context, out io.Writer, source n2k.Option, strict b
return receiveErr
}
summary.Messages++
+ countProgress(ctx)
if unknown, ok := message.(*pgn.UnknownPGN); ok {
summary.Undecodable++
+ summary.UndecodableByPGN[unknown.Info.PGN]++
summary.ByPGN[unknown.Info.PGN]++
continue
}
@@ -247,13 +277,11 @@ func runValidate(ctx context.Context, out io.Writer, source n2k.Option, strict b
summary.ByPGN[carrier.MessageInfo().PGN]++
}
}
- encoder := json.NewEncoder(out)
- encoder.SetIndent("", " ")
- if err := encoder.Encode(summary); err != nil {
- return fmt.Errorf("encoding summary: %w", err)
+ if err := writeValidation(out, summary, outputFormat); err != nil {
+ return err
}
if strict && summary.Undecodable > 0 {
- return fmt.Errorf("%d undecodable messages", summary.Undecodable)
+ return fmt.Errorf("%d undecodable messages; see undecodableByPgn for the failing PGNs", summary.Undecodable)
}
return nil
}
@@ -267,7 +295,7 @@ type deviceRecord struct {
ConfigInfo *pgn.ConfigurationInformation `json:"configInfo,omitempty"`
}
-func runActiveDevices(ctx context.Context, out io.Writer, source n2k.Option, reconnect bool, wait, claimTimeout time.Duration) error {
+func runActiveDevices(ctx context.Context, out io.Writer, source n2k.Option, reconnect bool, wait, claimTimeout time.Duration, outputFormat string) error {
opts := []n2k.Option{source, n2k.WithClaimTimeout(claimTimeout)}
if reconnect {
opts = append(opts, n2k.WithReconnect(n2k.ReconnectPolicy{}))
@@ -282,7 +310,6 @@ func runActiveDevices(ctx context.Context, out io.Writer, source n2k.Option, rec
select {
case <-timer.C:
case <-ctx.Done():
- return nil
}
if err := client.Err(); err != nil {
return err
@@ -301,10 +328,11 @@ func runActiveDevices(ctx context.Context, out io.Writer, source n2k.Option, rec
ConfigInfo: device.ConfigInfo,
})
}
- return encodeDevices(out, records)
+ setProgress(ctx, len(records))
+ return writeDevices(out, records, outputFormat)
}
-func runPassiveDevices(ctx context.Context, out io.Writer, source n2k.Option, wait time.Duration, bounded bool) error {
+func runPassiveDevices(ctx context.Context, out io.Writer, source n2k.Option, wait time.Duration, bounded bool, outputFormat string) error {
scanCtx := ctx
cancel := func() {}
if bounded {
@@ -322,10 +350,9 @@ func runPassiveDevices(ctx context.Context, out io.Writer, source n2k.Option, wa
}
inventory.observe(message)
}
- if ctx.Err() != nil {
- return nil
- }
- return encodeDevices(out, inventory.snapshot())
+ records := inventory.snapshot()
+ setProgress(ctx, len(records))
+ return writeDevices(out, records, outputFormat)
}
type deviceInventory struct {
@@ -458,31 +485,26 @@ func sortedPGNNumbers() []uint32 {
return numbers
}
-func runPGN(out io.Writer, value string) error {
+func runPGN(out io.Writer, value, outputFormat string) error {
+ infos, err := findPGNs(value)
+ if err != nil {
+ return err
+ }
+ if outputFormat == messageOutputText {
+ _, numericErr := strconv.ParseUint(value, 0, 32)
+ return writePGNTable(out, infos, value == "list" || (numericErr != nil && len(infos) > 1))
+ }
encoder := json.NewEncoder(out)
encoder.SetIndent("", " ")
if value == "list" {
- for _, number := range sortedPGNNumbers() {
- for _, info := range pgn.PgnInfoLookup[number] {
- if err := encoder.Encode(info); err != nil {
- return fmt.Errorf("encoding PGN metadata: %w", err)
- }
+ for _, info := range infos {
+ if err := encoder.Encode(info); err != nil {
+ return err
}
}
return nil
}
- number, err := strconv.ParseUint(value, 0, 32)
- if err != nil {
- return fmt.Errorf("invalid PGN %q", value)
- }
- infos := pgn.PgnInfoLookup[uint32(number)]
- if len(infos) == 0 {
- return fmt.Errorf("PGN %d is not in the typed metadata", number)
- }
- if err := encoder.Encode(infos); err != nil {
- return fmt.Errorf("encoding PGN metadata: %w", err)
- }
- return nil
+ return encoder.Encode(infos)
}
// sourceOption converts mutually exclusive source flags into the one n2k
@@ -507,9 +529,15 @@ func sourceOption(iface, usb, file, tcp, udp, format string, timing bool) (n2k.O
sources = append(sources, n2k.File(file, fileOpts...))
}
if tcp != "" {
+ if err := addressValidator("TCP")(tcp); err != nil {
+ return nil, err
+ }
sources = append(sources, n2k.TCP(tcp, stream))
}
if udp != "" {
+ if err := addressValidator("UDP")(udp); err != nil {
+ return nil, err
+ }
sources = append(sources, n2k.UDP(udp, stream))
}
if len(sources) != 1 {
diff --git a/cmd/n2k/completion.go b/cmd/n2k/completion.go
index bedb8de..5aa24bc 100644
--- a/cmd/n2k/completion.go
+++ b/cmd/n2k/completion.go
@@ -222,14 +222,13 @@ func completePGNs(prefix string) []completionItem {
}
for _, number := range sortedPGNNumbers() {
value := strconv.FormatUint(uint64(number), 10)
- if !strings.HasPrefix(value, prefix) {
- continue
- }
description := "typed PGN"
if infos := pgn.PgnInfoLookup[number]; len(infos) > 0 && infos[0].Description != "" {
description = sanitizeDescription(infos[0].Description)
}
- items = append(items, completionItem{value: value, description: description})
+ if strings.HasPrefix(value, prefix) || strings.Contains(strings.ToLower(description), strings.ToLower(prefix)) {
+ items = append(items, completionItem{value: value, description: description})
+ }
}
return items
}
diff --git a/cmd/n2k/input_validation.go b/cmd/n2k/input_validation.go
new file mode 100644
index 0000000..7ea89d7
--- /dev/null
+++ b/cmd/n2k/input_validation.go
@@ -0,0 +1,52 @@
+package main
+
+import (
+ "fmt"
+ "net"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/google/cel-go/cel"
+)
+
+// Match the metadata and decoded-message variables supported by n2k.Filter.
+var filterEnvironment = sync.OnceValues(func() (*cel.Env, error) {
+ return cel.NewEnv(
+ cel.Variable("pgn", cel.IntType), cel.Variable("source", cel.IntType),
+ cel.Variable("priority", cel.IntType), cel.Variable("destination", cel.IntType),
+ cel.Variable("msg", cel.DynType),
+ )
+})
+
+func validateFilter(expression string) error {
+ if strings.TrimSpace(expression) == "" {
+ return nil
+ }
+ env, err := filterEnvironment()
+ if err != nil {
+ return err
+ }
+ ast, issues := env.Compile(expression)
+ if issues != nil && issues.Err() != nil {
+ return fmt.Errorf("invalid filter; use a comparison such as pgn == 127250:\n%w", issues.Err())
+ }
+ if ast.OutputType() != cel.BoolType && ast.OutputType() != cel.DynType {
+ return fmt.Errorf("filter must produce true or false; use a comparison such as pgn == 127250")
+ }
+ return nil
+}
+
+func addressValidator(network string) func(string) error {
+ return func(value string) error {
+ host, port, err := net.SplitHostPort(value)
+ if err != nil || (network == "TCP" && strings.TrimSpace(host) == "") || strings.ContainsAny(host, " \t\r\n/") {
+ return fmt.Errorf("enter a %s address as host:port (for example 192.168.4.1:1457); UDP may use :1457", network)
+ }
+ number, err := strconv.Atoi(port)
+ if err != nil || number < 1 || number > 65535 {
+ return fmt.Errorf("%s port must be a number from 1 to 65535", network)
+ }
+ return nil
+ }
+}
diff --git a/cmd/n2k/inspection.go b/cmd/n2k/inspection.go
new file mode 100644
index 0000000..f8443d9
--- /dev/null
+++ b/cmd/n2k/inspection.go
@@ -0,0 +1,138 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "sort"
+ "strconv"
+ "strings"
+ "text/tabwriter"
+
+ "github.com/open-ships/n2k/pgn"
+)
+
+func summaryOutputFlag() flagSpec {
+ return flagSpec{name: "output", valueName: "format", defaultVal: messageOutputJSON, description: "result format", choices: []completionItem{
+ {value: "json", description: "JSON for scripts"}, {value: "text", description: "readable summary and tables"},
+ }}
+}
+
+func findPGNs(query string) ([]*pgn.PgnInfo, error) {
+ query = strings.TrimSpace(query)
+ if number, err := strconv.ParseUint(query, 0, 32); err == nil {
+ if infos := pgn.PgnInfoLookup[uint32(number)]; len(infos) > 0 {
+ return infos, nil
+ }
+ return nil, fmt.Errorf("PGN %d is not in the typed metadata; use n2k pgn list --output text to browse", number)
+ }
+ var matches []*pgn.PgnInfo
+ for _, number := range sortedPGNNumbers() {
+ for _, info := range pgn.PgnInfoLookup[number] {
+ if query == "list" || (query != "" && strings.Contains(strings.ToLower(info.Description+" "+info.Id), strings.ToLower(query))) {
+ matches = append(matches, info)
+ }
+ }
+ }
+ if len(matches) == 0 {
+ return nil, fmt.Errorf("no known PGNs match %q; try a number or name such as heading", query)
+ }
+ return matches, nil
+}
+
+func writePGNTable(out io.Writer, infos []*pgn.PgnInfo, listOnly bool) error {
+ writer := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
+ if listOnly {
+ _, _ = fmt.Fprintln(writer, "PGN\tNAME\tSCHEMA")
+ for _, info := range infos {
+ _, _ = fmt.Fprintf(writer, "%d\t%s\t%s\n", info.PGN, singleLine(info.Description), schemaConfidence(info))
+ }
+ _, _ = fmt.Fprintln(writer, "\nInspect fields with: n2k pgn --output text")
+ return writer.Flush()
+ }
+ for _, info := range infos {
+ _, _ = fmt.Fprintf(writer, "PGN %d · %s\nSchema: %s · Transport: %s\n\n", info.PGN, singleLine(info.Description), schemaConfidence(info), info.Type)
+ _, _ = fmt.Fprintln(writer, "FIELD\tUNITS\tRANGE / VALUES")
+ orders := make([]int, 0, len(info.Fields))
+ for order := range info.Fields {
+ orders = append(orders, order)
+ }
+ sort.Ints(orders)
+ for _, order := range orders {
+ field := info.Fields[order]
+ values := lookupName(field)
+ if field.RangeMin != nil && field.RangeMax != nil {
+ values = fmt.Sprintf("%g to %g", *field.RangeMin, *field.RangeMax)
+ }
+ if field.Description != "" {
+ values += " " + singleLine(field.Description)
+ }
+ _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\n", singleLine(field.Name), field.Unit, strings.TrimSpace(values))
+ }
+ }
+ return writer.Flush()
+}
+
+func schemaConfidence(info *pgn.PgnInfo) string {
+ if info.Fallback {
+ return "fallback"
+ }
+ if info.Complete {
+ return "complete"
+ }
+ return "partial"
+}
+
+func writeDevices(out io.Writer, devices []deviceRecord, format string) error {
+ if format != messageOutputText {
+ return encodeDevices(out, devices)
+ }
+ if len(devices) == 0 {
+ _, err := fmt.Fprintln(out, "No devices observed. Try a longer discovery window or a different source.")
+ return err
+ }
+ writer := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
+ _, _ = fmt.Fprintln(writer, "ADDRESS\tMANUFACTURER\tMODEL\tLAST SEEN")
+ for _, device := range devices {
+ manufacturer, model := "Unknown", "Not observed"
+ if device.Name != nil {
+ manufacturer = pgn.ManufacturerCodeConst(device.Name.ManufacturerCode).String()
+ }
+ if device.ProductInfo != nil && device.ProductInfo.ModelId != "" {
+ model = device.ProductInfo.ModelId
+ }
+ _, _ = fmt.Fprintf(writer, "%d\t%s\t%s\t%s\n", device.Address, singleLine(manufacturer), singleLine(model), device.LastSeen.Format("2006-01-02 15:04:05 MST"))
+ }
+ return writer.Flush()
+}
+
+func writeValidation(out io.Writer, summary validationSummary, format string) error {
+ if format != messageOutputText {
+ encoder := json.NewEncoder(out)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(summary)
+ }
+ writer := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
+ _, _ = fmt.Fprintf(writer, "%d messages · %d decoded · %d undecodable\n", summary.Messages, summary.Typed, summary.Undecodable)
+ if summary.Undecodable > 0 {
+ _, _ = fmt.Fprintln(writer, "\nPGN\tUNDECODABLE\tINSPECT")
+ numbers := make([]uint32, 0, len(summary.UndecodableByPGN))
+ for number := range summary.UndecodableByPGN {
+ numbers = append(numbers, number)
+ }
+ sort.Slice(numbers, func(i, j int) bool { return numbers[i] < numbers[j] })
+ for _, number := range numbers {
+ _, _ = fmt.Fprintf(writer, "%d\t%d\tn2k sniff --file --unknown --filter 'pgn == %d'\n", number, summary.UndecodableByPGN[number], number)
+ }
+ }
+ return writer.Flush()
+}
+
+func singleLine(value string) string {
+ return strings.Map(func(r rune) rune {
+ if r < 32 || r == 127 {
+ return ' '
+ }
+ return r
+ }, value)
+}
diff --git a/cmd/n2k/main.go b/cmd/n2k/main.go
index 6cd15b2..be88a3e 100644
--- a/cmd/n2k/main.go
+++ b/cmd/n2k/main.go
@@ -27,7 +27,7 @@ var (
)
func main() {
- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM)
defer stop()
app := newCLI(os.Stdin, os.Stdout, os.Stderr)
diff --git a/cmd/n2k/main_test.go b/cmd/n2k/main_test.go
index 7655877..b7b1503 100644
--- a/cmd/n2k/main_test.go
+++ b/cmd/n2k/main_test.go
@@ -482,6 +482,7 @@ func TestDevicesWizardBuildsPassiveCaptureInventory(t *testing.T) {
require.Equal(t, []string{
"devices",
"--file", "capture.log.gz",
+ "--output", "text",
}, config.args())
}
diff --git a/cmd/n2k/output.go b/cmd/n2k/output.go
index 1498fe4..c95fe33 100644
--- a/cmd/n2k/output.go
+++ b/cmd/n2k/output.go
@@ -143,6 +143,9 @@ func formatTypedField(message reflect.Value, field reflect.StructField, value re
}
if descriptor != nil {
if lookup := lookupName(descriptor); lookup != "" {
+ if label, ok := enumLabel(lookup, value); ok {
+ return strconv.Quote(label)
+ }
return lookup + "(" + fmt.Sprint(value.Interface()) + ")"
}
}
@@ -161,6 +164,35 @@ func formatTypedField(message reflect.Value, field reflect.StructField, value re
return string(encoded)
}
+// Use library labels for common marine lookups. Unknown values and lookup
+// families retain the explicit lookup name and wire value above.
+func enumLabel(lookup string, value reflect.Value) (string, bool) {
+ if !value.CanUint() || value.Uint() > 255 {
+ return "", false
+ }
+ number := uint8(value.Uint())
+ var label string
+ switch lookup {
+ case "DIRECTION_REFERENCE":
+ label = pgn.DirectionReferenceConst(number).String()
+ case "WIND_REFERENCE":
+ label = pgn.WindReferenceConst(number).String()
+ case "WATER_REFERENCE":
+ label = pgn.WaterReferenceConst(number).String()
+ case "DIRECTION_RUDDER":
+ label = pgn.DirectionRudderConst(number).String()
+ case "YES_NO":
+ label = pgn.YesNoConst(number).String()
+ case "OFF_ON":
+ label = pgn.OffOnConst(number).String()
+ case "OK_WARNING":
+ label = pgn.OkWarningConst(number).String()
+ default:
+ return "", false
+ }
+ return label, !strings.Contains(label, "Const(")
+}
+
func formatPhysicalValue(value float64, descriptor *pgn.FieldDescriptor) string {
if descriptor == nil || descriptor.Resolution == 0 {
return strconv.FormatFloat(value, 'f', -1, 64)
diff --git a/cmd/n2k/session.go b/cmd/n2k/session.go
new file mode 100644
index 0000000..8bf5b27
--- /dev/null
+++ b/cmd/n2k/session.go
@@ -0,0 +1,160 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/signal"
+ "sync/atomic"
+ "time"
+
+ "charm.land/huh/v2"
+)
+
+type progressKey struct{}
+type executionProgress struct{ count atomic.Int64 }
+
+func countProgress(ctx context.Context) {
+ if progress, ok := ctx.Value(progressKey{}).(*executionProgress); ok {
+ progress.count.Add(1)
+ }
+}
+
+func setProgress(ctx context.Context, count int) {
+ if progress, ok := ctx.Value(progressKey{}).(*executionProgress); ok {
+ progress.count.Store(int64(count))
+ }
+}
+
+func (app *cli) executeInteractive(ctx context.Context, config *wizardConfig) error {
+ args := config.args()
+ _, _ = fmt.Fprintf(app.errOut, "\n%s\n", renderCommandPreview(args))
+ if config.command == "pgn" || config.command == "update" {
+ return app.ExecuteContext(ctx, args)
+ }
+ runCtx, stop := signal.NotifyContext(ctx, os.Interrupt)
+ defer stop()
+ progress := &executionProgress{}
+ runCtx = context.WithValue(runCtx, progressKey{}, progress)
+ started := time.Now()
+ _, _ = fmt.Fprintln(app.errOut, "Opening source. Ctrl+C stops this run and keeps captured data.")
+ done := make(chan struct{})
+ finished := make(chan struct{})
+ go func() {
+ defer close(finished)
+ ticker := time.NewTicker(time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-done:
+ return
+ case <-ticker.C:
+ count := progress.count.Load()
+ state := "receiving"
+ if count == 0 {
+ state = "waiting for matching traffic"
+ }
+ if config.command == "devices" {
+ state = "discovering devices"
+ }
+ _, _ = fmt.Fprintf(app.errOut, " %s · %d %s · %s elapsed\n", state, count, config.progressUnit(), time.Since(started).Round(time.Second))
+ }
+ }
+ }()
+ err := app.ExecuteContext(runCtx, args)
+ close(done)
+ <-finished // Finish status writes before starting the next interactive form.
+ count := progress.count.Load()
+ state := "Finished"
+ if runCtx.Err() != nil {
+ state = "Stopped"
+ }
+ if err != nil {
+ state = "Failed"
+ }
+ _, _ = fmt.Fprintf(app.errOut, "%s: %d %s in %s.\n", state, count, config.progressUnit(), time.Since(started).Round(time.Millisecond))
+ if config.command == "record" && err == nil {
+ if config.outputPath == "-" {
+ _, _ = fmt.Fprintln(app.errOut, "Capture written to stdout.")
+ } else {
+ _, _ = fmt.Fprintf(app.errOut, "Saved capture: %s\n", expandPath(config.outputPath))
+ }
+ }
+ if count == 0 && err == nil {
+ _, _ = fmt.Fprintln(app.errOut, "No matching traffic was observed. Check the source and filter; for decoding, try including unknown PGNs.")
+ }
+ return err
+}
+
+func (config *wizardConfig) progressUnit() string {
+ if config.command == "record" {
+ return "observations saved"
+ }
+ if config.command == "devices" {
+ return "devices"
+ }
+ return "messages"
+}
+
+func (app *cli) nextWorkflowAction(ctx context.Context, failed, accessible bool) (string, error) {
+ action := "another"
+ options := []huh.Option[string]{
+ huh.NewOption("Edit settings", "edit"),
+ huh.NewOption("Run again", "retry"),
+ huh.NewOption("Another workflow", "another"),
+ huh.NewOption("Quit", "quit"),
+ }
+ if failed {
+ action = "edit"
+ options[1] = huh.NewOption("Retry", "retry")
+ }
+ form := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().Title("What next?").Options(options...).Value(&action)))
+ ok, err := app.runForm(ctx, form, accessible)
+ if !ok {
+ return "quit", err
+ }
+ return action, err
+}
+
+func freshCapturePath(now time.Time) string {
+ base := "capture-" + now.Format("20060102-150405")
+ for index := 0; ; index++ {
+ path := base + ".log"
+ if index > 0 {
+ path = fmt.Sprintf("%s-%d.log", base, index)
+ }
+ if _, err := os.Lstat(path); err != nil {
+ return path
+ }
+ }
+}
+
+func (app *cli) confirmOverwrite(ctx context.Context, config *wizardConfig, accessible bool) (bool, error) {
+ if config.command != "record" {
+ return true, nil
+ }
+ config.overwrite = false // Each run needs a fresh, explicit replacement choice.
+ inputPath := ""
+ if config.source == "file" {
+ inputPath = config.file
+ }
+ if err := checkRecordPaths(inputPath, config.outputPath); err != nil {
+ return false, err
+ }
+ if config.outputPath == "-" {
+ return true, nil
+ }
+ _, err := os.Lstat(expandPath(config.outputPath))
+ if os.IsNotExist(err) {
+ return true, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ form := huh.NewForm(huh.NewGroup(huh.NewConfirm().
+ Title("Replace the existing capture?").
+ Description(fmt.Sprintf("%s already exists. Replacing it erases its current contents.", config.outputPath)).
+ Affirmative("Replace").Negative("Keep file").Value(&config.overwrite)))
+ ok, err := app.runForm(ctx, form, accessible)
+ return ok && config.overwrite, err
+}
diff --git a/cmd/n2k/tui.go b/cmd/n2k/tui.go
index b13d7e2..99efee1 100644
--- a/cmd/n2k/tui.go
+++ b/cmd/n2k/tui.go
@@ -9,6 +9,7 @@ import (
"strings"
"time"
+ "charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
@@ -22,7 +23,14 @@ type paletteItem struct {
description string
}
-func (item paletteItem) Title() string { return item.title }
+func (item paletteItem) Title() string {
+ for index, choice := range commandPaletteItems() {
+ if choice.command == item.command {
+ return fmt.Sprintf("%d %s", index+1, item.title)
+ }
+ }
+ return item.title
+}
func (item paletteItem) Description() string { return item.description }
func (item paletteItem) FilterValue() string {
return item.command + " " + item.title + " " + item.description
@@ -34,6 +42,7 @@ type paletteModel struct {
canceled bool
width int
height int
+ isDark bool
}
func newPaletteModel() paletteModel {
@@ -50,7 +59,7 @@ func newPaletteModel() paletteModel {
model.SetFilteringEnabled(true)
model.SetStatusBarItemName("workflow", "workflows")
model.InfiniteScrolling = true
- return paletteModel{list: model, width: 84, height: 24}
+ return paletteModel{list: model, width: 84, height: 24, isDark: true}
}
func commandPaletteItems() []paletteItem {
@@ -63,7 +72,7 @@ func commandPaletteItems() []paletteItem {
{
command: "record",
title: "Record raw observations",
- description: "Capture replayable candump or context-rich observation JSON lines",
+ description: "Save a replayable capture or detailed JSON export",
},
{
command: "replay",
@@ -83,7 +92,7 @@ func commandPaletteItems() []paletteItem {
{
command: "pgn",
title: "Explore the PGN schema",
- description: "Autocomplete known PGNs and inspect fields, ranges, units, and confidence",
+ description: "Search PGNs by name or number; inspect fields, ranges, units, and confidence",
},
{
command: "update",
@@ -100,20 +109,24 @@ func (model paletteModel) Init() tea.Cmd {
func (model paletteModel) Update(message tea.Msg) (tea.Model, tea.Cmd) {
switch message := message.(type) {
case tea.WindowSizeMsg:
- model.width = max(48, message.Width)
- model.height = max(16, message.Height)
- model.list.SetSize(model.width-6, model.height-8)
+ model.width = max(1, message.Width)
+ model.height = max(1, message.Height)
+ model.resizeList()
case tea.BackgroundColorMsg:
- model.list.Styles = list.DefaultStyles(message.IsDark())
- delegate := list.NewDefaultDelegate()
- delegate.Styles = list.NewDefaultItemStyles(message.IsDark())
- delegate.SetSpacing(1)
- model.list.SetDelegate(delegate)
+ model.isDark = message.IsDark()
+ model.list.Styles = list.DefaultStyles(model.isDark)
+ model.resizeList()
case tea.KeyPressMsg:
key := message.String()
if model.list.FilterState() != list.Filtering {
switch key {
- case "q", "esc", "ctrl+c":
+ case "esc":
+ if model.list.FilterState() != list.Unfiltered {
+ break
+ }
+ model.canceled = true
+ return model, tea.Quit
+ case "q", "ctrl+c":
model.canceled = true
return model, tea.Quit
case "enter":
@@ -122,6 +135,9 @@ func (model paletteModel) Update(message tea.Msg) (tea.Model, tea.Cmd) {
return model, tea.Quit
}
case "1", "2", "3", "4", "5", "6", "7":
+ if model.list.FilterState() != list.Unfiltered {
+ break
+ }
index := int(key[0] - '1')
items := commandPaletteItems()
if index >= 0 && index < len(items) {
@@ -147,9 +163,15 @@ func (model paletteModel) View() tea.View {
subtitle := lipgloss.NewStyle().
Foreground(muted).
Render("NMEA 2000 developer console")
+ hintText := "1–7 configure • / filter • enter open • ? help • q quit"
+ if model.list.FilterState() == list.FilterApplied {
+ hintText = "esc clear filter • enter open • q quit"
+ } else if model.list.FilterState() == list.Filtering {
+ hintText = "enter apply filter • esc cancel filter"
+ }
hint := lipgloss.NewStyle().
Foreground(muted).
- Render("1–7 jump • / filter • enter configure • ? help • q quit")
+ Render(hintText)
body := lipgloss.JoinVertical(
lipgloss.Left,
title+" "+subtitle,
@@ -158,28 +180,87 @@ func (model paletteModel) View() tea.View {
"",
hint,
)
- view := tea.NewView(lipgloss.NewStyle().Padding(1, 3).Render(body))
+ content := lipgloss.NewStyle().Padding(1, 3).Render(body)
+ if model.compact() {
+ hint := "↑/↓ choose · enter open\n/ search · esc back · q quit"
+ if model.list.FilterState() == list.Filtering {
+ hint = "enter applies filter\nesc cancels filter"
+ }
+ content = lipgloss.NewStyle().Padding(0, 1).Render(title + "\n" + model.list.View() + "\n" + hint)
+ }
+ if model.width < 30 || model.height < 10 {
+ content = lipgloss.NewStyle().MaxWidth(model.width).MaxHeight(model.height).Render("Resize terminal to 30×10.\nq to quit")
+ }
+ view := tea.NewView(content)
view.AltScreen = true
view.WindowTitle = "n2k command center"
return view
}
+func (model paletteModel) compact() bool { return model.width < 70 || model.height < 24 }
+
+func (model *paletteModel) resizeList() {
+ delegate := list.NewDefaultDelegate()
+ delegate.Styles = list.NewDefaultItemStyles(model.isDark)
+ delegate.ShowDescription = !model.compact()
+ delegate.SetSpacing(0)
+ model.list.SetDelegate(delegate)
+ model.list.SetShowStatusBar(!model.compact())
+ model.list.SetShowHelp(!model.compact())
+ if model.compact() {
+ model.list.SetSize(max(1, model.width-2), max(1, model.height-5))
+ } else {
+ model.list.SetSize(max(1, model.width-6), max(1, model.height-8))
+ }
+}
+
func (app *cli) runInteractive(ctx context.Context, accessible bool) error {
accessible = accessible || envTruthy("N2K_ACCESSIBLE")
updated, err := app.maybeOfferUpdate(ctx, accessible)
if err != nil || updated {
return err
}
- command, selected, err := app.chooseCommand(ctx, accessible)
- if err != nil || !selected {
- return err
- }
- args, confirmed, err := app.configureCommand(ctx, command, accessible)
- if err != nil || !confirmed {
- return err
+ var config *wizardConfig
+ action := "another"
+ for ctx.Err() == nil {
+ if action == "another" {
+ command, selected, err := app.chooseCommand(ctx, accessible)
+ if err != nil || !selected {
+ return err
+ }
+ config = defaultWizardConfig(command)
+ action = "edit"
+ }
+ if action == "edit" {
+ _, confirmed, err := app.configureWizard(ctx, config, accessible)
+ if err != nil {
+ return err
+ }
+ if !confirmed {
+ action = "another"
+ continue
+ }
+ }
+ confirmed, runErr := app.confirmOverwrite(ctx, config, accessible)
+ if runErr == nil && !confirmed {
+ action = "edit"
+ continue
+ }
+ if runErr == nil {
+ runErr = app.executeInteractive(ctx, config)
+ }
+ if ctx.Err() != nil {
+ return nil
+ }
+ if runErr != nil {
+ _, _ = fmt.Fprintf(app.errOut, "\nn2k: %v\nYour settings are preserved.\n", runErr)
+ }
+ action, err = app.nextWorkflowAction(ctx, runErr != nil, accessible)
+ if err != nil || action == "quit" {
+ return err
+ }
}
- _, _ = fmt.Fprintf(app.errOut, "\n%s\n\n", renderCommandPreview(args))
- return app.ExecuteContext(ctx, args)
+ return nil
}
func (app *cli) chooseCommand(ctx context.Context, accessible bool) (string, bool, error) {
@@ -234,6 +315,7 @@ type wizardConfig struct {
unknown bool
output string
outputPath string
+ overwrite bool
recordFormat string
strict bool
wait string
@@ -250,8 +332,9 @@ func defaultWizardConfig(command string) *wizardConfig {
command: command,
source: "file",
format: "raw",
- output: "json",
- outputPath: "-",
+ output: "text",
+ outputPath: freshCapturePath(time.Now()),
+ timing: command == "replay",
recordFormat: "candump",
wait: "3s",
claimTimeout: "2s",
@@ -263,29 +346,47 @@ func defaultWizardConfig(command string) *wizardConfig {
}
}
-func (app *cli) configureCommand(ctx context.Context, command string, accessible bool) ([]string, bool, error) {
- config := defaultWizardConfig(command)
+func (app *cli) configureWizard(ctx context.Context, config *wizardConfig, accessible bool) ([]string, bool, error) {
+ command := config.command
+ config.run = true
+ config.overwrite = false
var groups []*huh.Group
switch command {
case "sniff", "record", "validate", "devices":
groups = append(groups, sourceWizardGroups(config)...)
groups = append(groups, commandOptionGroups(config)...)
case "replay":
- config.timing = true
groups = append(groups, replayWizardGroups(config)...)
case "pgn":
- groups = append(groups, pgnWizardGroups(config)...)
+ groups = append(groups, pgnWizardGroups(config, accessible)...)
case "update":
default:
return nil, false, fmt.Errorf("no interactive workflow for %q", command)
}
- groups = append(groups, confirmationGroup(config))
+ if command == "devices" || command == "validate" || command == "pgn" {
+ groups = append(groups, resultOutputGroup(config))
+ }
+ if !accessible {
+ groups = append(groups, confirmationGroup(config))
+ }
form := huh.NewForm(groups...).WithShowHelp(true).WithShowErrors(true)
ok, err := app.runForm(ctx, form, accessible)
if err != nil || !ok || !config.run {
return nil, false, err
}
+ if accessible {
+ // Huh's accessible renderer does not evaluate DescriptionFunc. Build
+ // the preview after the answers have been collected so it is reviewable.
+ confirmation := huh.NewForm(huh.NewGroup(
+ huh.NewNote().Title("Ready to run").Description(renderCommandPreview(config.args())),
+ huh.NewConfirm().Title("Run this command now?").Affirmative("Run").Negative("Cancel").Value(&config.run),
+ ))
+ ok, err = app.runForm(ctx, confirmation, true)
+ if err != nil || !ok || !config.run {
+ return nil, false, err
+ }
+ }
return config.args(), true, nil
}
@@ -368,7 +469,7 @@ func sourceWizardGroups(config *wizardConfig) []*huh.Group {
Title("Capture path").
Description("Plain or gzip candump -L/-l format; Tab accepts a suggestion").
Placeholder("capture.log").
- Suggestions([]string{"testdata/sample.log", "capture.log", "capture.log.gz"}).
+ SuggestionsFunc(func() []string { return pathSuggestions(config.file) }, &config.file).
Value(&config.file).
Validate(existingPath),
).WithHideFunc(func() bool { return config.source != "file" }),
@@ -376,7 +477,7 @@ func sourceWizardGroups(config *wizardConfig) []*huh.Group {
huh.NewInput().
Title("SocketCAN interface").
Placeholder("can0").
- Suggestions([]string{"can0", "vcan0"}).
+ SuggestionsFunc(func() []string { return completionValues(socketCANInterfaces()) }, &config.iface).
Value(&config.iface).
Validate(required("interface")),
).WithHideFunc(func() bool { return config.source != "interface" }),
@@ -384,7 +485,7 @@ func sourceWizardGroups(config *wizardConfig) []*huh.Group {
huh.NewInput().
Title("USB-CAN serial port").
Placeholder("/dev/ttyUSB0").
- Suggestions([]string{"/dev/ttyUSB0", "/dev/ttyACM0"}).
+ SuggestionsFunc(func() []string { return completionValues(completeUSBDevices(config.usb)) }, &config.usb).
Value(&config.usb).
Validate(required("serial port")),
).WithHideFunc(func() bool { return config.source != "usb" }),
@@ -394,7 +495,7 @@ func sourceWizardGroups(config *wizardConfig) []*huh.Group {
Placeholder("192.168.4.1:1457").
Suggestions([]string{"192.168.4.1:1457", "localhost:1457"}).
Value(&config.tcp).
- Validate(required("TCP address")),
+ Validate(addressValidator("TCP")),
).WithHideFunc(func() bool { return config.source != "tcp" }),
}
if config.allowReadOnly {
@@ -405,7 +506,7 @@ func sourceWizardGroups(config *wizardConfig) []*huh.Group {
Placeholder(":1457").
Suggestions([]string{":1457", "127.0.0.1:1457"}).
Value(&config.udp).
- Validate(required("UDP address")),
+ Validate(addressValidator("UDP")),
).WithHideFunc(func() bool { return config.source != "udp" }),
)
}
@@ -442,15 +543,15 @@ func commandOptionGroups(config *wizardConfig) []*huh.Group {
huh.NewGroup(
huh.NewInput().
Title("Output path").
- Description("Use - to stream to stdout").
+ Description("A new file is suggested. Existing files require a separate replacement choice; - streams to stdout.").
Value(&config.outputPath).
- Suggestions([]string{"-", "capture.log", "observations.jsonl"}).
+ SuggestionsFunc(func() []string { return pathSuggestions(config.outputPath) }, &config.outputPath).
Validate(required("output path")),
huh.NewSelect[string]().
Title("Capture format").
Options(
huh.NewOption("Replayable candump text", "candump"),
- huh.NewOption("Owned observation JSON lines", "jsonl"),
+ huh.NewOption("Detailed JSON export (cannot replay)", "jsonl"),
).
Value(&config.recordFormat),
),
@@ -494,16 +595,17 @@ func messageOptionGroups(config *wizardConfig) []*huh.Group {
huh.NewSelect[string]().
Title("Message output").
Options(
- huh.NewOption("Typed JSON lines with exact wire values", "json"),
- huh.NewOption("Concrete PGN types with physical values and units", "text"),
+ huh.NewOption("Readable physical values and units", "text"),
+ huh.NewOption("JSON lines with exact wire values", "json"),
).
Value(&config.output),
huh.NewInput().
Title("CEL filter").
- Description("Optional; metadata-only filters avoid decode work").
+ Description("Optional: pgn == 127250 keeps heading messages; Tab completes").
Placeholder("pgn == 127250").
Suggestions([]string{"pgn == 127250", "source == 0", "priority <= 3"}).
- Value(&config.filter),
+ Value(&config.filter).
+ Validate(validateFilter),
huh.NewConfirm().
Title("Include unknown PGNs?").
Value(&config.unknown),
@@ -518,7 +620,7 @@ func replayWizardGroups(config *wizardConfig) []*huh.Group {
huh.NewInput().
Title("Capture path").
Placeholder("capture.log").
- Suggestions([]string{"testdata/sample.log", "capture.log", "capture.log.gz"}).
+ SuggestionsFunc(func() []string { return pathSuggestions(config.file) }, &config.file).
Value(&config.file).
Validate(existingPath),
huh.NewConfirm().
@@ -531,32 +633,46 @@ func replayWizardGroups(config *wizardConfig) []*huh.Group {
)
}
-func pgnWizardGroups(config *wizardConfig) []*huh.Group {
- suggestions := make([]string, 0, len(pgn.PgnInfoLookup))
+func pgnWizardGroups(config *wizardConfig, accessible bool) []*huh.Group {
+ options := make([]huh.Option[string], 0, len(pgn.PgnInfoLookup))
for _, number := range sortedPGNNumbers() {
- suggestions = append(suggestions, strconv.FormatUint(uint64(number), 10))
+ infos := pgn.PgnInfoLookup[number]
+ label := fmt.Sprintf("%d · %s", number, infos[0].Description)
+ if len(infos) > 1 {
+ label += fmt.Sprintf(" (+%d variants)", len(infos)-1)
+ }
+ options = append(options, huh.NewOption(label, strconv.FormatUint(uint64(number), 10)))
+ }
+ var field huh.Field = huh.NewSelect[string]().Title("Find a PGN").Description("Press / to search by name or number, for example heading or 127250").Options(options...).Height(8).Value(&config.pgnNumber)
+ if accessible {
+ field = huh.NewInput().Title("PGN number or name").Description("A number shows fields; a name such as heading searches known PGNs.").Value(&config.pgnNumber).Validate(func(value string) error {
+ _, err := findPGNs(value)
+ return err
+ })
}
return []*huh.Group{
- huh.NewGroup(
- huh.NewSelect[string]().
- Title("PGN schema action").
- Options(
- huh.NewOption("Describe one PGN", "describe"),
- huh.NewOption("List every typed PGN variant", "list"),
- ).
- Value(&config.pgnAction),
- ),
- huh.NewGroup(
- huh.NewInput().
- Title("PGN number").
- Description("Type to narrow known PGNs; Tab accepts the suggestion").
- Suggestions(suggestions).
- Value(&config.pgnNumber).
- Validate(validatePGN),
- ).WithHideFunc(func() bool { return config.pgnAction == "list" }),
+ huh.NewGroup(huh.NewSelect[string]().Title("PGN schema action").Options(
+ huh.NewOption("Describe one PGN", "describe"), huh.NewOption("List known PGNs", "list"),
+ ).Value(&config.pgnAction)),
+ huh.NewGroup(field).WithHideFunc(func() bool { return config.pgnAction == "list" }),
}
}
+func pathSuggestions(prefix string) []string { return completionValues(completeFiles(prefix)) }
+func completionValues(items []completionItem) []string {
+ values := make([]string, 0, len(items))
+ for _, item := range items {
+ values = append(values, item.value)
+ }
+ return values
+}
+
+func resultOutputGroup(config *wizardConfig) *huh.Group {
+ return huh.NewGroup(huh.NewSelect[string]().Title("Result output").Options(
+ huh.NewOption("Readable summary and tables", "text"), huh.NewOption("JSON for scripts", "json"),
+ ).Value(&config.output))
+}
+
func confirmationGroup(config *wizardConfig) *huh.Group {
return huh.NewGroup(
huh.NewNote().
@@ -582,9 +698,9 @@ func (config *wizardConfig) args() []string {
args = append(args, config.file, "--timing="+strconv.FormatBool(config.timing))
case "pgn":
if config.pgnAction == "list" {
- return append(args, "list")
+ return append(args, "list", "--output", config.output)
}
- return append(args, config.pgnNumber)
+ return append(args, config.pgnNumber, "--output", config.output)
}
switch config.command {
@@ -598,11 +714,16 @@ func (config *wizardConfig) args() []string {
}
case "record":
args = append(args, "--out", config.outputPath, "--output-format", config.recordFormat)
+ if config.overwrite {
+ args = append(args, "--overwrite")
+ }
case "validate":
+ args = append(args, "--output", config.output)
if config.strict {
args = append(args, "--strict")
}
case "devices":
+ args = append(args, "--output", config.output)
if config.source != "file" {
args = append(args, "--wait", config.wait)
}
@@ -634,7 +755,12 @@ func (config *wizardConfig) sourceArgs() []string {
}
func (app *cli) runForm(ctx context.Context, form *huh.Form, accessible bool) (bool, error) {
- err := form.
+ if accessible {
+ _, _ = fmt.Fprintln(app.errOut, "Follow the prompts. Ctrl+C exits.")
+ } else {
+ _, _ = fmt.Fprintln(app.errOut, "Esc / Ctrl+C cancels · Shift+Tab goes back")
+ }
+ err := form.WithKeyMap(wizardKeyMap()).WithProgramOptions(tea.WithFilter(formKeyFilter(form))).
WithInput(app.in).
WithOutput(app.errOut).
WithAccessible(accessible).
@@ -652,7 +778,7 @@ func existingPath(value string) error {
if err := required("capture path")(value); err != nil {
return err
}
- info, err := os.Stat(value)
+ info, err := os.Stat(expandPath(value))
if err != nil {
return fmt.Errorf("cannot read %q: %w", value, err)
}
@@ -684,20 +810,6 @@ func durationValidator(label string) func(string) error {
}
}
-func validatePGN(value string) error {
- if value == "list" {
- return nil
- }
- number, err := strconv.ParseUint(value, 0, 32)
- if err != nil {
- return fmt.Errorf("enter a decimal or hexadecimal PGN")
- }
- if len(pgn.PgnInfoLookup[uint32(number)]) == 0 {
- return fmt.Errorf("PGN %d is not in the typed metadata", number)
- }
- return nil
-}
-
func renderCommandPreview(args []string) string {
parts := make([]string, 0, len(args)+1)
parts = append(parts, "n2k")
@@ -722,3 +834,27 @@ func envTruthy(name string) bool {
return false
}
}
+
+func wizardKeyMap() *huh.KeyMap {
+ keymap := huh.NewDefaultKeyMap()
+ keymap.Input.AcceptSuggestion.SetKeys("tab", "ctrl+e")
+ keymap.Input.AcceptSuggestion.SetHelp("tab", "complete")
+ keymap.Input.Next.SetKeys("enter")
+ return keymap
+}
+
+// Let a select field finish its search with Escape as its own help advertises.
+// Outside search, Escape cancels the form through Huh's standard quit key.
+func formKeyFilter(form *huh.Form) func(tea.Model, tea.Msg) tea.Msg {
+ return func(_ tea.Model, message tea.Msg) tea.Msg {
+ if pressed, ok := message.(tea.KeyPressMsg); ok && pressed.Code == tea.KeyEscape {
+ for _, binding := range form.GetFocusedField().KeyBinds() {
+ if key.Matches(pressed, binding) {
+ return message
+ }
+ }
+ return tea.KeyPressMsg(tea.Key{Code: 'c', Mod: tea.ModCtrl})
+ }
+ return message
+ }
+}
diff --git a/cmd/n2k/usability_test.go b/cmd/n2k/usability_test.go
new file mode 100644
index 0000000..3614a73
--- /dev/null
+++ b/cmd/n2k/usability_test.go
@@ -0,0 +1,267 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "charm.land/bubbles/v2/list"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/huh/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/open-ships/n2k"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRecordRequiresExplicitOverwrite(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "capture.log")
+ original := []byte("previous capture\n")
+ require.NoError(t, os.WriteFile(path, original, 0o600))
+ args := []string{"record", "--file", "../../testdata/sample.log", "--out", path}
+ _, _, err := executeCommand(context.Background(), args...)
+ require.ErrorContains(t, err, "--overwrite")
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+ require.Equal(t, original, data)
+ _, _, err = executeCommand(context.Background(), append(args, "--overwrite")...)
+ require.NoError(t, err)
+ _, _, err = executeCommand(context.Background(), "replay", path, "--timing=false")
+ require.NoError(t, err)
+}
+
+func TestRecordNeverOverwritesItsInput(t *testing.T) {
+ for _, alias := range []string{"same path", "hard link", "symbolic link", "gzip"} {
+ t.Run(alias, func(t *testing.T) {
+ dir := t.TempDir()
+ input := filepath.Join(dir, "source.log")
+ data, err := os.ReadFile("../../testdata/sample.log")
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(input, data, 0o600))
+ output := input
+ switch alias {
+ case "hard link", "symbolic link":
+ output = filepath.Join(dir, "alias.log")
+ if alias == "hard link" {
+ err = os.Link(input, output)
+ } else {
+ err = os.Symlink(input, output)
+ }
+ if err != nil {
+ t.Skipf("link unavailable: %v", err)
+ }
+ case "gzip":
+ input = gzipTestCapture(t, input)
+ output = input
+ data, err = os.ReadFile(input)
+ require.NoError(t, err)
+ }
+ _, _, err = executeCommand(context.Background(), "record", "--file", input, "--out", output, "--overwrite")
+ require.ErrorContains(t, err, "same capture")
+ remaining, err := os.ReadFile(input)
+ require.NoError(t, err)
+ require.Equal(t, data, remaining)
+ })
+ }
+}
+
+func TestCapturePreflightExplainsUnreadableInput(t *testing.T) {
+ for _, test := range []struct{ name, body, want string }{
+ {"empty", "", "is empty"},
+ {"text", "this is not a capture\n", "no readable CAN frames"},
+ {"json", " {\"kind\":\"frame\"}\n", "contains JSON"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "capture.log")
+ require.NoError(t, os.WriteFile(path, []byte(test.body), 0o600))
+ for _, input := range []string{path, gzipTestCapture(t, path)} {
+ for _, command := range []string{"sniff", "replay", "validate", "devices"} {
+ stdout, _, err := executeCommand(context.Background(), command, "--file", input)
+ require.ErrorContains(t, err, test.want, command)
+ require.Empty(t, stdout)
+ }
+ }
+ })
+ }
+}
+
+func TestJSONExportIsExplicitlyRejectedByReplay(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "observations.jsonl")
+ _, _, err := executeCommand(context.Background(), "record", "--file", "../../testdata/sample.log", "--out", path, "--output-format", "jsonl")
+ require.NoError(t, err)
+ _, _, err = executeCommand(context.Background(), "replay", path)
+ require.ErrorContains(t, err, "--output-format candump")
+}
+
+func TestCapturePreflightAllowsCommentsBeforeFrames(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "commented.log")
+ sample, err := os.ReadFile("../../testdata/sample.log")
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(path, append([]byte("[capture notes]\n"), sample...), 0o600))
+ stdout, _, err := executeCommand(context.Background(), "sniff", "--file", path, "--output", "text")
+ require.NoError(t, err)
+ require.Contains(t, stdout, "WaterDepth")
+}
+
+func TestInvalidSourceDoesNotReplaceExistingRecording(t *testing.T) {
+ dir := t.TempDir()
+ input, output := filepath.Join(dir, "invalid.log"), filepath.Join(dir, "keep.log")
+ require.NoError(t, os.WriteFile(input, []byte("invalid capture"), 0o600))
+ require.NoError(t, os.WriteFile(output, []byte("keep this"), 0o600))
+ _, _, err := executeCommand(context.Background(), "record", "--file", input, "--out", output, "--overwrite")
+ require.Error(t, err)
+ data, err := os.ReadFile(output)
+ require.NoError(t, err)
+ require.Equal(t, "keep this", string(data))
+}
+
+func TestFilterAndAddressErrorsPrecedeSourceIO(t *testing.T) {
+ for _, filter := range []string{"pgn = 127250", "pgn", "unknown_variable == 1"} {
+ _, _, err := executeCommand(context.Background(), "sniff", "--file", "missing.log", "--filter", filter)
+ require.ErrorContains(t, err, "filter")
+ require.NotContains(t, err.Error(), "opening capture")
+ }
+ for _, expression := range []string{"", "pgn == 127250", "msg.heading > 1 && source == 11"} {
+ require.NoError(t, validateFilter(expression))
+ }
+ for _, address := range []string{"gateway", "localhost:0", "localhost:99999", ":1457"} {
+ require.Error(t, addressValidator("TCP")(address))
+ }
+ require.NoError(t, addressValidator("UDP")(":1457"))
+ require.NoError(t, addressValidator("TCP")("[::1]:1457"))
+ _, _, err := executeCommand(context.Background(), "sniff", "--file", "--output", "text")
+ require.ErrorContains(t, err, "--file requires ")
+}
+
+func TestPaletteEscapeClearsAppliedFilterBeforeQuitting(t *testing.T) {
+ model := newPaletteModel()
+ model.list.SetFilterText("pgn")
+ model.list.SetFilterState(list.FilterApplied)
+ updated, _ := model.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEscape}))
+ model = updated.(paletteModel)
+ require.False(t, model.canceled)
+ require.Equal(t, list.Unfiltered, model.list.FilterState())
+ updated, _ = model.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEscape}))
+ require.True(t, updated.(paletteModel).canceled)
+}
+
+func TestFilteredPaletteDoesNotOpenHiddenNumberShortcut(t *testing.T) {
+ model := newPaletteModel()
+ model.list.SetFilterText("pgn")
+ model.list.SetFilterState(list.FilterApplied)
+ updated, _ := model.Update(tea.KeyPressMsg(tea.Key{Code: '3', Text: "3"}))
+ require.Empty(t, updated.(paletteModel).selected)
+}
+
+func TestWizardTabCompletesWithoutSubmittingPartialInput(t *testing.T) {
+ value := "test"
+ input := huh.NewInput().Value(&value).Suggestions([]string{"testdata/sample.log"}).Validate(existingPath)
+ input.WithKeyMap(wizardKeyMap())
+ input.Focus()
+ updated, _ := input.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyTab}))
+ require.Equal(t, "testdata/sample.log", value)
+ require.NotContains(t, updated.View(), "cannot read")
+}
+
+func TestFormEscapeLetsSearchHandleItsOwnKey(t *testing.T) {
+ field := huh.NewSelect[string]().Options(huh.NewOption("Vessel Heading", "127250"))
+ form := huh.NewForm(huh.NewGroup(field))
+ filter := formKeyFilter(form)
+ field.Focus()
+ field.Update(tea.KeyPressMsg(tea.Key{Code: '/', Text: "/"}))
+ escape := tea.KeyPressMsg(tea.Key{Code: tea.KeyEscape})
+ require.Equal(t, "esc", filter(nil, escape).(tea.KeyPressMsg).String())
+ field.Focus()
+ field.Update(tea.KeyPressMsg(tea.Key{Code: 'h', Text: "h"}))
+ field.Update(escape) // Finish entering the filter.
+ require.Equal(t, "esc", filter(nil, escape).(tea.KeyPressMsg).String())
+ field.Update(escape) // Clear the applied filter.
+ require.Equal(t, "ctrl+c", filter(nil, escape).(tea.KeyPressMsg).String())
+}
+
+func TestCompactPaletteKeepsControlsInsideTerminal(t *testing.T) {
+ for _, size := range [][2]int{{80, 24}, {40, 12}, {30, 10}, {20, 6}} {
+ model := newPaletteModel()
+ updated, _ := model.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]})
+ view := updated.(paletteModel).View().Content
+ require.LessOrEqual(t, lipgloss.Width(view), size[0])
+ require.LessOrEqual(t, lipgloss.Height(view), size[1])
+ if size[0] >= 30 {
+ require.Contains(t, view, "enter")
+ require.Contains(t, view, "quit")
+ }
+ }
+}
+
+func TestReadableInspectionAndJSONDefaults(t *testing.T) {
+ stdout, _, err := executeCommand(context.Background(), "pgn", "127250", "--output", "text")
+ require.NoError(t, err)
+ require.Contains(t, stdout, "Vessel Heading")
+ require.Contains(t, stdout, "Heading")
+ require.Less(t, len(strings.Split(stdout, "\n")), 25)
+ stdout, _, err = executeCommand(context.Background(), "pgn", "heading", "--output", "text")
+ require.NoError(t, err)
+ require.Contains(t, stdout, "127250")
+ stdout, _, err = executeCommand(context.Background(), "pgn", "127250")
+ require.NoError(t, err)
+ require.True(t, json.Valid([]byte(stdout)))
+ stdout, _, err = executeCommand(context.Background(), "devices", "--file", "../../testdata/sample.log", "--output", "text")
+ require.NoError(t, err)
+ require.Contains(t, stdout, "MANUFACTURER")
+ require.Contains(t, stdout, "LAST SEEN")
+ stdout, _, err = executeCommand(context.Background(), "validate", "--file", "../../testdata/sample.log")
+ require.NoError(t, err)
+ var summary validationSummary
+ require.NoError(t, json.Unmarshal([]byte(stdout), &summary))
+ total := 0
+ for _, count := range summary.UndecodableByPGN {
+ total += count
+ }
+ require.Equal(t, summary.Undecodable, total)
+ require.Positive(t, total)
+ stdout, _, err = executeCommand(context.Background(), "validate", "--file", "../../testdata/sample.log", "--output", "text", "--strict")
+ require.Error(t, err)
+ require.Contains(t, stdout, "UNDECODABLE")
+ require.Contains(t, stdout, "--unknown --filter")
+}
+
+func TestPathSuggestionsUseActualFilesystem(t *testing.T) {
+ dir := t.TempDir()
+ file := filepath.Join(dir, "weekend-sail.log")
+ require.NoError(t, os.WriteFile(file, []byte("capture"), 0o600))
+ require.Contains(t, pathSuggestions(filepath.Join(dir, "week")), file)
+}
+
+func TestInteractiveExecutionReportsSaveAndEmptyFilter(t *testing.T) {
+ var out, status bytes.Buffer
+ app := newCLI(strings.NewReader(""), &out, &status)
+ config := defaultWizardConfig("record")
+ config.file = "../../testdata/sample.log"
+ config.outputPath = filepath.Join(t.TempDir(), "saved.log")
+ require.NoError(t, app.executeInteractive(context.Background(), config))
+ require.Empty(t, out.String())
+ require.Contains(t, status.String(), "Saved capture: "+config.outputPath)
+ require.Contains(t, status.String(), "observations saved")
+ status.Reset()
+ config = defaultWizardConfig("sniff")
+ config.file = "../../testdata/sample.log"
+ config.filter = "pgn == 999999"
+ require.NoError(t, app.executeInteractive(context.Background(), config))
+ require.Contains(t, status.String(), "No matching traffic")
+}
+
+func TestStoppedRecordingFlushesAndRemainsReplayable(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
+ defer cancel()
+ path := filepath.Join(t.TempDir(), "partial.log")
+ source := n2k.File("../../testdata/sample.log", n2k.OriginalTiming())
+ require.NoError(t, runRecord(ctx, io.Discard, source, "../../testdata/sample.log", path, "candump", false))
+ stdout, _, err := executeCommand(context.Background(), "replay", path, "--timing=false", "--unknown")
+ require.NoError(t, err)
+ require.NotEmpty(t, stdout)
+}
diff --git a/go.mod b/go.mod
index 83c54b4..9e6abb3 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
charm.land/lipgloss/v2 v2.0.5
github.com/Masterminds/semver/v3 v3.5.0
github.com/charmbracelet/x/term v0.2.2
+ github.com/google/cel-go v0.27.0
github.com/open-ships/n2k v1.2.0
github.com/stretchr/testify v1.9.0
)
@@ -31,7 +32,6 @@ require (
github.com/creack/goselect v0.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
- github.com/google/cel-go v0.27.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect