@sixfathoms/lplex 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +303 -0
  2. package/package.json +8 -2
package/README.md ADDED
@@ -0,0 +1,303 @@
1
+ # @sixfathoms/lplex
2
+
3
+ TypeScript client for [lplex](https://github.com/sixfathoms/lplex), a CAN bus HTTP bridge for NMEA 2000.
4
+
5
+ Zero runtime dependencies. Works in browsers and Node 18+. Ships ESM, CJS, and TypeScript declarations.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @sixfathoms/lplex
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```typescript
16
+ import { Client } from "@sixfathoms/lplex";
17
+
18
+ const client = new Client("http://localhost:8089");
19
+
20
+ // list devices on the bus
21
+ const devices = await client.devices();
22
+
23
+ // stream frames
24
+ const stream = await client.subscribe({ pgn: [129025] });
25
+ for await (const event of stream) {
26
+ if (event.type === "frame") {
27
+ console.log(event.frame.pgn, event.frame.data);
28
+ }
29
+ }
30
+ ```
31
+
32
+ ## API
33
+
34
+ ### `new Client(baseURL, options?)`
35
+
36
+ Creates a client connected to an lplex server.
37
+
38
+ ```typescript
39
+ const client = new Client("http://inuc1.local:8089");
40
+ ```
41
+
42
+ Inject a custom `fetch` for testing or environments without a global one:
43
+
44
+ ```typescript
45
+ const client = new Client("http://localhost:8089", {
46
+ fetch: myCustomFetch,
47
+ });
48
+ ```
49
+
50
+ ### `client.devices(signal?): Promise<Device[]>`
51
+
52
+ Returns a snapshot of all NMEA 2000 devices discovered on the bus.
53
+
54
+ ```typescript
55
+ const devices = await client.devices();
56
+ for (const d of devices) {
57
+ console.log(`${d.manufacturer} (src=${d.src}): ${d.packet_count} packets`);
58
+ }
59
+ ```
60
+
61
+ ### `client.subscribe(filter?, signal?): Promise<AsyncIterable<Event>>`
62
+
63
+ Opens an ephemeral SSE stream. No session state, no replay. Frames flow until you stop reading or abort.
64
+
65
+ ```typescript
66
+ const stream = await client.subscribe();
67
+
68
+ for await (const event of stream) {
69
+ switch (event.type) {
70
+ case "frame":
71
+ console.log(event.frame.pgn, event.frame.src, event.frame.data);
72
+ break;
73
+ case "device":
74
+ console.log("device:", event.device.manufacturer, event.device.src);
75
+ break;
76
+ }
77
+ }
78
+ ```
79
+
80
+ #### Filtering
81
+
82
+ Pass a `Filter` to narrow the stream. Categories are AND'd, values within a category are OR'd.
83
+
84
+ ```typescript
85
+ const stream = await client.subscribe({
86
+ pgn: [129025, 129026], // Position Rapid OR COG/SOG Rapid
87
+ manufacturer: ["Garmin"], // AND from Garmin
88
+ });
89
+ ```
90
+
91
+ #### Cancellation
92
+
93
+ Use an `AbortSignal` to stop the stream:
94
+
95
+ ```typescript
96
+ const ac = new AbortController();
97
+ setTimeout(() => ac.abort(), 10_000);
98
+
99
+ const stream = await client.subscribe(undefined, ac.signal);
100
+ for await (const event of stream) {
101
+ console.log(event);
102
+ }
103
+ // loop exits when aborted
104
+ ```
105
+
106
+ ### `client.send(params, signal?): Promise<void>`
107
+
108
+ Transmit a CAN frame through the server to the bus.
109
+
110
+ ```typescript
111
+ await client.send({
112
+ pgn: 129025,
113
+ src: 0,
114
+ dst: 255,
115
+ prio: 6,
116
+ data: "00aabbccddee",
117
+ });
118
+ ```
119
+
120
+ ### `client.createSession(config, signal?): Promise<Session>`
121
+
122
+ Creates or reconnects a buffered session. The server buffers frames while you're disconnected. On reconnect, you pick up where you left off.
123
+
124
+ ```typescript
125
+ const session = await client.createSession({
126
+ clientId: "my-dashboard",
127
+ bufferTimeout: "PT5M",
128
+ filter: { pgn: [129025] },
129
+ });
130
+
131
+ console.log(`cursor at ${session.info.cursor}, head at ${session.info.seq}`);
132
+
133
+ const stream = await session.subscribe();
134
+ let lastSeq = 0;
135
+
136
+ for await (const event of stream) {
137
+ if (event.type === "frame") {
138
+ lastSeq = event.frame.seq;
139
+ console.log(JSON.stringify(event.frame));
140
+ }
141
+ }
142
+
143
+ // advance the cursor so the server can free buffer space
144
+ await session.ack(lastSeq);
145
+ ```
146
+
147
+ ### `session.subscribe(signal?): Promise<AsyncIterable<Event>>`
148
+
149
+ Opens the SSE stream for a buffered session. Replays from the cursor, then streams live.
150
+
151
+ ### `session.ack(seq, signal?): Promise<void>`
152
+
153
+ Advances the cursor to the given sequence number.
154
+
155
+ ### `session.info: SessionInfo`
156
+
157
+ The session metadata returned by the server on create/reconnect.
158
+
159
+ ### `session.lastAckedSeq: number`
160
+
161
+ The last sequence number successfully ACK'd (0 if never ACK'd).
162
+
163
+ ## Error Handling
164
+
165
+ All methods throw `HttpError` on non-success HTTP responses:
166
+
167
+ ```typescript
168
+ import { HttpError } from "@sixfathoms/lplex";
169
+
170
+ try {
171
+ await client.devices();
172
+ } catch (err) {
173
+ if (err instanceof HttpError) {
174
+ console.error(`HTTP ${err.status}: ${err.body}`);
175
+ }
176
+ }
177
+ ```
178
+
179
+ ## Browser Usage
180
+
181
+ The library uses only web platform APIs (`fetch`, `ReadableStream`, `TextDecoder`, `AbortSignal`), so it works in any modern browser without polyfills.
182
+
183
+ ```html
184
+ <script type="module">
185
+ import { Client } from "https://esm.sh/@sixfathoms/lplex";
186
+
187
+ const client = new Client("http://your-lplex-server:8089");
188
+ const devices = await client.devices();
189
+ console.log(devices);
190
+ </script>
191
+ ```
192
+
193
+ ### React
194
+
195
+ ```tsx
196
+ import { useState, useEffect } from "react";
197
+ import { Client, type Frame, type Filter } from "@sixfathoms/lplex";
198
+
199
+ function useFrames(serverUrl: string, filter?: Filter) {
200
+ const [frames, setFrames] = useState<Frame[]>([]);
201
+
202
+ useEffect(() => {
203
+ const ac = new AbortController();
204
+ const client = new Client(serverUrl);
205
+
206
+ (async () => {
207
+ const stream = await client.subscribe(filter, ac.signal);
208
+ for await (const event of stream) {
209
+ if (event.type === "frame") {
210
+ setFrames((prev) => [...prev.slice(-99), event.frame]);
211
+ }
212
+ }
213
+ })().catch(() => {});
214
+
215
+ return () => ac.abort();
216
+ }, [serverUrl]);
217
+
218
+ return frames;
219
+ }
220
+ ```
221
+
222
+ ## Types
223
+
224
+ All interfaces use `snake_case` field names matching the server's JSON wire format. No mapping layer.
225
+
226
+ ```typescript
227
+ interface Frame {
228
+ seq: number; // monotonic, starts at 1
229
+ ts: string; // RFC 3339 timestamp
230
+ prio: number; // 0-7
231
+ pgn: number; // Parameter Group Number
232
+ src: number; // source address (0-253)
233
+ dst: number; // destination (255 = broadcast)
234
+ data: string; // hex-encoded payload
235
+ }
236
+
237
+ interface Device {
238
+ src: number;
239
+ name: string;
240
+ manufacturer: string;
241
+ manufacturer_code: number;
242
+ device_class: number;
243
+ device_function: number;
244
+ device_instance: number;
245
+ unique_number: number;
246
+ model_id: string;
247
+ software_version: string;
248
+ model_version: string;
249
+ model_serial: string;
250
+ product_code: number;
251
+ first_seen: string;
252
+ last_seen: string;
253
+ packet_count: number;
254
+ byte_count: number;
255
+ }
256
+
257
+ type Event =
258
+ | { type: "frame"; frame: Frame }
259
+ | { type: "device"; device: Device };
260
+
261
+ interface Filter {
262
+ pgn?: number[];
263
+ manufacturer?: string[];
264
+ instance?: number[];
265
+ name?: string[];
266
+ }
267
+
268
+ interface SessionConfig {
269
+ clientId: string;
270
+ bufferTimeout: string; // ISO 8601 duration ("PT5M", "PT1H")
271
+ filter?: Filter;
272
+ }
273
+
274
+ interface SessionInfo {
275
+ client_id: string;
276
+ seq: number; // current head
277
+ cursor: number; // last ACK'd (0 = never)
278
+ devices: Device[];
279
+ }
280
+
281
+ interface SendParams {
282
+ pgn: number;
283
+ src: number;
284
+ dst: number;
285
+ prio: number;
286
+ data: string; // hex-encoded
287
+ }
288
+ ```
289
+
290
+ ## Server Endpoints
291
+
292
+ | Endpoint | Method | Purpose |
293
+ |---|---|---|
294
+ | `/events` | GET | Ephemeral SSE stream. Query params: `pgn`, `manufacturer`, `instance`, `name` (repeatable). |
295
+ | `/clients/{id}` | PUT | Create/reconnect buffered session. JSON body: `buffer_timeout`, `filter`. |
296
+ | `/clients/{id}/events` | GET | Buffered SSE stream with replay from cursor. |
297
+ | `/clients/{id}/ack` | PUT | ACK sequence number. JSON body: `{ "seq": N }`. Returns 204. |
298
+ | `/send` | POST | Transmit CAN frame. JSON body: `pgn`, `src`, `dst`, `prio`, `data`. Returns 202. |
299
+ | `/devices` | GET | Device snapshot. Returns JSON array. |
300
+
301
+ ## License
302
+
303
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sixfathoms/lplex",
3
- "version": "0.0.0",
3
+ "version": "0.1.0",
4
4
  "description": "TypeScript client for lplex CAN bus HTTP bridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -19,7 +19,8 @@
19
19
  }
20
20
  },
21
21
  "files": [
22
- "dist"
22
+ "dist",
23
+ "README.md"
23
24
  ],
24
25
  "scripts": {
25
26
  "build": "tsup",
@@ -31,5 +32,10 @@
31
32
  "typescript": "^5.5.0",
32
33
  "vitest": "^2.0.0"
33
34
  },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/sixfathoms/lplex-typescript",
38
+ "directory": "packages/lplex"
39
+ },
34
40
  "license": "MIT"
35
41
  }