From 2b20d004692c1c8d72c8473805b2007aac0b9bab Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Wed, 4 Mar 2026 11:54:48 -0800 Subject: [PATCH 1/2] Add values() method for last-seen bus state New client.values() fetches GET /values, returning the most recent frame per (device, PGN) pair. Adds PGNValue and DeviceValues types. Works on both boat (Client) and cloud (CloudClient.client()) paths. --- CLAUDE.md | 9 +++--- packages/lplex/README.md | 30 +++++++++++++++++++ packages/lplex/src/client.ts | 14 +++++++++ packages/lplex/src/index.ts | 2 ++ packages/lplex/src/types.ts | 19 ++++++++++++ packages/lplex/test/client.test.ts | 48 ++++++++++++++++++++++++++++++ 6 files changed, 118 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3a3cd71..e13b623 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ TypeScript client library for lplex, a CAN bus HTTP bridge for NMEA 2000. Monore ```bash npm install # install all workspace deps npm run build # build both packages (tsup) -npm run test # vitest (library only, 19 tests) +npm run test # vitest (library only, 30 tests) npm run lint # biome check npm run lint:fix # biome auto-fix npm run typecheck # tsc --noEmit on both packages @@ -31,15 +31,15 @@ npx tsx src/main.ts --server http://inuc1.local:8089 | File | Owns | |---|---| -| `src/types.ts` | `Frame`, `Device`, `Event` (discriminated union), `Filter`, `SessionConfig`, `SessionInfo`, `SendParams` | +| `src/types.ts` | `Frame`, `Device`, `Event` (discriminated union), `Filter`, `PGNValue`, `DeviceValues`, `SessionConfig`, `SessionInfo`, `SendParams` | | `src/errors.ts` | `LplexError`, `HttpError` | | `src/sse.ts` | `parseSSE` async generator: reads `data:` lines from `ReadableStream`, yields `Event` objects | -| `src/client.ts` | `Client` class: `devices()`, `subscribe()`, `send()`, `createSession()`. Injectable `fetch`. | +| `src/client.ts` | `Client` class: `devices()`, `values()`, `subscribe()`, `send()`, `createSession()`. Injectable `fetch`. | | `src/session.ts` | `Session` class: `subscribe()`, `ack()`, `info`, `lastAckedSeq` | | `src/index.ts` | Barrel exports | | `README.md` | npm package page README | | `test/sse.test.ts` | SSE parser unit tests (8 tests) | -| `test/client.test.ts` | Client + Session tests with injected fetch (11 tests) | +| `test/client.test.ts` | Client + Session tests with injected fetch (14 tests) | ### packages/lplex-cli/ File Map @@ -72,6 +72,7 @@ All types use `snake_case` field names matching the server's JSON output exactly | `/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. | +| `/values` | GET | Last-seen value per (device, PGN). Returns JSON array grouped by device. | ## Release diff --git a/packages/lplex/README.md b/packages/lplex/README.md index 8910f7b..ae35b5f 100644 --- a/packages/lplex/README.md +++ b/packages/lplex/README.md @@ -58,6 +58,20 @@ for (const d of devices) { } ``` +### `client.values(signal?): Promise` + +Returns the last-seen value for each (device, PGN) pair, grouped by device. Useful for getting a snapshot of current bus state without subscribing to SSE. + +```typescript +const snapshot = await client.values(); +for (const device of snapshot) { + console.log(`${device.manufacturer} (src=${device.src}):`); + for (const v of device.values) { + console.log(` PGN ${v.pgn}: ${v.data} @ ${v.ts}`); + } +} +``` + ### `client.subscribe(filter?, signal?): Promise>` Opens an ephemeral SSE stream. No session state, no replay. Frames flow until you stop reading or abort. @@ -285,6 +299,21 @@ interface SendParams { prio: number; data: string; // hex-encoded } + +interface PGNValue { + pgn: number; + ts: string; // RFC 3339 timestamp + data: string; // hex-encoded payload + seq: number; // sequence number +} + +interface DeviceValues { + name: string; // hex CAN NAME (empty if unknown) + src: number; // source address + manufacturer?: string; + model_id?: string; + values: PGNValue[]; // sorted by PGN +} ``` ## Server Endpoints @@ -297,6 +326,7 @@ interface SendParams { | `/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. | +| `/values` | GET | Last-seen value per (device, PGN). Returns JSON array grouped by device. | ## License diff --git a/packages/lplex/src/client.ts b/packages/lplex/src/client.ts index cb1a855..9f4e75e 100644 --- a/packages/lplex/src/client.ts +++ b/packages/lplex/src/client.ts @@ -3,6 +3,7 @@ import { Session } from "./session.js"; import { parseSSE } from "./sse.js"; import type { Device, + DeviceValues, Event, Filter, SendParams, @@ -38,6 +39,19 @@ export class Client { return resp.json() as Promise; } + /** Fetch the last-seen value for each (device, PGN) pair. */ + async values(signal?: AbortSignal): Promise { + const url = `${this.#baseURL}/values`; + const resp = await this.#fetch(url, { signal }); + + if (!resp.ok) { + const body = await resp.text(); + throw new HttpError("GET", url, resp.status, body); + } + + return resp.json() as Promise; + } + /** * Open an ephemeral SSE stream with optional filtering. * No session, no replay, no ACK. diff --git a/packages/lplex/src/index.ts b/packages/lplex/src/index.ts index ae31ca8..2f780af 100644 --- a/packages/lplex/src/index.ts +++ b/packages/lplex/src/index.ts @@ -9,6 +9,8 @@ export type { Device, Event, Filter, + PGNValue, + DeviceValues, SessionConfig, SessionInfo, SendParams, diff --git a/packages/lplex/src/types.ts b/packages/lplex/src/types.ts index 2b834f3..67735f0 100644 --- a/packages/lplex/src/types.ts +++ b/packages/lplex/src/types.ts @@ -70,6 +70,25 @@ export interface SendParams { data: string; } +// --- Values types --- + +/** A single PGN's last-known value for a device. */ +export interface PGNValue { + pgn: number; + ts: string; + data: string; + seq: number; +} + +/** Last-known values grouped by device. */ +export interface DeviceValues { + name: string; + src: number; + manufacturer?: string; + model_id?: string; + values: PGNValue[]; +} + // --- Cloud types --- /** Summary of a cloud instance, returned by GET /instances. */ diff --git a/packages/lplex/test/client.test.ts b/packages/lplex/test/client.test.ts index 46bebc2..8e00398 100644 --- a/packages/lplex/test/client.test.ts +++ b/packages/lplex/test/client.test.ts @@ -58,6 +58,54 @@ function errorResponse(status: number, body: string): Response { return new Response(body, { status }); } +describe("Client.values", () => { + it("fetches and returns last-known values", async () => { + const values = [ + { + name: "0x00deadbeef123456", + src: 1, + manufacturer: "Garmin", + model_id: "GPS 19x", + values: [ + { pgn: 129025, ts: "2026-03-04T10:00:00Z", data: "aabbccdd", seq: 100 }, + ], + }, + ]; + const mockFetch = async (url: string | URL | Request) => { + expect(url).toBe("http://localhost:8089/values"); + return jsonResponse(values); + }; + + const client = new Client("http://localhost:8089", { + fetch: mockFetch as typeof fetch, + }); + const result = await client.values(); + expect(result).toHaveLength(1); + expect(result[0].src).toBe(1); + expect(result[0].manufacturer).toBe("Garmin"); + expect(result[0].values).toHaveLength(1); + expect(result[0].values[0].pgn).toBe(129025); + expect(result[0].values[0].data).toBe("aabbccdd"); + }); + + it("returns empty array when no values", async () => { + const mockFetch = async () => jsonResponse([]); + const client = new Client("http://localhost:8089", { + fetch: mockFetch as typeof fetch, + }); + const result = await client.values(); + expect(result).toEqual([]); + }); + + it("throws HttpError on non-200", async () => { + const mockFetch = async () => errorResponse(500, "internal error"); + const client = new Client("http://localhost:8089", { + fetch: mockFetch as typeof fetch, + }); + await expect(client.values()).rejects.toThrow(HttpError); + }); +}); + describe("Client.devices", () => { it("fetches and returns the device list", async () => { const devices = [{ ...sampleDevice }]; From df42271653f2db3b96e2155ce2b3bd4d04cfca9f Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Wed, 4 Mar 2026 11:57:22 -0800 Subject: [PATCH 2/2] Fix biome formatting in values test --- packages/lplex/test/client.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/lplex/test/client.test.ts b/packages/lplex/test/client.test.ts index 8e00398..20fc5d3 100644 --- a/packages/lplex/test/client.test.ts +++ b/packages/lplex/test/client.test.ts @@ -67,7 +67,12 @@ describe("Client.values", () => { manufacturer: "Garmin", model_id: "GPS 19x", values: [ - { pgn: 129025, ts: "2026-03-04T10:00:00Z", data: "aabbccdd", seq: 100 }, + { + pgn: 129025, + ts: "2026-03-04T10:00:00Z", + data: "aabbccdd", + seq: 100, + }, ], }, ];