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
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Uint8Array>`, 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

Expand Down Expand Up @@ -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

Expand Down
30 changes: 30 additions & 0 deletions packages/lplex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,20 @@ for (const d of devices) {
}
```

### `client.values(signal?): Promise<DeviceValues[]>`

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<AsyncIterable<Event>>`

Opens an ephemeral SSE stream. No session state, no replay. Frames flow until you stop reading or abort.
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
14 changes: 14 additions & 0 deletions packages/lplex/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Session } from "./session.js";
import { parseSSE } from "./sse.js";
import type {
Device,
DeviceValues,
Event,
Filter,
SendParams,
Expand Down Expand Up @@ -38,6 +39,19 @@ export class Client {
return resp.json() as Promise<Device[]>;
}

/** Fetch the last-seen value for each (device, PGN) pair. */
async values(signal?: AbortSignal): Promise<DeviceValues[]> {
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<DeviceValues[]>;
}

/**
* Open an ephemeral SSE stream with optional filtering.
* No session, no replay, no ACK.
Expand Down
2 changes: 2 additions & 0 deletions packages/lplex/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export type {
Device,
Event,
Filter,
PGNValue,
DeviceValues,
SessionConfig,
SessionInfo,
SendParams,
Expand Down
19 changes: 19 additions & 0 deletions packages/lplex/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
53 changes: 53 additions & 0 deletions packages/lplex/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,59 @@ 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 }];
Expand Down