@meri-imperiumi/signalk-triplogger 1.3.1 → 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/.eslintrc.json +3 -0
- package/.github/workflows/publish.yml +10 -6
- package/.github/workflows/test.yml +2 -10
- package/CHANGELOG.md +28 -0
- package/README.md +1 -1
- package/index.js +115 -30
- package/package.json +3 -3
- package/tests/fake-app.js +78 -0
- package/tests/plugin.test.js +479 -0
package/.eslintrc.json
CHANGED
|
@@ -5,14 +5,19 @@ on:
|
|
|
5
5
|
tags:
|
|
6
6
|
- "*"
|
|
7
7
|
|
|
8
|
+
permissions:
|
|
9
|
+
id-token: write # Required for OIDC
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
8
12
|
jobs:
|
|
9
13
|
build:
|
|
10
14
|
runs-on: ubuntu-latest
|
|
11
15
|
steps:
|
|
12
16
|
- uses: actions/checkout@v3.0.2
|
|
13
|
-
- uses: actions/setup-node@v3.
|
|
17
|
+
- uses: actions/setup-node@v3.1.1
|
|
14
18
|
with:
|
|
15
|
-
node-version:
|
|
19
|
+
node-version: 24
|
|
20
|
+
package-manager-cache: false # never use caching in release builds
|
|
16
21
|
- run: npm install
|
|
17
22
|
- run: npm test
|
|
18
23
|
|
|
@@ -21,10 +26,9 @@ jobs:
|
|
|
21
26
|
runs-on: ubuntu-latest
|
|
22
27
|
steps:
|
|
23
28
|
- uses: actions/checkout@v3.0.2
|
|
24
|
-
- uses: actions/setup-node@v3.
|
|
29
|
+
- uses: actions/setup-node@v3.1.1
|
|
25
30
|
with:
|
|
26
|
-
node-version:
|
|
31
|
+
node-version: 24
|
|
27
32
|
registry-url: https://registry.npmjs.org/
|
|
33
|
+
package-manager-cache: false # never use caching in release builds
|
|
28
34
|
- run: npm publish
|
|
29
|
-
env:
|
|
30
|
-
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
|
@@ -8,22 +8,14 @@ jobs:
|
|
|
8
8
|
runs-on: ubuntu-latest
|
|
9
9
|
strategy:
|
|
10
10
|
matrix:
|
|
11
|
-
node-version: [
|
|
11
|
+
node-version: [24.x]
|
|
12
12
|
steps:
|
|
13
13
|
- uses: actions/checkout@v3.0.2
|
|
14
14
|
- name: Use Node.js ${{ matrix.node-version }}
|
|
15
|
-
uses: actions/setup-node@v3.
|
|
15
|
+
uses: actions/setup-node@v3.1.1
|
|
16
16
|
with:
|
|
17
17
|
node-version: ${{ matrix.node-version }}
|
|
18
18
|
- run: npm install
|
|
19
19
|
- run: npm test
|
|
20
20
|
env:
|
|
21
21
|
CI: true
|
|
22
|
-
merge-me:
|
|
23
|
-
name: Auto-merge dependency updates
|
|
24
|
-
needs: test
|
|
25
|
-
runs-on: ubuntu-latest
|
|
26
|
-
steps:
|
|
27
|
-
- uses: ridedott/merge-me-action@v2.10.22
|
|
28
|
-
with:
|
|
29
|
-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,29 +1,57 @@
|
|
|
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
|
+
|
|
18
|
+
## [1.3.2] - 2026-08-23
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- Added smoke tests for the plugin entry point (start/stop, subscription,
|
|
22
|
+
state-change resets, position logging, totals)
|
|
23
|
+
|
|
3
24
|
## [1.3.1] - 2026-06-16
|
|
4
25
|
|
|
26
|
+
### Added
|
|
5
27
|
- Added application icon for SK appstore
|
|
6
28
|
|
|
7
29
|
## [1.3.0] - 2026-06-10
|
|
8
30
|
|
|
31
|
+
### Fixed
|
|
9
32
|
- Don't start a new trip at startup if there's an ongoing one
|
|
10
33
|
|
|
11
34
|
## [1.2.0] - 2023-04-27
|
|
12
35
|
|
|
36
|
+
### Added
|
|
13
37
|
- Added support for populating `navigation.log` for totals
|
|
14
38
|
|
|
15
39
|
## [1.1.1] - 2023-02-28
|
|
16
40
|
|
|
41
|
+
### Fixed
|
|
17
42
|
- Fix issue when `navigation.position` doesn't contain coordinates
|
|
18
43
|
|
|
19
44
|
## [1.1.0] - 2022-03-04
|
|
20
45
|
|
|
46
|
+
### Added
|
|
21
47
|
- Persist logs for current day/month/year, also by sailing/motoring
|
|
22
48
|
|
|
23
49
|
## [1.0.1] - 2022-02-18
|
|
24
50
|
|
|
51
|
+
### Fixed
|
|
25
52
|
- Fix issue with reading lat and lon
|
|
26
53
|
|
|
27
54
|
## [1.0.0] - 2022-02-18
|
|
28
55
|
|
|
56
|
+
### Added
|
|
29
57
|
- Initial version, logging in-memory only
|
package/README.md
CHANGED
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
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meri-imperiumi/signalk-triplogger",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Log the length of the current trip",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"
|
|
8
|
-
"
|
|
7
|
+
"test": "node --test --test-force-exit \"tests/*.test.js\" && eslint .",
|
|
8
|
+
"lint": "eslint ."
|
|
9
9
|
},
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared test fakes: a mock Signal K app matching the patterns used by the
|
|
3
|
+
* signalk-aprsfi-ais-reporter test suite, adapted for the triplogger's
|
|
4
|
+
* needs (subscriptionmanager + handleMessage + setPluginStatus + data dir).
|
|
5
|
+
*
|
|
6
|
+
* @file fake-app.js
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/* eslint-disable class-methods-use-this */
|
|
10
|
+
|
|
11
|
+
const { join } = require('node:path');
|
|
12
|
+
const { mkdtempSync } = require('node:fs');
|
|
13
|
+
const { tmpdir } = require('node:os');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Minimal Signal K app fake with subscriptionmanager + handleMessage +
|
|
17
|
+
* setPluginStatus + setProviderStatus + getDataDirPath + error.
|
|
18
|
+
*
|
|
19
|
+
* The data path is a fresh temp directory per instance so the Logger can
|
|
20
|
+
* write log files without touching the project tree.
|
|
21
|
+
*/
|
|
22
|
+
class FakeSignalKApp {
|
|
23
|
+
constructor() {
|
|
24
|
+
this.selfId = 'urn:mrn:imo:mmsi:230999999';
|
|
25
|
+
this.subscriptionmanager = {
|
|
26
|
+
subscriptions: [],
|
|
27
|
+
subscribe(subscription, unsubscribes, onError, onDelta) {
|
|
28
|
+
this.subscriptions.push({ subscription, onDelta });
|
|
29
|
+
unsubscribes.push(() => {
|
|
30
|
+
const idx = this.subscriptions.findIndex(
|
|
31
|
+
(s) => s.subscription === subscription,
|
|
32
|
+
);
|
|
33
|
+
if (idx >= 0) this.subscriptions.splice(idx, 1);
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
this.statusMessages = [];
|
|
38
|
+
this.errors = [];
|
|
39
|
+
this.messages = [];
|
|
40
|
+
this.dataPath = mkdtempSync(join(tmpdir(), 'triplogger-test-'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
getDataDirPath() {
|
|
44
|
+
return this.dataPath;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setPluginStatus = (msg) => {
|
|
48
|
+
this.statusMessages.push({ type: 'status', msg });
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
setProviderStatus = (msg) => {
|
|
52
|
+
this.statusMessages.push({ type: 'status', msg });
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
handleMessage = (sourceId, message) => {
|
|
56
|
+
this.messages.push({ sourceId, message });
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
debug() {}
|
|
60
|
+
|
|
61
|
+
error(msg) {
|
|
62
|
+
this.errors.push(msg);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Emits a delta into the subscriptionmanager's registered handlers, for
|
|
68
|
+
* tests that want to feed navigation values.
|
|
69
|
+
*
|
|
70
|
+
* @param {FakeSignalKApp} app
|
|
71
|
+
* @param {object} delta
|
|
72
|
+
* @returns {void}
|
|
73
|
+
*/
|
|
74
|
+
function emitDelta(app, delta) {
|
|
75
|
+
app.subscriptionmanager.subscriptions.forEach(({ onDelta }) => onDelta(delta));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { FakeSignalKApp, emitDelta };
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke tests for the plugin entry point (plugin shape, start/stop,
|
|
3
|
+
* subscription, state-change resets, position logging, totals).
|
|
4
|
+
* @file plugin.test.js
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const test = require('node:test');
|
|
8
|
+
const assert = require('node:assert/strict');
|
|
9
|
+
const { readFileSync, writeFileSync } = require('node:fs');
|
|
10
|
+
const { join } = require('node:path');
|
|
11
|
+
|
|
12
|
+
const makePlugin = require('../index');
|
|
13
|
+
const { FakeSignalKApp, emitDelta } = require('./fake-app');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Poll until `predicate` returns truthy, retrying every ~10ms up to `timeout`
|
|
17
|
+
* milliseconds. Resolves true on success, false on timeout. Useful for
|
|
18
|
+
* awaiting async plugin side-effects (file I/O) without fixed sleeps.
|
|
19
|
+
*
|
|
20
|
+
* @param {() => boolean} predicate
|
|
21
|
+
* @param {number} [timeout]
|
|
22
|
+
* @returns {Promise<boolean>}
|
|
23
|
+
*/
|
|
24
|
+
function waitFor(predicate, timeout = 500) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const start = Date.now();
|
|
27
|
+
const tick = () => {
|
|
28
|
+
if (predicate()) {
|
|
29
|
+
resolve(true);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (Date.now() - start >= timeout) {
|
|
33
|
+
resolve(false);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
setTimeout(tick, 10);
|
|
37
|
+
};
|
|
38
|
+
tick();
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build a started plugin instance wired to a fresh fake app with the
|
|
44
|
+
* given settings (defaulted).
|
|
45
|
+
*
|
|
46
|
+
* @param {object} [overrides]
|
|
47
|
+
* @returns {{app: FakeSignalKApp, plugin: object}}
|
|
48
|
+
*/
|
|
49
|
+
function makeStarted(overrides) {
|
|
50
|
+
const app = new FakeSignalKApp();
|
|
51
|
+
const plugin = makePlugin(app);
|
|
52
|
+
plugin.start({
|
|
53
|
+
update_interval: 10000,
|
|
54
|
+
totals: true,
|
|
55
|
+
totals_base: 0,
|
|
56
|
+
...overrides,
|
|
57
|
+
});
|
|
58
|
+
return { app, plugin };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read a log file written by the Logger from the app's data dir.
|
|
63
|
+
*
|
|
64
|
+
* @param {FakeSignalKApp} app
|
|
65
|
+
* @param {string} name
|
|
66
|
+
* @returns {object}
|
|
67
|
+
*/
|
|
68
|
+
function readLog(app, name) {
|
|
69
|
+
return JSON.parse(readFileSync(join(app.dataPath, `${name}.json`), 'utf-8'));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pre-seed a log file in the app's data dir before start, so the Logger
|
|
74
|
+
* loads an existing state instead of starting fresh. Used to make the
|
|
75
|
+
* "new trip" transition deterministic without racing two state emits.
|
|
76
|
+
*
|
|
77
|
+
* @param {FakeSignalKApp} app
|
|
78
|
+
* @param {string} name
|
|
79
|
+
* @param {object} contents
|
|
80
|
+
* @returns {void}
|
|
81
|
+
*/
|
|
82
|
+
function seedLog(app, name, contents) {
|
|
83
|
+
writeFileSync(join(app.dataPath, `${name}.json`), JSON.stringify(contents, null, 2));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
test('creates a plugin object with the right id/name', () => {
|
|
87
|
+
const app = new FakeSignalKApp();
|
|
88
|
+
const plugin = makePlugin(app);
|
|
89
|
+
assert.strictEqual(plugin.id, 'signalk-triplogger');
|
|
90
|
+
assert.strictEqual(plugin.name, 'Trip logger');
|
|
91
|
+
assert.ok(plugin.description);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('schema is a JSON object with the expected options', () => {
|
|
95
|
+
const app = new FakeSignalKApp();
|
|
96
|
+
const plugin = makePlugin(app);
|
|
97
|
+
assert.strictEqual(plugin.schema.type, 'object');
|
|
98
|
+
assert.ok(plugin.schema.properties.update_interval);
|
|
99
|
+
assert.ok(plugin.schema.properties.totals);
|
|
100
|
+
assert.ok(plugin.schema.properties.totals_base);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('start subscribes to navigation.state and navigation.position', () => {
|
|
104
|
+
const { app, plugin } = makeStarted();
|
|
105
|
+
assert.strictEqual(app.subscriptionmanager.subscriptions.length, 1);
|
|
106
|
+
const sub = app.subscriptionmanager.subscriptions[0].subscription;
|
|
107
|
+
assert.strictEqual(sub.context, 'vessels.self');
|
|
108
|
+
const paths = sub.subscribe.map((s) => s.path);
|
|
109
|
+
assert.ok(paths.includes('navigation.state'));
|
|
110
|
+
assert.ok(paths.includes('navigation.position'));
|
|
111
|
+
plugin.stop();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('start sets an initial status message', () => {
|
|
115
|
+
const { app, plugin } = makeStarted();
|
|
116
|
+
assert.ok(
|
|
117
|
+
app.statusMessages.some(
|
|
118
|
+
(m) => m.type === 'status' && /waiting/i.test(m.msg),
|
|
119
|
+
),
|
|
120
|
+
`got ${JSON.stringify(app.statusMessages)}`,
|
|
121
|
+
);
|
|
122
|
+
plugin.stop();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('transitioning into a trip resets the log and posts a status', async () => {
|
|
126
|
+
const app = new FakeSignalKApp();
|
|
127
|
+
// A fresh current log is considered already in-trip. To make the
|
|
128
|
+
// transition into a trip deterministic (instead of racing two async
|
|
129
|
+
// state emits), seed an *ended* current log so inTrip() starts false,
|
|
130
|
+
// then emit a single trip-starting state.
|
|
131
|
+
seedLog(app, 'current', {
|
|
132
|
+
started: '2026-01-01T00:00:00.000Z',
|
|
133
|
+
ended: '2026-01-01T01:00:00.000Z',
|
|
134
|
+
total: 0,
|
|
135
|
+
states: {},
|
|
136
|
+
});
|
|
137
|
+
const plugin = makePlugin(app);
|
|
138
|
+
plugin.start({});
|
|
139
|
+
emitDelta(app, {
|
|
140
|
+
context: 'vessels.self',
|
|
141
|
+
updates: [
|
|
142
|
+
{ values: [{ path: 'navigation.state', value: 'sailing' }] },
|
|
143
|
+
],
|
|
144
|
+
});
|
|
145
|
+
await waitFor(
|
|
146
|
+
() => app.statusMessages.some((m) => /new trip/i.test(m.msg)),
|
|
147
|
+
);
|
|
148
|
+
plugin.stop();
|
|
149
|
+
assert.ok(
|
|
150
|
+
app.statusMessages.some(
|
|
151
|
+
(m) => m.type === 'status' && /new trip/i.test(m.msg),
|
|
152
|
+
),
|
|
153
|
+
`got ${JSON.stringify(app.statusMessages)}`,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('position updates append distance to the trip log and emit deltas', async () => {
|
|
158
|
+
const app = new FakeSignalKApp();
|
|
159
|
+
// Seed an ended current log so start isn't considered in-trip, then emit
|
|
160
|
+
// a single trip-starting state. This avoids racing two async state emits.
|
|
161
|
+
seedLog(app, 'current', {
|
|
162
|
+
started: '2026-01-01T00:00:00.000Z',
|
|
163
|
+
ended: '2026-01-01T01:00:00.000Z',
|
|
164
|
+
total: 0,
|
|
165
|
+
states: {},
|
|
166
|
+
});
|
|
167
|
+
const plugin = makePlugin(app);
|
|
168
|
+
plugin.start({ totals: true });
|
|
169
|
+
emitDelta(app, {
|
|
170
|
+
context: 'vessels.self',
|
|
171
|
+
updates: [{ values: [{ path: 'navigation.state', value: 'sailing' }] }],
|
|
172
|
+
});
|
|
173
|
+
// Wait for the new-trip reset to settle before sending positions.
|
|
174
|
+
await waitFor(
|
|
175
|
+
() => app.statusMessages.some((m) => /new trip/i.test(m.msg)),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// First position just establishes lastPosition; no distance yet.
|
|
179
|
+
emitDelta(app, {
|
|
180
|
+
context: 'vessels.self',
|
|
181
|
+
updates: [
|
|
182
|
+
{ values: [{ path: 'navigation.position', value: { latitude: 60.0, longitude: 24.0 } }] },
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Second position ~111m east of the first → appendTrip writes log files.
|
|
187
|
+
emitDelta(app, {
|
|
188
|
+
context: 'vessels.self',
|
|
189
|
+
updates: [
|
|
190
|
+
{ values: [{ path: 'navigation.position', value: { latitude: 60.001, longitude: 24.0 } }] },
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
await waitFor(() => {
|
|
194
|
+
const tripValue = app.messages
|
|
195
|
+
.flatMap(({ message }) => message.updates.flatMap((u) => u.values))
|
|
196
|
+
.find((v) => v.path === 'navigation.trip.log' && v.value > 0);
|
|
197
|
+
return !!tripValue;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
plugin.stop();
|
|
201
|
+
|
|
202
|
+
// A trip.log delta should have been published to the app.
|
|
203
|
+
const tripMsgs = app.messages.filter(
|
|
204
|
+
({ message }) => message.updates.some((u) => u.values.some((v) => v.path === 'navigation.trip.log')),
|
|
205
|
+
);
|
|
206
|
+
assert.ok(tripMsgs.length > 0, 'no navigation.trip.log delta was emitted');
|
|
207
|
+
|
|
208
|
+
// The current trip log file should be persisted with a positive total.
|
|
209
|
+
const current = readLog(app, 'current');
|
|
210
|
+
assert.ok(current.total > 0, `current.total was ${current.total}`);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('totals option also publishes navigation.log', async () => {
|
|
214
|
+
const app = new FakeSignalKApp();
|
|
215
|
+
seedLog(app, 'current', {
|
|
216
|
+
started: '2026-01-01T00:00:00.000Z',
|
|
217
|
+
ended: '2026-01-01T01:00:00.000Z',
|
|
218
|
+
total: 0,
|
|
219
|
+
states: {},
|
|
220
|
+
});
|
|
221
|
+
const plugin = makePlugin(app);
|
|
222
|
+
plugin.start({ totals: true, totals_base: 100 });
|
|
223
|
+
emitDelta(app, {
|
|
224
|
+
context: 'vessels.self',
|
|
225
|
+
updates: [{ values: [{ path: 'navigation.state', value: 'motoring' }] }],
|
|
226
|
+
});
|
|
227
|
+
await waitFor(
|
|
228
|
+
() => app.statusMessages.some((m) => /new trip/i.test(m.msg)),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
emitDelta(app, {
|
|
232
|
+
context: 'vessels.self',
|
|
233
|
+
updates: [
|
|
234
|
+
{ values: [{ path: 'navigation.position', value: { latitude: 60.0, longitude: 24.0 } }] },
|
|
235
|
+
],
|
|
236
|
+
});
|
|
237
|
+
emitDelta(app, {
|
|
238
|
+
context: 'vessels.self',
|
|
239
|
+
updates: [
|
|
240
|
+
{ values: [{ path: 'navigation.position', value: { latitude: 60.002, longitude: 24.0 } }] },
|
|
241
|
+
],
|
|
242
|
+
});
|
|
243
|
+
await waitFor(() => {
|
|
244
|
+
const logValue = app.messages
|
|
245
|
+
.flatMap(({ message }) => message.updates.flatMap((u) => u.values))
|
|
246
|
+
.find((v) => v.path === 'navigation.log' && v.value > 100);
|
|
247
|
+
return !!logValue;
|
|
248
|
+
});
|
|
249
|
+
plugin.stop();
|
|
250
|
+
|
|
251
|
+
const logMsgs = app.messages.filter(
|
|
252
|
+
({ message }) => message.updates.some((u) => u.values.some((v) => v.path === 'navigation.log')),
|
|
253
|
+
);
|
|
254
|
+
assert.ok(logMsgs.length > 0, 'no navigation.log delta was emitted');
|
|
255
|
+
const last = logMsgs[logMsgs.length - 1];
|
|
256
|
+
const { value } = last.message.updates
|
|
257
|
+
.flatMap((u) => u.values)
|
|
258
|
+
.find((v) => v.path === 'navigation.log');
|
|
259
|
+
// totals_base of 100 should be added on top of the logged distance.
|
|
260
|
+
assert.ok(value > 100, `navigation.log value was ${value}`);
|
|
261
|
+
});
|
|
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('stop clears subscriptions', () => {
|
|
475
|
+
const { app, plugin } = makeStarted();
|
|
476
|
+
assert.strictEqual(app.subscriptionmanager.subscriptions.length, 1);
|
|
477
|
+
plugin.stop();
|
|
478
|
+
assert.strictEqual(app.subscriptionmanager.subscriptions.length, 0);
|
|
479
|
+
});
|