{"id":46026558,"url":"https://github.com/sixfathoms/lplex","last_synced_at":"2026-05-07T17:03:02.757Z","repository":{"id":341299167,"uuid":"1169605172","full_name":"sixfathoms/lplex","owner":"sixfathoms","description":"NMEA 2000 CAN bus bridge: SocketCAN to SSE with journal recording, cloud replication over gRPC, and an embeddable Go core","archived":false,"fork":false,"pushed_at":"2026-03-24T21:52:57.000Z","size":1575,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-03-25T00:23:54.257Z","etag":null,"topics":["boat","boating","canbus","nmea2000","nmea2k"],"latest_commit_sha":null,"homepage":"https://sixfathoms.github.io/lplex/","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/sixfathoms.png","metadata":{"files":{"readme":"README.md","changelog":"changetracker/diff.go","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-28T23:38:49.000Z","updated_at":"2026-03-24T21:44:34.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/sixfathoms/lplex","commit_stats":null,"previous_names":["zourzouvillys/lplex"],"tags_count":7,"template":false,"template_full_name":null,"purl":"pkg:github/sixfathoms/lplex","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sixfathoms%2Flplex","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sixfathoms%2Flplex/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sixfathoms%2Flplex/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sixfathoms%2Flplex/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/sixfathoms","download_url":"https://codeload.github.com/sixfathoms/lplex/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sixfathoms%2Flplex/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31307174,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-02T12:59:32.332Z","status":"ssl_error","status_checked_at":"2026-04-02T12:54:48.875Z","response_time":89,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["boat","boating","canbus","nmea2000","nmea2k"],"created_at":"2026-03-01T03:03:33.603Z","updated_at":"2026-04-02T13:36:53.315Z","avatar_url":"https://github.com/sixfathoms.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# lplex\n\nCAN bus HTTP bridge for NMEA 2000. Reads raw CAN frames from a SocketCAN interface, reassembles fast-packets, tracks device discovery, and streams frames to clients over SSE with session management, filtering, and replay. Supports cloud replication for remote access to boat data over intermittent connections.\n\n- **Real-time SSE streaming** with [ephemeral and buffered session modes](#api), per-client filtering by PGN, manufacturer, instance, or device name\n- **Fast-packet reassembly** for multi-frame NMEA 2000 PGNs, with automatic device discovery via ISO requests\n- **[PGN decoding](#pgn-decoding)** of known NMEA 2000 message types into human-readable field values, with a [DSL-based code generator](#pgn-dsl) supporting variant dispatch for proprietary PGNs and per-PGN metadata (fast-packet, transmission interval, on-demand)\n- **[Journal recording](#journal-recording)** to block-based `.lpj` files with zstd compression, CRC32C checksums, and O(log N) time seeking\n- **[Retention and archival](#retention-and-archival)** with max-age/min-keep/max-size knobs, soft/hard thresholds, configurable overflow policy, and pluggable archive scripts\n- **[Cloud replication](#cloud-replication)** over gRPC with mTLS, live + backfill streams, hole tracking, and lazy per-instance Broker on the cloud side\n- **Pull-based Consumer** with tiered replay (journal files → ring buffer → live), so clients can catch up from any point in history\n- **[Embeddable core](#embedding-lplex)** as a Go package, mount the HTTP handler on any `ServeMux`\n- **[Go client library](#go-client-library-lplexc)** (`lplexc`) with mDNS discovery, subscriptions, device queries, and transmit\n- **[TypeScript client library](#typescript-client-library-sixfathomslplex)** (`@sixfathoms/lplex`) for browsers and Node.js, with CloudClient for lplex-cloud\n- **CAN transmit** via [POST /send](#transmit) with automatic fast-packet fragmentation\n\n## Installation\n\n### Client (lplex)\n\n```bash\n# Homebrew (macOS / Linux)\nbrew install sixfathoms/tap/lplex\n\n# From source\ngo install github.com/sixfathoms/lplex/cmd/lplex@latest\n```\n\n### Server (Linux only, requires SocketCAN)\n\n```bash\n# Debian/Ubuntu (.deb includes both lplex-server and lplex)\nsudo dpkg -i lplex_*.deb\nsudo systemctl start lplex-server\n\n# Docker\ndocker run --network host --device /dev/can0 ghcr.io/sixfathoms/lplex:latest\n\n# From source\ngo install github.com/sixfathoms/lplex/cmd/lplex-server@latest\n```\n\n### Cloud Server\n\n```bash\n# From source\ngo install github.com/sixfathoms/lplex/cmd/lplex-cloud@latest\n```\n\nDownload `.deb` packages from [GitHub Releases](https://github.com/sixfathoms/lplex/releases).\n\n### Go Client Library\n\n```bash\ngo get github.com/sixfathoms/lplex/lplexc@latest\n```\n\n### TypeScript Client Library\n\n```bash\nnpm install @sixfathoms/lplex\n```\n\nZero runtime dependencies. Works in browsers and Node 18+. Ships ESM, CJS, and TypeScript declarations. See [@sixfathoms/lplex on npm](https://www.npmjs.com/package/@sixfathoms/lplex).\n\n### Embedding lplex\n\nThe core package is importable, so you can embed lplex into your own service:\n\n```bash\ngo get github.com/sixfathoms/lplex@latest\n```\n\n```go\nimport (\n    \"log/slog\"\n    \"net/http\"\n    \"time\"\n\n    \"github.com/sixfathoms/lplex\"\n)\n\nfunc main() {\n    logger := slog.Default()\n\n    // Create the broker (owns ring buffer, device registry, fan-out).\n    broker := lplex.NewBroker(lplex.BrokerConfig{\n        RingSize:          65536,\n        MaxBufferDuration: 5 * time.Minute,\n        Logger:            logger,\n    })\n    go broker.Run()\n\n    // Mount the HTTP handler on a sub-path.\n    srv := lplex.NewServer(broker, logger)\n    mux := http.NewServeMux()\n    mux.Handle(\"/nmea/\", http.StripPrefix(\"/nmea\", srv))\n\n    // Feed frames from your own CAN source.\n    go func() {\n        for frame := range myFrameSource() {\n            broker.RxFrames() \u003c- lplex.RxFrame{\n                Timestamp: frame.Time,\n                Header:    lplex.CANHeader{Priority: 2, PGN: frame.PGN, Source: frame.Src, Destination: 0xFF},\n                Data:      frame.Data,\n            }\n        }\n    }()\n\n    // Optional: enable journal recording.\n    journalCh := make(chan lplex.RxFrame, 16384)\n    broker.SetJournal(journalCh)\n    // ... create JournalWriter and call Run in a goroutine.\n\n    http.ListenAndServe(\":8080\", mux)\n}\n```\n\nLifecycle: the broker goroutine exits when you call `broker.CloseRx()`. Close the journal channel after that, then wait for the journal writer to finish.\n\n## Quick Start\n\n### Server\n\n```bash\n# Start the server (requires SocketCAN interface)\nlplex-server -interface can0 -port 8089\n\n# With a config file\nlplex-server -config /etc/lplex/lplex-server.conf\n\n# With journal recording enabled\nlplex-server -interface can0 -port 8089 -journal-dir /var/log/lplex\n\n# With cloud replication\nlplex-server -interface can0 -replication-target cloud.example.com:9443 \\\n  -replication-instance-id boat-001 \\\n  -replication-tls-cert /etc/lplex/boat.crt \\\n  -replication-tls-key /etc/lplex/boat.key \\\n  -replication-tls-ca /etc/lplex/ca.crt\n\n# Or with systemd\nsudo systemctl enable --now lplex-server\n```\n\n### Cloud Server\n\n```bash\n# Start the cloud server with mTLS\nlplex-cloud -data-dir /data/lplex \\\n  -tls-cert /etc/lplex-cloud/server.crt \\\n  -tls-key /etc/lplex-cloud/server.key \\\n  -tls-client-ca /etc/lplex-cloud/ca.crt\n\n# With a config file\nlplex-cloud -config /etc/lplex-cloud/lplex-cloud.conf\n```\n\n### Client (lplex)\n\n```bash\n# Auto-discover via mDNS and stream all frames\nlplex dump\n\n# Connect to a specific server with filtering\nlplex dump --server http://inuc1.local:8089 --pgn 129025 --manufacturer Garmin\n\n# Decode known PGNs into human-readable fields\nlplex dump --decode\n\n# Filter on decoded field values (auto-enables --decode)\nlplex dump --where \"pgn == 130310 \u0026\u0026 water_temperature \u003c 280\"\nlplex dump --where 'register.name == \"State of Charge\"'\n\n# Only show frames with significant changes (suppress sensor noise)\nlplex dump --changes --decode\n\n# Buffered mode with automatic reconnect replay\nlplex dump --server http://inuc1.local:8089 --buffer-timeout PT5M\n\n# List devices on the bus\nlplex devices\n\n# Show last-known decoded values\nlplex values\n\n# Request a specific PGN from all devices\nlplex request --pgn 126996 --decode\n\n# Inspect a journal file\nlplex inspect recording.lpj\n\n# Simulate a boat from recorded journals (no CAN bus needed)\nlplex simulate --dir /path/to/journals/\nlplex simulate --file recording.lpj --speed 10\n\n# Docker: simulate from journal files, exit when done\ndocker run --rm -p 8090:8090 -v ./journals:/data:ro \\\n  --entrypoint /lplex ghcr.io/sixfathoms/lplex:latest \\\n  simulate --dir /data --speed 0 --exit-when-done\n```\n\n### Go Client Library (`lplexc`)\n\n```go\nimport \"github.com/sixfathoms/lplex/lplexc\"\n\n// Auto-discover the server\naddr, _ := lplexc.Discover(ctx)\nclient := lplexc.NewClient(addr)\n\n// Get devices on the bus\ndevices, _ := client.Devices(ctx)\n\n// Subscribe to position updates from Garmin devices\nsub, _ := client.Subscribe(ctx, \u0026lplexc.Filter{\n    PGNs:          []uint32{129025},\n    Manufacturers: []string{\"Garmin\"},\n})\ndefer sub.Close()\n\nfor {\n    ev, err := sub.Next()\n    if err != nil {\n        break\n    }\n    fmt.Printf(\"Position: src=%d data=%s\\n\", ev.Frame.Src, ev.Frame.Data)\n}\n```\n\n### TypeScript Client Library (`@sixfathoms/lplex`)\n\n```typescript\nimport { Client } from \"@sixfathoms/lplex\";\n\nconst client = new Client(\"http://inuc1.local:8089\");\n\n// Get devices on the bus\nconst devices = await client.devices();\n\n// Get current bus state snapshot\nconst snapshot = await client.values();\n\n// Subscribe to position updates from Garmin devices\nconst stream = await client.subscribe({\n  pgn: [129025],\n  manufacturer: [\"Garmin\"],\n});\n\nfor await (const event of stream) {\n  if (event.type === \"frame\") {\n    console.log(`Position: src=${event.frame.src} data=${event.frame.data}`);\n  }\n}\n```\n\nA `CloudClient` is also available for the lplex-cloud management API:\n\n```typescript\nimport { CloudClient } from \"@sixfathoms/lplex\";\n\nconst cloud = new CloudClient(\"https://cloud.example.com\");\nconst instances = await cloud.instances();\n\n// Get a regular Client scoped to a specific instance\nconst client = cloud.client(\"boat-001\");\nconst devices = await client.devices();\n```\n\n## Configuration\n\nlplex can be configured with CLI flags, a [HOCON](https://github.com/lightbend/config/blob/main/HOCON.md) config file, or both. CLI flags always take precedence over config file values.\n\n### Config file discovery\n\nUse `-config path/to/lplex-server.conf` to specify a config file explicitly. If `-config` is not set, lplex-server searches for:\n\n1. `./lplex-server.conf`\n2. `/etc/lplex/lplex-server.conf`\n3. `./lplex.conf` (backward compat)\n4. `/etc/lplex/lplex.conf` (backward compat)\n\nIf no config file is found, lplex-server continues with defaults (fully backward compatible).\n\n### Example config (boat)\n\n```hocon\ninterface = can0\nport = 8089\nmax-buffer-duration = PT5M\n\njournal {\n  dir = /var/log/lplex\n  prefix = nmea2k\n  block-size = 262144\n  compression = zstd\n\n  rotate {\n    duration = PT1H\n    size = 0\n  }\n\n  retention {\n    max-age = P30D\n    min-keep = PT24H\n  }\n\n  archive {\n    command = \"/usr/local/bin/archive-to-s3\"\n    trigger = \"on-rotate\"\n  }\n}\n\nreplication {\n  target = \"cloud.example.com:9443\"\n  instance-id = \"boat-001\"\n  tls {\n    cert = \"/etc/lplex/boat.crt\"\n    key = \"/etc/lplex/boat.key\"\n    ca = \"/etc/lplex/ca.crt\"\n  }\n}\n```\n\n### Example config (cloud)\n\n```hocon\ngrpc {\n  listen = \":9443\"\n  tls {\n    cert = \"/etc/lplex-cloud/server.crt\"\n    key = \"/etc/lplex-cloud/server.key\"\n    client-ca = \"/etc/lplex-cloud/ca.crt\"\n  }\n}\nhttp {\n  listen = \":8080\"\n}\ndata-dir = \"/data/lplex\"\n\njournal {\n  rotate-duration = PT1H\n  retention {\n    max-age = P90D\n    max-size = 53687091200\n  }\n  archive {\n    command = \"/usr/local/bin/archive-to-gcs\"\n    trigger = \"before-expire\"\n  }\n}\n```\n\nSee [`lplex-server.conf.example`](lplex-server.conf.example) and [`lplex-cloud.conf.example`](lplex-cloud.conf.example) for the full annotated versions.\n\n## Architecture\n\n```\nSocketCAN (can0)\n    |\nCANReader goroutine\n    |  reads extended CAN frames\n    |  reassembles fast-packets (multi-frame PGNs)\n    |\n    v\nrxFrames chan\n    |\nBroker goroutine (single writer, owns all state)\n    |  assigns monotonic sequence numbers\n    |  appends pre-serialized JSON to ring buffer (64k entries)\n    |  updates device registry (PGN 60928, PGN 126996)\n    |  fans out to sessions and ephemeral subscribers\n    |  sends ISO requests to discover new devices\n    |  feeds journal writer (if enabled)\n    |\n    +---\u003e ring buffer (pre-serialized JSON, power-of-2)\n    +---\u003e DeviceRegistry (keyed by source address)\n    +---\u003e ValueStore (last frame per source+PGN)\n    +---\u003e sessions map (buffered clients with cursors)\n    +---\u003e subscribers map (ephemeral clients, no state)\n    +---\u003e journal chan (optional, 16k buffer)\n    |\n    v\nHTTP Server (:8089)                JournalWriter goroutine\n    |                                   |  block-based .lpj files\n    +-- GET  /events                    |  zstd block compression\n    +-- PUT  /clients/{id}              |  CRC32C checksums\n    +-- GET  /clients/{id}/events       |  device table per block\n    +-- PUT  /clients/{id}/ack          |  O(log N) time seeking\n    +-- POST /send                      |  ~2-3 MB/hour at 200 fps\n    +-- POST /query                     v\n    +-- GET  /devices                .lpj journal files\n    +-- GET  /values\n    +-- GET  /replication/status\n\nCANWriter goroutine            ReplicationClient (optional)\n    |  fragments for TX            |  gRPC to cloud server\n    |  writes to SocketCAN         +-- Live: Consumer -\u003e LiveFrame stream\n                                   +-- Backfill: raw blocks -\u003e Block stream\n                                   +-- Reconnect: exponential backoff\n```\n\n## API\n\n### Ephemeral streaming\n\n`GET /events` with optional query params: `pgn`, `exclude_pgn`, `manufacturer`, `instance`, `name` (hex).\n\nNo session, no replay, no ACK. Zero server-side state after disconnect.\n\n### Buffered sessions\n\n1. `PUT /clients/{id}` with `{\"buffer_timeout\": \"PT5M\"}` to create/reconnect\n2. `GET /clients/{id}/events` for SSE (replays from cursor, then live)\n3. `PUT /clients/{id}/ack` with `{\"seq\": N}` to advance cursor\n\nDisconnected sessions keep their cursor for the buffer duration.\n\n### Transmit\n\nBoth `/send` and `/query` are disabled by default. Enable with `-send-enabled` or `send.enabled = true` in the config file. Use `send.rules` (HOCON string or object array) or `-send-rules` (semicolon-separated DSL) to define ordered allow/deny rules with PGN ranges and CAN NAME lists. HOCON config supports both string rules (`\"pgn:59904\"`) and native objects (`{ pgn = \"59904\", name = \"...\" }`). Rules are evaluated top-to-bottom, first match wins. Internal device discovery (ISO requests at startup) is not affected.\n\n`POST /send` with `{\"pgn\": 59904, \"src\": 254, \"dst\": 255, \"prio\": 6, \"data\": \"00ee00\"}`\n\n### Query on demand\n\n`POST /query` with `{\"pgn\": 129025, \"dst\": 255}` sends an ISO Request (PGN 59904) and waits for the response. Returns the first matching frame as JSON. Optional `\"timeout\": \"PT5S\"` (default 2s). Returns `504 Gateway Timeout` if no response arrives.\n\n### Devices\n\n`GET /devices` returns JSON array of all discovered NMEA 2000 devices.\n\n### Last values\n\n`GET /values` returns the most recently received frame for each (device, PGN) pair. Grouped by device, sorted by source address. Useful for getting a snapshot of bus state without subscribing to SSE.\n\nSupports the same filter query params as `/events`: `pgn`, `exclude_pgn`, `manufacturer`, `instance`, `name` (hex). Example: `GET /values?pgn=129025\u0026manufacturer=Garmin`.\n\n### Replication status (boat)\n\n`GET /replication/status` returns current replication state (available when replication is configured).\n\n## Cloud Replication\n\nlplex can replicate CAN bus data from a boat to a cloud instance over gRPC with mTLS. The boat initiates all connections (no public IP required). Data flows over two independent gRPC streams:\n\n- **Live stream**: realtime frames from the broker's head, delivered to the cloud within seconds\n- **Backfill stream**: raw journal blocks for filling historical gaps, newest-first\n\nOn reconnect after a connectivity gap, live data resumes immediately while backfill works through the gap in the background. The cloud runs a replica Broker per instance, so web clients connect to the cloud and get the same SSE API as if they were on the boat.\n\nSee [docs/cloud-replication.md](docs/cloud-replication.md) for the full protocol specification.\n\n### Cloud HTTP API\n\n| Endpoint | Description |\n|---|---|\n| `GET /instances` | List all instances |\n| `GET /instances/{id}/status` | Instance status (cursor, holes, lag) |\n| `GET /instances/{id}/events` | SSE stream from instance's broker |\n| `GET /instances/{id}/devices` | Device table |\n| `GET /instances/{id}/values` | Last-seen values per (device, PGN). Query params: `pgn`, `manufacturer`, `instance`, `name`. |\n| `GET /instances/{id}/replication/events?limit=N` | Replication event log (newest first, default 100, max 1024) |\n\n## Journal Recording\n\nlplex can record all CAN frames to disk as block-based binary journal files (`.lpj`) for future replay and analysis.\n\n```bash\n# Enable recording (zstd compression by default)\nlplex-server -interface can0 -journal-dir /var/log/lplex\n\n# With rotation (new file every hour)\nlplex-server -interface can0 -journal-dir /var/log/lplex -journal-rotate-duration PT1H\n\n# Disable compression\nlplex-server -interface can0 -journal-dir /var/log/lplex -journal-compression none\n```\n\n**Flags:**\n| Flag | Default | Description |\n|---|---|---|\n| `-journal-dir` | (disabled) | Directory for journal files |\n| `-journal-prefix` | `nmea2k` | Journal file name prefix |\n| `-journal-block-size` | `262144` | Block size (power of 2, min 4096) |\n| `-journal-compression` | `zstd` | Block compression: `none`, `zstd`, `zstd-dict` |\n| `-journal-rotate-duration` | `PT1H` | Rotate after duration (ISO 8601) |\n| `-journal-rotate-size` | `0` | Rotate after bytes (0 = disabled) |\n| `-journal-retention-max-age` | (disabled) | Delete files older than this (ISO 8601, e.g. `P30D`) |\n| `-journal-retention-min-keep` | (disabled) | Never delete files younger than this, unless max-size exceeded |\n| `-journal-retention-max-size` | `0` | Hard size cap in bytes; delete oldest files when exceeded |\n| `-journal-retention-soft-pct` | `80` | Proactive archive threshold as % of max-size (1-99) |\n| `-journal-retention-overflow-policy` | `delete-unarchived` | What to do when hard cap hit with failed archives |\n| `-journal-archive-command` | (disabled) | Path to archive script |\n| `-journal-archive-trigger` | (disabled) | When to archive: `on-rotate` or `before-expire` |\n\nBlocks are compressed individually with zstd (~4x ratio at 256KB blocks on typical CAN data, ~158 MB/day at 200 fps). Each block carries a device table so consumers can resolve source addresses without external state. A block index at end-of-file enables fast seeking; crash-truncated files are recovered via forward-scan. See [docs/format.md](docs/format.md) for the binary format specification.\n\n### Retention and Archival\n\nJournal files accumulate indefinitely unless you configure a retention policy. Retention and archival are available on both boat and cloud binaries.\n\n```bash\n# Keep at most 30 days of journals, but never delete files less than 24 hours old\nlplex-server -interface can0 -journal-dir /var/log/lplex \\\n  -journal-retention-max-age P30D -journal-retention-min-keep PT24H\n\n# Hard size cap: keep at most 10 GB, oldest files deleted first\nlplex-server -interface can0 -journal-dir /var/log/lplex \\\n  -journal-retention-max-size 10737418240\n\n# Archive to S3 on rotation, then delete after 30 days\nlplex-server -interface can0 -journal-dir /var/log/lplex \\\n  -journal-retention-max-age P30D \\\n  -journal-archive-command /usr/local/bin/archive-to-s3 \\\n  -journal-archive-trigger on-rotate\n```\n\n**Retention algorithm**: files are sorted oldest-first. Three zones govern behavior when `max-size` is set with archival:\n\n1. **Normal** (total \u003c= soft threshold): standard age-based expiration, archive-then-delete\n2. **Soft zone** (soft \u003c total \u003c= hard): proactively queue oldest non-archived files for archive\n3. **Hard zone** (total \u003e hard): expire files; if archives have failed, apply the overflow policy\n\n`max-size` overrides `min-keep` overrides `max-age`. The soft threshold defaults to 80% of `max-size` and only applies when both `max-size` and an archive command are configured.\n\n**Overflow policies** (when hard cap is hit and archives have failed):\n- `delete-unarchived` (default): delete files even if not archived, prioritizing continued recording\n- `pause-recording`: stop journal writes until archives free space, prioritizing archive completeness\n\n**Archive script protocol**: the script receives file paths as arguments and JSONL metadata on stdin (one line per file with `path`, `instance_id`, `size`, `created`). It must write JSONL to stdout with per-file status (`\"ok\"` or `\"error\"`). Failed files are retried with exponential backoff.\n\n**Archive triggers**:\n- `on-rotate`: archive immediately after a journal file is closed (eager, minimizes data loss window)\n- `before-expire`: archive only when a file is about to be deleted by retention (lazy, minimizes archive traffic)\n\n## PGN Decoding\n\nlplex can decode known NMEA 2000 PGNs into human-readable field values using the `--decode` flag:\n\n```bash\n# Terminal: decoded fields appear below each frame\nlplex dump --decode\n\n# JSON output: adds a \"decoded\" object to each frame\nlplex dump --decode --json\n\n# Journal replay with decoding\nlplex dump --file recording.lpj --decode\n```\n\nThe registry contains ~120 PGNs, of which ~30 have full decoders (position, heading, wind, depth, engine, battery, environment, etc.). The remaining PGNs are name-only: they carry descriptions and metadata (fast-packet, interval) but no field layout. Unknown PGNs pass through with raw hex data as usual.\n\n### Packet tests\n\nPGN decoders are verified by table-driven tests in `pgn/packets_test.go`. Each test vector specifies hex packet data and the expected decoded struct, with automatic round-trip verification. To add a test from real device data, capture a frame with `lplex dump --decode --json` and copy the `data` and `decoded` fields into a new entry.\n\n## PGN DSL\n\nPGN definitions live in `pgn/defs/*.pgn` using a compact DSL that describes bit-level field layouts. The code generator (`pgngen`) reads these files and produces Go structs with `Decode*`/`Encode` methods, a `Registry` map, Protobuf definitions, and JSON Schema.\n\n```bash\ngo generate ./pgn/...   # regenerate from pgn/defs/*.pgn\n```\n\n### Basic syntax\n\n```\n# Line comments start with #\n\npgn 129025 \"Position Rapid Update\" interval=100ms {\n  latitude   int32  :32  scale=1e-7  unit=\"deg\"\n  longitude  int32  :32  scale=1e-7  unit=\"deg\"\n}\n\npgn 129029 \"GNSS Position Data\" fast_packet interval=1000ms {\n  sid              uint8   :8\n  days_since_1970  uint16  :16\n  # ... more fields\n}\n\npgn 59904 \"ISO Request\" on_demand {\n  requested_pgn  uint32  :24\n}\n```\n\n#### PGN-level attributes\n\nAttributes between the description and opening `{` apply to the PGN as a whole:\n\n| Attribute | Description |\n|---|---|\n| `fast_packet` | PGN uses multi-frame fast-packet protocol |\n| `interval=\u003cduration\u003e` | Default transmission interval (`100ms`, `500ms`, `1s`, `2500ms`, `60s`). Stored as `time.Duration` in the registry. |\n| `on_demand` | Event-driven PGN, no periodic transmission |\n| `draft` | Definition is incomplete or reverse-engineered. Propagated to `PGNInfo.Draft`. |\n\nThese are code-generated into `PGNInfo` fields in `pgn.Registry` and used by `IsFastPacket()` to identify fast-packet PGNs.\n\n#### Name-only PGNs\n\nA PGN definition without braces registers the PGN's name and metadata (fast-packet, interval, etc.) without defining a field layout. The generated `Registry` entry has `Decode: nil`.\n\n```\npgn 129038 \"AIS Class A Position Report\" fast_packet\npgn 126983 \"Alert\" fast_packet\npgn 127493 \"Transmission Parameters Dynamic\" draft\n```\n\nThis is the canonical form for PGNs whose structure is unknown or not yet implemented. Use this instead of hardcoded name maps.\n\n#### Field definitions\n\nEach field has: `name  type  :bits  [attributes...]`\n\n| Element | Description |\n|---|---|\n| `name` | Field name (snake_case). Use `_` for reserved/padding bits, `?` for unknown/undocumented data. |\n| `type` | `uint8`, `uint16`, `uint32`, `uint64`, `int8`, `int16`, `int32`, `int64`, `float32`, `float64`, `string`, or an enum name |\n| `:bits` | Bit width of the field |\n| `scale=N` | Scaling factor: `decoded = raw * scale`. Output type becomes `float64`. |\n| `offset=N` | Offset: `decoded = raw * scale + offset` |\n| `unit=\"...\"` | Human-readable unit (e.g. `\"deg\"`, `\"m/s\"`, `\"rad\"`) |\n| `trim=\"...\"` | Right-trim these characters from decoded string fields (e.g. `trim=\"@ \"` for AIS names) |\n| `tolerance=N` | Change detection threshold for `ChangeTracker`. Fields with changes smaller than N are suppressed by `lplex dump --changes`. |\n| `value=N` | Dispatch constraint for variant PGNs (see below) |\n\n### Enums\n\nNamed enumerations for lookup fields:\n\n```\nenum HeadingReference {\n  0 = \"true\"\n  1 = \"magnetic\"\n}\n\npgn 127250 \"Vessel Heading\" {\n  sid                uint8             :8\n  heading            uint16            :16  scale=0.0001  unit=\"rad\"\n  heading_reference  HeadingReference  :2\n  _                                    :6\n}\n```\n\n### Lookups\n\nLookup tables map integer keys to human-readable names. Unlike enums, lookups don't change the field's Go type; the field stays its raw integer type and gets a `Name()` method for display.\n\n```\nlookup VictronRegister uint16 {\n  0x0100 = \"Product ID\"\n  0x0200 = \"Device Mode\"\n  0xED8F = \"DC Channel 1 Current\"\n}\n\npgn 61184 \"Victron Battery Register\" {\n  manufacturer_code  uint16  :11  value=358\n  _                          :2\n  industry_code      uint8   :3\n  register           uint16  :16  lookup=VictronRegister\n  payload            uint32  :32\n}\n```\n\nThe generator produces:\n- A `map[uint16]string` variable (`victronRegisterNames`) with all key-name pairs\n- A `RegisterName() string` method on the struct that returns the human-readable name (or empty string if unknown)\n- A `LookupFields() map[string]string` method for display code to wrap the field as `{\"id\": \u003craw\u003e, \"name\": \"...\"}`\n\nKeys support hex (`0xFF`) and decimal (`255`) literals. Valid key types: `uint8`, `uint16`, `uint32`, `uint64`.\n\n### Variant dispatch (`value=`)\n\nSome PGN numbers (notably 61184, Proprietary Single Frame) carry different payloads depending on a discriminator field value. The DSL supports this by allowing multiple `pgn` blocks with the same number, differentiated by `value=` constraints on a shared discriminator field.\n\n```\n# Victron devices use manufacturer_code=358\npgn 61184 \"Victron Battery Register\" {\n  manufacturer_code  uint16  :11  value=358\n  _                          :2\n  industry_code      uint8   :3\n  register           uint16  :16\n  payload            uint32  :32\n}\n\n# Garmin devices use manufacturer_code=229\npgn 61184 \"Garmin Proprietary\" {\n  manufacturer_code  uint16  :11  value=229\n  _                          :2\n  industry_code      uint8   :3\n  data               uint32  :32\n}\n```\n\nThe generator produces:\n- A separate struct and `Decode*`/`Encode` for each variant (`VictronBatteryRegister`, `GarminProprietary`)\n- A dispatch function `Decode61184(data []byte) (any, error)` that reads the discriminator from raw bytes and routes to the correct variant decoder\n- A single `Registry` entry for the PGN number pointing to the dispatch function\n\n**Rules and constraints:**\n\n| Rule | Detail |\n|---|---|\n| Discriminator field | All constrained variants must use the same field name, bit position, and bit width as the discriminator |\n| Unique values | Each `value=N` must be unique across all variants of the same PGN |\n| Default variant | A variant with no `value=` on any field acts as the fallback for unrecognized discriminator values. This is optional, not required. |\n| At most one default | Only one default variant (without `value=`) is allowed per PGN |\n| Minimum one constraint | At least one variant must have a `value=` constraint. Two defaults with no constraints is an error. |\n| Single constrained variant | Even a single `pgn` block with `value=` gets a dispatch function that rejects non-matching discriminator values |\n| No default means error | Without a default variant, unknown discriminator values return an error from the dispatch function |\n| Constrained encode | `Encode()` hardcodes the `value=N` literal instead of reading the struct field, so encoded frames always have the correct discriminator |\n| Reserved/unknown fields | `_` (padding) and `?` (unknown) fields cannot have `value=` |\n\n**Generated dispatch (conceptual):**\n\n```go\nfunc Decode61184(data []byte) (any, error) {\n    disc := binary.LittleEndian.Uint16(data[0:2]) \u0026 0x07FF\n    switch uint64(disc) {\n    case 358:\n        return DecodeVictronBatteryRegister(data)\n    case 229:\n        return DecodeGarminProprietary(data)\n    default:\n        return nil, fmt.Errorf(\"PGN 61184: unknown manufacturer_code value %d\", disc)\n    }\n}\n```\n\n### Repeated fields (`repeat=`)\n\nWhen a PGN has N identical consecutive fields (e.g. 28 two-bit switch indicators), use `repeat=N` to collapse them into a single line. The generator expands them at code-generation time into a slice or map in Go.\n\n```\n# Array mode (default): generates []uint8\npgn 127501 \"Binary Switch Bank Status\" {\n  instance    uint8   :8\n  indicator   uint8   :2  repeat=28\n}\n\n# Map mode: generates map[int]uint8 with 1-based keys\npgn 127501 \"Binary Switch Bank Status\" {\n  instance    uint8   :8\n  indicator   uint8   :2  repeat=28  group=\"map\"\n}\n\n# Override the auto-pluralized field name\npgn 127501 \"Binary Switch Bank Status\" {\n  instance    uint8   :8\n  indicator   uint8   :2  repeat=28  as=\"switches\"\n}\n```\n\n| Attribute | Description |\n|---|---|\n| `repeat=N` | Repeat this field N times (N \u003e= 2). Expands to N consecutive fields of the same type/width. |\n| `group=\"map\"` | Use `map[int]T` instead of `[]T` in Go. Keys are 1-based (NMEA convention). Default is array. |\n| `as=\"name\"` | Override the auto-pluralized field name. Default: basic English pluralization (`indicator` -\u003e `indicators`). |\n\n**Constraints:** `repeat=` cannot be used on reserved (`_`) or unknown (`?`) fields, or combined with `value=`, `lookup=`, or enum types. `group=` and `as=` require `repeat=`.\n\n**Generated code:** Decode produces a slice/map literal with unrolled bit reads. Encode uses bounds-checked (array) or key-checked (map) writes. Fields after a repeated field get correct bit offsets automatically.\n\n## Deployment\n\nThe `.deb` package installs a systemd service that binds to `can0`. Configure with a config file or environment variable:\n\n```bash\n# Option 1: config file (recommended)\nsudo cp lplex-server.conf.example /etc/lplex/lplex-server.conf\nsudo vi /etc/lplex/lplex-server.conf\n\n# Option 2: environment variable\n# Edit /etc/default/lplex-server:\nLPLEX_ARGS=\"-interface can0 -port 8089 -journal-dir /var/log/lplex -journal-compression zstd\"\n```\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsixfathoms%2Flplex","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsixfathoms%2Flplex","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsixfathoms%2Flplex/lists"}