@meri-imperiumi/signalk-triplogger 1.3.2 → 1.4.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
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [1.4.1] - 2026-09-10
6
+
7
+ ### Fixed
8
+ - Fix server crash when a log file is corrupt, empty, or not a JSON
9
+ object: parse errors are now caught inside the read callback instead of
10
+ escaping as an uncaught exception that killed signalk-server. The bad
11
+ file is backed up as `<name>.json.corrupt` and a fresh log is started
12
+ - Persist logs atomically (temp file + fsync + rename) so a crash or
13
+ power loss mid-write can no longer truncate log files
14
+
15
+ ## [1.4.0] - 2026-09-09
16
+
17
+ ### Added
18
+ - Option to publish the current trip log (`log_path`) and the total log
19
+ (`totals_path`) under configurable Signal K paths, for setups where the
20
+ standard `navigation.trip.log`/`navigation.log` paths are owned by another
21
+ provider. Non-standard paths are announced with `units: m` metadata
22
+
23
+ ### Fixed
24
+ - Don't overwrite the current trip log at startup: log preparation is now
25
+ serialized so distance appends and state handling always run against the
26
+ persisted log once it has finished loading from disk
27
+
3
28
  ## [1.3.2] - 2026-08-23
4
29
 
5
30
  ### Added
package/Logger.js CHANGED
@@ -1,15 +1,24 @@
1
1
  const fs = require('fs');
2
+ const { promisify } = require('util');
3
+
4
+ const fsWriteFile = promisify(fs.writeFile);
5
+ const fsOpen = promisify(fs.open);
6
+ const fsFsync = promisify(fs.fsync);
7
+ const fsClose = promisify(fs.close);
8
+ const fsRename = promisify(fs.rename);
2
9
 
3
10
  function toNM(meters) {
4
11
  return (meters / 1852).toFixed(2);
5
12
  }
6
13
 
7
14
  class Logger {
8
- constructor(filename) {
15
+ constructor(filename, onError) {
9
16
  this.state = null;
10
17
  this.filename = filename;
18
+ this.onError = onError;
11
19
  this.reset();
12
20
  this.unsaved = false;
21
+ this.saves = Promise.resolve();
13
22
  }
14
23
 
15
24
  reset() {
@@ -70,18 +79,36 @@ class Logger {
70
79
  }
71
80
 
72
81
  save() {
82
+ // Serialize writes: concurrent saves writing to the same temp file
83
+ // could interleave, and an older snapshot could be renamed over a
84
+ // newer one. Each save runs only after the previous one settles.
85
+ const previous = this.saves;
86
+ this.saves = previous.then(
87
+ () => this.write(),
88
+ () => this.write(),
89
+ );
90
+ return this.saves;
91
+ }
92
+
93
+ async write() {
73
94
  if (!this.unsaved) {
74
- return Promise.resolve();
95
+ return;
75
96
  }
76
- return new Promise((resolve, reject) => {
77
- fs.writeFile(this.filename, JSON.stringify(this.log, null, 2), (err) => {
78
- if (err) {
79
- reject(err);
80
- return;
81
- }
82
- resolve();
83
- });
84
- });
97
+ const tmpFile = `${this.filename}.tmp`;
98
+ await fsWriteFile(tmpFile, JSON.stringify(this.log, null, 2));
99
+ // Flush the temp file to disk before renaming: without fsync, a
100
+ // power loss can persist the rename while the file's contents never
101
+ // made it to disk, recreating a truncated log
102
+ const fd = await fsOpen(tmpFile, 'r+');
103
+ try {
104
+ await fsFsync(fd);
105
+ } finally {
106
+ await fsClose(fd);
107
+ }
108
+ // Rename over an existing file is atomic: readers see either the
109
+ // old log or the new one, never a partially written one
110
+ await fsRename(tmpFile, this.filename);
111
+ this.unsaved = false;
85
112
  }
86
113
 
87
114
  exists() {
@@ -103,9 +130,29 @@ class Logger {
103
130
  reject(err);
104
131
  return;
105
132
  }
106
- this.log = JSON.parse(contents);
107
- this.unsaved = false;
108
- resolve();
133
+ try {
134
+ const parsed = JSON.parse(contents);
135
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
136
+ throw new Error('Not a JSON object');
137
+ }
138
+ this.log = parsed;
139
+ this.unsaved = false;
140
+ resolve();
141
+ } catch (parseError) {
142
+ // Corrupt (for example truncated by a crash mid-write) or empty
143
+ // log file. Back it up for manual recovery and start a fresh log
144
+ // instead of failing the whole plugin.
145
+ const backup = `${this.filename}.corrupt`;
146
+ fs.rename(this.filename, backup, () => {
147
+ this.reset();
148
+ if (this.onError) {
149
+ this.onError(
150
+ `Corrupt log file ${this.filename} (${parseError.message}). Backed up to ${backup}, starting a fresh log`,
151
+ );
152
+ }
153
+ resolve();
154
+ });
155
+ }
109
156
  });
110
157
  });
111
158
  }
package/index.js CHANGED
@@ -27,37 +27,59 @@ 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(
56
+ getLogPath(logName),
57
+ (message) => app.error(message),
58
+ );
59
+ logs[logName] = log;
60
+ return log.exists()
61
+ .then((exists) => {
62
+ if (!exists) {
63
+ // New log, no need to load
64
+ return Promise.resolve();
65
+ }
66
+ return log.load();
67
+ });
68
+ }
69
+ return Promise.resolve();
70
+ })));
71
+ });
72
+ // Keep the chain usable even if a single preparation fails
73
+ logsPrepared = preparation.catch(() => {});
74
+ return preparation;
58
75
  }
59
76
 
60
77
  plugin.start = (options) => {
78
+ // Paths the logs are published under. Configure these when another
79
+ // provider owns the standard paths, for example when a club's total
80
+ // log is maintained elsewhere for their maintenance schedule.
81
+ const tripLogPath = options.log_path || 'navigation.trip.log';
82
+ const totalLogPath = options.totals_path || 'navigation.log';
61
83
  const subscription = {
62
84
  context: 'vessels.self',
63
85
  subscribe: [
@@ -77,7 +99,7 @@ module.exports = (app) => {
77
99
  const resetTime = logs.current.log.started;
78
100
  const values = [
79
101
  {
80
- path: 'navigation.trip.log',
102
+ path: tripLogPath,
81
103
  value: logs.current.log.total,
82
104
  },
83
105
  {
@@ -88,7 +110,7 @@ module.exports = (app) => {
88
110
  if (options.totals) {
89
111
  const base = options.totals_base || 0;
90
112
  values.push({
91
- path: 'navigation.log',
113
+ path: totalLogPath,
92
114
  value: logs.total.log.total + base,
93
115
  });
94
116
  }
@@ -118,14 +140,14 @@ module.exports = (app) => {
118
140
  .then(() => {
119
141
  const values = [
120
142
  {
121
- path: 'navigation.trip.log',
143
+ path: tripLogPath,
122
144
  value: logs.current.log.total,
123
145
  },
124
146
  ];
125
147
  if (options.totals) {
126
148
  const base = options.totals_base || 0;
127
149
  values.push({
128
- path: 'navigation.log',
150
+ path: totalLogPath,
129
151
  value: logs.total.log.total + base,
130
152
  });
131
153
  }
@@ -141,6 +163,9 @@ module.exports = (app) => {
141
163
  },
142
164
  ],
143
165
  });
166
+ })
167
+ .catch((err) => {
168
+ app.error(`Error:${err}`);
144
169
  });
145
170
  }
146
171
 
@@ -158,9 +183,53 @@ module.exports = (app) => {
158
183
  resetTrip();
159
184
  setStatus('New trip has started. Log reset');
160
185
  }
186
+ })
187
+ .catch((err) => {
188
+ app.error(`Error:${err}`);
161
189
  });
162
190
  }
163
191
 
192
+ function sendMeta() {
193
+ // Non-standard paths are not in the Signal K schema, so consumers
194
+ // cannot know their units. Tell the server they are meters.
195
+ const meta = [];
196
+ if (tripLogPath !== 'navigation.trip.log') {
197
+ meta.push({
198
+ path: tripLogPath,
199
+ value: {
200
+ units: 'm',
201
+ displayName: 'Current trip log',
202
+ description: 'Distance travelled during the current trip',
203
+ },
204
+ });
205
+ }
206
+ if (options.totals && totalLogPath !== 'navigation.log') {
207
+ meta.push({
208
+ path: totalLogPath,
209
+ value: {
210
+ units: 'm',
211
+ displayName: 'Total log',
212
+ description: 'Total distance travelled',
213
+ },
214
+ });
215
+ }
216
+ if (meta.length === 0) {
217
+ return;
218
+ }
219
+ app.handleMessage(plugin.id, {
220
+ context: `vessels.${app.selfId}`,
221
+ updates: [
222
+ {
223
+ source: {
224
+ label: plugin.id,
225
+ },
226
+ timestamp: (new Date().toISOString()),
227
+ meta,
228
+ },
229
+ ],
230
+ });
231
+ }
232
+
164
233
  app.subscriptionmanager.subscribe(
165
234
  subscription,
166
235
  unsubscribes,
@@ -204,6 +273,15 @@ module.exports = (app) => {
204
273
  },
205
274
  );
206
275
 
276
+ // Load persisted logs eagerly so that a trip ongoing from a previous
277
+ // session is continued when deltas arrive, instead of being reset
278
+ prepareLogs().catch((err) => {
279
+ app.error(`Error:${err}`);
280
+ });
281
+
282
+ // Declare units for any non-standard paths we publish to
283
+ sendMeta();
284
+
207
285
  setStatus('Waiting for updates');
208
286
  };
209
287
 
@@ -220,10 +298,20 @@ module.exports = (app) => {
220
298
  default: 10000,
221
299
  title: 'How often to update log, in milliseconds',
222
300
  },
301
+ log_path: {
302
+ type: 'string',
303
+ default: 'navigation.trip.log',
304
+ title: 'Signal K path to publish the current trip log to',
305
+ },
223
306
  totals: {
224
307
  type: 'boolean',
225
308
  default: true,
226
- title: 'Publish a total number in navigation.log',
309
+ title: 'Publish a total number in the total log path',
310
+ },
311
+ totals_path: {
312
+ type: 'string',
313
+ default: 'navigation.log',
314
+ title: 'Signal K path to publish the total log to',
227
315
  },
228
316
  totals_base: {
229
317
  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.1",
4
4
  "description": "Log the length of the current trip",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Smoke tests for the Logger: persistence, atomic saves, and recovery
3
+ * from corrupt or empty log files.
4
+ * @file logger.test.js
5
+ */
6
+
7
+ const test = require('node:test');
8
+ const assert = require('node:assert/strict');
9
+ const {
10
+ mkdtempSync,
11
+ readFileSync,
12
+ writeFileSync,
13
+ existsSync,
14
+ } = require('node:fs');
15
+ const { tmpdir } = require('node:os');
16
+ const { join } = require('node:path');
17
+
18
+ const Logger = require('../Logger');
19
+
20
+ /**
21
+ * Build a Logger with a fresh temp directory, a log filename inside it,
22
+ * and an error collector for the onError callback.
23
+ *
24
+ * @returns {{dir: string, filename: string, logger: Logger, errors: string[]}}
25
+ */
26
+ function makeLogger() {
27
+ const dir = mkdtempSync(join(tmpdir(), 'triplogger-logger-'));
28
+ const filename = join(dir, '2026-09-11.json');
29
+ const errors = [];
30
+ const logger = new Logger(filename, (message) => errors.push(message));
31
+ return {
32
+ dir,
33
+ filename,
34
+ logger,
35
+ errors,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Read a file in the logger's directory as JSON.
41
+ *
42
+ * @param {string} dir
43
+ * @param {string} name file name without directory
44
+ * @returns {object}
45
+ */
46
+ function readJson(dir, name) {
47
+ return JSON.parse(readFileSync(join(dir, name), 'utf-8'));
48
+ }
49
+
50
+ test('save persists the log as valid JSON and leaves no temp files', async () => {
51
+ const { dir, filename, logger } = makeLogger();
52
+ logger.setState('sailing');
53
+ logger.appendTrip(1000);
54
+ await logger.save();
55
+
56
+ const persisted = readJson(dir, '2026-09-11.json');
57
+ assert.strictEqual(persisted.total, 1000);
58
+ assert.strictEqual(persisted.states.sailing, 1000);
59
+ assert.ok(!existsSync(`${filename}.tmp`), 'temp file was left behind');
60
+ });
61
+
62
+ test('load restores a persisted log', async () => {
63
+ const { filename } = makeLogger();
64
+ const first = new Logger(filename);
65
+ first.setState('motoring');
66
+ first.appendTrip(500);
67
+ await first.save();
68
+
69
+ const errors = [];
70
+ const second = new Logger(filename, (m) => errors.push(m));
71
+ await second.load();
72
+ assert.strictEqual(second.toJSON().total, 500);
73
+ assert.strictEqual(second.toJSON().states.motoring, 500);
74
+ assert.strictEqual(errors.length, 0);
75
+ });
76
+
77
+ test('overlapping saves persist the newest state without corrupting the file', async () => {
78
+ const { dir, filename, logger } = makeLogger();
79
+ logger.appendTrip(1);
80
+ const first = logger.save();
81
+ // Mutate while the first save is still in flight
82
+ logger.appendTrip(2);
83
+ const second = logger.save();
84
+ await Promise.all([first, second]);
85
+
86
+ assert.strictEqual(readJson(dir, '2026-09-11.json').total, 3);
87
+ assert.ok(!existsSync(`${filename}.tmp`), 'temp file was left behind');
88
+ });
89
+
90
+ test('load recovers from a truncated log file by backing it up', async () => {
91
+ const {
92
+ dir,
93
+ filename,
94
+ logger,
95
+ errors,
96
+ } = makeLogger();
97
+ const corrupt = '{"started": "2026-09-11T00:00:00.000Z", "total": 50';
98
+ writeFileSync(filename, corrupt, 'utf-8');
99
+
100
+ await logger.load();
101
+
102
+ // The corrupt contents are preserved for manual recovery
103
+ assert.ok(existsSync(`${filename}.corrupt`), 'no .corrupt backup was made');
104
+ assert.strictEqual(readFileSync(`${filename}.corrupt`, 'utf-8'), corrupt);
105
+ // The corruption was reported
106
+ assert.ok(errors.length > 0, 'corruption was not reported via onError');
107
+ assert.match(errors[0], /corrupt/i);
108
+ // The logger works from a fresh log afterwards
109
+ assert.strictEqual(logger.toJSON().total, 0);
110
+ logger.appendTrip(100);
111
+ await logger.save();
112
+ assert.strictEqual(readJson(dir, '2026-09-11.json').total, 100);
113
+ });
114
+
115
+ test('load recovers from an empty log file', async () => {
116
+ const {
117
+ dir,
118
+ filename,
119
+ logger,
120
+ errors,
121
+ } = makeLogger();
122
+ writeFileSync(filename, '', 'utf-8');
123
+
124
+ await logger.load();
125
+
126
+ assert.ok(existsSync(`${filename}.corrupt`));
127
+ assert.ok(errors.length > 0, 'corruption was not reported via onError');
128
+ logger.appendTrip(100);
129
+ await logger.save();
130
+ assert.strictEqual(readJson(dir, '2026-09-11.json').total, 100);
131
+ });
132
+
133
+ test('load recovers from a log file containing a JSON scalar', async () => {
134
+ const { filename, logger, errors } = makeLogger();
135
+ writeFileSync(filename, 'null', 'utf-8');
136
+
137
+ await logger.load();
138
+
139
+ assert.ok(existsSync(`${filename}.corrupt`));
140
+ assert.ok(errors.length > 0, 'corruption was not reported via onError');
141
+ assert.strictEqual(logger.toJSON().total, 0);
142
+ });
143
+
144
+ test('load still rejects on read errors', async () => {
145
+ const { filename } = makeLogger();
146
+ const logger = new Logger(filename);
147
+ await assert.rejects(() => logger.load(), /ENOENT/);
148
+ });
@@ -260,6 +260,263 @@ 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
+
474
+ test('a corrupt persisted log is backed up and logging continues', async () => {
475
+ const app = new FakeSignalKApp();
476
+ // A log truncated by a crash mid-write. With the old code this threw
477
+ // an uncaught exception from the readFile callback, killing the whole
478
+ // server process on every startup.
479
+ const corrupt = '{"started": "2026-09-11T00:00:00.000Z", "total": 5000';
480
+ writeFileSync(join(app.dataPath, 'current.json'), corrupt, 'utf-8');
481
+ const plugin = makePlugin(app);
482
+ plugin.start({ totals: false });
483
+
484
+ emitDelta(app, {
485
+ context: 'vessels.self',
486
+ updates: [{ values: [{ path: 'navigation.state', value: 'sailing' }] }],
487
+ });
488
+ emitDelta(app, {
489
+ context: 'vessels.self',
490
+ updates: [
491
+ { values: [{ path: 'navigation.position', value: { latitude: 60.0, longitude: 24.0 } }] },
492
+ ],
493
+ });
494
+ emitDelta(app, {
495
+ context: 'vessels.self',
496
+ updates: [
497
+ { values: [{ path: 'navigation.position', value: { latitude: 60.001, longitude: 24.0 } }] },
498
+ ],
499
+ });
500
+ await waitFor(() => app.messages
501
+ .flatMap(({ message }) => message.updates.flatMap((u) => u.values))
502
+ .some((v) => v.path === 'navigation.trip.log' && v.value > 0));
503
+ plugin.stop();
504
+
505
+ // The corrupt contents are preserved for manual recovery...
506
+ assert.strictEqual(
507
+ readFileSync(join(app.dataPath, 'current.json.corrupt'), 'utf-8'),
508
+ corrupt,
509
+ );
510
+ // ...and the corruption was reported
511
+ assert.ok(
512
+ app.errors.some((e) => /corrupt/i.test(e)),
513
+ `got ${JSON.stringify(app.errors)}`,
514
+ );
515
+ // Logging continues from a fresh log
516
+ const current = readLog(app, 'current');
517
+ assert.ok(current.total > 0, `current.total was ${current.total}`);
518
+ });
519
+
263
520
  test('stop clears subscriptions', () => {
264
521
  const { app, plugin } = makeStarted();
265
522
  assert.strictEqual(app.subscriptionmanager.subscriptions.length, 1);