@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.
- package/README.md +303 -0
- 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.
|
|
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
|
}
|