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
package/lib/entries.js ADDED
@@ -0,0 +1,345 @@
1
+ const { withTransaction } = require('./database');
2
+ const { badRequest, conflict, notFound } = require('./errors');
3
+ const { coordinatesName, initialPlaceName } = require('./place-names');
4
+ const { distanceBetween, rememberPlaceName } = require('./places');
5
+ const { toPosition, paginate } = require('./rows');
6
+
7
+ const CHILD_TABLES = ['track_points', 'observations', 'propulsion_segments', 'events'];
8
+
9
+ // Still this close to where the last passage ended, the vessel is at that
10
+ // passage's destination: an alarm or a note there belongs to it.
11
+ const STILL_AT_ARRIVAL_METRES = 1852;
12
+
13
+ function toEntry(row) {
14
+ return {
15
+ id: row.id,
16
+ state: row.state,
17
+ startTime: row.start_time,
18
+ endTime: row.end_time,
19
+ stoppedSince: row.stopped_since,
20
+ startPosition: toPosition(row.start_lat, row.start_lon),
21
+ endPosition: toPosition(row.end_lat, row.end_lon),
22
+ startPlaceId: row.start_place_id,
23
+ endPlaceId: row.end_place_id,
24
+ startPlaceName: row.start_place_name,
25
+ endPlaceName: row.end_place_name,
26
+ startPlacePending: row.start_place_pending === 1,
27
+ endPlacePending: row.end_place_pending === 1,
28
+ distance: row.distance,
29
+ engineDuration: row.engine_duration,
30
+ sailDuration: row.sail_duration,
31
+ openedByEventId: row.opened_by_event_id,
32
+ createdAt: row.created_at,
33
+ updatedAt: row.updated_at
34
+ };
35
+ }
36
+
37
+ function requireEntryRow(db, id) {
38
+ const row = db.prepare('SELECT * FROM log_entries WHERE id = ?').get(id);
39
+ if (!row) {
40
+ throw notFound('entry', id);
41
+ }
42
+ return row;
43
+ }
44
+
45
+ function listEntries(db, { from, to, limit, offset }) {
46
+ const conditions = [];
47
+ const params = [];
48
+ if (from !== undefined) {
49
+ conditions.push('start_time >= ?');
50
+ params.push(from);
51
+ }
52
+ if (to !== undefined) {
53
+ conditions.push('start_time < ?');
54
+ params.push(to);
55
+ }
56
+ return paginate(db, {
57
+ table: 'log_entries',
58
+ where: conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '',
59
+ params,
60
+ orderBy: 'start_time DESC, id DESC',
61
+ limit,
62
+ offset,
63
+ map: toEntry
64
+ });
65
+ }
66
+
67
+ function getEntry(db, id) {
68
+ const entry = toEntry(requireEntryRow(db, id));
69
+ const count = (table) =>
70
+ db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE entry_id = ?`).get(id).n;
71
+ return {
72
+ ...entry,
73
+ counts: {
74
+ trackPoints: count('track_points'),
75
+ observations: count('observations'),
76
+ events: count('events')
77
+ }
78
+ };
79
+ }
80
+
81
+ // A segment still open counts up to `now`, so the totals of a passage in
82
+ // progress stay current.
83
+ function recomputeDurations(db, entryId, now) {
84
+ const totals = db
85
+ .prepare(
86
+ `SELECT type,
87
+ SUM(MAX(0, (julianday(COALESCE(end_time, ?)) - julianday(start_time)) * 86400)) AS seconds
88
+ FROM propulsion_segments
89
+ WHERE entry_id = ?
90
+ GROUP BY type`
91
+ )
92
+ .all(now, entryId);
93
+ const secondsOf = (type) => Math.round(totals.find((t) => t.type === type)?.seconds ?? 0);
94
+ db.prepare(
95
+ 'UPDATE log_entries SET engine_duration = ?, sail_duration = ?, updated_at = ? WHERE id = ?'
96
+ ).run(secondsOf('engine'), secondsOf('sail'), now, entryId);
97
+ }
98
+
99
+ function updateEntry(db, id, patch, { placeMatchRadius, now }) {
100
+ return withTransaction(db, () => {
101
+ const row = requireEntryRow(db, id);
102
+ const next = { ...row };
103
+
104
+ if (patch.startTime !== undefined) {
105
+ next.start_time = patch.startTime;
106
+ }
107
+ if (patch.endTime !== undefined) {
108
+ if (row.state === 'active') {
109
+ throw conflict(
110
+ 'entry_active',
111
+ `Entry ${id} is still in progress; close it rather than setting endTime`
112
+ );
113
+ }
114
+ next.end_time = patch.endTime;
115
+ }
116
+ if (next.end_time !== null && next.end_time < next.start_time) {
117
+ throw badRequest('endTime must not be earlier than startTime');
118
+ }
119
+
120
+ for (const side of ['start', 'end']) {
121
+ const position = patch[`${side}Position`];
122
+ const name = patch[`${side}PlaceName`];
123
+ if (position !== undefined) {
124
+ next[`${side}_lat`] = position ? position.lat : null;
125
+ next[`${side}_lon`] = position ? position.lon : null;
126
+ // A name still generated from coordinates follows the corrected position.
127
+ if (name === undefined && row[`${side}_place_pending`] === 1) {
128
+ next[`${side}_place_name`] = position ? coordinatesName(position) : null;
129
+ next[`${side}_place_pending`] = position ? 1 : 0;
130
+ }
131
+ }
132
+ if (name !== undefined) {
133
+ const at = toPosition(next[`${side}_lat`], next[`${side}_lon`]);
134
+ next[`${side}_place_name`] = name;
135
+ next[`${side}_place_pending`] = 0;
136
+ next[`${side}_place_id`] =
137
+ name !== null && at ? rememberPlaceName(db, at, name, placeMatchRadius, now) : null;
138
+ }
139
+ }
140
+
141
+ if (patch.distance !== undefined) {
142
+ next.distance = patch.distance;
143
+ }
144
+
145
+ db.prepare(
146
+ `UPDATE log_entries SET
147
+ start_time = ?, end_time = ?,
148
+ start_lat = ?, start_lon = ?, end_lat = ?, end_lon = ?,
149
+ start_place_id = ?, end_place_id = ?, start_place_name = ?, end_place_name = ?,
150
+ start_place_pending = ?, end_place_pending = ?,
151
+ distance = ?, updated_at = ?
152
+ WHERE id = ?`
153
+ ).run(
154
+ next.start_time,
155
+ next.end_time,
156
+ next.start_lat,
157
+ next.start_lon,
158
+ next.end_lat,
159
+ next.end_lon,
160
+ next.start_place_id,
161
+ next.end_place_id,
162
+ next.start_place_name,
163
+ next.end_place_name,
164
+ next.start_place_pending,
165
+ next.end_place_pending,
166
+ next.distance,
167
+ now,
168
+ id
169
+ );
170
+
171
+ return getEntry(db, id);
172
+ });
173
+ }
174
+
175
+ function closeEntry(db, id, { now, position, placeMatchRadius }) {
176
+ return withTransaction(db, () => {
177
+ const row = requireEntryRow(db, id);
178
+ if (row.state === 'closed') {
179
+ throw conflict('entry_already_closed', `Entry ${id} is already closed`);
180
+ }
181
+ // Closing by hand confirms an arrival: when detection has already seen the
182
+ // vessel stop, the passage ended then, not when someone pressed the button.
183
+ const stoppedAt = row.stopped_since ?? now;
184
+ const endTime = stoppedAt < row.start_time ? row.start_time : stoppedAt;
185
+ const end = toPosition(row.end_lat, row.end_lon) ?? position;
186
+ const place =
187
+ row.end_place_name === null
188
+ ? initialPlaceName(db, end, placeMatchRadius)
189
+ : { id: row.end_place_id, name: row.end_place_name, pending: row.end_place_pending };
190
+
191
+ db.prepare(
192
+ `UPDATE log_entries
193
+ SET state = 'closed', end_time = ?, stopped_since = NULL, end_lat = ?, end_lon = ?,
194
+ end_place_id = ?, end_place_name = ?, end_place_pending = ?, updated_at = ?
195
+ WHERE id = ?`
196
+ ).run(
197
+ endTime,
198
+ end ? end.lat : null,
199
+ end ? end.lon : null,
200
+ place.id,
201
+ place.name,
202
+ place.pending,
203
+ now,
204
+ id
205
+ );
206
+
207
+ return getEntry(db, id);
208
+ });
209
+ }
210
+
211
+ // The earlier entry survives and absorbs the later one, so the passage keeps
212
+ // its original id and departure.
213
+ function mergeEntries(db, id, otherId, now) {
214
+ if (id === otherId) {
215
+ throw badRequest('An entry cannot be merged with itself');
216
+ }
217
+
218
+ return withTransaction(db, () => {
219
+ const [earlier, later] = [requireEntryRow(db, id), requireEntryRow(db, otherId)].sort(
220
+ (a, b) => a.start_time.localeCompare(b.start_time) || a.id - b.id
221
+ );
222
+
223
+ if (earlier.state === 'active') {
224
+ throw conflict(
225
+ 'entry_active',
226
+ `Entry ${earlier.id} is still in progress and cannot absorb a later entry`
227
+ );
228
+ }
229
+
230
+ const between = db
231
+ .prepare(
232
+ `SELECT COUNT(*) AS n FROM log_entries
233
+ WHERE id NOT IN (?, ?) AND start_time >= ? AND start_time <= ?`
234
+ )
235
+ .get(earlier.id, later.id, earlier.start_time, later.start_time).n;
236
+ if (between > 0) {
237
+ throw conflict(
238
+ 'entries_not_consecutive',
239
+ `Entries ${earlier.id} and ${later.id} are not consecutive`
240
+ );
241
+ }
242
+
243
+ for (const table of CHILD_TABLES) {
244
+ db.prepare(`UPDATE ${table} SET entry_id = ? WHERE entry_id = ?`).run(earlier.id, later.id);
245
+ }
246
+ // Delete before reopening: if the later entry is active, the single-active
247
+ // index would otherwise reject the update below.
248
+ db.prepare('DELETE FROM log_entries WHERE id = ?').run(later.id);
249
+
250
+ db.prepare(
251
+ `UPDATE log_entries SET
252
+ state = ?, end_time = ?, stopped_since = ?,
253
+ end_lat = ?, end_lon = ?, end_place_id = ?, end_place_name = ?, end_place_pending = ?,
254
+ distance = ?, updated_at = ?
255
+ WHERE id = ?`
256
+ ).run(
257
+ later.state,
258
+ later.end_time,
259
+ later.stopped_since,
260
+ later.end_lat,
261
+ later.end_lon,
262
+ later.end_place_id,
263
+ later.end_place_name,
264
+ later.end_place_pending,
265
+ earlier.distance + later.distance,
266
+ now,
267
+ earlier.id
268
+ );
269
+ recomputeDurations(db, earlier.id, now);
270
+
271
+ return getEntry(db, earlier.id);
272
+ });
273
+ }
274
+
275
+ function activeEntryId(db) {
276
+ return db.prepare("SELECT id FROM log_entries WHERE state = 'active'").get()?.id ?? null;
277
+ }
278
+
279
+ // The last passage, while the vessel is still at its arrival; null otherwise.
280
+ function entryNearArrival(db, position) {
281
+ if (!position) {
282
+ return null;
283
+ }
284
+ const last = db
285
+ .prepare(
286
+ `SELECT id, end_lat, end_lon FROM log_entries WHERE state = 'closed'
287
+ ORDER BY end_time DESC, id DESC LIMIT 1`
288
+ )
289
+ .get();
290
+ const stillThere =
291
+ last &&
292
+ last.end_lat !== null &&
293
+ distanceBetween(position, { lat: last.end_lat, lon: last.end_lon }) <= STILL_AT_ARRIVAL_METRES;
294
+ return stillThere ? last.id : null;
295
+ }
296
+
297
+ // A passage opened by the crew casting off, before detection sees the vessel
298
+ // move. It starts stopped, so the first movement resumes it rather than opening
299
+ // another one, and a departure that never happens closes like any long stop.
300
+ function openEntryByHand(db, { time, position, placeMatchRadius, now }) {
301
+ const previousEnd = db.prepare('SELECT MAX(end_time) AS endTime FROM log_entries').get().endTime;
302
+ const startTime = previousEnd && time < previousEnd ? previousEnd : time;
303
+ const place = initialPlaceName(db, position, placeMatchRadius);
304
+ const { lastInsertRowid } = db
305
+ .prepare(
306
+ `INSERT INTO log_entries (
307
+ state, start_time, stopped_since, start_lat, start_lon, start_place_id, start_place_name,
308
+ start_place_pending, end_lat, end_lon, created_at, updated_at
309
+ ) VALUES ('active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
310
+ )
311
+ .run(
312
+ startTime,
313
+ startTime,
314
+ position ? position.lat : null,
315
+ position ? position.lon : null,
316
+ place.id,
317
+ place.name,
318
+ place.pending,
319
+ position ? position.lat : null,
320
+ position ? position.lon : null,
321
+ now,
322
+ now
323
+ );
324
+ return Number(lastInsertRowid);
325
+ }
326
+
327
+ function deleteEntry(db, id) {
328
+ requireEntryRow(db, id);
329
+ db.prepare('DELETE FROM log_entries WHERE id = ?').run(id);
330
+ }
331
+
332
+ module.exports = {
333
+ toEntry,
334
+ requireEntryRow,
335
+ listEntries,
336
+ getEntry,
337
+ updateEntry,
338
+ closeEntry,
339
+ mergeEntries,
340
+ deleteEntry,
341
+ recomputeDurations,
342
+ activeEntryId,
343
+ entryNearArrival,
344
+ openEntryByHand
345
+ };
package/lib/errors.js ADDED
@@ -0,0 +1,22 @@
1
+ class ApiError extends Error {
2
+ constructor(status, code, message) {
3
+ super(message);
4
+ this.status = status;
5
+ this.code = code;
6
+ }
7
+ }
8
+
9
+ function badRequest(message) {
10
+ return new ApiError(400, 'invalid_request', message);
11
+ }
12
+
13
+ function notFound(resource, id) {
14
+ const label = resource.replace(/_/g, ' ');
15
+ return new ApiError(404, `${resource}_not_found`, `No ${label} with id ${id}`);
16
+ }
17
+
18
+ function conflict(code, message) {
19
+ return new ApiError(409, code, message);
20
+ }
21
+
22
+ module.exports = { ApiError, badRequest, notFound, conflict };
@@ -0,0 +1,332 @@
1
+ const { createFreshnessTracker } = require('./detection');
2
+ const { activeEntryId, entryNearArrival } = require('./entries');
3
+
4
+ const METRES_PER_SECOND_PER_KNOT = 1852 / 3600;
5
+
6
+ const EVENT_DEFAULTS = { windSpeedThresholds: [20, 30], pressureDropThreshold: 4 };
7
+
8
+ const CHECK_INTERVAL_MS = 1000;
9
+
10
+ // Any notification reaching these states is critical, whatever its path.
11
+ const CRITICAL_STATES = new Set(['alarm', 'emergency']);
12
+ const NOTIFICATION_METADATA_KEYS = new Set([
13
+ 'meta',
14
+ 'values',
15
+ '$source',
16
+ 'timestamp',
17
+ 'pgn',
18
+ 'sentence'
19
+ ]);
20
+
21
+ const DISENGAGED_AUTOPILOT_STATES = new Set(['standby', 'off']);
22
+
23
+ const POSITION_MAX_AGE_MS = 2 * 60 * 1000;
24
+ // Thresholds compare the average over this window, so a gust does not count.
25
+ const WIND_WINDOW_MS = 2 * 60 * 1000;
26
+ const MIN_WIND_COVERAGE_MS = 60 * 1000;
27
+ const WIND_MAX_AGE_MS = 2 * 60 * 1000;
28
+ const PRESSURE_WINDOW_MS = 3 * 60 * 60 * 1000;
29
+ const PRESSURE_SAMPLE_INTERVAL_MS = 60 * 1000;
30
+ const PRESSURE_MAX_AGE_MS = 15 * 60 * 1000;
31
+
32
+ function iso(ms) {
33
+ return new Date(ms).toISOString();
34
+ }
35
+
36
+ function isFiniteNumber(value) {
37
+ return typeof value === 'number' && Number.isFinite(value);
38
+ }
39
+
40
+ function collectNotifications(node, path, found, depth = 0) {
41
+ if (!node || typeof node !== 'object' || depth > 12) {
42
+ return;
43
+ }
44
+ if ('value' in node) {
45
+ const { value } = node;
46
+ if (value === null || (typeof value === 'object' && typeof value.state === 'string')) {
47
+ found.set(path, value);
48
+ }
49
+ return;
50
+ }
51
+ for (const [key, child] of Object.entries(node)) {
52
+ if (!NOTIFICATION_METADATA_KEYS.has(key)) {
53
+ collectNotifications(child, `${path}.${key}`, found, depth + 1);
54
+ }
55
+ }
56
+ }
57
+
58
+ function createEventWatcher({ db, readSelfPath, settings, observe, clock = Date.now }) {
59
+ const readFresh = createFreshnessTracker(readSelfPath);
60
+
61
+ const seenNotifications = new Map();
62
+ const loggedNotifications = new Map();
63
+ let autopilot = null;
64
+ const windSamples = [];
65
+ const windAbove = new Map();
66
+ const pressureSamples = [];
67
+ let pressureFalling = false;
68
+
69
+ // An anchor alarm goes off between passages, at the last one's destination.
70
+ function entryFor(type, position) {
71
+ const active = activeEntryId(db);
72
+ if (active !== null || type === 'autopilot') {
73
+ return active;
74
+ }
75
+ return entryNearArrival(db, position);
76
+ }
77
+
78
+ function logEvent(cycle, type, subtype, { comment = null, payload, snapshot }) {
79
+ const entryId = entryFor(type, cycle.position);
80
+ if (entryId === null) {
81
+ return false;
82
+ }
83
+ const time = iso(cycle.now);
84
+ db.prepare(
85
+ `INSERT INTO events (entry_id, time, type, subtype, lat, lon, comment, payload, source, created_at)
86
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'auto', ?)`
87
+ ).run(
88
+ entryId,
89
+ time,
90
+ type,
91
+ subtype,
92
+ cycle.position ? cycle.position.lat : null,
93
+ cycle.position ? cycle.position.lon : null,
94
+ comment,
95
+ JSON.stringify(payload),
96
+ time
97
+ );
98
+ if (snapshot) {
99
+ cycle.snapshots.add(entryId);
100
+ }
101
+ return true;
102
+ }
103
+
104
+ // What was last written for a notification, read from the log the first
105
+ // time it is seen, so a restart neither repeats an alarm nor forgets that
106
+ // one was open.
107
+ function lastLoggedState(path) {
108
+ if (!loggedNotifications.has(path)) {
109
+ const row = db
110
+ .prepare(
111
+ `SELECT payload FROM events WHERE type = 'sk_alarm' AND subtype = ?
112
+ ORDER BY time DESC, id DESC LIMIT 1`
113
+ )
114
+ .get(path);
115
+ loggedNotifications.set(path, row ? JSON.parse(row.payload).state : null);
116
+ }
117
+ return loggedNotifications.get(path);
118
+ }
119
+
120
+ function checkNotifications(cycle) {
121
+ const current = new Map();
122
+ collectNotifications(readSelfPath('notifications'), 'notifications', current);
123
+ for (const path of seenNotifications.keys()) {
124
+ if (!current.has(path)) {
125
+ current.set(path, null);
126
+ }
127
+ }
128
+
129
+ for (const [path, value] of current) {
130
+ const state = value?.state ?? 'normal';
131
+ if (seenNotifications.get(path) === state) {
132
+ continue;
133
+ }
134
+ seenNotifications.set(path, state);
135
+
136
+ const logged = lastLoggedState(path);
137
+ const critical = CRITICAL_STATES.has(state);
138
+ // Log a critical state not yet logged, or the end of one that was; a
139
+ // clearing whose alarm never made it into the log is not news.
140
+ const worthLogging = critical ? logged !== state : CRITICAL_STATES.has(logged);
141
+ if (!worthLogging) {
142
+ continue;
143
+ }
144
+ const message = value?.message ?? null;
145
+ if (
146
+ logEvent(cycle, 'sk_alarm', path, {
147
+ comment: message,
148
+ payload: { state, message },
149
+ snapshot: critical
150
+ })
151
+ ) {
152
+ loggedNotifications.set(path, state);
153
+ }
154
+ }
155
+ }
156
+
157
+ function readAutopilotTarget() {
158
+ const target = readSelfPath('steering.autopilot.target')?.value;
159
+ if (isFiniteNumber(target)) {
160
+ return target;
161
+ }
162
+ const byKind = {};
163
+ for (const kind of ['headingTrue', 'headingMagnetic', 'windAngleApparent', 'windAngleTrue']) {
164
+ const value = readSelfPath(`steering.autopilot.target.${kind}`)?.value;
165
+ if (isFiniteNumber(value)) {
166
+ byKind[kind] = value;
167
+ }
168
+ }
169
+ return Object.keys(byKind).length > 0 ? byKind : null;
170
+ }
171
+
172
+ // The Autopilot API publishes `engaged`; older autopilot plugins only a
173
+ // state such as auto, wind or standby.
174
+ function readAutopilot() {
175
+ const state = readSelfPath('steering.autopilot.state')?.value;
176
+ const mode = readSelfPath('steering.autopilot.mode')?.value;
177
+ const engagedValue = readSelfPath('steering.autopilot.engaged')?.value;
178
+
179
+ let engaged = typeof engagedValue === 'boolean' ? engagedValue : null;
180
+ if (engaged === null && typeof state === 'string' && state !== 'alarm') {
181
+ engaged = !DISENGAGED_AUTOPILOT_STATES.has(state);
182
+ }
183
+ if (engaged === null) {
184
+ return null;
185
+ }
186
+ return {
187
+ engaged,
188
+ mode: typeof mode === 'string' ? mode : null,
189
+ state: typeof state === 'string' ? state : null
190
+ };
191
+ }
192
+
193
+ function checkAutopilot(cycle) {
194
+ const pilot = readAutopilot();
195
+ if (!pilot) {
196
+ return;
197
+ }
198
+ const activity = pilot.mode ?? pilot.state;
199
+ const previous = autopilot;
200
+ autopilot = { engaged: pilot.engaged, activity };
201
+
202
+ let subtype = null;
203
+ if (previous === null) {
204
+ const last = db
205
+ .prepare(
206
+ "SELECT subtype FROM events WHERE type = 'autopilot' ORDER BY time DESC, id DESC LIMIT 1"
207
+ )
208
+ .get();
209
+ if (pilot.engaged && (!last || last.subtype === 'disengaged')) {
210
+ subtype = 'engaged';
211
+ } else if (!pilot.engaged && last && last.subtype !== 'disengaged') {
212
+ subtype = 'disengaged';
213
+ }
214
+ } else if (pilot.engaged !== previous.engaged) {
215
+ subtype = pilot.engaged ? 'engaged' : 'disengaged';
216
+ } else if (pilot.engaged && activity !== previous.activity) {
217
+ subtype = 'mode_changed';
218
+ }
219
+
220
+ if (subtype) {
221
+ // The last target lingers in the data model after disengaging.
222
+ const target = pilot.engaged ? readAutopilotTarget() : null;
223
+ logEvent(cycle, 'autopilot', subtype, {
224
+ payload: { mode: pilot.mode, state: pilot.state, target },
225
+ snapshot: false
226
+ });
227
+ }
228
+ }
229
+
230
+ function checkWind(cycle) {
231
+ const speed = readFresh('environment.wind.speedTrue', cycle.now, WIND_MAX_AGE_MS);
232
+ if (isFiniteNumber(speed)) {
233
+ windSamples.push({ time: cycle.now, speed });
234
+ }
235
+ while (windSamples.length > 0 && windSamples[0].time <= cycle.now - WIND_WINDOW_MS) {
236
+ windSamples.shift();
237
+ }
238
+ if (windSamples.length === 0 || cycle.now - windSamples[0].time < MIN_WIND_COVERAGE_MS) {
239
+ return;
240
+ }
241
+ const mean = windSamples.reduce((sum, sample) => sum + sample.speed, 0) / windSamples.length;
242
+
243
+ for (const knots of settings.windSpeedThresholds) {
244
+ const threshold = knots * METRES_PER_SECOND_PER_KNOT;
245
+ const clearsBelow = threshold - Math.max(2 * METRES_PER_SECOND_PER_KNOT, 0.1 * threshold);
246
+ const wasAbove = windAbove.get(knots);
247
+ let isAbove = wasAbove;
248
+ if (mean >= threshold) {
249
+ isAbove = true;
250
+ } else if (mean < clearsBelow) {
251
+ isAbove = false;
252
+ }
253
+ if (isAbove === undefined) {
254
+ continue;
255
+ }
256
+ windAbove.set(knots, isAbove);
257
+ // The first reading only sets where the wind stands: strong wind already
258
+ // blowing when the plugin starts is a condition, not a crossing.
259
+ if (wasAbove !== undefined && wasAbove !== isAbove) {
260
+ logEvent(cycle, 'weather_threshold', isAbove ? 'wind_above' : 'wind_below', {
261
+ payload: { threshold, windSpeed: mean },
262
+ snapshot: true
263
+ });
264
+ }
265
+ }
266
+ }
267
+
268
+ function checkPressure(cycle) {
269
+ if (!settings.pressureDropThreshold) {
270
+ return;
271
+ }
272
+ const pressure = readFresh('environment.outside.pressure', cycle.now, PRESSURE_MAX_AGE_MS);
273
+ const latest = pressureSamples.at(-1);
274
+ if (
275
+ isFiniteNumber(pressure) &&
276
+ (!latest || cycle.now - latest.time >= PRESSURE_SAMPLE_INTERVAL_MS)
277
+ ) {
278
+ pressureSamples.push({ time: cycle.now, pressure });
279
+ }
280
+ while (
281
+ pressureSamples.length > 1 &&
282
+ pressureSamples[1].time <= cycle.now - PRESSURE_WINDOW_MS
283
+ ) {
284
+ pressureSamples.shift();
285
+ }
286
+
287
+ const current = pressureSamples.at(-1);
288
+ const reference = pressureSamples[0];
289
+ if (
290
+ !current ||
291
+ cycle.now - current.time > PRESSURE_MAX_AGE_MS ||
292
+ current.time - reference.time < PRESSURE_WINDOW_MS
293
+ ) {
294
+ return;
295
+ }
296
+
297
+ const drop = reference.pressure - current.pressure;
298
+ const threshold = settings.pressureDropThreshold * 100;
299
+ if (!pressureFalling && drop >= threshold) {
300
+ pressureFalling = true;
301
+ logEvent(cycle, 'weather_threshold', 'pressure_drop', {
302
+ payload: { drop, over: PRESSURE_WINDOW_MS / 1000, pressure: current.pressure },
303
+ snapshot: true
304
+ });
305
+ } else if (pressureFalling && drop < threshold / 2) {
306
+ pressureFalling = false;
307
+ }
308
+ }
309
+
310
+ return {
311
+ check() {
312
+ const now = clock();
313
+ const fix = readFresh('navigation.position', now, POSITION_MAX_AGE_MS);
314
+ const position =
315
+ fix && Number.isFinite(fix.latitude) && Number.isFinite(fix.longitude)
316
+ ? { lat: fix.latitude, lon: fix.longitude }
317
+ : null;
318
+ const cycle = { now, position, snapshots: new Set() };
319
+
320
+ checkNotifications(cycle);
321
+ checkAutopilot(cycle);
322
+ checkWind(cycle);
323
+ checkPressure(cycle);
324
+
325
+ for (const entryId of cycle.snapshots) {
326
+ observe(entryId, iso(now));
327
+ }
328
+ }
329
+ };
330
+ }
331
+
332
+ module.exports = { createEventWatcher, EVENT_DEFAULTS, CHECK_INTERVAL_MS };