@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 +15 -0
- package/index.js +115 -30
- package/package.json +1 -1
- package/tests/plugin.test.js +211 -0
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
package/tests/plugin.test.js
CHANGED
|
@@ -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);
|