@sixfathoms/lplex 0.0.0 → 0.2.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 +333 -0
- package/dist/index.cjs +66 -0
- package/dist/index.d.cts +83 -5
- package/dist/index.d.ts +83 -5
- package/dist/index.js +65 -0
- package/package.json +8 -2
package/README.md
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
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.values(signal?): Promise<DeviceValues[]>`
|
|
62
|
+
|
|
63
|
+
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.
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
const snapshot = await client.values();
|
|
67
|
+
for (const device of snapshot) {
|
|
68
|
+
console.log(`${device.manufacturer} (src=${device.src}):`);
|
|
69
|
+
for (const v of device.values) {
|
|
70
|
+
console.log(` PGN ${v.pgn}: ${v.data} @ ${v.ts}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### `client.subscribe(filter?, signal?): Promise<AsyncIterable<Event>>`
|
|
76
|
+
|
|
77
|
+
Opens an ephemeral SSE stream. No session state, no replay. Frames flow until you stop reading or abort.
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
const stream = await client.subscribe();
|
|
81
|
+
|
|
82
|
+
for await (const event of stream) {
|
|
83
|
+
switch (event.type) {
|
|
84
|
+
case "frame":
|
|
85
|
+
console.log(event.frame.pgn, event.frame.src, event.frame.data);
|
|
86
|
+
break;
|
|
87
|
+
case "device":
|
|
88
|
+
console.log("device:", event.device.manufacturer, event.device.src);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
#### Filtering
|
|
95
|
+
|
|
96
|
+
Pass a `Filter` to narrow the stream. Categories are AND'd, values within a category are OR'd.
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
const stream = await client.subscribe({
|
|
100
|
+
pgn: [129025, 129026], // Position Rapid OR COG/SOG Rapid
|
|
101
|
+
manufacturer: ["Garmin"], // AND from Garmin
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
#### Cancellation
|
|
106
|
+
|
|
107
|
+
Use an `AbortSignal` to stop the stream:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
const ac = new AbortController();
|
|
111
|
+
setTimeout(() => ac.abort(), 10_000);
|
|
112
|
+
|
|
113
|
+
const stream = await client.subscribe(undefined, ac.signal);
|
|
114
|
+
for await (const event of stream) {
|
|
115
|
+
console.log(event);
|
|
116
|
+
}
|
|
117
|
+
// loop exits when aborted
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### `client.send(params, signal?): Promise<void>`
|
|
121
|
+
|
|
122
|
+
Transmit a CAN frame through the server to the bus.
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
await client.send({
|
|
126
|
+
pgn: 129025,
|
|
127
|
+
src: 0,
|
|
128
|
+
dst: 255,
|
|
129
|
+
prio: 6,
|
|
130
|
+
data: "00aabbccddee",
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### `client.createSession(config, signal?): Promise<Session>`
|
|
135
|
+
|
|
136
|
+
Creates or reconnects a buffered session. The server buffers frames while you're disconnected. On reconnect, you pick up where you left off.
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
const session = await client.createSession({
|
|
140
|
+
clientId: "my-dashboard",
|
|
141
|
+
bufferTimeout: "PT5M",
|
|
142
|
+
filter: { pgn: [129025] },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
console.log(`cursor at ${session.info.cursor}, head at ${session.info.seq}`);
|
|
146
|
+
|
|
147
|
+
const stream = await session.subscribe();
|
|
148
|
+
let lastSeq = 0;
|
|
149
|
+
|
|
150
|
+
for await (const event of stream) {
|
|
151
|
+
if (event.type === "frame") {
|
|
152
|
+
lastSeq = event.frame.seq;
|
|
153
|
+
console.log(JSON.stringify(event.frame));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// advance the cursor so the server can free buffer space
|
|
158
|
+
await session.ack(lastSeq);
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### `session.subscribe(signal?): Promise<AsyncIterable<Event>>`
|
|
162
|
+
|
|
163
|
+
Opens the SSE stream for a buffered session. Replays from the cursor, then streams live.
|
|
164
|
+
|
|
165
|
+
### `session.ack(seq, signal?): Promise<void>`
|
|
166
|
+
|
|
167
|
+
Advances the cursor to the given sequence number.
|
|
168
|
+
|
|
169
|
+
### `session.info: SessionInfo`
|
|
170
|
+
|
|
171
|
+
The session metadata returned by the server on create/reconnect.
|
|
172
|
+
|
|
173
|
+
### `session.lastAckedSeq: number`
|
|
174
|
+
|
|
175
|
+
The last sequence number successfully ACK'd (0 if never ACK'd).
|
|
176
|
+
|
|
177
|
+
## Error Handling
|
|
178
|
+
|
|
179
|
+
All methods throw `HttpError` on non-success HTTP responses:
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
import { HttpError } from "@sixfathoms/lplex";
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
await client.devices();
|
|
186
|
+
} catch (err) {
|
|
187
|
+
if (err instanceof HttpError) {
|
|
188
|
+
console.error(`HTTP ${err.status}: ${err.body}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Browser Usage
|
|
194
|
+
|
|
195
|
+
The library uses only web platform APIs (`fetch`, `ReadableStream`, `TextDecoder`, `AbortSignal`), so it works in any modern browser without polyfills.
|
|
196
|
+
|
|
197
|
+
```html
|
|
198
|
+
<script type="module">
|
|
199
|
+
import { Client } from "https://esm.sh/@sixfathoms/lplex";
|
|
200
|
+
|
|
201
|
+
const client = new Client("http://your-lplex-server:8089");
|
|
202
|
+
const devices = await client.devices();
|
|
203
|
+
console.log(devices);
|
|
204
|
+
</script>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### React
|
|
208
|
+
|
|
209
|
+
```tsx
|
|
210
|
+
import { useState, useEffect } from "react";
|
|
211
|
+
import { Client, type Frame, type Filter } from "@sixfathoms/lplex";
|
|
212
|
+
|
|
213
|
+
function useFrames(serverUrl: string, filter?: Filter) {
|
|
214
|
+
const [frames, setFrames] = useState<Frame[]>([]);
|
|
215
|
+
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
const ac = new AbortController();
|
|
218
|
+
const client = new Client(serverUrl);
|
|
219
|
+
|
|
220
|
+
(async () => {
|
|
221
|
+
const stream = await client.subscribe(filter, ac.signal);
|
|
222
|
+
for await (const event of stream) {
|
|
223
|
+
if (event.type === "frame") {
|
|
224
|
+
setFrames((prev) => [...prev.slice(-99), event.frame]);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
})().catch(() => {});
|
|
228
|
+
|
|
229
|
+
return () => ac.abort();
|
|
230
|
+
}, [serverUrl]);
|
|
231
|
+
|
|
232
|
+
return frames;
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Types
|
|
237
|
+
|
|
238
|
+
All interfaces use `snake_case` field names matching the server's JSON wire format. No mapping layer.
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
interface Frame {
|
|
242
|
+
seq: number; // monotonic, starts at 1
|
|
243
|
+
ts: string; // RFC 3339 timestamp
|
|
244
|
+
prio: number; // 0-7
|
|
245
|
+
pgn: number; // Parameter Group Number
|
|
246
|
+
src: number; // source address (0-253)
|
|
247
|
+
dst: number; // destination (255 = broadcast)
|
|
248
|
+
data: string; // hex-encoded payload
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
interface Device {
|
|
252
|
+
src: number;
|
|
253
|
+
name: string;
|
|
254
|
+
manufacturer: string;
|
|
255
|
+
manufacturer_code: number;
|
|
256
|
+
device_class: number;
|
|
257
|
+
device_function: number;
|
|
258
|
+
device_instance: number;
|
|
259
|
+
unique_number: number;
|
|
260
|
+
model_id: string;
|
|
261
|
+
software_version: string;
|
|
262
|
+
model_version: string;
|
|
263
|
+
model_serial: string;
|
|
264
|
+
product_code: number;
|
|
265
|
+
first_seen: string;
|
|
266
|
+
last_seen: string;
|
|
267
|
+
packet_count: number;
|
|
268
|
+
byte_count: number;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
type Event =
|
|
272
|
+
| { type: "frame"; frame: Frame }
|
|
273
|
+
| { type: "device"; device: Device };
|
|
274
|
+
|
|
275
|
+
interface Filter {
|
|
276
|
+
pgn?: number[];
|
|
277
|
+
manufacturer?: string[];
|
|
278
|
+
instance?: number[];
|
|
279
|
+
name?: string[];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
interface SessionConfig {
|
|
283
|
+
clientId: string;
|
|
284
|
+
bufferTimeout: string; // ISO 8601 duration ("PT5M", "PT1H")
|
|
285
|
+
filter?: Filter;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
interface SessionInfo {
|
|
289
|
+
client_id: string;
|
|
290
|
+
seq: number; // current head
|
|
291
|
+
cursor: number; // last ACK'd (0 = never)
|
|
292
|
+
devices: Device[];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
interface SendParams {
|
|
296
|
+
pgn: number;
|
|
297
|
+
src: number;
|
|
298
|
+
dst: number;
|
|
299
|
+
prio: number;
|
|
300
|
+
data: string; // hex-encoded
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
interface PGNValue {
|
|
304
|
+
pgn: number;
|
|
305
|
+
ts: string; // RFC 3339 timestamp
|
|
306
|
+
data: string; // hex-encoded payload
|
|
307
|
+
seq: number; // sequence number
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
interface DeviceValues {
|
|
311
|
+
name: string; // hex CAN NAME (empty if unknown)
|
|
312
|
+
src: number; // source address
|
|
313
|
+
manufacturer?: string;
|
|
314
|
+
model_id?: string;
|
|
315
|
+
values: PGNValue[]; // sorted by PGN
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## Server Endpoints
|
|
320
|
+
|
|
321
|
+
| Endpoint | Method | Purpose |
|
|
322
|
+
|---|---|---|
|
|
323
|
+
| `/events` | GET | Ephemeral SSE stream. Query params: `pgn`, `manufacturer`, `instance`, `name` (repeatable). |
|
|
324
|
+
| `/clients/{id}` | PUT | Create/reconnect buffered session. JSON body: `buffer_timeout`, `filter`. |
|
|
325
|
+
| `/clients/{id}/events` | GET | Buffered SSE stream with replay from cursor. |
|
|
326
|
+
| `/clients/{id}/ack` | PUT | ACK sequence number. JSON body: `{ "seq": N }`. Returns 204. |
|
|
327
|
+
| `/send` | POST | Transmit CAN frame. JSON body: `pgn`, `src`, `dst`, `prio`, `data`. Returns 202. |
|
|
328
|
+
| `/devices` | GET | Device snapshot. Returns JSON array. |
|
|
329
|
+
| `/values` | GET | Last-seen value per (device, PGN). Returns JSON array grouped by device. |
|
|
330
|
+
|
|
331
|
+
## License
|
|
332
|
+
|
|
333
|
+
MIT
|
package/dist/index.cjs
CHANGED
|
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
Client: () => Client,
|
|
24
|
+
CloudClient: () => CloudClient,
|
|
24
25
|
HttpError: () => HttpError,
|
|
25
26
|
LplexError: () => LplexError,
|
|
26
27
|
Session: () => Session
|
|
@@ -169,6 +170,16 @@ var Client = class {
|
|
|
169
170
|
}
|
|
170
171
|
return resp.json();
|
|
171
172
|
}
|
|
173
|
+
/** Fetch the last-seen value for each (device, PGN) pair. */
|
|
174
|
+
async values(signal) {
|
|
175
|
+
const url = `${this.#baseURL}/values`;
|
|
176
|
+
const resp = await this.#fetch(url, { signal });
|
|
177
|
+
if (!resp.ok) {
|
|
178
|
+
const body = await resp.text();
|
|
179
|
+
throw new HttpError("GET", url, resp.status, body);
|
|
180
|
+
}
|
|
181
|
+
return resp.json();
|
|
182
|
+
}
|
|
172
183
|
/**
|
|
173
184
|
* Open an ephemeral SSE stream with optional filtering.
|
|
174
185
|
* No session, no replay, no ACK.
|
|
@@ -247,9 +258,64 @@ function filterToJSON(f) {
|
|
|
247
258
|
if (f.name?.length) m.name = f.name;
|
|
248
259
|
return m;
|
|
249
260
|
}
|
|
261
|
+
|
|
262
|
+
// src/cloud.ts
|
|
263
|
+
var CloudClient = class {
|
|
264
|
+
#baseURL;
|
|
265
|
+
#fetch;
|
|
266
|
+
#fetchOpt;
|
|
267
|
+
constructor(baseURL, options) {
|
|
268
|
+
this.#baseURL = baseURL.replace(/\/+$/, "");
|
|
269
|
+
this.#fetch = options?.fetch ?? globalThis.fetch.bind(globalThis);
|
|
270
|
+
this.#fetchOpt = options ?? {};
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Returns a {@link Client} scoped to a specific instance.
|
|
274
|
+
* The returned client's `devices()`, `subscribe()`, etc. hit the
|
|
275
|
+
* cloud's per-instance endpoints.
|
|
276
|
+
*/
|
|
277
|
+
client(instanceId) {
|
|
278
|
+
const opts = {};
|
|
279
|
+
if (this.#fetchOpt.fetch) opts.fetch = this.#fetchOpt.fetch;
|
|
280
|
+
return new Client(`${this.#baseURL}/instances/${instanceId}`, opts);
|
|
281
|
+
}
|
|
282
|
+
/** List all known instances. */
|
|
283
|
+
async instances(signal) {
|
|
284
|
+
const url = `${this.#baseURL}/instances`;
|
|
285
|
+
const resp = await this.#fetch(url, { signal });
|
|
286
|
+
if (!resp.ok) {
|
|
287
|
+
const body = await resp.text();
|
|
288
|
+
throw new HttpError("GET", url, resp.status, body);
|
|
289
|
+
}
|
|
290
|
+
const data = await resp.json();
|
|
291
|
+
return data.instances;
|
|
292
|
+
}
|
|
293
|
+
/** Get detailed replication status for one instance. */
|
|
294
|
+
async status(instanceId, signal) {
|
|
295
|
+
const url = `${this.#baseURL}/instances/${instanceId}/status`;
|
|
296
|
+
const resp = await this.#fetch(url, { signal });
|
|
297
|
+
if (!resp.ok) {
|
|
298
|
+
const body = await resp.text();
|
|
299
|
+
throw new HttpError("GET", url, resp.status, body);
|
|
300
|
+
}
|
|
301
|
+
return resp.json();
|
|
302
|
+
}
|
|
303
|
+
/** Fetch recent replication diagnostic events for an instance. */
|
|
304
|
+
async replicationEvents(instanceId, limit, signal) {
|
|
305
|
+
let url = `${this.#baseURL}/instances/${instanceId}/replication/events`;
|
|
306
|
+
if (limit !== void 0) url += `?limit=${limit}`;
|
|
307
|
+
const resp = await this.#fetch(url, { signal });
|
|
308
|
+
if (!resp.ok) {
|
|
309
|
+
const body = await resp.text();
|
|
310
|
+
throw new HttpError("GET", url, resp.status, body);
|
|
311
|
+
}
|
|
312
|
+
return resp.json();
|
|
313
|
+
}
|
|
314
|
+
};
|
|
250
315
|
// Annotate the CommonJS export names for ESM import in node:
|
|
251
316
|
0 && (module.exports = {
|
|
252
317
|
Client,
|
|
318
|
+
CloudClient,
|
|
253
319
|
HttpError,
|
|
254
320
|
LplexError,
|
|
255
321
|
Session
|
package/dist/index.d.cts
CHANGED
|
@@ -67,12 +67,61 @@ interface SendParams {
|
|
|
67
67
|
prio: number;
|
|
68
68
|
data: string;
|
|
69
69
|
}
|
|
70
|
+
/** A single PGN's last-known value for a device. */
|
|
71
|
+
interface PGNValue {
|
|
72
|
+
pgn: number;
|
|
73
|
+
ts: string;
|
|
74
|
+
data: string;
|
|
75
|
+
seq: number;
|
|
76
|
+
}
|
|
77
|
+
/** Last-known values grouped by device. */
|
|
78
|
+
interface DeviceValues {
|
|
79
|
+
name: string;
|
|
80
|
+
src: number;
|
|
81
|
+
manufacturer?: string;
|
|
82
|
+
model_id?: string;
|
|
83
|
+
values: PGNValue[];
|
|
84
|
+
}
|
|
85
|
+
/** Summary of a cloud instance, returned by GET /instances. */
|
|
86
|
+
interface InstanceSummary {
|
|
87
|
+
id: string;
|
|
88
|
+
connected: boolean;
|
|
89
|
+
cursor: number;
|
|
90
|
+
boat_head_seq: number;
|
|
91
|
+
holes: number;
|
|
92
|
+
lag_seqs: number;
|
|
93
|
+
last_seen: string;
|
|
94
|
+
}
|
|
95
|
+
/** A sequence range representing a gap in the replication stream. */
|
|
96
|
+
interface SeqRange {
|
|
97
|
+
start: number;
|
|
98
|
+
end: number;
|
|
99
|
+
}
|
|
100
|
+
/** Detailed replication status for one instance. */
|
|
101
|
+
interface InstanceStatus {
|
|
102
|
+
id: string;
|
|
103
|
+
connected: boolean;
|
|
104
|
+
cursor: number;
|
|
105
|
+
boat_head_seq: number;
|
|
106
|
+
boat_journal_bytes: number;
|
|
107
|
+
holes: SeqRange[];
|
|
108
|
+
lag_seqs: number;
|
|
109
|
+
last_seen: string;
|
|
110
|
+
}
|
|
111
|
+
/** Event types emitted by the replication pipeline. */
|
|
112
|
+
type ReplicationEventType = "live_start" | "live_stop" | "backfill_start" | "backfill_stop" | "block_received" | "checkpoint";
|
|
113
|
+
/** A single diagnostic event from the replication pipeline. */
|
|
114
|
+
interface ReplicationEvent {
|
|
115
|
+
time: string;
|
|
116
|
+
type: ReplicationEventType;
|
|
117
|
+
detail?: Record<string, unknown>;
|
|
118
|
+
}
|
|
70
119
|
|
|
71
|
-
type FetchFn$
|
|
120
|
+
type FetchFn$2 = typeof globalThis.fetch;
|
|
72
121
|
declare class Session {
|
|
73
122
|
#private;
|
|
74
123
|
/** @internal Created by Client.createSession, not for direct use. */
|
|
75
|
-
constructor(baseURL: string, fetchFn: FetchFn$
|
|
124
|
+
constructor(baseURL: string, fetchFn: FetchFn$2, info: SessionInfo);
|
|
76
125
|
get info(): SessionInfo;
|
|
77
126
|
get lastAckedSeq(): number;
|
|
78
127
|
/**
|
|
@@ -84,15 +133,17 @@ declare class Session {
|
|
|
84
133
|
ack(seq: number, signal?: AbortSignal): Promise<void>;
|
|
85
134
|
}
|
|
86
135
|
|
|
87
|
-
type FetchFn = typeof globalThis.fetch;
|
|
136
|
+
type FetchFn$1 = typeof globalThis.fetch;
|
|
88
137
|
interface ClientOptions {
|
|
89
|
-
fetch?: FetchFn;
|
|
138
|
+
fetch?: FetchFn$1;
|
|
90
139
|
}
|
|
91
140
|
declare class Client {
|
|
92
141
|
#private;
|
|
93
142
|
constructor(baseURL: string, options?: ClientOptions);
|
|
94
143
|
/** Fetch a snapshot of all NMEA 2000 devices discovered by the server. */
|
|
95
144
|
devices(signal?: AbortSignal): Promise<Device[]>;
|
|
145
|
+
/** Fetch the last-seen value for each (device, PGN) pair. */
|
|
146
|
+
values(signal?: AbortSignal): Promise<DeviceValues[]>;
|
|
96
147
|
/**
|
|
97
148
|
* Open an ephemeral SSE stream with optional filtering.
|
|
98
149
|
* No session, no replay, no ACK.
|
|
@@ -104,6 +155,33 @@ declare class Client {
|
|
|
104
155
|
createSession(config: SessionConfig, signal?: AbortSignal): Promise<Session>;
|
|
105
156
|
}
|
|
106
157
|
|
|
158
|
+
type FetchFn = typeof globalThis.fetch;
|
|
159
|
+
interface CloudClientOptions {
|
|
160
|
+
fetch?: FetchFn;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Client for the lplex-cloud management API.
|
|
164
|
+
*
|
|
165
|
+
* For per-instance data (devices, SSE), use {@link client} to get a
|
|
166
|
+
* regular {@link Client} scoped to that instance.
|
|
167
|
+
*/
|
|
168
|
+
declare class CloudClient {
|
|
169
|
+
#private;
|
|
170
|
+
constructor(baseURL: string, options?: CloudClientOptions);
|
|
171
|
+
/**
|
|
172
|
+
* Returns a {@link Client} scoped to a specific instance.
|
|
173
|
+
* The returned client's `devices()`, `subscribe()`, etc. hit the
|
|
174
|
+
* cloud's per-instance endpoints.
|
|
175
|
+
*/
|
|
176
|
+
client(instanceId: string): Client;
|
|
177
|
+
/** List all known instances. */
|
|
178
|
+
instances(signal?: AbortSignal): Promise<InstanceSummary[]>;
|
|
179
|
+
/** Get detailed replication status for one instance. */
|
|
180
|
+
status(instanceId: string, signal?: AbortSignal): Promise<InstanceStatus>;
|
|
181
|
+
/** Fetch recent replication diagnostic events for an instance. */
|
|
182
|
+
replicationEvents(instanceId: string, limit?: number, signal?: AbortSignal): Promise<ReplicationEvent[]>;
|
|
183
|
+
}
|
|
184
|
+
|
|
107
185
|
declare class LplexError extends Error {
|
|
108
186
|
constructor(message: string);
|
|
109
187
|
}
|
|
@@ -113,4 +191,4 @@ declare class HttpError extends LplexError {
|
|
|
113
191
|
constructor(method: string, path: string, status: number, body: string);
|
|
114
192
|
}
|
|
115
193
|
|
|
116
|
-
export { Client, type ClientOptions, type Device, type Event, type Filter, type Frame, HttpError, LplexError, type SendParams, Session, type SessionConfig, type SessionInfo };
|
|
194
|
+
export { Client, type ClientOptions, CloudClient, type CloudClientOptions, type Device, type DeviceValues, type Event, type Filter, type Frame, HttpError, type InstanceStatus, type InstanceSummary, LplexError, type PGNValue, type ReplicationEvent, type ReplicationEventType, type SendParams, type SeqRange, Session, type SessionConfig, type SessionInfo };
|
package/dist/index.d.ts
CHANGED
|
@@ -67,12 +67,61 @@ interface SendParams {
|
|
|
67
67
|
prio: number;
|
|
68
68
|
data: string;
|
|
69
69
|
}
|
|
70
|
+
/** A single PGN's last-known value for a device. */
|
|
71
|
+
interface PGNValue {
|
|
72
|
+
pgn: number;
|
|
73
|
+
ts: string;
|
|
74
|
+
data: string;
|
|
75
|
+
seq: number;
|
|
76
|
+
}
|
|
77
|
+
/** Last-known values grouped by device. */
|
|
78
|
+
interface DeviceValues {
|
|
79
|
+
name: string;
|
|
80
|
+
src: number;
|
|
81
|
+
manufacturer?: string;
|
|
82
|
+
model_id?: string;
|
|
83
|
+
values: PGNValue[];
|
|
84
|
+
}
|
|
85
|
+
/** Summary of a cloud instance, returned by GET /instances. */
|
|
86
|
+
interface InstanceSummary {
|
|
87
|
+
id: string;
|
|
88
|
+
connected: boolean;
|
|
89
|
+
cursor: number;
|
|
90
|
+
boat_head_seq: number;
|
|
91
|
+
holes: number;
|
|
92
|
+
lag_seqs: number;
|
|
93
|
+
last_seen: string;
|
|
94
|
+
}
|
|
95
|
+
/** A sequence range representing a gap in the replication stream. */
|
|
96
|
+
interface SeqRange {
|
|
97
|
+
start: number;
|
|
98
|
+
end: number;
|
|
99
|
+
}
|
|
100
|
+
/** Detailed replication status for one instance. */
|
|
101
|
+
interface InstanceStatus {
|
|
102
|
+
id: string;
|
|
103
|
+
connected: boolean;
|
|
104
|
+
cursor: number;
|
|
105
|
+
boat_head_seq: number;
|
|
106
|
+
boat_journal_bytes: number;
|
|
107
|
+
holes: SeqRange[];
|
|
108
|
+
lag_seqs: number;
|
|
109
|
+
last_seen: string;
|
|
110
|
+
}
|
|
111
|
+
/** Event types emitted by the replication pipeline. */
|
|
112
|
+
type ReplicationEventType = "live_start" | "live_stop" | "backfill_start" | "backfill_stop" | "block_received" | "checkpoint";
|
|
113
|
+
/** A single diagnostic event from the replication pipeline. */
|
|
114
|
+
interface ReplicationEvent {
|
|
115
|
+
time: string;
|
|
116
|
+
type: ReplicationEventType;
|
|
117
|
+
detail?: Record<string, unknown>;
|
|
118
|
+
}
|
|
70
119
|
|
|
71
|
-
type FetchFn$
|
|
120
|
+
type FetchFn$2 = typeof globalThis.fetch;
|
|
72
121
|
declare class Session {
|
|
73
122
|
#private;
|
|
74
123
|
/** @internal Created by Client.createSession, not for direct use. */
|
|
75
|
-
constructor(baseURL: string, fetchFn: FetchFn$
|
|
124
|
+
constructor(baseURL: string, fetchFn: FetchFn$2, info: SessionInfo);
|
|
76
125
|
get info(): SessionInfo;
|
|
77
126
|
get lastAckedSeq(): number;
|
|
78
127
|
/**
|
|
@@ -84,15 +133,17 @@ declare class Session {
|
|
|
84
133
|
ack(seq: number, signal?: AbortSignal): Promise<void>;
|
|
85
134
|
}
|
|
86
135
|
|
|
87
|
-
type FetchFn = typeof globalThis.fetch;
|
|
136
|
+
type FetchFn$1 = typeof globalThis.fetch;
|
|
88
137
|
interface ClientOptions {
|
|
89
|
-
fetch?: FetchFn;
|
|
138
|
+
fetch?: FetchFn$1;
|
|
90
139
|
}
|
|
91
140
|
declare class Client {
|
|
92
141
|
#private;
|
|
93
142
|
constructor(baseURL: string, options?: ClientOptions);
|
|
94
143
|
/** Fetch a snapshot of all NMEA 2000 devices discovered by the server. */
|
|
95
144
|
devices(signal?: AbortSignal): Promise<Device[]>;
|
|
145
|
+
/** Fetch the last-seen value for each (device, PGN) pair. */
|
|
146
|
+
values(signal?: AbortSignal): Promise<DeviceValues[]>;
|
|
96
147
|
/**
|
|
97
148
|
* Open an ephemeral SSE stream with optional filtering.
|
|
98
149
|
* No session, no replay, no ACK.
|
|
@@ -104,6 +155,33 @@ declare class Client {
|
|
|
104
155
|
createSession(config: SessionConfig, signal?: AbortSignal): Promise<Session>;
|
|
105
156
|
}
|
|
106
157
|
|
|
158
|
+
type FetchFn = typeof globalThis.fetch;
|
|
159
|
+
interface CloudClientOptions {
|
|
160
|
+
fetch?: FetchFn;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Client for the lplex-cloud management API.
|
|
164
|
+
*
|
|
165
|
+
* For per-instance data (devices, SSE), use {@link client} to get a
|
|
166
|
+
* regular {@link Client} scoped to that instance.
|
|
167
|
+
*/
|
|
168
|
+
declare class CloudClient {
|
|
169
|
+
#private;
|
|
170
|
+
constructor(baseURL: string, options?: CloudClientOptions);
|
|
171
|
+
/**
|
|
172
|
+
* Returns a {@link Client} scoped to a specific instance.
|
|
173
|
+
* The returned client's `devices()`, `subscribe()`, etc. hit the
|
|
174
|
+
* cloud's per-instance endpoints.
|
|
175
|
+
*/
|
|
176
|
+
client(instanceId: string): Client;
|
|
177
|
+
/** List all known instances. */
|
|
178
|
+
instances(signal?: AbortSignal): Promise<InstanceSummary[]>;
|
|
179
|
+
/** Get detailed replication status for one instance. */
|
|
180
|
+
status(instanceId: string, signal?: AbortSignal): Promise<InstanceStatus>;
|
|
181
|
+
/** Fetch recent replication diagnostic events for an instance. */
|
|
182
|
+
replicationEvents(instanceId: string, limit?: number, signal?: AbortSignal): Promise<ReplicationEvent[]>;
|
|
183
|
+
}
|
|
184
|
+
|
|
107
185
|
declare class LplexError extends Error {
|
|
108
186
|
constructor(message: string);
|
|
109
187
|
}
|
|
@@ -113,4 +191,4 @@ declare class HttpError extends LplexError {
|
|
|
113
191
|
constructor(method: string, path: string, status: number, body: string);
|
|
114
192
|
}
|
|
115
193
|
|
|
116
|
-
export { Client, type ClientOptions, type Device, type Event, type Filter, type Frame, HttpError, LplexError, type SendParams, Session, type SessionConfig, type SessionInfo };
|
|
194
|
+
export { Client, type ClientOptions, CloudClient, type CloudClientOptions, type Device, type DeviceValues, type Event, type Filter, type Frame, HttpError, type InstanceStatus, type InstanceSummary, LplexError, type PGNValue, type ReplicationEvent, type ReplicationEventType, type SendParams, type SeqRange, Session, type SessionConfig, type SessionInfo };
|
package/dist/index.js
CHANGED
|
@@ -140,6 +140,16 @@ var Client = class {
|
|
|
140
140
|
}
|
|
141
141
|
return resp.json();
|
|
142
142
|
}
|
|
143
|
+
/** Fetch the last-seen value for each (device, PGN) pair. */
|
|
144
|
+
async values(signal) {
|
|
145
|
+
const url = `${this.#baseURL}/values`;
|
|
146
|
+
const resp = await this.#fetch(url, { signal });
|
|
147
|
+
if (!resp.ok) {
|
|
148
|
+
const body = await resp.text();
|
|
149
|
+
throw new HttpError("GET", url, resp.status, body);
|
|
150
|
+
}
|
|
151
|
+
return resp.json();
|
|
152
|
+
}
|
|
143
153
|
/**
|
|
144
154
|
* Open an ephemeral SSE stream with optional filtering.
|
|
145
155
|
* No session, no replay, no ACK.
|
|
@@ -218,8 +228,63 @@ function filterToJSON(f) {
|
|
|
218
228
|
if (f.name?.length) m.name = f.name;
|
|
219
229
|
return m;
|
|
220
230
|
}
|
|
231
|
+
|
|
232
|
+
// src/cloud.ts
|
|
233
|
+
var CloudClient = class {
|
|
234
|
+
#baseURL;
|
|
235
|
+
#fetch;
|
|
236
|
+
#fetchOpt;
|
|
237
|
+
constructor(baseURL, options) {
|
|
238
|
+
this.#baseURL = baseURL.replace(/\/+$/, "");
|
|
239
|
+
this.#fetch = options?.fetch ?? globalThis.fetch.bind(globalThis);
|
|
240
|
+
this.#fetchOpt = options ?? {};
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Returns a {@link Client} scoped to a specific instance.
|
|
244
|
+
* The returned client's `devices()`, `subscribe()`, etc. hit the
|
|
245
|
+
* cloud's per-instance endpoints.
|
|
246
|
+
*/
|
|
247
|
+
client(instanceId) {
|
|
248
|
+
const opts = {};
|
|
249
|
+
if (this.#fetchOpt.fetch) opts.fetch = this.#fetchOpt.fetch;
|
|
250
|
+
return new Client(`${this.#baseURL}/instances/${instanceId}`, opts);
|
|
251
|
+
}
|
|
252
|
+
/** List all known instances. */
|
|
253
|
+
async instances(signal) {
|
|
254
|
+
const url = `${this.#baseURL}/instances`;
|
|
255
|
+
const resp = await this.#fetch(url, { signal });
|
|
256
|
+
if (!resp.ok) {
|
|
257
|
+
const body = await resp.text();
|
|
258
|
+
throw new HttpError("GET", url, resp.status, body);
|
|
259
|
+
}
|
|
260
|
+
const data = await resp.json();
|
|
261
|
+
return data.instances;
|
|
262
|
+
}
|
|
263
|
+
/** Get detailed replication status for one instance. */
|
|
264
|
+
async status(instanceId, signal) {
|
|
265
|
+
const url = `${this.#baseURL}/instances/${instanceId}/status`;
|
|
266
|
+
const resp = await this.#fetch(url, { signal });
|
|
267
|
+
if (!resp.ok) {
|
|
268
|
+
const body = await resp.text();
|
|
269
|
+
throw new HttpError("GET", url, resp.status, body);
|
|
270
|
+
}
|
|
271
|
+
return resp.json();
|
|
272
|
+
}
|
|
273
|
+
/** Fetch recent replication diagnostic events for an instance. */
|
|
274
|
+
async replicationEvents(instanceId, limit, signal) {
|
|
275
|
+
let url = `${this.#baseURL}/instances/${instanceId}/replication/events`;
|
|
276
|
+
if (limit !== void 0) url += `?limit=${limit}`;
|
|
277
|
+
const resp = await this.#fetch(url, { signal });
|
|
278
|
+
if (!resp.ok) {
|
|
279
|
+
const body = await resp.text();
|
|
280
|
+
throw new HttpError("GET", url, resp.status, body);
|
|
281
|
+
}
|
|
282
|
+
return resp.json();
|
|
283
|
+
}
|
|
284
|
+
};
|
|
221
285
|
export {
|
|
222
286
|
Client,
|
|
287
|
+
CloudClient,
|
|
223
288
|
HttpError,
|
|
224
289
|
LplexError,
|
|
225
290
|
Session
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sixfathoms/lplex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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
|
}
|