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/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 };
|