@meri-imperiumi/signalk-triplogger 1.3.2 → 1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [1.4.0] - 2026-09-09
6
+
7
+ ### Added
8
+ - Option to publish the current trip log (`log_path`) and the total log
9
+ (`totals_path`) under configurable Signal K paths, for setups where the
10
+ standard `navigation.trip.log`/`navigation.log` paths are owned by another
11
+ provider. Non-standard paths are announced with `units: m` metadata
12
+
13
+ ### Fixed
14
+ - Don't overwrite the current trip log at startup: log preparation is now
15
+ serialized so distance appends and state handling always run against the
16
+ persisted log once it has finished loading from disk
17
+
3
18
  ## [1.3.2] - 2026-08-23
4
19
 
5
20
  ### Added
package/index.js CHANGED
@@ -27,37 +27,56 @@ module.exports = (app) => {
27
27
  return join(app.getDataDirPath(), `${logName}.json`);
28
28
  }
29
29
 
30
+ let logsPrepared = Promise.resolve();
31
+
30
32
  function prepareLogs() {
31
- const newLogs = getLogNames();
32
- return Promise.all(Object.keys(logs).map((logName) => {
33
- // Close old logs
34
- if (newLogs.indexOf(logName) === -1) {
35
- // If log is not in the new log list, end it
36
- const oldLog = logs[logName];
37
- delete logs[logName];
38
- return oldLog.endTrip();
39
- }
40
- return Promise.resolve();
41
- }))
42
- .then(() => Promise.all(newLogs.map((logName) => {
43
- // Load new logs
44
- if (!logs[logName]) {
45
- // New log, or saved log?
46
- logs[logName] = new Logger(getLogPath(logName));
47
- return logs[logName].exists()
48
- .then((exists) => {
49
- if (!exists) {
50
- // New log, no need to load
51
- return Promise.resolve();
52
- }
53
- return logs[logName].load();
54
- });
33
+ // Serialize preparations: a Logger instance is created synchronously,
34
+ // but only loaded from disk asynchronously. Without serialization a
35
+ // second call (for example, a position delta right after a state
36
+ // delta at startup) would see the instance already in `logs` and
37
+ // proceed before `load()` has finished, appending to and saving a
38
+ // fresh zeroed log over the persisted one.
39
+ const preparation = logsPrepared.then(() => {
40
+ const newLogs = getLogNames();
41
+ return Promise.all(Object.keys(logs).map((logName) => {
42
+ // Close old logs
43
+ if (newLogs.indexOf(logName) === -1) {
44
+ // If log is not in the new log list, end it
45
+ const oldLog = logs[logName];
46
+ delete logs[logName];
47
+ return oldLog.endTrip();
55
48
  }
56
49
  return Promise.resolve();
57
- })));
50
+ }))
51
+ .then(() => Promise.all(newLogs.map((logName) => {
52
+ // Load new logs
53
+ if (!logs[logName]) {
54
+ // New log, or saved log?
55
+ const log = new Logger(getLogPath(logName));
56
+ logs[logName] = log;
57
+ return log.exists()
58
+ .then((exists) => {
59
+ if (!exists) {
60
+ // New log, no need to load
61
+ return Promise.resolve();
62
+ }
63
+ return log.load();
64
+ });
65
+ }
66
+ return Promise.resolve();
67
+ })));
68
+ });
69
+ // Keep the chain usable even if a single preparation fails
70
+ logsPrepared = preparation.catch(() => {});
71
+ return preparation;
58
72
  }
59
73
 
60
74
  plugin.start = (options) => {
75
+ // Paths the logs are published under. Configure these when another
76
+ // provider owns the standard paths, for example when a club's total
77
+ // log is maintained elsewhere for their maintenance schedule.
78
+ const tripLogPath = options.log_path || 'navigation.trip.log';
79
+ const totalLogPath = options.totals_path || 'navigation.log';
61
80
  const subscription = {
62
81
  context: 'vessels.self',
63
82
  subscribe: [
@@ -77,7 +96,7 @@ module.exports = (app) => {
77
96
  const resetTime = logs.current.log.started;
78
97
  const values = [
79
98
  {
80
- path: 'navigation.trip.log',
99
+ path: tripLogPath,
81
100
  value: logs.current.log.total,
82
101
  },
83
102
  {
@@ -88,7 +107,7 @@ module.exports = (app) => {
88
107
  if (options.totals) {
89
108
  const base = options.totals_base || 0;
90
109
  values.push({
91
- path: 'navigation.log',
110
+ path: totalLogPath,
92
111
  value: logs.total.log.total + base,
93
112
  });
94
113
  }
@@ -118,14 +137,14 @@ module.exports = (app) => {
118
137
  .then(() => {
119
138
  const values = [
120
139
  {
121
- path: 'navigation.trip.log',
140
+ path: tripLogPath,
122
141
  value: logs.current.log.total,
123
142
  },
124
143
  ];
125
144
  if (options.totals) {
126
145
  const base = options.totals_base || 0;
127
146
  values.push({
128
- path: 'navigation.log',
147
+ path: totalLogPath,
129
148
  value: logs.total.log.total + base,
130
149
  });
131
150
  }
@@ -141,6 +160,9 @@ module.exports = (app) => {
141
160
  },
142
161
  ],
143
162
  });
163
+ })
164
+ .catch((err) => {
165
+ app.error(`Error:${err}`);
144
166
  });
145
167
  }
146
168
 
@@ -158,9 +180,53 @@ module.exports = (app) => {
158
180
  resetTrip();
159
181
  setStatus('New trip has started. Log reset');
160
182
  }
183
+ })
184
+ .catch((err) => {
185
+ app.error(`Error:${err}`);
161
186
  });
162
187
  }
163
188
 
189
+ function sendMeta() {
190
+ // Non-standard paths are not in the Signal K schema, so consumers
191
+ // cannot know their units. Tell the server they are meters.
192
+ const meta = [];
193
+ if (tripLogPath !== 'navigation.trip.log') {
194
+ meta.push({
195
+ path: tripLogPath,
196
+ value: {
197
+ units: 'm',
198
+ displayName: 'Current trip log',
199
+ description: 'Distance travelled during the current trip',
200
+ },
201
+ });
202
+ }
203
+ if (options.totals && totalLogPath !== 'navigation.log') {
204
+ meta.push({
205
+ path: totalLogPath,
206
+ value: {
207
+ units: 'm',
208
+ displayName: 'Total log',
209
+ description: 'Total distance travelled',
210
+ },
211
+ });
212
+ }
213
+ if (meta.length === 0) {
214
+ return;
215
+ }
216
+ app.handleMessage(plugin.id, {
217
+ context: `vessels.${app.selfId}`,
218
+ updates: [
219
+ {
220
+ source: {
221
+ label: plugin.id,
222
+ },
223
+ timestamp: (new Date().toISOString()),
224
+ meta,
225
+ },
226
+ ],
227
+ });
228
+ }
229
+
164
230
  app.subscriptionmanager.subscribe(
165
231
  subscription,
166
232
  unsubscribes,
@@ -204,6 +270,15 @@ module.exports = (app) => {
204
270
  },
205
271
  );
206
272
 
273
+ // Load persisted logs eagerly so that a trip ongoing from a previous
274
+ // session is continued when deltas arrive, instead of being reset
275
+ prepareLogs().catch((err) => {
276
+ app.error(`Error:${err}`);
277
+ });
278
+
279
+ // Declare units for any non-standard paths we publish to
280
+ sendMeta();
281
+
207
282
  setStatus('Waiting for updates');
208
283
  };
209
284
 
@@ -220,10 +295,20 @@ module.exports = (app) => {
220
295
  default: 10000,
221
296
  title: 'How often to update log, in milliseconds',
222
297
  },
298
+ log_path: {
299
+ type: 'string',
300
+ default: 'navigation.trip.log',
301
+ title: 'Signal K path to publish the current trip log to',
302
+ },
223
303
  totals: {
224
304
  type: 'boolean',
225
305
  default: true,
226
- title: 'Publish a total number in navigation.log',
306
+ title: 'Publish a total number in the total log path',
307
+ },
308
+ totals_path: {
309
+ type: 'string',
310
+ default: 'navigation.log',
311
+ title: 'Signal K path to publish the total log to',
227
312
  },
228
313
  totals_base: {
229
314
  type: 'number',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-triplogger",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "description": "Log the length of the current trip",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -260,6 +260,217 @@ test('totals option also publishes navigation.log', async () => {
260
260
  assert.ok(value > 100, `navigation.log value was ${value}`);
261
261
  });
262
262
 
263
+ test('startup with an ongoing trip continues it instead of resetting to zero', async () => {
264
+ const app = new FakeSignalKApp();
265
+ // An ongoing trip from a previous session: started, never ended.
266
+ seedLog(app, 'current', {
267
+ started: '2026-01-01T00:00:00.000Z',
268
+ ended: null,
269
+ total: 5000,
270
+ states: { sailing: 5000 },
271
+ });
272
+ const plugin = makePlugin(app);
273
+ plugin.start({ totals: false });
274
+
275
+ // Emit state and positions back-to-back, in the same tick, so they
276
+ // race against the async log loading from disk. This used to overwrite
277
+ // current.json with a fresh zeroed log before the saved one was loaded.
278
+ emitDelta(app, {
279
+ context: 'vessels.self',
280
+ updates: [{ values: [{ path: 'navigation.state', value: 'sailing' }] }],
281
+ });
282
+ emitDelta(app, {
283
+ context: 'vessels.self',
284
+ updates: [
285
+ { values: [{ path: 'navigation.position', value: { latitude: 60.0, longitude: 24.0 } }] },
286
+ ],
287
+ });
288
+ emitDelta(app, {
289
+ context: 'vessels.self',
290
+ updates: [
291
+ { values: [{ path: 'navigation.position', value: { latitude: 60.001, longitude: 24.0 } }] },
292
+ ],
293
+ });
294
+ await waitFor(() => app.messages
295
+ .flatMap(({ message }) => message.updates.flatMap((u) => u.values))
296
+ .some((v) => v.path === 'navigation.trip.log'));
297
+ plugin.stop();
298
+
299
+ // The persisted trip must survive startup: no reset to zero, and the
300
+ // newly logged distance accumulates on top of the existing total.
301
+ const current = readLog(app, 'current');
302
+ assert.ok(
303
+ current.total >= 5000,
304
+ `current.total was ${current.total}, expected the ongoing trip to continue`,
305
+ );
306
+ assert.ok(
307
+ !app.statusMessages.some((m) => /new trip/i.test(m.msg)),
308
+ `unexpected new-trip reset at startup: ${JSON.stringify(app.statusMessages)}`,
309
+ );
310
+
311
+ // The published trip log should also continue from the persisted total.
312
+ const tripValues = app.messages
313
+ .flatMap(({ message }) => message.updates.flatMap((u) => u.values))
314
+ .filter((v) => v.path === 'navigation.trip.log');
315
+ assert.ok(tripValues.length > 0, 'no navigation.trip.log delta was emitted');
316
+ assert.ok(
317
+ tripValues[tripValues.length - 1].value >= 5000,
318
+ `last trip.log value was ${tripValues[tripValues.length - 1].value}`,
319
+ );
320
+ });
321
+
322
+ /**
323
+ * Flatten all value objects the app has received from the plugin.
324
+ * Meta-only messages have no `values`, so those updates are skipped.
325
+ *
326
+ * @param {FakeSignalKApp} app
327
+ * @returns {Array<{path: string, value: number}>}
328
+ */
329
+ function emittedValues(app) {
330
+ return app.messages.flatMap(
331
+ ({ message }) => message.updates.filter((u) => u.values).flatMap((u) => u.values),
332
+ );
333
+ }
334
+
335
+ /**
336
+ * Flatten all meta entries the app has received from the plugin.
337
+ *
338
+ * @param {FakeSignalKApp} app
339
+ * @returns {Array<{path: string, value: object}>}
340
+ */
341
+ function emittedMeta(app) {
342
+ return app.messages.flatMap(
343
+ ({ message }) => message.updates.filter((u) => u.meta).flatMap((u) => u.meta),
344
+ );
345
+ }
346
+
347
+ test('custom log_path publishes the trip log under the configured path', async () => {
348
+ const app = new FakeSignalKApp();
349
+ seedLog(app, 'current', {
350
+ started: '2026-01-01T00:00:00.000Z',
351
+ ended: '2026-01-01T01:00:00.000Z',
352
+ total: 0,
353
+ states: {},
354
+ });
355
+ const plugin = makePlugin(app);
356
+ plugin.start({
357
+ log_path: 'navigation.trip.currentTripLog',
358
+ totals: false,
359
+ });
360
+ emitDelta(app, {
361
+ context: 'vessels.self',
362
+ updates: [{ values: [{ path: 'navigation.state', value: 'sailing' }] }],
363
+ });
364
+ await waitFor(
365
+ () => app.statusMessages.some((m) => /new trip/i.test(m.msg)),
366
+ );
367
+ emitDelta(app, {
368
+ context: 'vessels.self',
369
+ updates: [
370
+ { values: [{ path: 'navigation.position', value: { latitude: 60.0, longitude: 24.0 } }] },
371
+ ],
372
+ });
373
+ emitDelta(app, {
374
+ context: 'vessels.self',
375
+ updates: [
376
+ { values: [{ path: 'navigation.position', value: { latitude: 60.001, longitude: 24.0 } }] },
377
+ ],
378
+ });
379
+ await waitFor(
380
+ () => emittedValues(app).some(
381
+ (v) => v.path === 'navigation.trip.currentTripLog' && v.value > 0,
382
+ ),
383
+ );
384
+ plugin.stop();
385
+
386
+ // The trip log is published under the configured custom path...
387
+ assert.ok(
388
+ emittedValues(app).some((v) => v.path === 'navigation.trip.currentTripLog'),
389
+ 'no delta was emitted to navigation.trip.currentTripLog',
390
+ );
391
+ // ...and not under the standard path
392
+ assert.ok(
393
+ !emittedValues(app).some((v) => v.path === 'navigation.trip.log'),
394
+ 'navigation.trip.log should not be written when log_path is set',
395
+ );
396
+ // The non-standard path is announced with units metadata
397
+ const tripMeta = emittedMeta(app).find((m) => m.path === 'navigation.trip.currentTripLog');
398
+ assert.ok(tripMeta, 'no meta was sent for the custom trip log path');
399
+ assert.strictEqual(tripMeta.value.units, 'm');
400
+ });
401
+
402
+ test('custom totals_path publishes the total under the configured path', async () => {
403
+ const app = new FakeSignalKApp();
404
+ seedLog(app, 'current', {
405
+ started: '2026-01-01T00:00:00.000Z',
406
+ ended: '2026-01-01T01:00:00.000Z',
407
+ total: 0,
408
+ states: {},
409
+ });
410
+ const plugin = makePlugin(app);
411
+ plugin.start({
412
+ totals: true,
413
+ totals_base: 100,
414
+ totals_path: 'navigation.log.clubTotal',
415
+ });
416
+ emitDelta(app, {
417
+ context: 'vessels.self',
418
+ updates: [{ values: [{ path: 'navigation.state', value: 'motoring' }] }],
419
+ });
420
+ await waitFor(
421
+ () => app.statusMessages.some((m) => /new trip/i.test(m.msg)),
422
+ );
423
+ emitDelta(app, {
424
+ context: 'vessels.self',
425
+ updates: [
426
+ { values: [{ path: 'navigation.position', value: { latitude: 60.0, longitude: 24.0 } }] },
427
+ ],
428
+ });
429
+ emitDelta(app, {
430
+ context: 'vessels.self',
431
+ updates: [
432
+ { values: [{ path: 'navigation.position', value: { latitude: 60.002, longitude: 24.0 } }] },
433
+ ],
434
+ });
435
+ await waitFor(
436
+ () => emittedValues(app).some(
437
+ (v) => v.path === 'navigation.log.clubTotal' && v.value > 100,
438
+ ),
439
+ );
440
+ plugin.stop();
441
+
442
+ // The total is published under the configured custom path...
443
+ assert.ok(
444
+ emittedValues(app).some((v) => v.path === 'navigation.log.clubTotal'),
445
+ 'no delta was emitted to navigation.log.clubTotal',
446
+ );
447
+ // ...and not under the standard path
448
+ assert.ok(
449
+ !emittedValues(app).some((v) => v.path === 'navigation.log'),
450
+ 'navigation.log should not be written when totals_path is set',
451
+ );
452
+ // The trip log keeps its standard path unless configured otherwise
453
+ assert.ok(
454
+ emittedValues(app).some((v) => v.path === 'navigation.trip.log'),
455
+ 'navigation.trip.log should still be emitted with only totals_path set',
456
+ );
457
+ // The non-standard path is announced with units metadata
458
+ const totalMeta = emittedMeta(app).find((m) => m.path === 'navigation.log.clubTotal');
459
+ assert.ok(totalMeta, 'no meta was sent for the custom totals path');
460
+ assert.strictEqual(totalMeta.value.units, 'm');
461
+ // No meta is needed for the standard trip path
462
+ assert.ok(
463
+ !emittedMeta(app).some((m) => m.path === 'navigation.trip.log'),
464
+ 'standard paths should not get meta',
465
+ );
466
+ });
467
+
468
+ test('standard paths send no meta messages', () => {
469
+ const { app, plugin } = makeStarted();
470
+ assert.strictEqual(emittedMeta(app).length, 0);
471
+ plugin.stop();
472
+ });
473
+
263
474
  test('stop clears subscriptions', () => {
264
475
  const { app, plugin } = makeStarted();
265
476
  assert.strictEqual(app.subscriptionmanager.subscriptions.length, 1);