signalk-siparu 0.1.21 → 0.1.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-siparu",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Kept aboard, proven ashore. An impartial, timestamped record of every voyage: position, wind, depth and logbook, written on the boat and readable from anywhere. A read-only Signal K plugin with a built-in dashboard.",
5
5
  "keywords": [
6
6
  "signalk-node-server-plugin",
@@ -189,6 +189,59 @@ export type SnapshotsResponse = {
189
189
  message: string;
190
190
  };
191
191
  };
192
+ /**
193
+ * The voyages RPC, a third sibling on the same live socket. Where history asks for one gauge's
194
+ * series and snapshots for whole rows over a window, this asks for the boat's recent voyages -
195
+ * the list the local /voyages REST serves. It carries no query, only how many of the newest to
196
+ * return; like its siblings it is a read of the boat's own store and reaches nothing near
197
+ * Signal K. The boat clamps the count, so a request cannot ask for more than she will give.
198
+ */
199
+ export interface VoyagesRequest {
200
+ type: 'voyages';
201
+ id: string;
202
+ /** How many of the newest voyages to return. Clamped boat-side to the REST bounds. */
203
+ limit: number;
204
+ }
205
+ /** The boat's answer to one VoyagesRequest. The voyages or a reason, never both. */
206
+ export type VoyagesResponse = {
207
+ type: 'voyages';
208
+ id: string;
209
+ result: VoyageListResult;
210
+ } | {
211
+ type: 'voyages';
212
+ id: string;
213
+ error: {
214
+ code: string;
215
+ message: string;
216
+ };
217
+ };
218
+ /**
219
+ * The track RPC, a fourth sibling on the same live socket. Where voyages asks for the list, this
220
+ * asks for one voyage's recorded path - the fixes the local /voyages/:id/track REST serves,
221
+ * drawn as a line on a chart ashore. It carries the voyage's id and nothing else; like its
222
+ * siblings it is a read of the boat's own store and reaches nothing near Signal K. The boat
223
+ * decimates a long track before she sends it, so a request cannot pull an unbounded stream over
224
+ * the wire (a day under way at 1 Hz is tens of thousands of fixes).
225
+ */
226
+ export interface TrackRequest {
227
+ type: 'track';
228
+ id: string;
229
+ /** Which voyage to draw, by its Voyage.id. */
230
+ voyageId: number;
231
+ }
232
+ /** The boat's answer to one TrackRequest. The path or a reason, never both. */
233
+ export type TrackResponse = {
234
+ type: 'track';
235
+ id: string;
236
+ result: TrackResult;
237
+ } | {
238
+ type: 'track';
239
+ id: string;
240
+ error: {
241
+ code: string;
242
+ message: string;
243
+ };
244
+ };
192
245
  export interface LiveResult extends Snapshot {
193
246
  /** Seconds since the newest delta touched any subscribed path; null before first delta. */
194
247
  data_age_s: number | null;
@@ -295,6 +348,10 @@ export interface Voyage {
295
348
  end_port: string | null;
296
349
  status: 'open' | 'closed';
297
350
  }
351
+ /** The boat's answer to one VoyagesRequest: her recent voyages, newest first. */
352
+ export interface VoyageListResult {
353
+ voyages: Voyage[];
354
+ }
298
355
  /** One aggregation window on the voyage stats card. */
299
356
  export interface VoyageWindowStats {
300
357
  distance_nm: number;
@@ -315,6 +372,15 @@ export interface TrackPoint {
315
372
  /** Knots, rounded; null when unavailable. */
316
373
  sog: number | null;
317
374
  }
375
+ /**
376
+ * The boat's answer to one TrackRequest: one voyage's path, oldest fix first.
377
+ * `decimated` is true when the boat thinned a long track before sending it, so a reader can say
378
+ * the line is a faithful shape but not every recorded fix.
379
+ */
380
+ export interface TrackResult {
381
+ track: TrackPoint[];
382
+ decimated: boolean;
383
+ }
318
384
  /** One nearby AIS vessel, sanitized and distance-filtered server-side. */
319
385
  export interface AisTarget {
320
386
  mmsi: string;
@@ -62,6 +62,7 @@ const rollup_1 = require("./rollup");
62
62
  const store_1 = require("./store");
63
63
  const time_1 = require("./time");
64
64
  const live_1 = require("./live");
65
+ const track_1 = require("./track");
65
66
  const uplink_1 = require("./uplink");
66
67
  const voyagelog_1 = require("./voyagelog");
67
68
  const PLUGIN_ID = 'siparu';
@@ -349,6 +350,15 @@ module.exports = (app) => {
349
350
  // Her whole recorded rows over a window, the logbook read - the same store the local
350
351
  // /snapshots serves, reached here and nowhere near Signal K.
351
352
  onSnapshotsQuery: (q) => qs.snapshots(q, Date.now()),
353
+ // Her recent voyages, the list the local /voyages serves. The count is clamped to the
354
+ // same 1..500 bounds the REST route enforces, since vl.list does not clamp its own.
355
+ onVoyagesQuery: async (limit) => ({
356
+ voyages: vl.list(Math.min(Math.max(1, limit || 50), 500))
357
+ }),
358
+ // One voyage's path, the line the local /voyages/:id/track serves. Decimated before it
359
+ // crosses the wire: vl.track returns every recorded fix (a long voyage is tens of
360
+ // thousands), which the local REST may serve but a single timed socket reply may not.
361
+ onTrackQuery: (voyageId) => vl.track(voyageId, Date.now()).then(track_1.decimateTrack),
352
362
  debug: (msg) => app.debug(msg)
353
363
  });
354
364
  liveUplink = ws;
@@ -16,7 +16,7 @@
16
16
  * fleet, and every owner would have to walk down to their boat to fix a bug that was ours.
17
17
  */
18
18
  import type { RemoteLink } from './remotelink';
19
- import type { PathSeriesResult, SnapshotsQuery, SnapshotsResult } from './contract';
19
+ import type { PathSeriesResult, SnapshotsQuery, SnapshotsResult, TrackResult, VoyageListResult } from './contract';
20
20
  /**
21
21
  * How often a frame goes up while the socket is open, when she is under way.
22
22
  *
@@ -99,6 +99,20 @@ export interface LiveDeps {
99
99
  * so an old relay or a boat wired without it simply never grows the ear.
100
100
  */
101
101
  onSnapshotsQuery?: (query: SnapshotsQuery) => Promise<SnapshotsResult>;
102
+ /**
103
+ * Answers a shore voyages request - her recent voyages, the list the local REST /voyages
104
+ * serves - from the same store. A third sibling of onHistoryQuery: a read, never a command,
105
+ * and it never reaches Signal K. Absent leaves the socket deaf to voyages requests, so an old
106
+ * relay or a boat wired without it simply never grows the ear.
107
+ */
108
+ onVoyagesQuery?: (limit: number) => Promise<VoyageListResult>;
109
+ /**
110
+ * Answers a shore track request - one voyage's recorded path, the line the local REST
111
+ * /voyages/:id/track serves - from the same store. A fourth sibling of onHistoryQuery: a read,
112
+ * never a command, and it never reaches Signal K. Absent leaves the socket deaf to track
113
+ * requests, so an old relay or a boat wired without it simply never grows the ear.
114
+ */
115
+ onTrackQuery?: (voyageId: number) => Promise<TrackResult>;
102
116
  debug: (msg: string) => void;
103
117
  /** Injected in tests. In production this is the `ws` adapter at the bottom of the file. */
104
118
  connect?: (url: string, token: string) => LiveSocket;
@@ -186,9 +200,24 @@ export declare class LiveUplink {
186
200
  */
187
201
  private handleSnapshots;
188
202
  /**
189
- * Send a history or snapshots answer, but only if it still belongs to the socket that asked.
190
- * A query reads the disk while the line may drop and redial underneath it; the generation
191
- * guard is what keeps a slow answer from landing on a fresh connection that never asked.
203
+ * A voyages request from the shore, answered from the boat's own store - a third sibling of
204
+ * handleHistory, and just as narrow. Parse, act only if it is a voyages request, and read the
205
+ * store, never Signal K. The boat clamps the count before it reads, so a request cannot ask
206
+ * her for more than she will give.
207
+ */
208
+ private handleVoyages;
209
+ /**
210
+ * A track request from the shore, answered from the boat's own store - a fourth sibling of
211
+ * handleHistory, and just as narrow. Parse, act only if it is a track request, and read the
212
+ * store, never Signal K. The boat decimates a long path before it answers, so a request cannot
213
+ * pull an unbounded stream over the wire.
214
+ */
215
+ private handleTrack;
216
+ /**
217
+ * Send a history, snapshots, voyages or track answer, but only if it still belongs to the
218
+ * socket that asked. A query reads the disk while the line may drop and redial underneath it;
219
+ * the generation guard is what keeps a slow answer from landing on a fresh connection that
220
+ * never asked.
192
221
  */
193
222
  private reply;
194
223
  private keepalive;
@@ -202,11 +202,14 @@ class LiveUplink {
202
202
  return;
203
203
  }
204
204
  // Beyond a pong, the shore may ask the boat to send back her own recorded history: one
205
- // gauge's series (handleHistory) or whole snapshot rows (handleSnapshots). Neither is a
206
- // command; each drops in silence anything that is not its own request, and anything that
207
- // is neither is not acted on at all, because the shore may not steer a boat.
205
+ // gauge's series (handleHistory), whole snapshot rows (handleSnapshots), her recent voyages
206
+ // (handleVoyages) or one voyage's path (handleTrack). None is a command; each drops in
207
+ // silence anything that is not its own request, and anything that is none is not acted on
208
+ // at all, because the shore may not steer a boat.
208
209
  this.handleHistory(gen, data);
209
210
  this.handleSnapshots(gen, data);
211
+ this.handleVoyages(gen, data);
212
+ this.handleTrack(gen, data);
210
213
  });
211
214
  sock.onClose((code) => {
212
215
  if (gen !== this.gen)
@@ -353,9 +356,68 @@ class LiveUplink {
353
356
  });
354
357
  }
355
358
  /**
356
- * Send a history or snapshots answer, but only if it still belongs to the socket that asked.
357
- * A query reads the disk while the line may drop and redial underneath it; the generation
358
- * guard is what keeps a slow answer from landing on a fresh connection that never asked.
359
+ * A voyages request from the shore, answered from the boat's own store - a third sibling of
360
+ * handleHistory, and just as narrow. Parse, act only if it is a voyages request, and read the
361
+ * store, never Signal K. The boat clamps the count before it reads, so a request cannot ask
362
+ * her for more than she will give.
363
+ */
364
+ handleVoyages(gen, data) {
365
+ const handler = this.deps.onVoyagesQuery;
366
+ if (!handler)
367
+ return;
368
+ let msg;
369
+ try {
370
+ msg = JSON.parse(data);
371
+ }
372
+ catch {
373
+ return;
374
+ }
375
+ if (!isVoyagesRequest(msg))
376
+ return;
377
+ const { id, limit } = msg;
378
+ handler(limit).then((result) => this.reply(gen, { type: 'voyages', id, result }), (err) => {
379
+ this.deps.debug(`voyages query failed: ${String(err)}`);
380
+ this.reply(gen, {
381
+ type: 'voyages',
382
+ id,
383
+ error: { code: 'VOYAGES_FAILED', message: 'voyages query failed' }
384
+ });
385
+ });
386
+ }
387
+ /**
388
+ * A track request from the shore, answered from the boat's own store - a fourth sibling of
389
+ * handleHistory, and just as narrow. Parse, act only if it is a track request, and read the
390
+ * store, never Signal K. The boat decimates a long path before it answers, so a request cannot
391
+ * pull an unbounded stream over the wire.
392
+ */
393
+ handleTrack(gen, data) {
394
+ const handler = this.deps.onTrackQuery;
395
+ if (!handler)
396
+ return;
397
+ let msg;
398
+ try {
399
+ msg = JSON.parse(data);
400
+ }
401
+ catch {
402
+ return;
403
+ }
404
+ if (!isTrackRequest(msg))
405
+ return;
406
+ const { id, voyageId } = msg;
407
+ handler(voyageId).then((result) => this.reply(gen, { type: 'track', id, result }), (err) => {
408
+ this.deps.debug(`track query failed: ${String(err)}`);
409
+ this.reply(gen, {
410
+ type: 'track',
411
+ id,
412
+ error: { code: 'TRACK_FAILED', message: 'track query failed' }
413
+ });
414
+ });
415
+ }
416
+ /**
417
+ * Send a history, snapshots, voyages or track answer, but only if it still belongs to the
418
+ * socket that asked. A query reads the disk while the line may drop and redial underneath it;
419
+ * the generation guard is what keeps a slow answer from landing on a fresh connection that
420
+ * never asked.
359
421
  */
360
422
  reply(gen, msg) {
361
423
  if (gen !== this.gen || !this.sock)
@@ -493,6 +555,28 @@ function isSnapshotsRequest(m) {
493
555
  typeof o.query === 'object' &&
494
556
  o.query !== null);
495
557
  }
558
+ /**
559
+ * A voyages request, told apart the same way: the type tag is the gate. It carries no query,
560
+ * only a count - so the tag, the id and a numeric limit are checked. The limit's bounds are the
561
+ * boat's to enforce (she clamps it before reading), so they are not re-checked here.
562
+ */
563
+ function isVoyagesRequest(m) {
564
+ if (typeof m !== 'object' || m === null)
565
+ return false;
566
+ const o = m;
567
+ return o.type === 'voyages' && typeof o.id === 'string' && typeof o.limit === 'number';
568
+ }
569
+ /**
570
+ * A track request, told apart the same way: the type tag is the gate. It carries a voyage id, so
571
+ * the tag, the id and a numeric voyageId are checked. Whether that voyage exists is the store's
572
+ * to answer (an unknown id reads back an empty path), so it is not re-checked here.
573
+ */
574
+ function isTrackRequest(m) {
575
+ if (typeof m !== 'object' || m === null)
576
+ return false;
577
+ const o = m;
578
+ return o.type === 'track' && typeof o.id === 'string' && typeof o.voyageId === 'number';
579
+ }
496
580
  /**
497
581
  * The real socket.
498
582
  *
@@ -0,0 +1,16 @@
1
+ import type { TrackPoint, TrackResult } from './contract';
2
+ /**
3
+ * The most fixes a track carries over the wire.
4
+ *
5
+ * A day under way at roughly 1 Hz is tens of thousands of fixes; a line on a chart needs a small
6
+ * fraction of that, and the shore's request waits on a single reply with a fixed timeout. The
7
+ * local REST is left untouched - a browser aboard has no such limit and can ask for every fix -
8
+ * so this caps only what crosses the wire.
9
+ */
10
+ export declare const MAX_TRACK_POINTS = 2000;
11
+ /**
12
+ * Thin a track to at most `max` fixes at an even stride, always keeping the first and last so the
13
+ * line still starts and ends where the voyage did. Returns the track untouched when it already
14
+ * fits, with a flag saying whether anything was dropped - a faithful shape, not every fix.
15
+ */
16
+ export declare function decimateTrack(points: TrackPoint[], max?: number): TrackResult;
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_TRACK_POINTS = void 0;
4
+ exports.decimateTrack = decimateTrack;
5
+ /**
6
+ * The most fixes a track carries over the wire.
7
+ *
8
+ * A day under way at roughly 1 Hz is tens of thousands of fixes; a line on a chart needs a small
9
+ * fraction of that, and the shore's request waits on a single reply with a fixed timeout. The
10
+ * local REST is left untouched - a browser aboard has no such limit and can ask for every fix -
11
+ * so this caps only what crosses the wire.
12
+ */
13
+ exports.MAX_TRACK_POINTS = 2000;
14
+ /**
15
+ * Thin a track to at most `max` fixes at an even stride, always keeping the first and last so the
16
+ * line still starts and ends where the voyage did. Returns the track untouched when it already
17
+ * fits, with a flag saying whether anything was dropped - a faithful shape, not every fix.
18
+ */
19
+ function decimateTrack(points, max = exports.MAX_TRACK_POINTS) {
20
+ if (points.length <= max || max < 2)
21
+ return { track: points, decimated: false };
22
+ const stride = Math.ceil(points.length / max);
23
+ const out = [];
24
+ // length > max >= 2 here, so the first and last are always present.
25
+ for (let i = 0; i < points.length; i += stride)
26
+ out.push(points[i]);
27
+ const last = points[points.length - 1];
28
+ if (out[out.length - 1] !== last)
29
+ out.push(last);
30
+ return { track: out, decimated: true };
31
+ }