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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +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. |
| `/values` | GET | Last-seen value per (device, PGN). Query params: `pgn`, `manufacturer`, `instance`, `name` (repeatable). Returns JSON array grouped by device. |

## Release

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

### `client.values(signal?): Promise<DeviceValues[]>`
### `client.values(filter?, 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.

Expand All @@ -72,6 +72,15 @@ for (const device of snapshot) {
}
```

Pass a `Filter` to narrow results by PGN and/or device criteria:

```typescript
const positions = await client.values({
pgn: [129025],
manufacturer: ["Garmin"],
});
```

### `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 @@ -326,7 +335,7 @@ interface DeviceValues {
| `/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. |
| `/values` | GET | Last-seen value per (device, PGN). Query params: `pgn`, `manufacturer`, `instance`, `name` (repeatable). Returns JSON array grouped by device. |

## License

Expand Down
6 changes: 4 additions & 2 deletions packages/lplex/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ export class Client {
}

/** Fetch the last-seen value for each (device, PGN) pair. */
async values(signal?: AbortSignal): Promise<DeviceValues[]> {
const url = `${this.#baseURL}/values`;
async values(filter?: Filter, signal?: AbortSignal): Promise<DeviceValues[]> {
let url = `${this.#baseURL}/values`;
const qs = filterToQueryString(filter);
if (qs) url += `?${qs}`;
const resp = await this.#fetch(url, { signal });

if (!resp.ok) {
Expand Down
45 changes: 45 additions & 0 deletions packages/lplex/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,51 @@ describe("Client.values", () => {
expect(result).toEqual([]);
});

it("encodes filter as query params", async () => {
let capturedURL = "";
const mockFetch = async (url: string | URL | Request) => {
capturedURL = String(url);
return jsonResponse([]);
};

const client = new Client("http://localhost:8089", {
fetch: mockFetch as typeof fetch,
});
await client.values({ pgn: [129025, 129026], manufacturer: ["Garmin"] });

const parsed = new URL(capturedURL);
expect(parsed.searchParams.getAll("pgn")).toEqual(["129025", "129026"]);
expect(parsed.searchParams.getAll("manufacturer")).toEqual(["Garmin"]);
});

it("omits query string when filter is empty", async () => {
let capturedURL = "";
const mockFetch = async (url: string | URL | Request) => {
capturedURL = String(url);
return jsonResponse([]);
};

const client = new Client("http://localhost:8089", {
fetch: mockFetch as typeof fetch,
});
await client.values({});
expect(capturedURL).toBe("http://localhost:8089/values");
});

it("omits query string when no filter", async () => {
let capturedURL = "";
const mockFetch = async (url: string | URL | Request) => {
capturedURL = String(url);
return jsonResponse([]);
};

const client = new Client("http://localhost:8089", {
fetch: mockFetch as typeof fetch,
});
await client.values();
expect(capturedURL).toBe("http://localhost:8089/values");
});

it("throws HttpError on non-200", async () => {
const mockFetch = async () => errorResponse(500, "internal error");
const client = new Client("http://localhost:8089", {
Expand Down