signalk-webhook-bridge 2.0.0 → 2.0.1

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/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ All notable changes to Webhook Bridge will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/),
6
6
  and this project uses [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [2.0.1] - 2026-09-13
9
+
10
+ ### Changed
11
+
12
+ - Webhook captures are now aligned to the server clock rather than being timed from when the plugin starts.
13
+ - A 60-minute interval now captures on the hour.
14
+ - A 30-minute interval captures at `:00` and `:30`.
15
+ - A 15-minute interval captures at `:00`, `:15`, `:30` and `:45`.
16
+ - The next capture time is recalculated after every snapshot to avoid gradual timer drift.
17
+ - Updated configuration help text to explain clock-aligned scheduling.
18
+
19
+ ### Added
20
+
21
+ - Automated tests for clock-aligned capture scheduling, including hourly, half-hourly, quarter-hourly, five-minute and day-boundary behaviour.
22
+
8
23
  ## [2.0.0] - 2026-09-13
9
24
 
10
25
  ### Added
package/README.md CHANGED
@@ -6,6 +6,8 @@ Choose the Signal K data paths you want to send, assign simple field names, sele
6
6
 
7
7
  If the webhook is unavailable, data is stored locally in a persistent queue and automatically delivered in order when the connection returns.
8
8
 
9
+ Captures are aligned to the Signal K server's local clock. For example, a 60-minute interval captures on the hour, while a 15-minute interval captures at :00, :15, :30 and :45.
10
+
9
11
  ## Features
10
12
 
11
13
  - Send selected Signal K data to any HTTP webhook
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-webhook-bridge",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Send selected Signal K data paths to an external webhook with unit conversion, persistent queueing and automatic retry.",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -60,4 +60,4 @@
60
60
  "webpack": "^5.110.3",
61
61
  "webpack-cli": "^7.2.3"
62
62
  }
63
- }
63
+ }
package/plugin/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Signal K Webhook Bridge.
5
5
  *
6
- * Captures configured Signal K values at a selected interval,
6
+ * Captures configured Signal K values at clock-aligned intervals,
7
7
  * stores every snapshot in a persistent SQLite FIFO queue,
8
8
  * and delivers queued entries to the configured webhook in order.
9
9
  * Adds output-unit labels, retries backlog on startup and after each capture,
@@ -13,6 +13,7 @@
13
13
  const { getAvailablePaths } = require("./paths");
14
14
  const { convertValue, getOutputUnitLabel } = require("./units");
15
15
  const { sendWebhook } = require("./webhook");
16
+ const { getNextAlignedCaptureTime } = require("./schedule");
16
17
  const storage = require("./storage");
17
18
 
18
19
  module.exports = function (app) {
@@ -124,6 +125,36 @@ module.exports = function (app) {
124
125
  }
125
126
  }
126
127
 
128
+ /**
129
+ * Schedule one capture at the next clock boundary.
130
+ *
131
+ * A new timeout is calculated after every capture rather than using
132
+ * setInterval(), preventing gradual timing drift.
133
+ */
134
+ function scheduleNextCapture() {
135
+ if (stopped || !currentSettings) {
136
+ return;
137
+ }
138
+
139
+ const intervalMinutes = Number(currentSettings.sendFreq) || 10;
140
+
141
+ const nextCapture = getNextAlignedCaptureTime(intervalMinutes);
142
+
143
+ const delay = Math.max(0, nextCapture.getTime() - Date.now());
144
+
145
+ app.debug(
146
+ `Next webhook capture scheduled for ${nextCapture.toISOString()}`,
147
+ );
148
+
149
+ captureTimer = setTimeout(() => {
150
+ captureTimer = null;
151
+
152
+ captureSnapshot();
153
+
154
+ scheduleNextCapture();
155
+ }, delay);
156
+ }
157
+
127
158
  /**
128
159
  * Deliver queued entries in strict FIFO order.
129
160
  *
@@ -187,7 +218,6 @@ module.exports = function (app) {
187
218
  * This preserves FIFO order and prevents newer entries
188
219
  * overtaking an entry that has not been delivered.
189
220
  */
190
-
191
221
  lastError = error.message;
192
222
  deliveryState = "waiting";
193
223
 
@@ -213,17 +243,22 @@ module.exports = function (app) {
213
243
  }
214
244
  }
215
245
 
216
-
217
- // Complete deferred cleanup after any in-flight request has settled.
246
+ /*
247
+ * Complete deferred cleanup after any in-flight request
248
+ * has settled.
249
+ */
218
250
  if (stopped) {
219
251
  storage.close();
220
252
  }
221
253
  }
222
254
  }
223
255
 
224
- // Open storage, attempt backlog delivery and schedule the first capture after one interval.
256
+ /**
257
+ * Open storage, attempt backlog delivery and schedule the first
258
+ * clock-aligned capture.
259
+ */
225
260
  plugin.start = function (settings) {
226
- app.debug("Starting Signal K Webhook Bridge");
261
+ app.debug("Starting Webhook Bridge");
227
262
 
228
263
  currentSettings = settings || {};
229
264
  stopped = false;
@@ -248,34 +283,36 @@ module.exports = function (app) {
248
283
  * sendFreq is configured in minutes.
249
284
  */
250
285
  const sendFreq = Number(currentSettings.sendFreq) || 10;
251
- const intervalMilliseconds = sendFreq * 60 * 1000;
252
286
 
253
287
  app.debug(
254
288
  `Webhook capture interval set to ${sendFreq} minute${
255
289
  sendFreq === 1 ? "" : "s"
256
- }`,
290
+ }, aligned to the clock`,
257
291
  );
258
292
 
259
293
  /*
260
294
  * Immediately attempt to clear any backlog left from a previous
261
295
  * offline period or server restart.
262
296
  *
263
- * We do NOT capture a new snapshot immediately. New snapshots
264
- * follow the configured interval.
297
+ * We do NOT capture a new snapshot immediately. The first new
298
+ * snapshot is taken at the next clock-aligned boundary.
265
299
  */
266
300
  processQueue();
267
301
 
268
- captureTimer = setInterval(captureSnapshot, intervalMilliseconds);
302
+ scheduleNextCapture();
269
303
  };
270
304
 
271
- // Stop future captures; an in-flight delivery finishes before its worker closes storage.
305
+ /**
306
+ * Stop future captures; an in-flight delivery finishes before its
307
+ * worker closes storage.
308
+ */
272
309
  plugin.stop = function () {
273
- app.debug("Stopping Signal K Webhook Bridge");
310
+ app.debug("Stopping Webhook Bridge");
274
311
 
275
312
  stopped = true;
276
313
 
277
314
  if (captureTimer) {
278
- clearInterval(captureTimer);
315
+ clearTimeout(captureTimer);
279
316
  captureTimer = null;
280
317
  }
281
318
 
@@ -325,6 +362,7 @@ module.exports = function (app) {
325
362
  });
326
363
  }
327
364
  });
365
+
328
366
  router.get("/status", (req, res) => {
329
367
  try {
330
368
  const queueCount = storage.count();
@@ -371,8 +409,11 @@ module.exports = function (app) {
371
409
  };
372
410
 
373
411
  /**
374
- * Describe webhook settings and path mappings for the schema-based configuration form.
375
- * Path choices are discovered when requested; the custom panel filters units by metadata.
412
+ * Describe webhook settings and path mappings for the schema-based
413
+ * configuration form.
414
+ *
415
+ * Path choices are discovered when requested; the custom panel
416
+ * filters units by metadata.
376
417
  */
377
418
  plugin.schema = function () {
378
419
  const availablePaths = getAvailablePaths(app);
@@ -405,7 +446,7 @@ module.exports = function (app) {
405
446
  type: "number",
406
447
  title: "Send Interval",
407
448
  description:
408
- "How often data should be captured and sent, in minutes.",
449
+ "Capture interval in minutes, aligned to the server clock. For example, 60 sends on the hour and 15 sends at :00, :15, :30 and :45.",
409
450
  default: 10,
410
451
  minimum: 1,
411
452
  },
@@ -0,0 +1,21 @@
1
+ function getNextAlignedCaptureTime(intervalMinutes, now = new Date()) {
2
+ const interval = Number(intervalMinutes);
3
+
4
+ if (!Number.isFinite(interval) || interval <= 0) {
5
+ throw new Error("Capture interval must be greater than zero");
6
+ }
7
+
8
+ const startOfDay = new Date(now);
9
+ startOfDay.setHours(0, 0, 0, 0);
10
+
11
+ const intervalMs = interval * 60 * 1000;
12
+ const elapsedToday = now.getTime() - startOfDay.getTime();
13
+
14
+ const nextElapsed = Math.floor(elapsedToday / intervalMs + 1) * intervalMs;
15
+
16
+ return new Date(startOfDay.getTime() + nextElapsed);
17
+ }
18
+
19
+ module.exports = {
20
+ getNextAlignedCaptureTime,
21
+ };