diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffd5193..5eba628 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 - name: Install run: npm ci diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c7cbc4d..a0422cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 registry-url: https://registry.npmjs.org - name: Install @@ -42,14 +42,6 @@ jobs: - name: Publish @sixfathoms/lplex working-directory: packages/lplex run: npm publish --provenance --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: Publish @sixfathoms/lplex-cli - working-directory: packages/lplex-cli - run: npm publish --provenance --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create GitHub Release run: gh release create "$GITHUB_REF_NAME" --generate-notes diff --git a/package-lock.json b/package-lock.json index 24619c6..df9eb33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2698,7 +2698,7 @@ }, "packages/lplex": { "name": "@sixfathoms/lplex", - "version": "0.1.0", + "version": "0.0.0", "license": "MIT", "devDependencies": { "tsup": "^8.0.0", @@ -2708,10 +2708,10 @@ }, "packages/lplex-cli": { "name": "@sixfathoms/lplex-cli", - "version": "0.1.0", + "version": "0.0.0", "license": "MIT", "dependencies": { - "@sixfathoms/lplex": "^0.1.0", + "@sixfathoms/lplex": "*", "bonjour-service": "^1.3.0" }, "bin": { diff --git a/packages/lplex-cli/package.json b/packages/lplex-cli/package.json index 673ef45..2269c2d 100644 --- a/packages/lplex-cli/package.json +++ b/packages/lplex-cli/package.json @@ -1,6 +1,7 @@ { "name": "@sixfathoms/lplex-cli", - "version": "0.1.0", + "private": true, + "version": "0.0.0", "description": "CLI client for lplex CAN bus HTTP bridge (like lplexdump, but in TypeScript)", "type": "module", "bin": { @@ -11,7 +12,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@sixfathoms/lplex": "^0.1.0", + "@sixfathoms/lplex": "*", "bonjour-service": "^1.3.0" }, "devDependencies": { diff --git a/packages/lplex/README.md b/packages/lplex/README.md new file mode 100644 index 0000000..8910f7b --- /dev/null +++ b/packages/lplex/README.md @@ -0,0 +1,303 @@ +# @sixfathoms/lplex + +TypeScript client for [lplex](https://github.com/sixfathoms/lplex), a CAN bus HTTP bridge for NMEA 2000. + +Zero runtime dependencies. Works in browsers and Node 18+. Ships ESM, CJS, and TypeScript declarations. + +## Install + +```bash +npm install @sixfathoms/lplex +``` + +## Quick Start + +```typescript +import { Client } from "@sixfathoms/lplex"; + +const client = new Client("http://localhost:8089"); + +// list devices on the bus +const devices = await client.devices(); + +// stream frames +const stream = await client.subscribe({ pgn: [129025] }); +for await (const event of stream) { + if (event.type === "frame") { + console.log(event.frame.pgn, event.frame.data); + } +} +``` + +## API + +### `new Client(baseURL, options?)` + +Creates a client connected to an lplex server. + +```typescript +const client = new Client("http://inuc1.local:8089"); +``` + +Inject a custom `fetch` for testing or environments without a global one: + +```typescript +const client = new Client("http://localhost:8089", { + fetch: myCustomFetch, +}); +``` + +### `client.devices(signal?): Promise` + +Returns a snapshot of all NMEA 2000 devices discovered on the bus. + +```typescript +const devices = await client.devices(); +for (const d of devices) { + console.log(`${d.manufacturer} (src=${d.src}): ${d.packet_count} packets`); +} +``` + +### `client.subscribe(filter?, signal?): Promise>` + +Opens an ephemeral SSE stream. No session state, no replay. Frames flow until you stop reading or abort. + +```typescript +const stream = await client.subscribe(); + +for await (const event of stream) { + switch (event.type) { + case "frame": + console.log(event.frame.pgn, event.frame.src, event.frame.data); + break; + case "device": + console.log("device:", event.device.manufacturer, event.device.src); + break; + } +} +``` + +#### Filtering + +Pass a `Filter` to narrow the stream. Categories are AND'd, values within a category are OR'd. + +```typescript +const stream = await client.subscribe({ + pgn: [129025, 129026], // Position Rapid OR COG/SOG Rapid + manufacturer: ["Garmin"], // AND from Garmin +}); +``` + +#### Cancellation + +Use an `AbortSignal` to stop the stream: + +```typescript +const ac = new AbortController(); +setTimeout(() => ac.abort(), 10_000); + +const stream = await client.subscribe(undefined, ac.signal); +for await (const event of stream) { + console.log(event); +} +// loop exits when aborted +``` + +### `client.send(params, signal?): Promise` + +Transmit a CAN frame through the server to the bus. + +```typescript +await client.send({ + pgn: 129025, + src: 0, + dst: 255, + prio: 6, + data: "00aabbccddee", +}); +``` + +### `client.createSession(config, signal?): Promise` + +Creates or reconnects a buffered session. The server buffers frames while you're disconnected. On reconnect, you pick up where you left off. + +```typescript +const session = await client.createSession({ + clientId: "my-dashboard", + bufferTimeout: "PT5M", + filter: { pgn: [129025] }, +}); + +console.log(`cursor at ${session.info.cursor}, head at ${session.info.seq}`); + +const stream = await session.subscribe(); +let lastSeq = 0; + +for await (const event of stream) { + if (event.type === "frame") { + lastSeq = event.frame.seq; + console.log(JSON.stringify(event.frame)); + } +} + +// advance the cursor so the server can free buffer space +await session.ack(lastSeq); +``` + +### `session.subscribe(signal?): Promise>` + +Opens the SSE stream for a buffered session. Replays from the cursor, then streams live. + +### `session.ack(seq, signal?): Promise` + +Advances the cursor to the given sequence number. + +### `session.info: SessionInfo` + +The session metadata returned by the server on create/reconnect. + +### `session.lastAckedSeq: number` + +The last sequence number successfully ACK'd (0 if never ACK'd). + +## Error Handling + +All methods throw `HttpError` on non-success HTTP responses: + +```typescript +import { HttpError } from "@sixfathoms/lplex"; + +try { + await client.devices(); +} catch (err) { + if (err instanceof HttpError) { + console.error(`HTTP ${err.status}: ${err.body}`); + } +} +``` + +## Browser Usage + +The library uses only web platform APIs (`fetch`, `ReadableStream`, `TextDecoder`, `AbortSignal`), so it works in any modern browser without polyfills. + +```html + +``` + +### React + +```tsx +import { useState, useEffect } from "react"; +import { Client, type Frame, type Filter } from "@sixfathoms/lplex"; + +function useFrames(serverUrl: string, filter?: Filter) { + const [frames, setFrames] = useState([]); + + useEffect(() => { + const ac = new AbortController(); + const client = new Client(serverUrl); + + (async () => { + const stream = await client.subscribe(filter, ac.signal); + for await (const event of stream) { + if (event.type === "frame") { + setFrames((prev) => [...prev.slice(-99), event.frame]); + } + } + })().catch(() => {}); + + return () => ac.abort(); + }, [serverUrl]); + + return frames; +} +``` + +## Types + +All interfaces use `snake_case` field names matching the server's JSON wire format. No mapping layer. + +```typescript +interface Frame { + seq: number; // monotonic, starts at 1 + ts: string; // RFC 3339 timestamp + prio: number; // 0-7 + pgn: number; // Parameter Group Number + src: number; // source address (0-253) + dst: number; // destination (255 = broadcast) + data: string; // hex-encoded payload +} + +interface Device { + src: number; + name: string; + manufacturer: string; + manufacturer_code: number; + device_class: number; + device_function: number; + device_instance: number; + unique_number: number; + model_id: string; + software_version: string; + model_version: string; + model_serial: string; + product_code: number; + first_seen: string; + last_seen: string; + packet_count: number; + byte_count: number; +} + +type Event = + | { type: "frame"; frame: Frame } + | { type: "device"; device: Device }; + +interface Filter { + pgn?: number[]; + manufacturer?: string[]; + instance?: number[]; + name?: string[]; +} + +interface SessionConfig { + clientId: string; + bufferTimeout: string; // ISO 8601 duration ("PT5M", "PT1H") + filter?: Filter; +} + +interface SessionInfo { + client_id: string; + seq: number; // current head + cursor: number; // last ACK'd (0 = never) + devices: Device[]; +} + +interface SendParams { + pgn: number; + src: number; + dst: number; + prio: number; + data: string; // hex-encoded +} +``` + +## Server Endpoints + +| Endpoint | Method | Purpose | +|---|---|---| +| `/events` | GET | Ephemeral SSE stream. Query params: `pgn`, `manufacturer`, `instance`, `name` (repeatable). | +| `/clients/{id}` | PUT | Create/reconnect buffered session. JSON body: `buffer_timeout`, `filter`. | +| `/clients/{id}/events` | GET | Buffered SSE stream with replay from cursor. | +| `/clients/{id}/ack` | PUT | ACK sequence number. JSON body: `{ "seq": N }`. Returns 204. | +| `/send` | POST | Transmit CAN frame. JSON body: `pgn`, `src`, `dst`, `prio`, `data`. Returns 202. | +| `/devices` | GET | Device snapshot. Returns JSON array. | + +## License + +MIT diff --git a/packages/lplex/package.json b/packages/lplex/package.json index 5d43556..68027c3 100644 --- a/packages/lplex/package.json +++ b/packages/lplex/package.json @@ -1,6 +1,6 @@ { "name": "@sixfathoms/lplex", - "version": "0.1.0", + "version": "0.0.0", "description": "TypeScript client for lplex CAN bus HTTP bridge", "type": "module", "main": "./dist/index.cjs", @@ -18,7 +18,7 @@ } } }, - "files": ["dist"], + "files": ["dist", "README.md"], "scripts": { "build": "tsup", "test": "vitest run",