Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions .github/tui.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
52 changes: 43 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[![Release](https://img.shields.io/github/v/release/open-ships/n2k-cli)](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.

Expand Down Expand Up @@ -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:
Expand All @@ -96,13 +114,17 @@ 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
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)'
```

Expand Down Expand Up @@ -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.<Type>`, 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
Expand Down
124 changes: 124 additions & 0 deletions cmd/n2k/capture_safety.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading