@sixfathoms/lplex 0.2.1 → 0.4.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/dist/index.cjs CHANGED
@@ -89,8 +89,16 @@ async function* parseSSE(body) {
89
89
  }
90
90
  }
91
91
  function classify(obj) {
92
- if ("type" in obj && obj.type === "device") {
93
- return { type: "device", device: obj };
92
+ if ("type" in obj) {
93
+ if (obj.type === "device") {
94
+ return { type: "device", device: obj };
95
+ }
96
+ if (obj.type === "device_removed") {
97
+ return {
98
+ type: "device_removed",
99
+ deviceRemoved: obj
100
+ };
101
+ }
94
102
  }
95
103
  if ("seq" in obj) {
96
104
  return { type: "frame", frame: obj };
@@ -185,14 +193,27 @@ var Client = class {
185
193
  /**
186
194
  * Open an ephemeral SSE stream with optional filtering.
187
195
  * No session, no replay, no ACK.
196
+ *
197
+ * Accepts either a Filter (for backwards compatibility) or
198
+ * SubscribeOptions for additional control (decode, signal).
188
199
  */
189
- async subscribe(filter, signal) {
200
+ async subscribe(filterOrOptions, signal) {
201
+ let filter;
202
+ let decode;
203
+ let sig = signal;
204
+ if (filterOrOptions && isSubscribeOptions(filterOrOptions)) {
205
+ filter = filterOrOptions.filter;
206
+ decode = filterOrOptions.decode;
207
+ sig = filterOrOptions.signal ?? sig;
208
+ } else {
209
+ filter = filterOrOptions;
210
+ }
190
211
  let url = `${this.#baseURL}/events`;
191
- const qs = filterToQueryString(filter);
212
+ const qs = filterToQueryString(filter, decode);
192
213
  if (qs) url += `?${qs}`;
193
214
  const resp = await this.#fetch(url, {
194
215
  headers: { Accept: "text/event-stream" },
195
- signal
216
+ signal: sig
196
217
  });
197
218
  if (!resp.ok) {
198
219
  const body = await resp.text();
@@ -203,6 +224,18 @@ var Client = class {
203
224
  }
204
225
  return parseSSE(resp.body);
205
226
  }
227
+ /** Fetch the last-seen decoded values for each (device, PGN) pair. */
228
+ async decodedValues(filter, signal) {
229
+ let url = `${this.#baseURL}/values/decoded`;
230
+ const qs = filterToQueryString(filter);
231
+ if (qs) url += `?${qs}`;
232
+ const resp = await this.#fetch(url, { signal });
233
+ if (!resp.ok) {
234
+ const body = await resp.text();
235
+ throw new HttpError("GET", url, resp.status, body);
236
+ }
237
+ return resp.json();
238
+ }
206
239
  /** Transmit a CAN frame through the server. */
207
240
  async send(params, signal) {
208
241
  const url = `${this.#baseURL}/send`;
@@ -217,6 +250,85 @@ var Client = class {
217
250
  throw new HttpError("POST", url, resp.status, body);
218
251
  }
219
252
  }
253
+ /**
254
+ * Send an ISO Request (PGN 59904) and wait for the response frame.
255
+ * Returns the response frame, or throws HttpError on timeout (408).
256
+ */
257
+ async query(params, signal) {
258
+ const url = `${this.#baseURL}/query`;
259
+ const resp = await this.#fetch(url, {
260
+ method: "POST",
261
+ headers: { "Content-Type": "application/json" },
262
+ body: JSON.stringify(params),
263
+ signal
264
+ });
265
+ if (!resp.ok) {
266
+ const body = await resp.text();
267
+ throw new HttpError("POST", url, resp.status, body);
268
+ }
269
+ return resp.json();
270
+ }
271
+ /**
272
+ * Query historical frames (requires journaling on the server).
273
+ * Returns an array of frames matching the query parameters.
274
+ */
275
+ async history(params, signal) {
276
+ const qs = new URLSearchParams();
277
+ qs.set("from", params.from);
278
+ if (params.to) qs.set("to", params.to);
279
+ for (const p of params.pgn ?? []) qs.append("pgn", p.toString());
280
+ for (const s of params.src ?? []) qs.append("src", s.toString());
281
+ if (params.limit !== void 0) qs.set("limit", params.limit.toString());
282
+ if (params.interval) qs.set("interval", params.interval);
283
+ if (params.decode) qs.set("decode", "true");
284
+ const url = `${this.#baseURL}/history?${qs.toString()}`;
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
+ return resp.json();
291
+ }
292
+ /** Check server health (GET /healthz). */
293
+ async health(signal) {
294
+ const url = `${this.#baseURL}/healthz`;
295
+ const resp = await this.#fetch(url, { signal });
296
+ if (!resp.ok) {
297
+ const body = await resp.text();
298
+ throw new HttpError("GET", url, resp.status, body);
299
+ }
300
+ return resp.json();
301
+ }
302
+ /** Liveness probe (GET /livez). */
303
+ async liveness(signal) {
304
+ const url = `${this.#baseURL}/livez`;
305
+ const resp = await this.#fetch(url, { signal });
306
+ if (!resp.ok) {
307
+ const body = await resp.text();
308
+ throw new HttpError("GET", url, resp.status, body);
309
+ }
310
+ return resp.json();
311
+ }
312
+ /** Readiness probe (GET /readyz). */
313
+ async readiness(signal) {
314
+ const url = `${this.#baseURL}/readyz`;
315
+ const resp = await this.#fetch(url, { signal });
316
+ if (!resp.ok) {
317
+ const body = await resp.text();
318
+ throw new HttpError("GET", url, resp.status, body);
319
+ }
320
+ return resp.json();
321
+ }
322
+ /** Fetch boat-side replication status (only available when replication is configured). */
323
+ async replicationStatus(signal) {
324
+ const url = `${this.#baseURL}/replication/status`;
325
+ const resp = await this.#fetch(url, { signal });
326
+ if (!resp.ok) {
327
+ const body = await resp.text();
328
+ throw new HttpError("GET", url, resp.status, body);
329
+ }
330
+ return resp.json();
331
+ }
220
332
  /** Create or reconnect a buffered session on the server. */
221
333
  async createSession(config, signal) {
222
334
  const url = `${this.#baseURL}/clients/${config.clientId}`;
@@ -240,24 +352,37 @@ var Client = class {
240
352
  return new Session(this.#baseURL, this.#fetch, info);
241
353
  }
242
354
  };
355
+ function isSubscribeOptions(obj) {
356
+ return "decode" in obj || "signal" in obj || "filter" in obj;
357
+ }
243
358
  function filterIsEmpty(f) {
244
- return !f.pgn?.length && !f.manufacturer?.length && !f.instance?.length && !f.name?.length;
359
+ return !f.pgn?.length && !f.exclude_pgn?.length && !f.manufacturer?.length && !f.instance?.length && !f.name?.length && !f.exclude_name?.length && !f.bus?.length;
245
360
  }
246
- function filterToQueryString(f) {
247
- if (!f || filterIsEmpty(f)) return "";
361
+ function filterToQueryString(f, decode) {
362
+ if ((!f || filterIsEmpty(f)) && !decode) return "";
248
363
  const params = new URLSearchParams();
249
- for (const p of f.pgn ?? []) params.append("pgn", p.toString());
250
- for (const m of f.manufacturer ?? []) params.append("manufacturer", m);
251
- for (const i of f.instance ?? []) params.append("instance", i.toString());
252
- for (const n of f.name ?? []) params.append("name", n);
364
+ if (f) {
365
+ for (const p of f.pgn ?? []) params.append("pgn", p.toString());
366
+ for (const p of f.exclude_pgn ?? [])
367
+ params.append("exclude_pgn", p.toString());
368
+ for (const m of f.manufacturer ?? []) params.append("manufacturer", m);
369
+ for (const i of f.instance ?? []) params.append("instance", i.toString());
370
+ for (const n of f.name ?? []) params.append("name", n);
371
+ for (const n of f.exclude_name ?? []) params.append("exclude_name", n);
372
+ for (const b of f.bus ?? []) params.append("bus", b);
373
+ }
374
+ if (decode) params.set("decode", "true");
253
375
  return params.toString();
254
376
  }
255
377
  function filterToJSON(f) {
256
378
  const m = {};
257
379
  if (f.pgn?.length) m.pgn = f.pgn;
380
+ if (f.exclude_pgn?.length) m.exclude_pgn = f.exclude_pgn;
258
381
  if (f.manufacturer?.length) m.manufacturer = f.manufacturer;
259
382
  if (f.instance?.length) m.instance = f.instance;
260
383
  if (f.name?.length) m.name = f.name;
384
+ if (f.exclude_name?.length) m.exclude_name = f.exclude_name;
385
+ if (f.bus?.length) m.bus = f.bus;
261
386
  return m;
262
387
  }
263
388
 
@@ -313,6 +438,36 @@ var CloudClient = class {
313
438
  }
314
439
  return resp.json();
315
440
  }
441
+ /** Check cloud server health (GET /healthz). */
442
+ async health(signal) {
443
+ const url = `${this.#baseURL}/healthz`;
444
+ const resp = await this.#fetch(url, { signal });
445
+ if (!resp.ok) {
446
+ const body = await resp.text();
447
+ throw new HttpError("GET", url, resp.status, body);
448
+ }
449
+ return resp.json();
450
+ }
451
+ /** Liveness probe (GET /livez). */
452
+ async liveness(signal) {
453
+ const url = `${this.#baseURL}/livez`;
454
+ const resp = await this.#fetch(url, { signal });
455
+ if (!resp.ok) {
456
+ const body = await resp.text();
457
+ throw new HttpError("GET", url, resp.status, body);
458
+ }
459
+ return resp.json();
460
+ }
461
+ /** Readiness probe (GET /readyz). */
462
+ async readiness(signal) {
463
+ const url = `${this.#baseURL}/readyz`;
464
+ const resp = await this.#fetch(url, { signal });
465
+ if (!resp.ok) {
466
+ const body = await resp.text();
467
+ throw new HttpError("GET", url, resp.status, body);
468
+ }
469
+ return resp.json();
470
+ }
316
471
  };
317
472
  // Annotate the CommonJS export names for ESM import in node:
318
473
  0 && (module.exports = {
package/dist/index.d.cts CHANGED
@@ -2,14 +2,17 @@
2
2
  interface Frame {
3
3
  seq: number;
4
4
  ts: string;
5
+ bus?: string;
5
6
  prio: number;
6
7
  pgn: number;
7
8
  src: number;
8
9
  dst: number;
9
10
  data: string;
11
+ decoded?: Record<string, unknown>;
10
12
  }
11
13
  /** An NMEA 2000 device discovered on the bus. */
12
14
  interface Device {
15
+ bus?: string;
13
16
  src: number;
14
17
  name: string;
15
18
  manufacturer: string;
@@ -28,6 +31,12 @@ interface Device {
28
31
  packet_count: number;
29
32
  byte_count: number;
30
33
  }
34
+ /** A device-removed notification from the bus. */
35
+ interface DeviceRemoved {
36
+ type: "device_removed";
37
+ bus?: string;
38
+ src: number;
39
+ }
31
40
  /** Discriminated union for SSE events. */
32
41
  type Event = {
33
42
  type: "frame";
@@ -35,6 +44,9 @@ type Event = {
35
44
  } | {
36
45
  type: "device";
37
46
  device: Device;
47
+ } | {
48
+ type: "device_removed";
49
+ deviceRemoved: DeviceRemoved;
38
50
  };
39
51
  /**
40
52
  * Filter for CAN frames.
@@ -42,9 +54,19 @@ type Event = {
42
54
  */
43
55
  interface Filter {
44
56
  pgn?: number[];
57
+ exclude_pgn?: number[];
45
58
  manufacturer?: string[];
46
59
  instance?: number[];
47
60
  name?: string[];
61
+ exclude_name?: string[];
62
+ bus?: string[];
63
+ }
64
+ /** Options for ephemeral SSE subscription. */
65
+ interface SubscribeOptions {
66
+ filter?: Filter;
67
+ /** When true, frames include decoded field values. */
68
+ decode?: boolean;
69
+ signal?: AbortSignal;
48
70
  }
49
71
  /** Configuration for creating a buffered session. */
50
72
  interface SessionConfig {
@@ -66,6 +88,7 @@ interface SendParams {
66
88
  dst: number;
67
89
  prio: number;
68
90
  data: string;
91
+ bus?: string;
69
92
  }
70
93
  /** A single PGN's last-known value for a device. */
71
94
  interface PGNValue {
@@ -82,6 +105,91 @@ interface DeviceValues {
82
105
  model_id?: string;
83
106
  values: PGNValue[];
84
107
  }
108
+ /** A single PGN's decoded value for a device. */
109
+ interface DecodedPGNValue {
110
+ pgn: number;
111
+ ts: string;
112
+ data: string;
113
+ seq: number;
114
+ decoded: Record<string, unknown>;
115
+ }
116
+ /** Decoded values grouped by device. */
117
+ interface DecodedDeviceValues {
118
+ name: string;
119
+ src: number;
120
+ manufacturer?: string;
121
+ model_id?: string;
122
+ values: DecodedPGNValue[];
123
+ }
124
+ /** Parameters for an ISO Request query (POST /query). */
125
+ interface QueryParams {
126
+ pgn: number;
127
+ /** Destination address. Defaults to 0xFF (broadcast) on the server. */
128
+ dst?: number;
129
+ timeout?: string;
130
+ bus?: string;
131
+ }
132
+ /** Parameters for historical data query (GET /history). */
133
+ interface HistoryParams {
134
+ /** Start timestamp (RFC 3339). */
135
+ from: string;
136
+ /** End timestamp (RFC 3339). Defaults to now. */
137
+ to?: string;
138
+ /** Filter by PGN(s). */
139
+ pgn?: number[];
140
+ /** Filter by source address(es). */
141
+ src?: number[];
142
+ /** Max frames to return. Defaults to 10000. */
143
+ limit?: number;
144
+ /** Downsample interval (e.g. "1s", "PT1M"). */
145
+ interval?: string;
146
+ /** Include decoded values in response. */
147
+ decode?: boolean;
148
+ }
149
+ /** Broker health details. */
150
+ interface BrokerHealth {
151
+ status: string;
152
+ frames_total: number;
153
+ head_seq: number;
154
+ last_frame_time: string;
155
+ device_count: number;
156
+ ring_entries: number;
157
+ ring_capacity: number;
158
+ }
159
+ /** Replication component health (within health response). */
160
+ interface ReplicationHealth {
161
+ status: string;
162
+ connected: boolean;
163
+ live_lag: number;
164
+ backfill_remaining_seqs: number;
165
+ last_ack: string;
166
+ }
167
+ /** Health check response from GET /healthz or /readyz. */
168
+ interface HealthStatus {
169
+ status: string;
170
+ broker?: BrokerHealth;
171
+ replication?: ReplicationHealth;
172
+ components?: Record<string, unknown>;
173
+ /** Cloud-only: total known instances. */
174
+ instances_total?: number;
175
+ /** Cloud-only: currently connected instances. */
176
+ instances_connected?: number;
177
+ }
178
+ /** Boat-side replication status from GET /replication/status. */
179
+ interface ReplicationStatus {
180
+ connected: boolean;
181
+ instance_id: string;
182
+ local_head_seq: number;
183
+ cloud_cursor: number;
184
+ holes: SeqRange[];
185
+ live_lag: number;
186
+ backfill_remaining_seqs: number;
187
+ last_ack: string;
188
+ live_frames_sent: number;
189
+ backfill_blocks_sent: number;
190
+ backfill_bytes_sent: number;
191
+ reconnects: number;
192
+ }
85
193
  /** Summary of a cloud instance, returned by GET /instances. */
86
194
  interface InstanceSummary {
87
195
  id: string;
@@ -147,10 +255,33 @@ declare class Client {
147
255
  /**
148
256
  * Open an ephemeral SSE stream with optional filtering.
149
257
  * No session, no replay, no ACK.
258
+ *
259
+ * Accepts either a Filter (for backwards compatibility) or
260
+ * SubscribeOptions for additional control (decode, signal).
150
261
  */
151
- subscribe(filter?: Filter, signal?: AbortSignal): Promise<AsyncIterable<Event>>;
262
+ subscribe(filterOrOptions?: Filter | SubscribeOptions, signal?: AbortSignal): Promise<AsyncIterable<Event>>;
263
+ /** Fetch the last-seen decoded values for each (device, PGN) pair. */
264
+ decodedValues(filter?: Filter, signal?: AbortSignal): Promise<DecodedDeviceValues[]>;
152
265
  /** Transmit a CAN frame through the server. */
153
266
  send(params: SendParams, signal?: AbortSignal): Promise<void>;
267
+ /**
268
+ * Send an ISO Request (PGN 59904) and wait for the response frame.
269
+ * Returns the response frame, or throws HttpError on timeout (408).
270
+ */
271
+ query(params: QueryParams, signal?: AbortSignal): Promise<Frame>;
272
+ /**
273
+ * Query historical frames (requires journaling on the server).
274
+ * Returns an array of frames matching the query parameters.
275
+ */
276
+ history(params: HistoryParams, signal?: AbortSignal): Promise<Frame[]>;
277
+ /** Check server health (GET /healthz). */
278
+ health(signal?: AbortSignal): Promise<HealthStatus>;
279
+ /** Liveness probe (GET /livez). */
280
+ liveness(signal?: AbortSignal): Promise<HealthStatus>;
281
+ /** Readiness probe (GET /readyz). */
282
+ readiness(signal?: AbortSignal): Promise<HealthStatus>;
283
+ /** Fetch boat-side replication status (only available when replication is configured). */
284
+ replicationStatus(signal?: AbortSignal): Promise<ReplicationStatus>;
154
285
  /** Create or reconnect a buffered session on the server. */
155
286
  createSession(config: SessionConfig, signal?: AbortSignal): Promise<Session>;
156
287
  }
@@ -180,6 +311,12 @@ declare class CloudClient {
180
311
  status(instanceId: string, signal?: AbortSignal): Promise<InstanceStatus>;
181
312
  /** Fetch recent replication diagnostic events for an instance. */
182
313
  replicationEvents(instanceId: string, limit?: number, signal?: AbortSignal): Promise<ReplicationEvent[]>;
314
+ /** Check cloud server health (GET /healthz). */
315
+ health(signal?: AbortSignal): Promise<HealthStatus>;
316
+ /** Liveness probe (GET /livez). */
317
+ liveness(signal?: AbortSignal): Promise<HealthStatus>;
318
+ /** Readiness probe (GET /readyz). */
319
+ readiness(signal?: AbortSignal): Promise<HealthStatus>;
183
320
  }
184
321
 
185
322
  declare class LplexError extends Error {
@@ -191,4 +328,4 @@ declare class HttpError extends LplexError {
191
328
  constructor(method: string, path: string, status: number, body: string);
192
329
  }
193
330
 
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 };
331
+ export { type BrokerHealth, Client, type ClientOptions, CloudClient, type CloudClientOptions, type DecodedDeviceValues, type DecodedPGNValue, type Device, type DeviceRemoved, type DeviceValues, type Event, type Filter, type Frame, type HealthStatus, type HistoryParams, HttpError, type InstanceStatus, type InstanceSummary, LplexError, type PGNValue, type QueryParams, type ReplicationEvent, type ReplicationEventType, type ReplicationHealth, type ReplicationStatus, type SendParams, type SeqRange, Session, type SessionConfig, type SessionInfo, type SubscribeOptions };
package/dist/index.d.ts CHANGED
@@ -2,14 +2,17 @@
2
2
  interface Frame {
3
3
  seq: number;
4
4
  ts: string;
5
+ bus?: string;
5
6
  prio: number;
6
7
  pgn: number;
7
8
  src: number;
8
9
  dst: number;
9
10
  data: string;
11
+ decoded?: Record<string, unknown>;
10
12
  }
11
13
  /** An NMEA 2000 device discovered on the bus. */
12
14
  interface Device {
15
+ bus?: string;
13
16
  src: number;
14
17
  name: string;
15
18
  manufacturer: string;
@@ -28,6 +31,12 @@ interface Device {
28
31
  packet_count: number;
29
32
  byte_count: number;
30
33
  }
34
+ /** A device-removed notification from the bus. */
35
+ interface DeviceRemoved {
36
+ type: "device_removed";
37
+ bus?: string;
38
+ src: number;
39
+ }
31
40
  /** Discriminated union for SSE events. */
32
41
  type Event = {
33
42
  type: "frame";
@@ -35,6 +44,9 @@ type Event = {
35
44
  } | {
36
45
  type: "device";
37
46
  device: Device;
47
+ } | {
48
+ type: "device_removed";
49
+ deviceRemoved: DeviceRemoved;
38
50
  };
39
51
  /**
40
52
  * Filter for CAN frames.
@@ -42,9 +54,19 @@ type Event = {
42
54
  */
43
55
  interface Filter {
44
56
  pgn?: number[];
57
+ exclude_pgn?: number[];
45
58
  manufacturer?: string[];
46
59
  instance?: number[];
47
60
  name?: string[];
61
+ exclude_name?: string[];
62
+ bus?: string[];
63
+ }
64
+ /** Options for ephemeral SSE subscription. */
65
+ interface SubscribeOptions {
66
+ filter?: Filter;
67
+ /** When true, frames include decoded field values. */
68
+ decode?: boolean;
69
+ signal?: AbortSignal;
48
70
  }
49
71
  /** Configuration for creating a buffered session. */
50
72
  interface SessionConfig {
@@ -66,6 +88,7 @@ interface SendParams {
66
88
  dst: number;
67
89
  prio: number;
68
90
  data: string;
91
+ bus?: string;
69
92
  }
70
93
  /** A single PGN's last-known value for a device. */
71
94
  interface PGNValue {
@@ -82,6 +105,91 @@ interface DeviceValues {
82
105
  model_id?: string;
83
106
  values: PGNValue[];
84
107
  }
108
+ /** A single PGN's decoded value for a device. */
109
+ interface DecodedPGNValue {
110
+ pgn: number;
111
+ ts: string;
112
+ data: string;
113
+ seq: number;
114
+ decoded: Record<string, unknown>;
115
+ }
116
+ /** Decoded values grouped by device. */
117
+ interface DecodedDeviceValues {
118
+ name: string;
119
+ src: number;
120
+ manufacturer?: string;
121
+ model_id?: string;
122
+ values: DecodedPGNValue[];
123
+ }
124
+ /** Parameters for an ISO Request query (POST /query). */
125
+ interface QueryParams {
126
+ pgn: number;
127
+ /** Destination address. Defaults to 0xFF (broadcast) on the server. */
128
+ dst?: number;
129
+ timeout?: string;
130
+ bus?: string;
131
+ }
132
+ /** Parameters for historical data query (GET /history). */
133
+ interface HistoryParams {
134
+ /** Start timestamp (RFC 3339). */
135
+ from: string;
136
+ /** End timestamp (RFC 3339). Defaults to now. */
137
+ to?: string;
138
+ /** Filter by PGN(s). */
139
+ pgn?: number[];
140
+ /** Filter by source address(es). */
141
+ src?: number[];
142
+ /** Max frames to return. Defaults to 10000. */
143
+ limit?: number;
144
+ /** Downsample interval (e.g. "1s", "PT1M"). */
145
+ interval?: string;
146
+ /** Include decoded values in response. */
147
+ decode?: boolean;
148
+ }
149
+ /** Broker health details. */
150
+ interface BrokerHealth {
151
+ status: string;
152
+ frames_total: number;
153
+ head_seq: number;
154
+ last_frame_time: string;
155
+ device_count: number;
156
+ ring_entries: number;
157
+ ring_capacity: number;
158
+ }
159
+ /** Replication component health (within health response). */
160
+ interface ReplicationHealth {
161
+ status: string;
162
+ connected: boolean;
163
+ live_lag: number;
164
+ backfill_remaining_seqs: number;
165
+ last_ack: string;
166
+ }
167
+ /** Health check response from GET /healthz or /readyz. */
168
+ interface HealthStatus {
169
+ status: string;
170
+ broker?: BrokerHealth;
171
+ replication?: ReplicationHealth;
172
+ components?: Record<string, unknown>;
173
+ /** Cloud-only: total known instances. */
174
+ instances_total?: number;
175
+ /** Cloud-only: currently connected instances. */
176
+ instances_connected?: number;
177
+ }
178
+ /** Boat-side replication status from GET /replication/status. */
179
+ interface ReplicationStatus {
180
+ connected: boolean;
181
+ instance_id: string;
182
+ local_head_seq: number;
183
+ cloud_cursor: number;
184
+ holes: SeqRange[];
185
+ live_lag: number;
186
+ backfill_remaining_seqs: number;
187
+ last_ack: string;
188
+ live_frames_sent: number;
189
+ backfill_blocks_sent: number;
190
+ backfill_bytes_sent: number;
191
+ reconnects: number;
192
+ }
85
193
  /** Summary of a cloud instance, returned by GET /instances. */
86
194
  interface InstanceSummary {
87
195
  id: string;
@@ -147,10 +255,33 @@ declare class Client {
147
255
  /**
148
256
  * Open an ephemeral SSE stream with optional filtering.
149
257
  * No session, no replay, no ACK.
258
+ *
259
+ * Accepts either a Filter (for backwards compatibility) or
260
+ * SubscribeOptions for additional control (decode, signal).
150
261
  */
151
- subscribe(filter?: Filter, signal?: AbortSignal): Promise<AsyncIterable<Event>>;
262
+ subscribe(filterOrOptions?: Filter | SubscribeOptions, signal?: AbortSignal): Promise<AsyncIterable<Event>>;
263
+ /** Fetch the last-seen decoded values for each (device, PGN) pair. */
264
+ decodedValues(filter?: Filter, signal?: AbortSignal): Promise<DecodedDeviceValues[]>;
152
265
  /** Transmit a CAN frame through the server. */
153
266
  send(params: SendParams, signal?: AbortSignal): Promise<void>;
267
+ /**
268
+ * Send an ISO Request (PGN 59904) and wait for the response frame.
269
+ * Returns the response frame, or throws HttpError on timeout (408).
270
+ */
271
+ query(params: QueryParams, signal?: AbortSignal): Promise<Frame>;
272
+ /**
273
+ * Query historical frames (requires journaling on the server).
274
+ * Returns an array of frames matching the query parameters.
275
+ */
276
+ history(params: HistoryParams, signal?: AbortSignal): Promise<Frame[]>;
277
+ /** Check server health (GET /healthz). */
278
+ health(signal?: AbortSignal): Promise<HealthStatus>;
279
+ /** Liveness probe (GET /livez). */
280
+ liveness(signal?: AbortSignal): Promise<HealthStatus>;
281
+ /** Readiness probe (GET /readyz). */
282
+ readiness(signal?: AbortSignal): Promise<HealthStatus>;
283
+ /** Fetch boat-side replication status (only available when replication is configured). */
284
+ replicationStatus(signal?: AbortSignal): Promise<ReplicationStatus>;
154
285
  /** Create or reconnect a buffered session on the server. */
155
286
  createSession(config: SessionConfig, signal?: AbortSignal): Promise<Session>;
156
287
  }
@@ -180,6 +311,12 @@ declare class CloudClient {
180
311
  status(instanceId: string, signal?: AbortSignal): Promise<InstanceStatus>;
181
312
  /** Fetch recent replication diagnostic events for an instance. */
182
313
  replicationEvents(instanceId: string, limit?: number, signal?: AbortSignal): Promise<ReplicationEvent[]>;
314
+ /** Check cloud server health (GET /healthz). */
315
+ health(signal?: AbortSignal): Promise<HealthStatus>;
316
+ /** Liveness probe (GET /livez). */
317
+ liveness(signal?: AbortSignal): Promise<HealthStatus>;
318
+ /** Readiness probe (GET /readyz). */
319
+ readiness(signal?: AbortSignal): Promise<HealthStatus>;
183
320
  }
184
321
 
185
322
  declare class LplexError extends Error {
@@ -191,4 +328,4 @@ declare class HttpError extends LplexError {
191
328
  constructor(method: string, path: string, status: number, body: string);
192
329
  }
193
330
 
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 };
331
+ export { type BrokerHealth, Client, type ClientOptions, CloudClient, type CloudClientOptions, type DecodedDeviceValues, type DecodedPGNValue, type Device, type DeviceRemoved, type DeviceValues, type Event, type Filter, type Frame, type HealthStatus, type HistoryParams, HttpError, type InstanceStatus, type InstanceSummary, LplexError, type PGNValue, type QueryParams, type ReplicationEvent, type ReplicationEventType, type ReplicationHealth, type ReplicationStatus, type SendParams, type SeqRange, Session, type SessionConfig, type SessionInfo, type SubscribeOptions };
package/dist/index.js CHANGED
@@ -59,8 +59,16 @@ async function* parseSSE(body) {
59
59
  }
60
60
  }
61
61
  function classify(obj) {
62
- if ("type" in obj && obj.type === "device") {
63
- return { type: "device", device: obj };
62
+ if ("type" in obj) {
63
+ if (obj.type === "device") {
64
+ return { type: "device", device: obj };
65
+ }
66
+ if (obj.type === "device_removed") {
67
+ return {
68
+ type: "device_removed",
69
+ deviceRemoved: obj
70
+ };
71
+ }
64
72
  }
65
73
  if ("seq" in obj) {
66
74
  return { type: "frame", frame: obj };
@@ -155,14 +163,27 @@ var Client = class {
155
163
  /**
156
164
  * Open an ephemeral SSE stream with optional filtering.
157
165
  * No session, no replay, no ACK.
166
+ *
167
+ * Accepts either a Filter (for backwards compatibility) or
168
+ * SubscribeOptions for additional control (decode, signal).
158
169
  */
159
- async subscribe(filter, signal) {
170
+ async subscribe(filterOrOptions, signal) {
171
+ let filter;
172
+ let decode;
173
+ let sig = signal;
174
+ if (filterOrOptions && isSubscribeOptions(filterOrOptions)) {
175
+ filter = filterOrOptions.filter;
176
+ decode = filterOrOptions.decode;
177
+ sig = filterOrOptions.signal ?? sig;
178
+ } else {
179
+ filter = filterOrOptions;
180
+ }
160
181
  let url = `${this.#baseURL}/events`;
161
- const qs = filterToQueryString(filter);
182
+ const qs = filterToQueryString(filter, decode);
162
183
  if (qs) url += `?${qs}`;
163
184
  const resp = await this.#fetch(url, {
164
185
  headers: { Accept: "text/event-stream" },
165
- signal
186
+ signal: sig
166
187
  });
167
188
  if (!resp.ok) {
168
189
  const body = await resp.text();
@@ -173,6 +194,18 @@ var Client = class {
173
194
  }
174
195
  return parseSSE(resp.body);
175
196
  }
197
+ /** Fetch the last-seen decoded values for each (device, PGN) pair. */
198
+ async decodedValues(filter, signal) {
199
+ let url = `${this.#baseURL}/values/decoded`;
200
+ const qs = filterToQueryString(filter);
201
+ if (qs) url += `?${qs}`;
202
+ const resp = await this.#fetch(url, { signal });
203
+ if (!resp.ok) {
204
+ const body = await resp.text();
205
+ throw new HttpError("GET", url, resp.status, body);
206
+ }
207
+ return resp.json();
208
+ }
176
209
  /** Transmit a CAN frame through the server. */
177
210
  async send(params, signal) {
178
211
  const url = `${this.#baseURL}/send`;
@@ -187,6 +220,85 @@ var Client = class {
187
220
  throw new HttpError("POST", url, resp.status, body);
188
221
  }
189
222
  }
223
+ /**
224
+ * Send an ISO Request (PGN 59904) and wait for the response frame.
225
+ * Returns the response frame, or throws HttpError on timeout (408).
226
+ */
227
+ async query(params, signal) {
228
+ const url = `${this.#baseURL}/query`;
229
+ const resp = await this.#fetch(url, {
230
+ method: "POST",
231
+ headers: { "Content-Type": "application/json" },
232
+ body: JSON.stringify(params),
233
+ signal
234
+ });
235
+ if (!resp.ok) {
236
+ const body = await resp.text();
237
+ throw new HttpError("POST", url, resp.status, body);
238
+ }
239
+ return resp.json();
240
+ }
241
+ /**
242
+ * Query historical frames (requires journaling on the server).
243
+ * Returns an array of frames matching the query parameters.
244
+ */
245
+ async history(params, signal) {
246
+ const qs = new URLSearchParams();
247
+ qs.set("from", params.from);
248
+ if (params.to) qs.set("to", params.to);
249
+ for (const p of params.pgn ?? []) qs.append("pgn", p.toString());
250
+ for (const s of params.src ?? []) qs.append("src", s.toString());
251
+ if (params.limit !== void 0) qs.set("limit", params.limit.toString());
252
+ if (params.interval) qs.set("interval", params.interval);
253
+ if (params.decode) qs.set("decode", "true");
254
+ const url = `${this.#baseURL}/history?${qs.toString()}`;
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
+ return resp.json();
261
+ }
262
+ /** Check server health (GET /healthz). */
263
+ async health(signal) {
264
+ const url = `${this.#baseURL}/healthz`;
265
+ const resp = await this.#fetch(url, { signal });
266
+ if (!resp.ok) {
267
+ const body = await resp.text();
268
+ throw new HttpError("GET", url, resp.status, body);
269
+ }
270
+ return resp.json();
271
+ }
272
+ /** Liveness probe (GET /livez). */
273
+ async liveness(signal) {
274
+ const url = `${this.#baseURL}/livez`;
275
+ const resp = await this.#fetch(url, { signal });
276
+ if (!resp.ok) {
277
+ const body = await resp.text();
278
+ throw new HttpError("GET", url, resp.status, body);
279
+ }
280
+ return resp.json();
281
+ }
282
+ /** Readiness probe (GET /readyz). */
283
+ async readiness(signal) {
284
+ const url = `${this.#baseURL}/readyz`;
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
+ return resp.json();
291
+ }
292
+ /** Fetch boat-side replication status (only available when replication is configured). */
293
+ async replicationStatus(signal) {
294
+ const url = `${this.#baseURL}/replication/status`;
295
+ const resp = await this.#fetch(url, { signal });
296
+ if (!resp.ok) {
297
+ const body = await resp.text();
298
+ throw new HttpError("GET", url, resp.status, body);
299
+ }
300
+ return resp.json();
301
+ }
190
302
  /** Create or reconnect a buffered session on the server. */
191
303
  async createSession(config, signal) {
192
304
  const url = `${this.#baseURL}/clients/${config.clientId}`;
@@ -210,24 +322,37 @@ var Client = class {
210
322
  return new Session(this.#baseURL, this.#fetch, info);
211
323
  }
212
324
  };
325
+ function isSubscribeOptions(obj) {
326
+ return "decode" in obj || "signal" in obj || "filter" in obj;
327
+ }
213
328
  function filterIsEmpty(f) {
214
- return !f.pgn?.length && !f.manufacturer?.length && !f.instance?.length && !f.name?.length;
329
+ return !f.pgn?.length && !f.exclude_pgn?.length && !f.manufacturer?.length && !f.instance?.length && !f.name?.length && !f.exclude_name?.length && !f.bus?.length;
215
330
  }
216
- function filterToQueryString(f) {
217
- if (!f || filterIsEmpty(f)) return "";
331
+ function filterToQueryString(f, decode) {
332
+ if ((!f || filterIsEmpty(f)) && !decode) return "";
218
333
  const params = new URLSearchParams();
219
- for (const p of f.pgn ?? []) params.append("pgn", p.toString());
220
- for (const m of f.manufacturer ?? []) params.append("manufacturer", m);
221
- for (const i of f.instance ?? []) params.append("instance", i.toString());
222
- for (const n of f.name ?? []) params.append("name", n);
334
+ if (f) {
335
+ for (const p of f.pgn ?? []) params.append("pgn", p.toString());
336
+ for (const p of f.exclude_pgn ?? [])
337
+ params.append("exclude_pgn", p.toString());
338
+ for (const m of f.manufacturer ?? []) params.append("manufacturer", m);
339
+ for (const i of f.instance ?? []) params.append("instance", i.toString());
340
+ for (const n of f.name ?? []) params.append("name", n);
341
+ for (const n of f.exclude_name ?? []) params.append("exclude_name", n);
342
+ for (const b of f.bus ?? []) params.append("bus", b);
343
+ }
344
+ if (decode) params.set("decode", "true");
223
345
  return params.toString();
224
346
  }
225
347
  function filterToJSON(f) {
226
348
  const m = {};
227
349
  if (f.pgn?.length) m.pgn = f.pgn;
350
+ if (f.exclude_pgn?.length) m.exclude_pgn = f.exclude_pgn;
228
351
  if (f.manufacturer?.length) m.manufacturer = f.manufacturer;
229
352
  if (f.instance?.length) m.instance = f.instance;
230
353
  if (f.name?.length) m.name = f.name;
354
+ if (f.exclude_name?.length) m.exclude_name = f.exclude_name;
355
+ if (f.bus?.length) m.bus = f.bus;
231
356
  return m;
232
357
  }
233
358
 
@@ -283,6 +408,36 @@ var CloudClient = class {
283
408
  }
284
409
  return resp.json();
285
410
  }
411
+ /** Check cloud server health (GET /healthz). */
412
+ async health(signal) {
413
+ const url = `${this.#baseURL}/healthz`;
414
+ const resp = await this.#fetch(url, { signal });
415
+ if (!resp.ok) {
416
+ const body = await resp.text();
417
+ throw new HttpError("GET", url, resp.status, body);
418
+ }
419
+ return resp.json();
420
+ }
421
+ /** Liveness probe (GET /livez). */
422
+ async liveness(signal) {
423
+ const url = `${this.#baseURL}/livez`;
424
+ const resp = await this.#fetch(url, { signal });
425
+ if (!resp.ok) {
426
+ const body = await resp.text();
427
+ throw new HttpError("GET", url, resp.status, body);
428
+ }
429
+ return resp.json();
430
+ }
431
+ /** Readiness probe (GET /readyz). */
432
+ async readiness(signal) {
433
+ const url = `${this.#baseURL}/readyz`;
434
+ const resp = await this.#fetch(url, { signal });
435
+ if (!resp.ok) {
436
+ const body = await resp.text();
437
+ throw new HttpError("GET", url, resp.status, body);
438
+ }
439
+ return resp.json();
440
+ }
286
441
  };
287
442
  export {
288
443
  Client,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sixfathoms/lplex",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "TypeScript client for lplex CAN bus HTTP bridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",