signalk-edge-link 2.5.0 → 2.6.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.
@@ -51,42 +51,37 @@ const crypto = __importStar(require("crypto"));
51
51
  const config_io_1 = require("./config-io");
52
52
  const constants_1 = require("./constants");
53
53
  const { readFile, writeFile, mkdir } = fs_1.promises;
54
- /**
55
- * Create a debounced config-change handler.
56
- */
57
54
  function createDebouncedConfigHandler(opts) {
58
55
  const { name, getFilePath, processConfig, state, instanceId, app, readFallback } = opts;
59
- return function handleChange() {
56
+ async function runLoad() {
57
+ if (state.stopped)
58
+ return;
59
+ let content;
60
+ const filePath = getFilePath();
61
+ if (readFallback !== undefined) {
62
+ content = filePath ? await readFile(filePath, "utf-8").catch(() => null) : null;
63
+ }
64
+ else {
65
+ content = filePath ? await readFile(filePath, "utf-8") : null;
66
+ }
67
+ if (state.stopped)
68
+ return;
69
+ const hashSource = content || JSON.stringify(readFallback) || "";
70
+ const contentHash = crypto.createHash(constants_1.CONTENT_HASH_ALGORITHM).update(hashSource).digest("hex");
71
+ if (contentHash === state.configContentHashes[name]) {
72
+ app.debug(`[${instanceId}] ${name} file unchanged, skipping`);
73
+ return;
74
+ }
75
+ const parsed = content ? JSON.parse(content) : readFallback;
76
+ await processConfig(parsed);
77
+ if (!state.stopped) {
78
+ state.configContentHashes[name] = contentHash;
79
+ }
80
+ }
81
+ const handleChange = function () {
60
82
  clearTimeout(state.configDebounceTimers[name]);
61
83
  state.configDebounceTimers[name] = setTimeout(() => {
62
- (async () => {
63
- if (state.stopped)
64
- return;
65
- let content;
66
- const filePath = getFilePath();
67
- if (readFallback !== undefined) {
68
- content = filePath ? await readFile(filePath, "utf-8").catch(() => null) : null;
69
- }
70
- else {
71
- content = filePath ? await readFile(filePath, "utf-8") : null;
72
- }
73
- if (state.stopped)
74
- return;
75
- const hashSource = content || JSON.stringify(readFallback) || "";
76
- const contentHash = crypto
77
- .createHash(constants_1.CONTENT_HASH_ALGORITHM)
78
- .update(hashSource)
79
- .digest("hex");
80
- if (contentHash === state.configContentHashes[name]) {
81
- app.debug(`[${instanceId}] ${name} file unchanged, skipping`);
82
- return;
83
- }
84
- const parsed = content ? JSON.parse(content) : readFallback;
85
- await processConfig(parsed);
86
- if (!state.stopped) {
87
- state.configContentHashes[name] = contentHash;
88
- }
89
- })().catch((err) => {
84
+ runLoad().catch((err) => {
90
85
  if (state.stopped)
91
86
  return;
92
87
  const msg = err instanceof Error ? err.message : String(err);
@@ -94,6 +89,19 @@ function createDebouncedConfigHandler(opts) {
94
89
  });
95
90
  }, constants_1.FILE_WATCH_DEBOUNCE_DELAY);
96
91
  };
92
+ handleChange.flush = async function flush() {
93
+ clearTimeout(state.configDebounceTimers[name]);
94
+ try {
95
+ await runLoad();
96
+ }
97
+ catch (err) {
98
+ if (state.stopped)
99
+ return;
100
+ const msg = err instanceof Error ? err.message : String(err);
101
+ app.error(`[${instanceId}] Error handling ${name} change: ${msg}`);
102
+ }
103
+ };
104
+ return handleChange;
97
105
  }
98
106
  /**
99
107
  * Create a file-system watcher with automatic recovery on error or rename.
package/lib/instance.js CHANGED
@@ -34,6 +34,7 @@ const config_watcher_1 = require("./config-watcher");
34
34
  const metadata_1 = require("./metadata");
35
35
  const delta_sanitizer_1 = require("./delta-sanitizer");
36
36
  const source_snapshot_1 = require("./source-snapshot");
37
+ const values_snapshot_1 = require("./values-snapshot");
37
38
  const DELTA_SEND_MAX_RETRIES = 1;
38
39
  const DELTA_SEND_RETRY_BACKOFF_MS = 100;
39
40
  const SOURCE_SNAPSHOT_INTERVAL_MS = 60000;
@@ -110,6 +111,7 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
110
111
  metaDiffFlushTimer: null,
111
112
  metaSnapshotTimers: [],
112
113
  lastMetaRequestAt: 0,
114
+ lastFullStatusRequestAt: 0,
113
115
  sourceRegistry: (0, source_replication_1.createSourceRegistry)(app)
114
116
  };
115
117
  const metricsApi = (0, metrics_1.default)();
@@ -383,6 +385,22 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
383
385
  app.debug(`[${instanceId}] META_REQUEST snapshot failed: ${msg}`);
384
386
  });
385
387
  }
388
+ /** Minimum gap between server-initiated full-status replays. Prevents a
389
+ * restarting or misconfigured server from flooding the link. */
390
+ const FULL_STATUS_REQUEST_RATE_LIMIT_MS = 10000;
391
+ /** Server asked for a full values snapshot (FULL_STATUS_REQUEST control
392
+ * packet). Replays the entire current Signal K tree to the server.
393
+ * Rate-limited to prevent replay floods across rapid server restarts. */
394
+ function handleFullStatusRequest() {
395
+ const now = Date.now();
396
+ if (now - state.lastFullStatusRequestAt < FULL_STATUS_REQUEST_RATE_LIMIT_MS) {
397
+ app.debug(`[${instanceId}] FULL_STATUS_REQUEST rate-limited, skipping`);
398
+ return;
399
+ }
400
+ state.lastFullStatusRequestAt = now;
401
+ app.debug(`[${instanceId}] FULL_STATUS_REQUEST received — replaying values snapshot`);
402
+ replayValuesSnapshot("full-status-request");
403
+ }
386
404
  async function sendSourceSnapshot() {
387
405
  if (state.stopped ||
388
406
  !state.readyToSend ||
@@ -404,6 +422,48 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
404
422
  app.debug(`[${instanceId}] source snapshot send failed: ${msg}`);
405
423
  }
406
424
  }
425
+ /**
426
+ * Replay every value currently in the local Signal K tree by feeding
427
+ * synthetic deltas through `processDelta`. The subscription manager only
428
+ * delivers *future* deltas, so values published into the tree before
429
+ * `subscribe()` ran (one-shot startup deltas, or deltas published by a
430
+ * co-located edge-link server-mode instance via `app.handleMessage`) would
431
+ * otherwise never reach the receiver. Triggered on initial subscribe
432
+ * success, on subscribe-retry success, and on UDP socket recovery so the
433
+ * receiver gets re-primed if it restarted.
434
+ *
435
+ * Returns silently if the SignalK app object doesn't expose `signalk`
436
+ * (older signalk-server versions or test mocks), or while the instance is
437
+ * not yet ready to send.
438
+ */
439
+ function replayValuesSnapshot(reason) {
440
+ if (state.stopped || !state.readyToSend || !state.processDelta) {
441
+ return;
442
+ }
443
+ let snapshot;
444
+ try {
445
+ snapshot = (0, values_snapshot_1.collectValuesSnapshot)(appProxy);
446
+ }
447
+ catch (err) {
448
+ const msg = err instanceof Error ? err.message : String(err);
449
+ app.debug(`[${instanceId}] values snapshot collect failed (${reason}): ${msg}`);
450
+ return;
451
+ }
452
+ if (snapshot.length === 0) {
453
+ return;
454
+ }
455
+ app.debug(`[${instanceId}] Replaying ${snapshot.length} value-snapshot delta(s) (${reason})`);
456
+ for (const delta of snapshot) {
457
+ try {
458
+ state.processDelta(delta);
459
+ }
460
+ catch (err) {
461
+ const msg = err instanceof Error ? err.message : String(err);
462
+ app.debug(`[${instanceId}] values snapshot replay failed (${reason}): ${msg}`);
463
+ return;
464
+ }
465
+ }
466
+ }
407
467
  function restartSourceSnapshotTimer() {
408
468
  clearInterval(state.sourceSnapshotTimer ?? undefined);
409
469
  state.sourceSnapshotTimer = null;
@@ -598,6 +658,9 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
598
658
  }
599
659
  state.readyToSend = true;
600
660
  _setStatus("Subscription restored", true);
661
+ // Replay current tree state so any value that arrived in the tree
662
+ // while we were retrying isn't permanently lost.
663
+ replayValuesSnapshot("subscription retry");
601
664
  }
602
665
  catch (retryError) {
603
666
  const msg = retryError instanceof Error ? retryError.message : String(retryError);
@@ -649,6 +712,12 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
649
712
  if (state.metaConfig?.enabled) {
650
713
  scheduleMetadataSnapshot(2000);
651
714
  }
715
+ // Replay every value already present in the tree. Without this,
716
+ // one-shot startup deltas published before subscribe() ran (e.g. by
717
+ // a co-located edge-link server-mode instance) never reach the
718
+ // receiver, since the subscription manager only delivers future
719
+ // events.
720
+ replayValuesSnapshot("initial subscribe");
652
721
  }
653
722
  catch (subscribeError) {
654
723
  // Re-subscribe failed — restore old handlers so stop() can still
@@ -713,8 +782,16 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
713
782
  }
714
783
  ];
715
784
  state.configWatcherObjects = watcherConfigs.map((cfg) => (0, config_watcher_1.createWatcherWithRecovery)({ ...cfg, instanceId, app, state }));
716
- // Trigger initial subscription load
717
- handleSubscriptionChange();
785
+ // Trigger initial subscription load immediately (no debounce). The
786
+ // debounce delay exists to coalesce file-system change events; for the
787
+ // one-shot startup load it just widens the window during which deltas
788
+ // produced by co-located plugins are emitted before our subscription
789
+ // is registered with the subscriptionmanager — those deltas would be
790
+ // silently dropped since the manager only delivers future events.
791
+ handleSubscriptionChange.flush().catch((err) => {
792
+ const msg = err instanceof Error ? err.message : String(err);
793
+ app.error(`[${instanceId}] Initial subscription load failed: ${msg}`);
794
+ });
718
795
  app.debug(`[${instanceId}] Configuration file watchers initialized`);
719
796
  }
720
797
  catch (err) {
@@ -1001,6 +1078,10 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
1001
1078
  if (state.metaConfig?.enabled) {
1002
1079
  scheduleMetadataSnapshot(1000);
1003
1080
  }
1081
+ // Re-prime the receiver's value tree too — a restarted
1082
+ // receiver lost everything we sent before, and the
1083
+ // subscription manager won't replay past deltas.
1084
+ replayValuesSnapshot("socket recovery");
1004
1085
  }
1005
1086
  catch (recoveryErr) {
1006
1087
  state.socketRecoveryInProgress = false;
@@ -1064,6 +1145,9 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
1064
1145
  if (typeof v2Pipeline.setMetaRequestHandler === "function") {
1065
1146
  v2Pipeline.setMetaRequestHandler(handleMetaRequest);
1066
1147
  }
1148
+ if (typeof v2Pipeline.setFullStatusRequestHandler === "function") {
1149
+ v2Pipeline.setFullStatusRequestHandler(handleFullStatusRequest);
1150
+ }
1067
1151
  v2Pipeline.startMetricsPublishing();
1068
1152
  if (options.congestionControl && options.congestionControl.enabled) {
1069
1153
  v2Pipeline.startCongestionControl();
@@ -1146,6 +1230,7 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
1146
1230
  Object.keys(state.configContentHashes).forEach((k) => delete state.configContentHashes[k]);
1147
1231
  state.excludedSentences = ["GSV"];
1148
1232
  state.lastPacketTime = 0;
1233
+ state.lastFullStatusRequestAt = 0;
1149
1234
  // Reset metrics
1150
1235
  resetMetrics();
1151
1236
  // Clear timers
package/lib/packet.js CHANGED
@@ -55,7 +55,9 @@ const PacketType = Object.freeze({
55
55
  HEARTBEAT: 0x04,
56
56
  HELLO: 0x05,
57
57
  METADATA: 0x06,
58
- META_REQUEST: 0x07
58
+ META_REQUEST: 0x07,
59
+ /** Server → client: request a full values snapshot replay. */
60
+ FULL_STATUS_REQUEST: 0x08
59
61
  });
60
62
  exports.PacketType = PacketType;
61
63
  /**
@@ -176,6 +178,14 @@ class PacketBuilder {
176
178
  buildMetaRequestPacket(options = {}) {
177
179
  return this._buildPacket(PacketType.META_REQUEST, Buffer.alloc(0), {}, options);
178
180
  }
181
+ /**
182
+ * Build a FULL_STATUS_REQUEST control packet (server → client).
183
+ * Payload is empty. Instructs the client to replay its full values snapshot
184
+ * so the server can rebuild state after a restart.
185
+ */
186
+ buildFullStatusRequestPacket(options = {}) {
187
+ return this._buildPacket(PacketType.FULL_STATUS_REQUEST, Buffer.alloc(0), {}, options);
188
+ }
179
189
  /**
180
190
  * Build an ACK packet
181
191
  * @param {number} ackedSequence - Sequence number being acknowledged
@@ -404,11 +414,13 @@ class PacketParser {
404
414
  payload = payloadData;
405
415
  }
406
416
  else {
407
- // HEARTBEAT and META_REQUEST packets carry a 0-byte payload with no CRC
408
- // — accept as-is. ACK / NAK / HELLO must include a 2-byte CRC16 trailer;
409
- // reject undersized payloads so forged control frames cannot slip
410
- // through unverified.
411
- if (type !== PacketType.HEARTBEAT && type !== PacketType.META_REQUEST) {
417
+ // HEARTBEAT, META_REQUEST, and FULL_STATUS_REQUEST carry a 0-byte
418
+ // payload with no CRC — accept as-is. ACK / NAK / HELLO must include
419
+ // a 2-byte CRC16 trailer; reject undersized payloads so forged control
420
+ // frames cannot slip through unverified.
421
+ if (type !== PacketType.HEARTBEAT &&
422
+ type !== PacketType.META_REQUEST &&
423
+ type !== PacketType.FULL_STATUS_REQUEST) {
412
424
  if (payload.length < 2) {
413
425
  throw new Error(`Control packet payload too short for CRC: ${payload.length} byte(s)`);
414
426
  }
@@ -504,7 +516,8 @@ function getTypeName(type) {
504
516
  [PacketType.HEARTBEAT]: "HEARTBEAT",
505
517
  [PacketType.HELLO]: "HELLO",
506
518
  [PacketType.METADATA]: "METADATA",
507
- [PacketType.META_REQUEST]: "META_REQUEST"
519
+ [PacketType.META_REQUEST]: "META_REQUEST",
520
+ [PacketType.FULL_STATUS_REQUEST]: "FULL_STATUS_REQUEST"
508
521
  };
509
522
  return names[type] || "UNKNOWN";
510
523
  }
@@ -106,6 +106,9 @@ function createPipelineV2Client(app, state, metricsApi) {
106
106
  // (META_REQUEST control packet). Wired up by instance.ts, which is the only
107
107
  // layer that knows how to build a snapshot from `app.signalk.retrieve()`.
108
108
  let metaRequestHandler = null;
109
+ // Callback fired when the server sends FULL_STATUS_REQUEST, asking the client
110
+ // to replay its complete current values snapshot.
111
+ let fullStatusRequestHandler = null;
109
112
  let metaEnvelopeSeq = 0;
110
113
  let sourceEnvelopeSeq = 0;
111
114
  // Seed all four meta bandwidth counters so downstream consumers (metrics
@@ -632,6 +635,9 @@ function createPipelineV2Client(app, state, metricsApi) {
632
635
  function setMetaRequestHandler(handler) {
633
636
  metaRequestHandler = handler;
634
637
  }
638
+ function setFullStatusRequestHandler(handler) {
639
+ fullStatusRequestHandler = handler;
640
+ }
635
641
  /**
636
642
  * Handle incoming ACK packet from server.
637
643
  * Removes acknowledged packets from the retransmit queue.
@@ -772,6 +778,21 @@ function createPipelineV2Client(app, state, metricsApi) {
772
778
  }
773
779
  }
774
780
  }
781
+ else if (parsed.type === packet_1.PacketType.FULL_STATUS_REQUEST) {
782
+ // Server asks us to replay our full values snapshot (e.g. after a
783
+ // server restart). Rate-limited in instance.ts to prevent abuse.
784
+ if (fullStatusRequestHandler) {
785
+ try {
786
+ Promise.resolve(fullStatusRequestHandler()).catch((err) => {
787
+ app.debug(`FULL_STATUS_REQUEST handler rejected: ${err instanceof Error ? err.message : String(err)}`);
788
+ });
789
+ }
790
+ catch (err) {
791
+ const errMsg = err instanceof Error ? err.message : String(err);
792
+ app.debug(`FULL_STATUS_REQUEST handler error: ${errMsg}`);
793
+ }
794
+ }
795
+ }
775
796
  // Ignore other packet types on client side
776
797
  }
777
798
  catch (err) {
@@ -1110,6 +1131,7 @@ function createPipelineV2Client(app, state, metricsApi) {
1110
1131
  sendMetadata,
1111
1132
  sendSourceSnapshot,
1112
1133
  setMetaRequestHandler,
1134
+ setFullStatusRequestHandler,
1113
1135
  getPacketBuilder,
1114
1136
  getRetransmitQueue,
1115
1137
  getMetricsPublisher,
@@ -178,6 +178,7 @@ function createPipelineV2Server(app, state, metricsApi) {
178
178
  rateLimitCount: 0,
179
179
  rateLimitWindowStart: Date.now(),
180
180
  metaRequested: false,
181
+ statusRequested: false,
181
182
  lastMetaEnvSeq: null,
182
183
  seenMetaChunkIdx: new Set(),
183
184
  lastSourceEnvSeq: null,
@@ -212,6 +213,8 @@ function createPipelineV2Server(app, state, metricsApi) {
212
213
  rateLimitWindowStart: Date.now(),
213
214
  // META_REQUEST bookkeeping
214
215
  metaRequested: false,
216
+ // FULL_STATUS_REQUEST bookkeeping
217
+ statusRequested: false,
215
218
  // Stale-envelope rejection for METADATA packets
216
219
  lastMetaEnvSeq: null,
217
220
  seenMetaChunkIdx: new Set(),
@@ -491,6 +494,7 @@ function createPipelineV2Server(app, state, metricsApi) {
491
494
  seenChunkIdx.clear();
492
495
  if (!isSource) {
493
496
  session.metaRequested = false;
497
+ session.statusRequested = false;
494
498
  }
495
499
  }
496
500
  if (lastEnvSeq !== null) {
@@ -610,6 +614,7 @@ function createPipelineV2Server(app, state, metricsApi) {
610
614
  session.lastMetaEnvSeq = null;
611
615
  session.seenMetaChunkIdx.clear();
612
616
  session.metaRequested = false;
617
+ session.statusRequested = false;
613
618
  }
614
619
  if (session.lastMetaEnvSeq !== null) {
615
620
  const distance = (envSeq - session.lastMetaEnvSeq) >>> 0;
@@ -697,6 +702,21 @@ function createPipelineV2Server(app, state, metricsApi) {
697
702
  recordError("general", `v2 META decode error: ${msg}`);
698
703
  }
699
704
  }
705
+ /**
706
+ * Build and send a FULL_STATUS_REQUEST (0x08) control packet to a client.
707
+ * Instructs the client to replay its complete current values snapshot so the
708
+ * server can rebuild state immediately after a restart.
709
+ */
710
+ async function _sendFullStatusRequest(session, secretKey) {
711
+ try {
712
+ const packet = packetBuilder.buildFullStatusRequestPacket({ secretKey });
713
+ await _sendUDP(packet, { address: session.address, port: session.port });
714
+ app.debug(`[v2-server] FULL_STATUS_REQUEST sent to ${session.key}`);
715
+ }
716
+ catch (err) {
717
+ throw err;
718
+ }
719
+ }
700
720
  /**
701
721
  * Build and send a META_REQUEST (0x07) control packet to a client.
702
722
  * Instructs the client to emit a fresh metadata snapshot — used on first
@@ -819,6 +839,15 @@ function createPipelineV2Server(app, state, metricsApi) {
819
839
  app.debug(`[v2-server] META_REQUEST send failed: ${err instanceof Error ? err.message : String(err)}`);
820
840
  });
821
841
  }
842
+ // If the operator enabled full-status-on-restart, also request a values
843
+ // snapshot. Capped at one per session so rapid HELLOs (e.g. NAT churn)
844
+ // don't create repeated replay bursts.
845
+ if (session && !session.statusRequested && state.options?.requestFullStatusOnRestart) {
846
+ session.statusRequested = true;
847
+ _sendFullStatusRequest(session, secretKey).catch((err) => {
848
+ app.debug(`[v2-server] FULL_STATUS_REQUEST send failed: ${err instanceof Error ? err.message : String(err)}`);
849
+ });
850
+ }
822
851
  return;
823
852
  }
824
853
  if (parsed.type === packet_1.PacketType.METADATA) {
@@ -892,6 +921,14 @@ function createPipelineV2Server(app, state, metricsApi) {
892
921
  if (session) {
893
922
  session.hasReceivedData = true;
894
923
  }
924
+ // On first DATA from a new session, request full-status replay if enabled.
925
+ // This covers the case where the client sends data before its next HELLO.
926
+ if (session && !session.statusRequested && state.options?.requestFullStatusOnRestart) {
927
+ session.statusRequested = true;
928
+ _sendFullStatusRequest(session, secretKey).catch((err) => {
929
+ app.debug(`[v2-server] FULL_STATUS_REQUEST (data trigger) send failed: ${err instanceof Error ? err.message : String(err)}`);
930
+ });
931
+ }
895
932
  const dataSeq = parsed.sequence >>> 0;
896
933
  if (session) {
897
934
  if (seqResult.resynced || session.lossBaseSeq === null) {
@@ -15,7 +15,7 @@
15
15
  * results to `RJSFSchema` at call sites.
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.alertThresholdsProperty = exports.skipOwnDataProperty = exports.enableNotificationsProperty = exports.bondingProperty = exports.congestionControlProperty = exports.serverReliabilityProperty = exports.clientReliabilityProperty = exports.clientTransportProperties = exports.v1ClientPingProperties = exports.commonConnectionProperties = void 0;
18
+ exports.alertThresholdsProperty = exports.skipOwnDataProperty = exports.enableNotificationsProperty = exports.bondingProperty = exports.congestionControlProperty = exports.serverReliabilityProperty = exports.requestFullStatusOnRestartProperty = exports.clientReliabilityProperty = exports.clientTransportProperties = exports.v1ClientPingProperties = exports.commonConnectionProperties = void 0;
19
19
  exports.buildConnectionItemSchema = buildConnectionItemSchema;
20
20
  exports.buildWebappConnectionSchema = buildWebappConnectionSchema;
21
21
  const crypto_constants_1 = require("./crypto-constants");
@@ -246,6 +246,12 @@ exports.clientReliabilityProperty = {
246
246
  }
247
247
  };
248
248
  // ── v2/v3 reliability (server pipeline — ACK/NAK timing) ──────────────────────
249
+ exports.requestFullStatusOnRestartProperty = {
250
+ type: "boolean",
251
+ title: "Request Full Status on Server Start (v2/v3 only)",
252
+ description: "When enabled, the server sends a request to each client on first contact asking it to replay its complete current values snapshot. This rebuilds the server's state immediately after a restart instead of waiting for incremental deltas to arrive.",
253
+ default: false
254
+ };
249
255
  exports.serverReliabilityProperty = {
250
256
  type: "object",
251
257
  title: "Reliability Settings (v2/v3 only)",
@@ -511,6 +517,7 @@ function buildConnectionItemSchema() {
511
517
  {
512
518
  properties: {
513
519
  serverType: { enum: ["server"] },
520
+ requestFullStatusOnRestart: exports.requestFullStatusOnRestartProperty,
514
521
  reliability: exports.serverReliabilityProperty
515
522
  }
516
523
  },
@@ -567,6 +574,7 @@ function buildWebappConnectionSchema(isClient, protocolVersion) {
567
574
  }
568
575
  }
569
576
  else if (isReliableProtocol) {
577
+ props.requestFullStatusOnRestart = exports.requestFullStatusOnRestartProperty;
570
578
  props.reliability = exports.serverReliabilityProperty;
571
579
  }
572
580
  return { type: "object", required, properties: props };
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.collectValuesSnapshot = collectValuesSnapshot;
4
+ // Reserved leaf-level keys we must not descend into when walking the tree.
5
+ const SK_LEAF_KEYS = new Set([
6
+ "value",
7
+ "values",
8
+ "timestamp",
9
+ "$source",
10
+ "meta",
11
+ "sentence",
12
+ "pgn"
13
+ ]);
14
+ // Top-level tree keys that aren't context groups.
15
+ const SK_NON_CONTEXT_KEYS = new Set(["self", "version", "sources"]);
16
+ function isRecord(value) {
17
+ return value !== null && typeof value === "object" && !Array.isArray(value);
18
+ }
19
+ function readLeafFromNode(obj) {
20
+ // A Signal K value leaf has either a `value` property with sibling
21
+ // `timestamp`, or a multi-source `values` map. Single-source leaves are the
22
+ // common case so we handle them first.
23
+ if ("value" in obj && typeof obj.timestamp === "string") {
24
+ return {
25
+ value: obj.value,
26
+ timestamp: obj.timestamp,
27
+ source: typeof obj.$source === "string" ? obj.$source : undefined
28
+ };
29
+ }
30
+ return null;
31
+ }
32
+ function walkValues(node, pathParts, onLeaf) {
33
+ if (!isRecord(node)) {
34
+ return;
35
+ }
36
+ // Multi-source case wins when present: `values` is { sourceLabel:
37
+ // { value, timestamp } } and is more authoritative than the top-level
38
+ // `value`/`timestamp` (which mirror the latest of the multi-source map).
39
+ // Emit one leaf per source so the receiver retains attribution, then stop
40
+ // — the rest of the node is per-source bookkeeping.
41
+ if (isRecord(node.values)) {
42
+ for (const [sourceLabel, sourceData] of Object.entries(node.values)) {
43
+ if (isRecord(sourceData) &&
44
+ "value" in sourceData &&
45
+ typeof sourceData.timestamp === "string") {
46
+ onLeaf({
47
+ path: pathParts.join("."),
48
+ value: sourceData.value,
49
+ timestamp: sourceData.timestamp,
50
+ source: sourceLabel
51
+ });
52
+ }
53
+ }
54
+ return;
55
+ }
56
+ // Single-source leaf.
57
+ const single = readLeafFromNode(node);
58
+ if (single !== null) {
59
+ onLeaf({
60
+ path: pathParts.join("."),
61
+ value: single.value,
62
+ timestamp: single.timestamp,
63
+ source: single.source
64
+ });
65
+ return;
66
+ }
67
+ // Container — descend.
68
+ for (const key of Object.keys(node)) {
69
+ if (SK_LEAF_KEYS.has(key)) {
70
+ continue;
71
+ }
72
+ walkValues(node[key], pathParts.concat(key), onLeaf);
73
+ }
74
+ }
75
+ /**
76
+ * Build synthetic deltas for every value currently in the Signal K tree.
77
+ *
78
+ * Returns one delta per `(context, source)` pair, with all matching leaves
79
+ * grouped into a single `updates[].values[]` array. `DeltaUpdate.timestamp`
80
+ * is per-update (not per-leaf), so the latest timestamp across the group is
81
+ * used — receivers treat the delta as "current state" anyway.
82
+ *
83
+ * Returns [] when `app.signalk` isn't exposed (older signalk-server) or the
84
+ * tree is empty.
85
+ */
86
+ function collectValuesSnapshot(app) {
87
+ if (!app.signalk || typeof app.signalk.retrieve !== "function") {
88
+ return [];
89
+ }
90
+ let tree;
91
+ try {
92
+ const retrieved = app.signalk.retrieve();
93
+ if (!isRecord(retrieved)) {
94
+ return [];
95
+ }
96
+ tree = retrieved;
97
+ }
98
+ catch {
99
+ return [];
100
+ }
101
+ // Group leaves by (context, source) so we emit one delta per group.
102
+ const grouped = new Map();
103
+ for (const contextGroup of Object.keys(tree)) {
104
+ if (SK_NON_CONTEXT_KEYS.has(contextGroup)) {
105
+ continue;
106
+ }
107
+ const group = tree[contextGroup];
108
+ if (!isRecord(group)) {
109
+ continue;
110
+ }
111
+ for (const contextId of Object.keys(group)) {
112
+ const contextNode = group[contextId];
113
+ if (!isRecord(contextNode)) {
114
+ continue;
115
+ }
116
+ const context = `${contextGroup}.${contextId}`;
117
+ walkValues(contextNode, [], (leaf) => {
118
+ const key = `${context}|${leaf.source ?? ""}`;
119
+ const existing = grouped.get(key);
120
+ if (existing) {
121
+ existing.values.push({ path: leaf.path, value: leaf.value });
122
+ if (leaf.timestamp > existing.timestamp) {
123
+ existing.timestamp = leaf.timestamp;
124
+ }
125
+ }
126
+ else {
127
+ grouped.set(key, {
128
+ context,
129
+ source: leaf.source,
130
+ timestamp: leaf.timestamp,
131
+ values: [{ path: leaf.path, value: leaf.value }]
132
+ });
133
+ }
134
+ });
135
+ }
136
+ }
137
+ const deltas = [];
138
+ for (const entry of grouped.values()) {
139
+ if (entry.values.length === 0) {
140
+ continue;
141
+ }
142
+ const update = {
143
+ timestamp: entry.timestamp,
144
+ values: entry.values
145
+ };
146
+ if (entry.source) {
147
+ update.$source = entry.source;
148
+ }
149
+ deltas.push({ context: entry.context, updates: [update] });
150
+ }
151
+ return deltas;
152
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-edge-link",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "SignalK Edge Link. Secure UDP link for data exchange.",
5
5
  "main": "lib/index.js",
6
6
  "files": [