signalk-chiplog 1.0.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 +52 -0
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/index.js +327 -0
- package/lib/api.js +427 -0
- package/lib/database.js +264 -0
- package/lib/detection.js +407 -0
- package/lib/entries.js +345 -0
- package/lib/errors.js +22 -0
- package/lib/event-watcher.js +332 -0
- package/lib/events.js +287 -0
- package/lib/export.js +220 -0
- package/lib/formats.js +215 -0
- package/lib/manoeuvre-types.js +84 -0
- package/lib/observation-recorder.js +152 -0
- package/lib/place-names.js +208 -0
- package/lib/places.js +128 -0
- package/lib/propulsion-detector.js +156 -0
- package/lib/propulsion.js +75 -0
- package/lib/rows.js +15 -0
- package/lib/track-recorder.js +149 -0
- package/lib/track.js +58 -0
- package/lib/usb-scheduler.js +131 -0
- package/lib/validation.js +105 -0
- package/package.json +67 -0
- package/public/app.css +603 -0
- package/public/entry/entry.css +528 -0
- package/public/entry/icons/apple-touch-icon.png +0 -0
- package/public/entry/icons/icon-192.png +0 -0
- package/public/entry/icons/icon-512.png +0 -0
- package/public/entry/index.html +22 -0
- package/public/entry/js/access.mjs +116 -0
- package/public/entry/js/clock.mjs +29 -0
- package/public/entry/js/components/AccessGate.mjs +70 -0
- package/public/entry/js/components/Dialogs.mjs +64 -0
- package/public/entry/js/components/ManoeuvrePad.mjs +106 -0
- package/public/entry/js/components/NotePanel.mjs +34 -0
- package/public/entry/js/components/RecentList.mjs +113 -0
- package/public/entry/js/components/SketchPanel.mjs +181 -0
- package/public/entry/js/components/StatusHeader.mjs +69 -0
- package/public/entry/js/journal.mjs +139 -0
- package/public/entry/js/main.mjs +360 -0
- package/public/entry/js/outbox.mjs +124 -0
- package/public/entry/js/strokes.mjs +81 -0
- package/public/entry/manifest.webmanifest +26 -0
- package/public/entry/sw.js +75 -0
- package/public/icon.svg +7 -0
- package/public/index.html +18 -0
- package/public/js/api.mjs +85 -0
- package/public/js/auth.mjs +41 -0
- package/public/js/components/ExportView.mjs +182 -0
- package/public/js/components/LogView.mjs +132 -0
- package/public/js/components/PassageView.mjs +343 -0
- package/public/js/components/PropulsionStrip.mjs +60 -0
- package/public/js/components/StatusBar.mjs +55 -0
- package/public/js/components/Timeline.mjs +226 -0
- package/public/js/components/TrackMap.mjs +85 -0
- package/public/js/components/common.mjs +70 -0
- package/public/js/context.mjs +56 -0
- package/public/js/days.mjs +56 -0
- package/public/js/format.mjs +84 -0
- package/public/js/i18n.mjs +480 -0
- package/public/js/ids.mjs +10 -0
- package/public/js/main.mjs +48 -0
- package/public/js/status.mjs +21 -0
- package/public/vendor/htm-LICENSE +202 -0
- package/public/vendor/leaflet/LICENSE +26 -0
- package/public/vendor/leaflet/images/layers-2x.png +0 -0
- package/public/vendor/leaflet/images/layers.png +0 -0
- package/public/vendor/leaflet/images/marker-icon-2x.png +0 -0
- package/public/vendor/leaflet/images/marker-icon.png +0 -0
- package/public/vendor/leaflet/images/marker-shadow.png +0 -0
- package/public/vendor/leaflet/leaflet.css +661 -0
- package/public/vendor/leaflet/leaflet.js +6 -0
- package/public/vendor/preact-LICENSE +21 -0
- package/public/vendor/preact-htm.mjs +1 -0
package/index.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
const { registerRoutes } = require('./lib/api');
|
|
2
|
+
const { openDatabase } = require('./lib/database');
|
|
3
|
+
const { createPassageDetector, DETECTION_DEFAULTS, TICK_INTERVAL_MS } = require('./lib/detection');
|
|
4
|
+
const { ApiError } = require('./lib/errors');
|
|
5
|
+
const { createEventWatcher, CHECK_INTERVAL_MS, EVENT_DEFAULTS } = require('./lib/event-watcher');
|
|
6
|
+
const { OBSERVATION_DEFAULTS } = require('./lib/observation-recorder');
|
|
7
|
+
const { createPlaceNamer, GEOCODING_DEFAULTS } = require('./lib/place-names');
|
|
8
|
+
const { PROPULSION_DEFAULTS } = require('./lib/propulsion-detector');
|
|
9
|
+
const { createTrackRecorder, SAMPLE_INTERVAL_MS, TRACK_DEFAULTS } = require('./lib/track-recorder');
|
|
10
|
+
const {
|
|
11
|
+
createUsbExportScheduler,
|
|
12
|
+
CHECK_INTERVAL_MS: USB_CHECK_INTERVAL_MS,
|
|
13
|
+
USB_EXPORT_DEFAULTS
|
|
14
|
+
} = require('./lib/usb-scheduler');
|
|
15
|
+
|
|
16
|
+
const { version } = require('./package.json');
|
|
17
|
+
|
|
18
|
+
const DEFAULT_PLACE_MATCH_RADIUS = 200;
|
|
19
|
+
const FIRST_NAMING_DELAY_MS = 5 * 1000;
|
|
20
|
+
const NAMING_ERROR_RETRY_MS = 5 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
const MOTION_LABELS = { underway: 'Under way', stopped: 'Stopped', unknown: 'Waiting for data' };
|
|
23
|
+
const MODE_LABELS = { autostate: 'navigation.state', fallback: 'speed fallback' };
|
|
24
|
+
|
|
25
|
+
function readVesselPosition(app) {
|
|
26
|
+
const value = app.getSelfPath('navigation.position')?.value;
|
|
27
|
+
return value && Number.isFinite(value.latitude) && Number.isFinite(value.longitude)
|
|
28
|
+
? { lat: value.latitude, lon: value.longitude }
|
|
29
|
+
: null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function describeDetection({ mode, motion, propulsion, activeEntryId }) {
|
|
33
|
+
const under = propulsion === null ? '' : ` under ${propulsion}`;
|
|
34
|
+
const passage = activeEntryId === null ? '' : `, passage ${activeEntryId} open`;
|
|
35
|
+
return `${MOTION_LABELS[motion]}${under}${passage} (${MODE_LABELS[mode]})`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = function (app) {
|
|
39
|
+
const plugin = {};
|
|
40
|
+
let database = null;
|
|
41
|
+
let settings = null;
|
|
42
|
+
let detector = null;
|
|
43
|
+
let namer = null;
|
|
44
|
+
let usbExport = null;
|
|
45
|
+
let namingTimer = null;
|
|
46
|
+
let timers = [];
|
|
47
|
+
let lastStatus = null;
|
|
48
|
+
|
|
49
|
+
plugin.id = 'signalk-chiplog';
|
|
50
|
+
plugin.name = 'Chiplog';
|
|
51
|
+
plugin.description = 'Automated digital logbook for Signal K';
|
|
52
|
+
|
|
53
|
+
plugin.schema = {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
stopClosureMinutes: {
|
|
57
|
+
type: 'number',
|
|
58
|
+
title: 'Stop duration that ends a passage (minutes)',
|
|
59
|
+
description:
|
|
60
|
+
'Shorter stops, such as waiting for a lock or a lunch anchorage, stay within the same logbook entry',
|
|
61
|
+
default: DETECTION_DEFAULTS.stopClosureMinutes,
|
|
62
|
+
minimum: 1
|
|
63
|
+
},
|
|
64
|
+
fallbackUnderwaySpeed: {
|
|
65
|
+
type: 'number',
|
|
66
|
+
title: 'Under-way speed when navigation.state is unavailable (knots)',
|
|
67
|
+
description:
|
|
68
|
+
'Used only without signalk-autostate: the vessel counts as under way when its average speed over ground exceeds this, and as stopped below half of it',
|
|
69
|
+
default: DETECTION_DEFAULTS.fallbackUnderwaySpeed,
|
|
70
|
+
minimum: 0.1
|
|
71
|
+
},
|
|
72
|
+
defaultPropulsion: {
|
|
73
|
+
type: 'string',
|
|
74
|
+
title: 'Propulsion assumed without engine data',
|
|
75
|
+
description:
|
|
76
|
+
'Used when neither propulsion.*.revolutions, propulsion.*.state nor navigation.state says whether the engine is running',
|
|
77
|
+
enum: ['sail', 'engine'],
|
|
78
|
+
default: PROPULSION_DEFAULTS.defaultPropulsion
|
|
79
|
+
},
|
|
80
|
+
observationIntervalMinutes: {
|
|
81
|
+
type: 'number',
|
|
82
|
+
title: 'Instrument snapshot interval (minutes)',
|
|
83
|
+
description:
|
|
84
|
+
'During a passage, instrument readings are logged on this clock boundary — on the hour by default — as well as at departure, arrival and each manoeuvre',
|
|
85
|
+
default: OBSERVATION_DEFAULTS.observationIntervalMinutes,
|
|
86
|
+
minimum: 1
|
|
87
|
+
},
|
|
88
|
+
trackIntervalSeconds: {
|
|
89
|
+
type: 'number',
|
|
90
|
+
title: 'Track point interval (seconds)',
|
|
91
|
+
description:
|
|
92
|
+
'A track point is recorded at least this often while moving, plus extra points on turns and speed changes',
|
|
93
|
+
default: TRACK_DEFAULTS.trackIntervalSeconds,
|
|
94
|
+
minimum: 1
|
|
95
|
+
},
|
|
96
|
+
placeMatchRadius: {
|
|
97
|
+
type: 'number',
|
|
98
|
+
title: 'Place matching radius (m)',
|
|
99
|
+
description: 'A departure or arrival within this distance of a known place reuses its name',
|
|
100
|
+
default: DEFAULT_PLACE_MATCH_RADIUS,
|
|
101
|
+
minimum: 1
|
|
102
|
+
},
|
|
103
|
+
geocodingEnabled: {
|
|
104
|
+
type: 'boolean',
|
|
105
|
+
title: 'Name departures and arrivals with online geocoding',
|
|
106
|
+
description:
|
|
107
|
+
"Sends the position of departures and arrivals that match no known place to the geocoding service below. Names come from OpenStreetMap (© OpenStreetMap contributors, ODbL). Without it, they're named after their coordinates until corrected",
|
|
108
|
+
default: GEOCODING_DEFAULTS.geocodingEnabled
|
|
109
|
+
},
|
|
110
|
+
geocodingUrl: {
|
|
111
|
+
type: 'string',
|
|
112
|
+
title: 'Geocoding service (Nominatim-compatible)',
|
|
113
|
+
description: 'The public OpenStreetMap instance by default, or a self-hosted Nominatim',
|
|
114
|
+
default: GEOCODING_DEFAULTS.geocodingUrl
|
|
115
|
+
},
|
|
116
|
+
usbExportPath: {
|
|
117
|
+
type: 'string',
|
|
118
|
+
title: 'USB export directory',
|
|
119
|
+
description:
|
|
120
|
+
'Directory the logbook is copied to for abandon-ship recovery, e.g. the mount point of a USB drive. Leave empty to turn the USB copy off'
|
|
121
|
+
},
|
|
122
|
+
usbExportIntervalMinutes: {
|
|
123
|
+
type: 'number',
|
|
124
|
+
title: 'Automatic USB copy interval (minutes)',
|
|
125
|
+
description:
|
|
126
|
+
'Passages new or changed since the last copy are written to the USB drive this often; 0 turns the periodic copy off',
|
|
127
|
+
default: USB_EXPORT_DEFAULTS.usbExportIntervalMinutes,
|
|
128
|
+
minimum: 0
|
|
129
|
+
},
|
|
130
|
+
usbExportOnArrival: {
|
|
131
|
+
type: 'boolean',
|
|
132
|
+
title: 'Copy to the USB drive at each arrival',
|
|
133
|
+
default: USB_EXPORT_DEFAULTS.usbExportOnArrival
|
|
134
|
+
},
|
|
135
|
+
windSpeedThresholds: {
|
|
136
|
+
type: 'array',
|
|
137
|
+
title: 'Wind speed thresholds (knots)',
|
|
138
|
+
description:
|
|
139
|
+
'The log records when the true wind, averaged over two minutes, rises above or falls back below each of these speeds',
|
|
140
|
+
items: { type: 'number', minimum: 1 },
|
|
141
|
+
default: EVENT_DEFAULTS.windSpeedThresholds
|
|
142
|
+
},
|
|
143
|
+
pressureDropThreshold: {
|
|
144
|
+
type: 'number',
|
|
145
|
+
title: 'Barometric drop warning (hPa over 3 hours)',
|
|
146
|
+
description:
|
|
147
|
+
'The log records a pressure fall of at least this much over three hours; 0 disables it',
|
|
148
|
+
default: EVENT_DEFAULTS.pressureDropThreshold,
|
|
149
|
+
minimum: 0
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
function runDetection() {
|
|
155
|
+
try {
|
|
156
|
+
const outcome = detector.tick();
|
|
157
|
+
usbExport.afterDetection(outcome);
|
|
158
|
+
const usbError = usbExport.status().lastError;
|
|
159
|
+
const status = `${describeDetection(outcome)}${usbError ? ` — USB copy failing: ${usbError.message}` : ''}`;
|
|
160
|
+
if (status !== lastStatus) {
|
|
161
|
+
app.setPluginStatus(status);
|
|
162
|
+
lastStatus = status;
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
lastStatus = null;
|
|
166
|
+
app.error(`Passage detection failed: ${err.stack ?? err}`);
|
|
167
|
+
app.setPluginError(`Passage detection failed: ${err.message}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Geocoding is a network call, so it runs as its own chain of timeouts rather
|
|
172
|
+
// than inside detection: each lookup says when the next one is due.
|
|
173
|
+
async function runNaming() {
|
|
174
|
+
const current = namer;
|
|
175
|
+
let result;
|
|
176
|
+
try {
|
|
177
|
+
result = await current.resolveNext();
|
|
178
|
+
} catch (err) {
|
|
179
|
+
app.error(`Place naming failed: ${err.stack ?? err}`);
|
|
180
|
+
result = { retryInMs: NAMING_ERROR_RETRY_MS };
|
|
181
|
+
}
|
|
182
|
+
if (namer !== current || result.outcome === 'stopped') {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (result.outcome === 'failed') {
|
|
186
|
+
// Expected whenever the boat is out of reach of a network.
|
|
187
|
+
app.debug(
|
|
188
|
+
`Geocoding unavailable, retrying in ${Math.round(result.retryInMs / 60000)} min: ${result.error.message}`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
namingTimer = setTimeout(runNaming, result.retryInMs);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// For work that runs every second: log a failure once, not on every run.
|
|
195
|
+
function guarded(label, work) {
|
|
196
|
+
let failing = false;
|
|
197
|
+
return () => {
|
|
198
|
+
try {
|
|
199
|
+
work();
|
|
200
|
+
failing = false;
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (!failing) {
|
|
203
|
+
app.error(`${label} failed: ${err.stack ?? err}`);
|
|
204
|
+
}
|
|
205
|
+
failing = true;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
plugin.start = function (config = {}) {
|
|
211
|
+
try {
|
|
212
|
+
const { db, migrated } = openDatabase(app.getDataDirPath());
|
|
213
|
+
database = db;
|
|
214
|
+
settings = {
|
|
215
|
+
stopClosureMinutes: config.stopClosureMinutes ?? DETECTION_DEFAULTS.stopClosureMinutes,
|
|
216
|
+
fallbackUnderwaySpeed:
|
|
217
|
+
config.fallbackUnderwaySpeed ?? DETECTION_DEFAULTS.fallbackUnderwaySpeed,
|
|
218
|
+
defaultPropulsion: config.defaultPropulsion ?? PROPULSION_DEFAULTS.defaultPropulsion,
|
|
219
|
+
observationIntervalMinutes:
|
|
220
|
+
config.observationIntervalMinutes ?? OBSERVATION_DEFAULTS.observationIntervalMinutes,
|
|
221
|
+
trackIntervalSeconds: config.trackIntervalSeconds ?? TRACK_DEFAULTS.trackIntervalSeconds,
|
|
222
|
+
placeMatchRadius: config.placeMatchRadius ?? DEFAULT_PLACE_MATCH_RADIUS,
|
|
223
|
+
geocodingEnabled: config.geocodingEnabled ?? GEOCODING_DEFAULTS.geocodingEnabled,
|
|
224
|
+
geocodingUrl: config.geocodingUrl || GEOCODING_DEFAULTS.geocodingUrl,
|
|
225
|
+
usbExportPath: config.usbExportPath || null,
|
|
226
|
+
usbExportIntervalMinutes:
|
|
227
|
+
config.usbExportIntervalMinutes ?? USB_EXPORT_DEFAULTS.usbExportIntervalMinutes,
|
|
228
|
+
usbExportOnArrival: config.usbExportOnArrival ?? USB_EXPORT_DEFAULTS.usbExportOnArrival,
|
|
229
|
+
windSpeedThresholds: config.windSpeedThresholds ?? EVENT_DEFAULTS.windSpeedThresholds,
|
|
230
|
+
pressureDropThreshold: config.pressureDropThreshold ?? EVENT_DEFAULTS.pressureDropThreshold
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (migrated.to > migrated.from) {
|
|
234
|
+
app.debug(`Database schema migrated from version ${migrated.from} to ${migrated.to}`);
|
|
235
|
+
}
|
|
236
|
+
} catch (err) {
|
|
237
|
+
app.setPluginError(`Cannot open the logbook database: ${err.message}`);
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const readSelfPath = (path) => app.getSelfPath(path);
|
|
242
|
+
detector = createPassageDetector({ db: database, readSelfPath, settings });
|
|
243
|
+
const recorder = createTrackRecorder({ db: database, readSelfPath, settings });
|
|
244
|
+
const watcher = createEventWatcher({
|
|
245
|
+
db: database,
|
|
246
|
+
readSelfPath,
|
|
247
|
+
settings,
|
|
248
|
+
observe: (entryId, time) => detector.observeEvent(entryId, time)
|
|
249
|
+
});
|
|
250
|
+
namer = createPlaceNamer({
|
|
251
|
+
db: database,
|
|
252
|
+
settings,
|
|
253
|
+
userAgent: `signalk-chiplog/${version}`
|
|
254
|
+
});
|
|
255
|
+
usbExport = createUsbExportScheduler({
|
|
256
|
+
db: database,
|
|
257
|
+
settings,
|
|
258
|
+
log: (level, message) => (level === 'error' ? app.error(message) : app.debug(message))
|
|
259
|
+
});
|
|
260
|
+
namingTimer = setTimeout(runNaming, FIRST_NAMING_DELAY_MS);
|
|
261
|
+
lastStatus = null;
|
|
262
|
+
runDetection();
|
|
263
|
+
timers = [
|
|
264
|
+
setInterval(runDetection, TICK_INTERVAL_MS),
|
|
265
|
+
setInterval(
|
|
266
|
+
guarded('Track recording', () => recorder.sample()),
|
|
267
|
+
SAMPLE_INTERVAL_MS
|
|
268
|
+
),
|
|
269
|
+
setInterval(
|
|
270
|
+
guarded('Event watching', () => watcher.check()),
|
|
271
|
+
CHECK_INTERVAL_MS
|
|
272
|
+
),
|
|
273
|
+
setInterval(
|
|
274
|
+
guarded('USB copy scheduling', () => usbExport.tick()),
|
|
275
|
+
USB_CHECK_INTERVAL_MS
|
|
276
|
+
)
|
|
277
|
+
];
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
plugin.stop = function () {
|
|
281
|
+
timers.forEach(clearInterval);
|
|
282
|
+
timers = [];
|
|
283
|
+
clearTimeout(namingTimer);
|
|
284
|
+
namer?.stop();
|
|
285
|
+
namer = null;
|
|
286
|
+
usbExport?.stop();
|
|
287
|
+
usbExport = null;
|
|
288
|
+
detector = null;
|
|
289
|
+
if (database) {
|
|
290
|
+
database.close();
|
|
291
|
+
database = null;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// The server registers routes once, before start() and even while the plugin
|
|
296
|
+
// is disabled, and never removes them: every request must check the database.
|
|
297
|
+
plugin.registerWithRouter = function (router) {
|
|
298
|
+
registerRoutes(router, {
|
|
299
|
+
getContext() {
|
|
300
|
+
if (!database) {
|
|
301
|
+
throw new ApiError(
|
|
302
|
+
503,
|
|
303
|
+
'plugin_not_started',
|
|
304
|
+
'Chiplog is not running; enable the plugin'
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
db: database,
|
|
309
|
+
config: settings,
|
|
310
|
+
now: () => new Date().toISOString(),
|
|
311
|
+
vesselPosition: () => readVesselPosition(app),
|
|
312
|
+
observeEvent: (entryId, time) => detector.observeEvent(entryId, time),
|
|
313
|
+
usbExport,
|
|
314
|
+
detection: () => ({
|
|
315
|
+
mode: detector.mode(),
|
|
316
|
+
motion: detector.motion(),
|
|
317
|
+
propulsion: detector.propulsion(),
|
|
318
|
+
stateIssue: detector.stateIssue()
|
|
319
|
+
})
|
|
320
|
+
};
|
|
321
|
+
},
|
|
322
|
+
logError: (err) => app.error(`API request failed: ${err.stack ?? err}`)
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
return plugin;
|
|
327
|
+
};
|