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.
Files changed (76) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/README.md +290 -0
  4. package/index.js +327 -0
  5. package/lib/api.js +427 -0
  6. package/lib/database.js +264 -0
  7. package/lib/detection.js +407 -0
  8. package/lib/entries.js +345 -0
  9. package/lib/errors.js +22 -0
  10. package/lib/event-watcher.js +332 -0
  11. package/lib/events.js +287 -0
  12. package/lib/export.js +220 -0
  13. package/lib/formats.js +215 -0
  14. package/lib/manoeuvre-types.js +84 -0
  15. package/lib/observation-recorder.js +152 -0
  16. package/lib/place-names.js +208 -0
  17. package/lib/places.js +128 -0
  18. package/lib/propulsion-detector.js +156 -0
  19. package/lib/propulsion.js +75 -0
  20. package/lib/rows.js +15 -0
  21. package/lib/track-recorder.js +149 -0
  22. package/lib/track.js +58 -0
  23. package/lib/usb-scheduler.js +131 -0
  24. package/lib/validation.js +105 -0
  25. package/package.json +67 -0
  26. package/public/app.css +603 -0
  27. package/public/entry/entry.css +528 -0
  28. package/public/entry/icons/apple-touch-icon.png +0 -0
  29. package/public/entry/icons/icon-192.png +0 -0
  30. package/public/entry/icons/icon-512.png +0 -0
  31. package/public/entry/index.html +22 -0
  32. package/public/entry/js/access.mjs +116 -0
  33. package/public/entry/js/clock.mjs +29 -0
  34. package/public/entry/js/components/AccessGate.mjs +70 -0
  35. package/public/entry/js/components/Dialogs.mjs +64 -0
  36. package/public/entry/js/components/ManoeuvrePad.mjs +106 -0
  37. package/public/entry/js/components/NotePanel.mjs +34 -0
  38. package/public/entry/js/components/RecentList.mjs +113 -0
  39. package/public/entry/js/components/SketchPanel.mjs +181 -0
  40. package/public/entry/js/components/StatusHeader.mjs +69 -0
  41. package/public/entry/js/journal.mjs +139 -0
  42. package/public/entry/js/main.mjs +360 -0
  43. package/public/entry/js/outbox.mjs +124 -0
  44. package/public/entry/js/strokes.mjs +81 -0
  45. package/public/entry/manifest.webmanifest +26 -0
  46. package/public/entry/sw.js +75 -0
  47. package/public/icon.svg +7 -0
  48. package/public/index.html +18 -0
  49. package/public/js/api.mjs +85 -0
  50. package/public/js/auth.mjs +41 -0
  51. package/public/js/components/ExportView.mjs +182 -0
  52. package/public/js/components/LogView.mjs +132 -0
  53. package/public/js/components/PassageView.mjs +343 -0
  54. package/public/js/components/PropulsionStrip.mjs +60 -0
  55. package/public/js/components/StatusBar.mjs +55 -0
  56. package/public/js/components/Timeline.mjs +226 -0
  57. package/public/js/components/TrackMap.mjs +85 -0
  58. package/public/js/components/common.mjs +70 -0
  59. package/public/js/context.mjs +56 -0
  60. package/public/js/days.mjs +56 -0
  61. package/public/js/format.mjs +84 -0
  62. package/public/js/i18n.mjs +480 -0
  63. package/public/js/ids.mjs +10 -0
  64. package/public/js/main.mjs +48 -0
  65. package/public/js/status.mjs +21 -0
  66. package/public/vendor/htm-LICENSE +202 -0
  67. package/public/vendor/leaflet/LICENSE +26 -0
  68. package/public/vendor/leaflet/images/layers-2x.png +0 -0
  69. package/public/vendor/leaflet/images/layers.png +0 -0
  70. package/public/vendor/leaflet/images/marker-icon-2x.png +0 -0
  71. package/public/vendor/leaflet/images/marker-icon.png +0 -0
  72. package/public/vendor/leaflet/images/marker-shadow.png +0 -0
  73. package/public/vendor/leaflet/leaflet.css +661 -0
  74. package/public/vendor/leaflet/leaflet.js +6 -0
  75. package/public/vendor/preact-LICENSE +21 -0
  76. package/public/vendor/preact-htm.mjs +1 -0
@@ -0,0 +1,407 @@
1
+ const { withTransaction } = require('./database');
2
+ const { initialPlaceName } = require('./place-names');
3
+ const { createObservationRecorder } = require('./observation-recorder');
4
+ const { createPropulsionTracker } = require('./propulsion-detector');
5
+ const { toPosition } = require('./rows');
6
+
7
+ const METRES_PER_SECOND_PER_KNOT = 1852 / 3600;
8
+
9
+ const TICK_INTERVAL_MS = 15 * 1000;
10
+ // Speed is averaged over this window, so a gust swinging the boat at anchor
11
+ // does not start a passage.
12
+ const SPEED_WINDOW_MS = 3 * 60 * 1000;
13
+ const MIN_SPEED_SAMPLES = 6;
14
+ const HEARTBEAT_INTERVAL_MS = 60 * 1000;
15
+ const LATE_CLOSURE_MARGIN_MS = 5 * 60 * 1000;
16
+ // signalk-autostate needs about ten minutes to notice a change; raw speed
17
+ // samples older than this are no longer trusted to date one.
18
+ const MAX_REFINEMENT_AGE_MS = 20 * 60 * 1000;
19
+
20
+ // How long a Signal K value stays current after it last changed.
21
+ // signalk-autostate republishes navigation.state at least every ten minutes.
22
+ const MAX_AGE_MS = {
23
+ 'navigation.state': 20 * 60 * 1000,
24
+ 'navigation.speedOverGround': 2 * 60 * 1000,
25
+ 'navigation.position': 2 * 60 * 1000
26
+ };
27
+
28
+ // navigation.state can have several sources: an AIS class A transponder
29
+ // reports the boat's own navigational status — often left undefined, or at
30
+ // "under way using engine" while moored — which would otherwise hide
31
+ // signalk-autostate's decision. Its value is preferred whenever present.
32
+ const AUTOSTATE_SOURCE_PREFIX = 'signalk-autostate';
33
+
34
+ const STOPPED_STATES = new Set(['moored', 'anchored', 'aground', 'not-under-way']);
35
+ const UNDERWAY_STATES = new Set([
36
+ 'sailing',
37
+ 'motoring',
38
+ 'not under command',
39
+ 'towing < 200m',
40
+ 'towing > 200m',
41
+ 'pushing',
42
+ 'fishing',
43
+ 'fishing-hampered',
44
+ 'trawling',
45
+ 'trawling-shooting',
46
+ 'trawling-hauling',
47
+ 'pilotage',
48
+ 'restricted manouverability',
49
+ 'restricted manouverability towing < 200m',
50
+ 'restricted manouverability towing > 200m',
51
+ 'restricted manouverability underwater operations',
52
+ 'constrained by draft',
53
+ 'mine clearance'
54
+ ]);
55
+
56
+ const DETECTION_DEFAULTS = { stopClosureMinutes: 30, fallbackUnderwaySpeed: 1 };
57
+
58
+ function iso(ms) {
59
+ return new Date(ms).toISOString();
60
+ }
61
+
62
+ function classifyState(state) {
63
+ if (STOPPED_STATES.has(state)) {
64
+ return 'stopped';
65
+ }
66
+ return UNDERWAY_STATES.has(state) ? 'underway' : null;
67
+ }
68
+
69
+ // A value is trusted when its timestamp changed recently by our own clock, so
70
+ // a system clock that is off — a Raspberry Pi without a real-time clock boots
71
+ // with the wrong date — does not make live data look stale. Only a value seen
72
+ // for the first time is judged against the system clock.
73
+ function createFreshnessTracker(readSelfPath) {
74
+ const seen = new Map();
75
+
76
+ // `key` names what is tracked; `node` defaults to the value at that path, and
77
+ // is given to track one source of a path on its own.
78
+ return function readFresh(key, now, maxAge = MAX_AGE_MS[key], node = readSelfPath(key)) {
79
+ if (!node || node.value === null || node.value === undefined || !node.timestamp) {
80
+ return undefined;
81
+ }
82
+
83
+ const record = seen.get(key);
84
+ if (!record || record.timestamp !== node.timestamp) {
85
+ let observedAt = now;
86
+ if (!record) {
87
+ const stamped = Date.parse(node.timestamp);
88
+ observedAt = Math.abs(now - stamped) <= maxAge ? Math.min(now, stamped) : null;
89
+ }
90
+ seen.set(key, { timestamp: node.timestamp, observedAt });
91
+ }
92
+
93
+ const { observedAt } = seen.get(key);
94
+ return observedAt !== null && now - observedAt <= maxAge ? node.value : undefined;
95
+ };
96
+ }
97
+
98
+ // The reading of navigation.state detection relies on — signalk-autostate's
99
+ // when it publishes one — with its source, from Signal K's per-source values.
100
+ function stateReading(node) {
101
+ if (!node) {
102
+ return null;
103
+ }
104
+ const sources = node.values && typeof node.values === 'object' ? node.values : {};
105
+ const autostate = Object.keys(sources).find((source) =>
106
+ source.startsWith(AUTOSTATE_SOURCE_PREFIX)
107
+ );
108
+ if (autostate) {
109
+ return { source: autostate, node: sources[autostate] };
110
+ }
111
+ return { source: node.$source ?? null, node };
112
+ }
113
+
114
+ // Why detection is not following navigation.state, for the apps to explain.
115
+ function describeStateIssue(reading, freshValue) {
116
+ const value = reading?.node?.value;
117
+ if (!reading || value === undefined) {
118
+ return { reason: 'absent' };
119
+ }
120
+ const detail = { source: reading.source, value, updatedAt: reading.node.timestamp ?? null };
121
+ if (value === null) {
122
+ return { reason: 'pending', ...detail };
123
+ }
124
+ return { reason: freshValue === undefined ? 'stale' : 'unrecognised', ...detail };
125
+ }
126
+
127
+ function createSpeedTracker({ underwaySpeed, stoppedSpeed }) {
128
+ const samples = [];
129
+ let motion = 'unknown';
130
+ // Contiguous streaks of raw samples, used to date and place transitions more
131
+ // precisely than the averaged or autostate decision can.
132
+ let lastStill = null;
133
+ let leftStillAt = null;
134
+ let stillSince = null;
135
+
136
+ function prune(now) {
137
+ while (samples.length > 0 && samples[0].time < now - SPEED_WINDOW_MS) {
138
+ samples.shift();
139
+ }
140
+ }
141
+
142
+ return {
143
+ observe(time, sog, position) {
144
+ samples.push({ time, sog });
145
+ const point = { time, position };
146
+ if (sog < stoppedSpeed) {
147
+ lastStill = point;
148
+ leftStillAt = null;
149
+ stillSince = stillSince ?? point;
150
+ } else {
151
+ leftStillAt = leftStillAt ?? point;
152
+ stillSince = null;
153
+ }
154
+ },
155
+
156
+ motion(now) {
157
+ prune(now);
158
+ if (samples.length === 0) {
159
+ motion = 'unknown';
160
+ } else if (samples.length >= MIN_SPEED_SAMPLES) {
161
+ const mean = samples.reduce((sum, sample) => sum + sample.sog, 0) / samples.length;
162
+ if (mean >= underwaySpeed) {
163
+ motion = 'underway';
164
+ } else if (mean < stoppedSpeed) {
165
+ motion = 'stopped';
166
+ }
167
+ }
168
+ return motion;
169
+ },
170
+
171
+ departure(now) {
172
+ if (!leftStillAt || now - leftStillAt.time > MAX_REFINEMENT_AGE_MS) {
173
+ return null;
174
+ }
175
+ const berth = lastStill && now - lastStill.time <= MAX_REFINEMENT_AGE_MS ? lastStill : null;
176
+ return { time: leftStillAt.time, position: berth?.position ?? leftStillAt.position };
177
+ },
178
+
179
+ arrival(now) {
180
+ return stillSince && now - stillSince.time <= MAX_REFINEMENT_AGE_MS ? stillSince : null;
181
+ }
182
+ };
183
+ }
184
+
185
+ function createPassageDetector({ db, readSelfPath, settings, clock = Date.now }) {
186
+ const readFresh = createFreshnessTracker(readSelfPath);
187
+ const underwaySpeed = settings.fallbackUnderwaySpeed * METRES_PER_SECOND_PER_KNOT;
188
+ const speeds = createSpeedTracker({ underwaySpeed, stoppedSpeed: underwaySpeed / 2 });
189
+ const closureMs = settings.stopClosureMinutes * 60 * 1000;
190
+ const propulsion = createPropulsionTracker({ db, readSelfPath, readFresh, settings });
191
+ const observations = createObservationRecorder({ db, readSelfPath, readFresh, settings });
192
+
193
+ let mode = 'fallback';
194
+ let stateIssue = { reason: 'absent' };
195
+ let motion = 'unknown';
196
+ let propulsionType = null;
197
+ let resumed = false;
198
+
199
+ const activeEntry = () => db.prepare("SELECT * FROM log_entries WHERE state = 'active'").get();
200
+
201
+ function storedEnd(entry) {
202
+ return toPosition(entry.end_lat, entry.end_lon);
203
+ }
204
+
205
+ function sense(now) {
206
+ const position = readFresh('navigation.position', now);
207
+ const current =
208
+ position && Number.isFinite(position.latitude) && Number.isFinite(position.longitude)
209
+ ? { lat: position.latitude, lon: position.longitude }
210
+ : null;
211
+
212
+ const sog = readFresh('navigation.speedOverGround', now);
213
+ if (typeof sog === 'number' && Number.isFinite(sog)) {
214
+ speeds.observe(now, sog, current);
215
+ }
216
+
217
+ observations.sense(now);
218
+ const reading = stateReading(readSelfPath('navigation.state'));
219
+ const navigationState = reading
220
+ ? readFresh(
221
+ `navigation.state@${reading.source}`,
222
+ now,
223
+ MAX_AGE_MS['navigation.state'],
224
+ reading.node
225
+ )
226
+ : undefined;
227
+ const fromState = classifyState(navigationState);
228
+ mode = fromState ? 'autostate' : 'fallback';
229
+ stateIssue = fromState ? null : describeStateIssue(reading, navigationState);
230
+ return {
231
+ motion: fromState ?? speeds.motion(now),
232
+ position: current,
233
+ propulsion: propulsion.sense(now, navigationState)
234
+ };
235
+ }
236
+
237
+ // When a stopped passage gets going again, it resumed when raw speed left
238
+ // standstill, not when the averaged or autostate decision caught up.
239
+ function resumeTime(entry, now) {
240
+ const moving = speeds.departure(now);
241
+ const stoppedAt = Date.parse(entry.stopped_since);
242
+ return moving && moving.time > stoppedAt ? moving.time : now;
243
+ }
244
+
245
+ function recordMovement(entry, now, position, { force = false } = {}) {
246
+ const last = entry.last_moving_at === null ? null : Date.parse(entry.last_moving_at);
247
+ if (!force && last !== null && now - last < HEARTBEAT_INTERVAL_MS) {
248
+ return;
249
+ }
250
+ const at = position ?? storedEnd(entry);
251
+ db.prepare(
252
+ `UPDATE log_entries
253
+ SET last_moving_at = ?, end_lat = ?, end_lon = ?, stopped_since = NULL, updated_at = ?
254
+ WHERE id = ?`
255
+ ).run(iso(now), at ? at.lat : null, at ? at.lon : null, iso(now), entry.id);
256
+ }
257
+
258
+ function markStopped(entry, { time, position }, now) {
259
+ const at = position ?? storedEnd(entry);
260
+ const stoppedAt = time < entry.start_time ? entry.start_time : time;
261
+ db.prepare(
262
+ `UPDATE log_entries SET stopped_since = ?, end_lat = ?, end_lon = ?, updated_at = ?
263
+ WHERE id = ?`
264
+ ).run(stoppedAt, at ? at.lat : null, at ? at.lon : null, iso(now), entry.id);
265
+ return activeEntry();
266
+ }
267
+
268
+ function arrivalOf(entry, now, position) {
269
+ const still = speeds.arrival(now);
270
+ if (still && iso(still.time) >= entry.start_time) {
271
+ return { time: iso(still.time), position: still.position ?? position };
272
+ }
273
+ // Without a speed streak, the last recorded movement is the best bound.
274
+ return { time: entry.last_moving_at ?? iso(now), position: storedEnd(entry) ?? position };
275
+ }
276
+
277
+ function closePassage(entry, now) {
278
+ // A name the crew typed before arrival is kept as it is.
279
+ const place =
280
+ entry.end_place_name === null
281
+ ? initialPlaceName(db, storedEnd(entry), settings.placeMatchRadius)
282
+ : { id: entry.end_place_id, name: entry.end_place_name, pending: entry.end_place_pending };
283
+ db.prepare(
284
+ `UPDATE log_entries
285
+ SET state = 'closed', end_time = stopped_since, stopped_since = NULL,
286
+ end_place_id = ?, end_place_name = ?, end_place_pending = ?, updated_at = ?
287
+ WHERE id = ?`
288
+ ).run(place.id, place.name, place.pending, iso(now), entry.id);
289
+ }
290
+
291
+ function openPassage(now, position) {
292
+ const departure = speeds.departure(now) ?? { time: now, position };
293
+ const previousEnd = db
294
+ .prepare('SELECT MAX(end_time) AS endTime FROM log_entries')
295
+ .get().endTime;
296
+ const startTime =
297
+ previousEnd && iso(departure.time) < previousEnd ? previousEnd : iso(departure.time);
298
+ const start = departure.position ?? position;
299
+ const place = initialPlaceName(db, start, settings.placeMatchRadius);
300
+
301
+ const { lastInsertRowid } = db
302
+ .prepare(
303
+ `INSERT INTO log_entries (
304
+ state, start_time, start_lat, start_lon, start_place_id, start_place_name,
305
+ start_place_pending, last_moving_at, end_lat, end_lon, created_at, updated_at
306
+ ) VALUES ('active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
307
+ )
308
+ .run(
309
+ startTime,
310
+ start ? start.lat : null,
311
+ start ? start.lon : null,
312
+ place.id,
313
+ place.name,
314
+ place.pending,
315
+ iso(now),
316
+ position ? position.lat : null,
317
+ position ? position.lon : null,
318
+ iso(now),
319
+ iso(now)
320
+ );
321
+ return Number(lastInsertRowid);
322
+ }
323
+
324
+ function step(now, current, position, previous) {
325
+ let entry = activeEntry();
326
+ let resumedAt = null;
327
+ let opened = null;
328
+ let closed = null;
329
+
330
+ // A passage left open across a restart with no movement recorded for longer
331
+ // than the tolerance ended while the plugin was not running — typically
332
+ // power switched off on arrival.
333
+ if (!resumed) {
334
+ resumed = true;
335
+ if (entry && entry.stopped_since === null) {
336
+ const lastMoving = entry.last_moving_at ?? entry.start_time;
337
+ if (now - Date.parse(lastMoving) >= closureMs) {
338
+ entry = markStopped(entry, { time: lastMoving, position: storedEnd(entry) }, now);
339
+ }
340
+ }
341
+ }
342
+
343
+ if (entry && entry.stopped_since === null) {
344
+ if (current === 'underway') {
345
+ recordMovement(entry, now, position);
346
+ } else if (current === 'stopped') {
347
+ entry = markStopped(entry, arrivalOf(entry, now, position), now);
348
+ }
349
+ }
350
+
351
+ if (entry && entry.stopped_since !== null) {
352
+ const stoppedFor = now - Date.parse(entry.stopped_since);
353
+ if (stoppedFor >= closureMs) {
354
+ closePassage(entry, now);
355
+ // Closed well after the tolerance ran out means the plugin was not
356
+ // running: current conditions say nothing about that arrival.
357
+ closed = { id: entry.id, late: stoppedFor > closureMs + LATE_CLOSURE_MARGIN_MS };
358
+ entry = null;
359
+ } else if (current === 'underway') {
360
+ resumedAt = resumeTime(entry, now);
361
+ recordMovement(entry, now, position, { force: true });
362
+ }
363
+ }
364
+
365
+ // Only a transition opens a passage: an entry closed by hand while still
366
+ // moving must not be reopened on the next tick.
367
+ if (!entry && current === 'underway' && previous !== 'underway') {
368
+ opened = openPassage(now, position);
369
+ }
370
+
371
+ return { resumedAt, opened, closed };
372
+ }
373
+
374
+ return {
375
+ tick() {
376
+ const now = clock();
377
+ const sensed = sense(now);
378
+ const previous = motion;
379
+ withTransaction(db, () => {
380
+ const outcome = step(now, sensed.motion, sensed.position, previous);
381
+ propulsion.reconcile(now, sensed.propulsion, outcome);
382
+ observations.afterDetection(now, outcome);
383
+ });
384
+ motion = sensed.motion;
385
+ propulsionType = motion === 'underway' ? sensed.propulsion.type : null;
386
+ return {
387
+ mode,
388
+ motion,
389
+ propulsion: propulsionType,
390
+ activeEntryId: activeEntry()?.id ?? null
391
+ };
392
+ },
393
+ mode: () => mode,
394
+ stateIssue: () => stateIssue,
395
+ motion: () => motion,
396
+ propulsion: () => propulsionType,
397
+ observeEvent: (entryId, time) => observations.recordEvent(entryId, time, clock())
398
+ };
399
+ }
400
+
401
+ module.exports = {
402
+ createFreshnessTracker,
403
+ createPassageDetector,
404
+ DETECTION_DEFAULTS,
405
+ MAX_REFINEMENT_AGE_MS,
406
+ TICK_INTERVAL_MS
407
+ };