From 810c7bdf33d7e8751dab8f26882f51627c4b383f Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Wed, 4 Mar 2026 12:24:32 -0800 Subject: [PATCH] Add filter support to values() method The values() method now accepts an optional Filter parameter, matching the subscribe() API. Filter is encoded as query params (pgn, manufacturer, instance, name) on the /values request. --- CLAUDE.md | 2 +- packages/lplex/README.md | 13 +++++++-- packages/lplex/src/client.ts | 6 ++-- packages/lplex/test/client.test.ts | 45 ++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e13b623..2553b25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/packages/lplex/README.md b/packages/lplex/README.md index ae35b5f..01d70bc 100644 --- a/packages/lplex/README.md +++ b/packages/lplex/README.md @@ -58,7 +58,7 @@ for (const d of devices) { } ``` -### `client.values(signal?): Promise` +### `client.values(filter?, 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. @@ -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>` Opens an ephemeral SSE stream. No session state, no replay. Frames flow until you stop reading or abort. @@ -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 diff --git a/packages/lplex/src/client.ts b/packages/lplex/src/client.ts index 9f4e75e..5330cf3 100644 --- a/packages/lplex/src/client.ts +++ b/packages/lplex/src/client.ts @@ -40,8 +40,10 @@ export class Client { } /** Fetch the last-seen value for each (device, PGN) pair. */ - async values(signal?: AbortSignal): Promise { - const url = `${this.#baseURL}/values`; + async values(filter?: Filter, signal?: AbortSignal): Promise { + let url = `${this.#baseURL}/values`; + const qs = filterToQueryString(filter); + if (qs) url += `?${qs}`; const resp = await this.#fetch(url, { signal }); if (!resp.ok) { diff --git a/packages/lplex/test/client.test.ts b/packages/lplex/test/client.test.ts index 20fc5d3..ed8ce1b 100644 --- a/packages/lplex/test/client.test.ts +++ b/packages/lplex/test/client.test.ts @@ -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", {