signalk-chiplog 2.0.0 → 2.2.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 +186 -37
- package/README.md +340 -134
- package/index.js +80 -40
- package/lib/api.js +109 -1
- package/lib/background-schedule.js +67 -0
- package/lib/crew.js +185 -0
- package/lib/database.js +103 -1
- package/lib/departure-forecast.js +150 -0
- package/lib/detection.js +45 -14
- package/lib/entries.js +96 -48
- package/lib/event-watcher.js +1 -3
- package/lib/events.js +64 -16
- package/lib/export.js +20 -1
- package/lib/landmark-finder.js +209 -0
- package/lib/landmarks.js +210 -0
- package/lib/logbook-pdf.js +368 -17
- package/lib/observation-recorder.js +11 -5
- package/lib/tide-forecaster.js +23 -116
- package/lib/weather-forecaster.js +147 -0
- package/package.json +2 -1
- package/public/app.css +212 -2
- package/public/entry/entry.css +71 -0
- package/public/entry/js/components/CrewDialog.mjs +200 -0
- package/public/entry/js/main.mjs +114 -0
- package/public/entry/sw.js +2 -0
- package/public/js/animation/camera.mjs +180 -0
- package/public/js/animation/formats.mjs +45 -0
- package/public/js/animation/mercator.mjs +50 -0
- package/public/js/animation/mp4.mjs +77 -0
- package/public/js/animation/player.mjs +120 -0
- package/public/js/animation/renderer.mjs +326 -0
- package/public/js/animation/schedule.mjs +49 -0
- package/public/js/animation/storyboard.mjs +195 -0
- package/public/js/animation/tiles.mjs +219 -0
- package/public/js/animation/timeline.mjs +186 -0
- package/public/js/components/AnimationExport.mjs +173 -0
- package/public/js/components/AnimationView.mjs +439 -0
- package/public/js/components/CrewCard.mjs +22 -0
- package/public/js/components/ExportView.mjs +3 -8
- package/public/js/components/PassageView.mjs +49 -19
- package/public/js/components/Timeline.mjs +30 -7
- package/public/js/components/WeatherCard.mjs +134 -0
- package/public/js/context.mjs +7 -0
- package/public/js/days.mjs +10 -0
- package/public/js/format.mjs +15 -0
- package/public/js/i18n.mjs +246 -0
- package/public/js/landmarks.mjs +128 -0
- package/public/js/main.mjs +8 -5
- package/public/js/weather.mjs +193 -0
- package/public/vendor/mediabunny-LICENSE +373 -0
- package/public/vendor/mediabunny.min.mjs +50 -0
package/lib/database.js
CHANGED
|
@@ -46,7 +46,9 @@ const MIGRATIONS = [
|
|
|
46
46
|
start_time TEXT NOT NULL,
|
|
47
47
|
end_time TEXT,
|
|
48
48
|
-- Set when the vessel stops, cleared if it moves again before the
|
|
49
|
-
-- configured tolerance elapses (SPEC §3.1).
|
|
49
|
+
-- configured tolerance elapses (SPEC §3.1). Since migration 13, only a
|
|
50
|
+
-- passage the crew opened by casting off waits like this; detection
|
|
51
|
+
-- closes the others at once.
|
|
50
52
|
stopped_since TEXT,
|
|
51
53
|
start_lat REAL,
|
|
52
54
|
start_lon REAL,
|
|
@@ -256,6 +258,106 @@ const MIGRATIONS = [
|
|
|
256
258
|
-- publishes none, or the passage was opened after the fact.
|
|
257
259
|
ALTER TABLE log_entries ADD COLUMN start_tanks TEXT;
|
|
258
260
|
ALTER TABLE log_entries ADD COLUMN start_batteries TEXT;
|
|
261
|
+
`,
|
|
262
|
+
`
|
|
263
|
+
-- The marine weather forecast fetched near a passage's departure (SPEC
|
|
264
|
+
-- §4.5.3), shaped like tide_forecasts: "points" is JSON [{ time, windSpeed,
|
|
265
|
+
-- ..., currentDirection }], hourly over the 24 h from departure, in SI units
|
|
266
|
+
-- with null for what the service did not give; empty once fetched if it had
|
|
267
|
+
-- nothing for the position.
|
|
268
|
+
CREATE TABLE weather_forecasts (
|
|
269
|
+
entry_id INTEGER PRIMARY KEY REFERENCES log_entries (id) ON DELETE CASCADE,
|
|
270
|
+
lat REAL NOT NULL,
|
|
271
|
+
lon REAL NOT NULL,
|
|
272
|
+
fetched_at TEXT NOT NULL,
|
|
273
|
+
points TEXT NOT NULL
|
|
274
|
+
);
|
|
275
|
+
`,
|
|
276
|
+
`
|
|
277
|
+
-- Who closed a passage: detection, on arrival, or the crew. Only a passage
|
|
278
|
+
-- detection closed is reopened by a departure within stopClosureMinutes
|
|
279
|
+
-- (SPEC §4.2); the crew closing one confirms the arrival. NULL while active,
|
|
280
|
+
-- and for passages closed before this column existed.
|
|
281
|
+
ALTER TABLE log_entries ADD COLUMN closed_by TEXT CHECK (closed_by IN ('detection', 'crew'));
|
|
282
|
+
`,
|
|
283
|
+
`
|
|
284
|
+
-- Crew list (SPEC §4.11): a small global roster of who might be aboard,
|
|
285
|
+
-- ever -- unlike manoeuvre_types there is no builtin/sort_order, since the
|
|
286
|
+
-- list is short enough to sort alphabetically at read time, like places.
|
|
287
|
+
CREATE TABLE crew_members (
|
|
288
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
289
|
+
name TEXT NOT NULL,
|
|
290
|
+
role TEXT,
|
|
291
|
+
created_at TEXT NOT NULL,
|
|
292
|
+
updated_at TEXT NOT NULL
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
-- Who was aboard a given passage -- the schema's first many-to-many
|
|
296
|
+
-- relationship. name/role are denormalised on purpose, exactly like
|
|
297
|
+
-- log_entries.start_place_name: a logbook is a historical record, so
|
|
298
|
+
-- correcting or deleting a crew_members row (ON DELETE SET NULL) must
|
|
299
|
+
-- never rewrite who a past passage says was aboard.
|
|
300
|
+
CREATE TABLE log_entry_crew (
|
|
301
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
302
|
+
entry_id INTEGER NOT NULL REFERENCES log_entries (id) ON DELETE CASCADE,
|
|
303
|
+
crew_member_id INTEGER REFERENCES crew_members (id) ON DELETE SET NULL,
|
|
304
|
+
name TEXT NOT NULL,
|
|
305
|
+
role TEXT,
|
|
306
|
+
created_at TEXT NOT NULL
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
CREATE INDEX idx_log_entry_crew_entry ON log_entry_crew (entry_id);
|
|
310
|
+
|
|
311
|
+
-- A roster member is assigned to a given entry at most once. An ad hoc
|
|
312
|
+
-- crew member with no roster row (crew_member_id NULL) has nothing to
|
|
313
|
+
-- deduplicate against, hence the partial index.
|
|
314
|
+
CREATE UNIQUE INDEX idx_log_entry_crew_unique_member
|
|
315
|
+
ON log_entry_crew (entry_id, crew_member_id)
|
|
316
|
+
WHERE crew_member_id IS NOT NULL;
|
|
317
|
+
`,
|
|
318
|
+
`
|
|
319
|
+
-- Landmarks -- amers (SPEC §4.13): the named features a journal line's
|
|
320
|
+
-- position is read against, "1.2 nm NE (053°) of Phare du Cap-Ferret".
|
|
321
|
+
-- Fetched from OpenStreetMap area by area and kept: the bearing itself is
|
|
322
|
+
-- worked out at read time, so a past passage fills in as soon as its area
|
|
323
|
+
-- is known, and nothing here is part of the logbook's record.
|
|
324
|
+
CREATE TABLE landmarks (
|
|
325
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
326
|
+
osm_type TEXT NOT NULL CHECK (osm_type IN ('node', 'way', 'relation')),
|
|
327
|
+
osm_id INTEGER NOT NULL,
|
|
328
|
+
name TEXT NOT NULL,
|
|
329
|
+
kind TEXT NOT NULL CHECK (
|
|
330
|
+
kind IN ('lighthouse', 'light', 'cape', 'landmark', 'beacon', 'harbour')
|
|
331
|
+
),
|
|
332
|
+
lat REAL NOT NULL,
|
|
333
|
+
lon REAL NOT NULL,
|
|
334
|
+
-- The nominal range of its light in metres, when OpenStreetMap gives one:
|
|
335
|
+
-- how far out this amer is still worth naming a position from.
|
|
336
|
+
light_range REAL,
|
|
337
|
+
created_at TEXT NOT NULL,
|
|
338
|
+
updated_at TEXT NOT NULL
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
CREATE UNIQUE INDEX idx_landmarks_osm ON landmarks (osm_type, osm_id);
|
|
342
|
+
|
|
343
|
+
-- Supports the bounding-box prefilter, as for places.
|
|
344
|
+
CREATE INDEX idx_landmarks_position ON landmarks (lat, lon);
|
|
345
|
+
|
|
346
|
+
-- The half-degree cells landmarks have already been fetched for. A fetch
|
|
347
|
+
-- covers its cell plus a margin, so every position inside the cell has all
|
|
348
|
+
-- the amers within range in the table -- one request per cell, ever.
|
|
349
|
+
CREATE TABLE landmark_areas (
|
|
350
|
+
cell_lat INTEGER NOT NULL,
|
|
351
|
+
cell_lon INTEGER NOT NULL,
|
|
352
|
+
fetched_at TEXT NOT NULL,
|
|
353
|
+
PRIMARY KEY (cell_lat, cell_lon)
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
-- Cleared once every cell a closed passage sailed through has been fetched.
|
|
357
|
+
-- An open passage stays pending: it keeps moving into new cells. Existing
|
|
358
|
+
-- passages default to pending, so their areas are fetched too.
|
|
359
|
+
ALTER TABLE log_entries ADD COLUMN landmarks_pending INTEGER NOT NULL DEFAULT 1
|
|
360
|
+
CHECK (landmarks_pending IN (0, 1));
|
|
259
361
|
`
|
|
260
362
|
];
|
|
261
363
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
const { withTransaction } = require('./database');
|
|
2
|
+
|
|
3
|
+
// What the tide and weather forecasters share: fetch, once per passage, a
|
|
4
|
+
// forecast near the departure position for the 24 h from departure, and keep
|
|
5
|
+
// it in a table of its own -- one row per entry, `points` as JSON
|
|
6
|
+
// [{ time, ... }] (SPEC §4.5.2, §4.5.3).
|
|
7
|
+
|
|
8
|
+
const IDLE_MS = 60 * 1000;
|
|
9
|
+
// Offline is the normal state at sea; retry patiently rather than hammering.
|
|
10
|
+
const FIRST_RETRY_MS = 5 * 60 * 1000;
|
|
11
|
+
const MAX_RETRY_MS = 60 * 60 * 1000;
|
|
12
|
+
// A fetch this long after departure would no longer be "near departure" --
|
|
13
|
+
// the forecast window has mostly gone by, so give up rather than keep trying.
|
|
14
|
+
const MAX_FETCH_AGE_MS = 3 * 60 * 60 * 1000;
|
|
15
|
+
const WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
const REQUEST_TIMEOUT_MS = 10 * 1000;
|
|
17
|
+
|
|
18
|
+
function iso(ms) {
|
|
19
|
+
return new Date(ms).toISOString();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The entry most recently departed that has no forecast yet -- fetched or
|
|
23
|
+
// found to have none, either way recorded as a row, so this never re-selects
|
|
24
|
+
// it. Bounded to recent departures: a fetch long after departure would mean
|
|
25
|
+
// requesting a window mostly in the past, which the forecast API does not
|
|
26
|
+
// have, and repeatedly retrying an entry the boat was offline for the whole
|
|
27
|
+
// relevant window of would never succeed anyway.
|
|
28
|
+
function nextPending(db, table, now) {
|
|
29
|
+
return db
|
|
30
|
+
.prepare(
|
|
31
|
+
`SELECT id, start_time, start_lat AS lat, start_lon AS lon FROM log_entries
|
|
32
|
+
WHERE start_lat IS NOT NULL AND start_lon IS NOT NULL
|
|
33
|
+
AND start_time >= ?
|
|
34
|
+
AND id NOT IN (SELECT entry_id FROM ${table})
|
|
35
|
+
ORDER BY id DESC LIMIT 1`
|
|
36
|
+
)
|
|
37
|
+
.get(iso(now - MAX_FETCH_AGE_MS));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function save(db, table, entryId, position, now, points) {
|
|
41
|
+
withTransaction(db, () => {
|
|
42
|
+
db.prepare(
|
|
43
|
+
`INSERT INTO ${table} (entry_id, lat, lon, fetched_at, points)
|
|
44
|
+
VALUES (?, ?, ?, ?, ?)
|
|
45
|
+
ON CONFLICT (entry_id) DO NOTHING`
|
|
46
|
+
).run(entryId, position.lat, position.lon, iso(now), JSON.stringify(points));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Open-Meteo answers hourly times as "2026-09-17T08:00", in UTC as asked.
|
|
51
|
+
function utcTime(time) {
|
|
52
|
+
return `${time}:00.000Z`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A GET to an Open-Meteo-style service. Rate limiting or server trouble is
|
|
56
|
+
// worth retrying later (throws); any other refusal -- an unrecognised
|
|
57
|
+
// parameter, say -- is a final answer of "nothing usable" (null).
|
|
58
|
+
async function getJson(fetch, url, { userAgent, signal, label }) {
|
|
59
|
+
const response = await fetch(url, {
|
|
60
|
+
headers: { 'User-Agent': userAgent, Accept: 'application/json' },
|
|
61
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
|
|
62
|
+
});
|
|
63
|
+
if (response.status === 429 || response.status >= 500) {
|
|
64
|
+
throw new Error(`${label} answered HTTP ${response.status}`);
|
|
65
|
+
}
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return response.json();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// `fetchPoints(position, signal)` returns every point the service has (the
|
|
73
|
+
// window is cut here), throws to be retried, or returns [] for no data.
|
|
74
|
+
function createDepartureForecaster({ db, table, isEnabled, fetchPoints }) {
|
|
75
|
+
const stopping = new AbortController();
|
|
76
|
+
// Consecutive failures for `failingEntryId`: the retry delay grows with
|
|
77
|
+
// them, and starts afresh for another passage -- a new one should not
|
|
78
|
+
// inherit the long delay an earlier, abandoned one had reached.
|
|
79
|
+
let failures = 0;
|
|
80
|
+
let failingEntryId = null;
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
async resolveNext() {
|
|
84
|
+
if (!isEnabled()) {
|
|
85
|
+
return { outcome: 'disabled', retryInMs: IDLE_MS };
|
|
86
|
+
}
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
const pending = nextPending(db, table, now);
|
|
89
|
+
if (!pending) {
|
|
90
|
+
failures = 0;
|
|
91
|
+
return { outcome: 'idle', retryInMs: IDLE_MS };
|
|
92
|
+
}
|
|
93
|
+
if (pending.id !== failingEntryId) {
|
|
94
|
+
failures = 0;
|
|
95
|
+
failingEntryId = pending.id;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const departure = Date.parse(pending.start_time);
|
|
99
|
+
const position = { lat: pending.lat, lon: pending.lon };
|
|
100
|
+
|
|
101
|
+
let all;
|
|
102
|
+
try {
|
|
103
|
+
all = await fetchPoints(position, stopping.signal);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (stopping.signal.aborted) {
|
|
106
|
+
return { outcome: 'stopped' };
|
|
107
|
+
}
|
|
108
|
+
failures += 1;
|
|
109
|
+
return {
|
|
110
|
+
outcome: 'failed',
|
|
111
|
+
error,
|
|
112
|
+
retryInMs: Math.min(FIRST_RETRY_MS * 2 ** (failures - 1), MAX_RETRY_MS)
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (stopping.signal.aborted) {
|
|
116
|
+
return { outcome: 'stopped' };
|
|
117
|
+
}
|
|
118
|
+
failures = 0;
|
|
119
|
+
|
|
120
|
+
const points = all.filter((point) => {
|
|
121
|
+
const time = Date.parse(point.time);
|
|
122
|
+
return time >= departure && time < departure + WINDOW_MS;
|
|
123
|
+
});
|
|
124
|
+
save(db, table, pending.id, position, now, points);
|
|
125
|
+
return { outcome: points.length > 0 ? 'fetched' : 'no_data', retryInMs: 0 };
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
stop() {
|
|
129
|
+
stopping.abort();
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function readForecast(db, table, entryId) {
|
|
135
|
+
const row = db.prepare(`SELECT * FROM ${table} WHERE entry_id = ?`).get(entryId);
|
|
136
|
+
if (!row) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const points = JSON.parse(row.points);
|
|
140
|
+
if (points.length === 0) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
position: { lat: row.lat, lon: row.lon },
|
|
145
|
+
fetchedAt: row.fetched_at,
|
|
146
|
+
points
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { createDepartureForecaster, readForecast, getJson, utcTime };
|
package/lib/detection.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
const { withTransaction } = require('./database');
|
|
2
|
+
const crew = require('./crew');
|
|
3
|
+
const { recordStopover, reopenableEntry } = require('./entries');
|
|
2
4
|
const { initialPlaceName } = require('./place-names');
|
|
3
5
|
const { createObservationRecorder } = require('./observation-recorder');
|
|
4
6
|
const { createPropulsionTracker } = require('./propulsion-detector');
|
|
@@ -280,7 +282,9 @@ function createPassageDetector({ db, readSelfPath, settings, clock = Date.now })
|
|
|
280
282
|
return { time: entry.last_moving_at ?? iso(now), position: storedEnd(entry) ?? position };
|
|
281
283
|
}
|
|
282
284
|
|
|
283
|
-
|
|
285
|
+
// Returns what the tick reports about the closure. `late` means the plugin was
|
|
286
|
+
// not running at the arrival: current conditions say nothing about it.
|
|
287
|
+
function closePassage(entry, now, { late = false } = {}) {
|
|
284
288
|
// A name the crew typed before arrival is kept as it is.
|
|
285
289
|
const place =
|
|
286
290
|
entry.end_place_name === null
|
|
@@ -289,13 +293,27 @@ function createPassageDetector({ db, readSelfPath, settings, clock = Date.now })
|
|
|
289
293
|
db.prepare(
|
|
290
294
|
`UPDATE log_entries
|
|
291
295
|
SET state = 'closed', end_time = stopped_since, stopped_since = NULL,
|
|
292
|
-
end_place_id = ?, end_place_name = ?, end_place_pending = ?,
|
|
296
|
+
end_place_id = ?, end_place_name = ?, end_place_pending = ?, closed_by = 'detection',
|
|
297
|
+
updated_at = ?
|
|
293
298
|
WHERE id = ?`
|
|
294
299
|
).run(place.id, place.name, place.pending, iso(now), entry.id);
|
|
300
|
+
return { id: entry.id, endTime: entry.stopped_since, late };
|
|
295
301
|
}
|
|
296
302
|
|
|
297
|
-
|
|
298
|
-
|
|
303
|
+
// A departure soon after detection closed the last passage carries it on:
|
|
304
|
+
// the stop it ended at stays on its timeline as a stopover.
|
|
305
|
+
function reopenPassage(entry, now, position) {
|
|
306
|
+
recordStopover(db, entry, iso(now));
|
|
307
|
+
db.prepare(
|
|
308
|
+
`UPDATE log_entries
|
|
309
|
+
SET state = 'active', end_time = NULL, closed_by = NULL, end_place_id = NULL,
|
|
310
|
+
end_place_name = NULL, end_place_pending = 0, updated_at = ?
|
|
311
|
+
WHERE id = ?`
|
|
312
|
+
).run(iso(now), entry.id);
|
|
313
|
+
recordMovement(activeEntry(), now, position, { force: true });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function openPassage(now, position, departure) {
|
|
299
317
|
const previousEnd = db
|
|
300
318
|
.prepare('SELECT MAX(end_time) AS endTime FROM log_entries')
|
|
301
319
|
.get().endTime;
|
|
@@ -324,13 +342,16 @@ function createPassageDetector({ db, readSelfPath, settings, clock = Date.now })
|
|
|
324
342
|
iso(now),
|
|
325
343
|
iso(now)
|
|
326
344
|
);
|
|
327
|
-
|
|
345
|
+
const entryId = Number(lastInsertRowid);
|
|
346
|
+
crew.copyCrewFromPreviousEntry(db, entryId, iso(now));
|
|
347
|
+
return entryId;
|
|
328
348
|
}
|
|
329
349
|
|
|
330
350
|
function step(now, current, position, previous) {
|
|
331
351
|
let entry = activeEntry();
|
|
332
352
|
let resumedAt = null;
|
|
333
353
|
let opened = null;
|
|
354
|
+
let reopened = null;
|
|
334
355
|
let closed = null;
|
|
335
356
|
|
|
336
357
|
// A passage left open across a restart with no movement recorded for longer
|
|
@@ -342,6 +363,8 @@ function createPassageDetector({ db, readSelfPath, settings, clock = Date.now })
|
|
|
342
363
|
const lastMoving = entry.last_moving_at ?? entry.start_time;
|
|
343
364
|
if (now - Date.parse(lastMoving) >= closureMs) {
|
|
344
365
|
entry = markStopped(entry, { time: lastMoving, position: storedEnd(entry) }, now);
|
|
366
|
+
closed = closePassage(entry, now, { late: true });
|
|
367
|
+
entry = null;
|
|
345
368
|
}
|
|
346
369
|
}
|
|
347
370
|
}
|
|
@@ -350,21 +373,21 @@ function createPassageDetector({ db, readSelfPath, settings, clock = Date.now })
|
|
|
350
373
|
if (current === 'underway') {
|
|
351
374
|
recordMovement(entry, now, position);
|
|
352
375
|
} else if (current === 'stopped') {
|
|
376
|
+
// Closed as soon as the vessel stops; moving again soon reopens it.
|
|
353
377
|
entry = markStopped(entry, arrivalOf(entry, now, position), now);
|
|
378
|
+
closed = closePassage(entry, now);
|
|
379
|
+
entry = null;
|
|
354
380
|
}
|
|
355
381
|
}
|
|
356
382
|
|
|
383
|
+
// Still stopped while active: a passage the crew opened by casting off,
|
|
384
|
+
// not under way yet (or one a previous version left waiting out its stop).
|
|
357
385
|
if (entry && entry.stopped_since !== null) {
|
|
358
386
|
const stoppedFor = now - Date.parse(entry.stopped_since);
|
|
359
387
|
if (stoppedFor >= closureMs) {
|
|
360
|
-
closePassage(entry, now
|
|
361
|
-
// Closed well after the tolerance ran out means the plugin was not
|
|
362
|
-
// running: current conditions say nothing about that arrival.
|
|
363
|
-
closed = {
|
|
364
|
-
id: entry.id,
|
|
365
|
-
endTime: entry.stopped_since,
|
|
388
|
+
closed = closePassage(entry, now, {
|
|
366
389
|
late: stoppedFor > closureMs + LATE_CLOSURE_MARGIN_MS
|
|
367
|
-
};
|
|
390
|
+
});
|
|
368
391
|
entry = null;
|
|
369
392
|
} else if (current === 'underway') {
|
|
370
393
|
resumedAt = resumeTime(entry, now);
|
|
@@ -375,10 +398,18 @@ function createPassageDetector({ db, readSelfPath, settings, clock = Date.now })
|
|
|
375
398
|
// Only a transition opens a passage: an entry closed by hand while still
|
|
376
399
|
// moving must not be reopened on the next tick.
|
|
377
400
|
if (!entry && current === 'underway' && previous !== 'underway') {
|
|
378
|
-
|
|
401
|
+
const departure = speeds.departure(now) ?? { time: now, position };
|
|
402
|
+
const previousEntry = reopenableEntry(db, iso(departure.time), closureMs);
|
|
403
|
+
if (previousEntry) {
|
|
404
|
+
reopenPassage(previousEntry, now, position);
|
|
405
|
+
resumedAt = departure.time;
|
|
406
|
+
reopened = previousEntry.id;
|
|
407
|
+
} else {
|
|
408
|
+
opened = openPassage(now, position, departure);
|
|
409
|
+
}
|
|
379
410
|
}
|
|
380
411
|
|
|
381
|
-
return { resumedAt, opened, closed };
|
|
412
|
+
return { resumedAt, opened, reopened, closed };
|
|
382
413
|
}
|
|
383
414
|
|
|
384
415
|
return {
|
package/lib/entries.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { withTransaction } = require('./database');
|
|
2
|
+
const crew = require('./crew');
|
|
2
3
|
const { badRequest, conflict, notFound } = require('./errors');
|
|
3
4
|
const { coordinatesName, initialPlaceName } = require('./place-names');
|
|
4
5
|
const { distanceBetween, rememberPlaceName } = require('./places');
|
|
@@ -10,6 +11,9 @@ const CHILD_TABLES = ['track_points', 'observations', 'propulsion_segments', 'ev
|
|
|
10
11
|
// passage's destination: an alarm or a note there belongs to it.
|
|
11
12
|
const STILL_AT_ARRIVAL_METRES = 1852;
|
|
12
13
|
|
|
14
|
+
// Logged with no passage open, these open one: the crew is leaving.
|
|
15
|
+
const DEPARTURE_MANOEUVRES = ['cast_off', 'anchor_up'];
|
|
16
|
+
|
|
13
17
|
function toEntry(row) {
|
|
14
18
|
return {
|
|
15
19
|
id: row.id,
|
|
@@ -119,7 +123,8 @@ function getEntry(db, id) {
|
|
|
119
123
|
},
|
|
120
124
|
maxSpeed,
|
|
121
125
|
maxWindSpeed: wind.tws ?? wind.aws,
|
|
122
|
-
maxWindApparent: wind.tws === null && wind.aws !== null
|
|
126
|
+
maxWindApparent: wind.tws === null && wind.aws !== null,
|
|
127
|
+
crew: crew.listEntryCrew(db, id)
|
|
123
128
|
};
|
|
124
129
|
}
|
|
125
130
|
|
|
@@ -211,13 +216,16 @@ function updateEntry(db, id, patch, { placeMatchRadius, now }) {
|
|
|
211
216
|
next.distance = patch.distance;
|
|
212
217
|
}
|
|
213
218
|
|
|
219
|
+
// landmarks_pending: an edited departure or arrival position may sit in an
|
|
220
|
+
// area no landmarks have been fetched for (SPEC §4.13); the lookup checks
|
|
221
|
+
// and clears it again.
|
|
214
222
|
db.prepare(
|
|
215
223
|
`UPDATE log_entries SET
|
|
216
224
|
start_time = ?, end_time = ?,
|
|
217
225
|
start_lat = ?, start_lon = ?, end_lat = ?, end_lon = ?,
|
|
218
226
|
start_place_id = ?, end_place_id = ?, start_place_name = ?, end_place_name = ?,
|
|
219
227
|
start_place_pending = ?, end_place_pending = ?,
|
|
220
|
-
distance = ?, updated_at = ?
|
|
228
|
+
distance = ?, landmarks_pending = 1, updated_at = ?
|
|
221
229
|
WHERE id = ?`
|
|
222
230
|
).run(
|
|
223
231
|
next.start_time,
|
|
@@ -260,7 +268,8 @@ function closeEntry(db, id, { now, position, placeMatchRadius }) {
|
|
|
260
268
|
db.prepare(
|
|
261
269
|
`UPDATE log_entries
|
|
262
270
|
SET state = 'closed', end_time = ?, stopped_since = NULL, end_lat = ?, end_lon = ?,
|
|
263
|
-
end_place_id = ?, end_place_name = ?, end_place_pending = ?,
|
|
271
|
+
end_place_id = ?, end_place_name = ?, end_place_pending = ?, closed_by = 'crew',
|
|
272
|
+
updated_at = ?
|
|
264
273
|
WHERE id = ?`
|
|
265
274
|
).run(
|
|
266
275
|
endTime,
|
|
@@ -277,6 +286,37 @@ function closeEntry(db, id, { now, position, placeMatchRadius }) {
|
|
|
277
286
|
});
|
|
278
287
|
}
|
|
279
288
|
|
|
289
|
+
// The end of a passage that goes on -- merged into the next one, or reopened
|
|
290
|
+
// by a departure soon after -- would leave no trace on the timeline once
|
|
291
|
+
// overwritten; it is kept as a stopover event at that stop instead. Placed no
|
|
292
|
+
// earlier than the entry_end reading, so it follows it on the timeline instead
|
|
293
|
+
// of appearing to predate the passage's own arrival. Falls back to the entry's
|
|
294
|
+
// end time when there is no reading to follow -- closed by hand, or with no
|
|
295
|
+
// instrument data. The comment carries the raw place name too, like an
|
|
296
|
+
// sk_alarm's message, so it still reads in a CSV export or before any
|
|
297
|
+
// translation is applied.
|
|
298
|
+
function recordStopover(db, row, now) {
|
|
299
|
+
if (row.end_place_name === null) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const reading = db
|
|
303
|
+
.prepare(`SELECT MAX(time) AS time FROM observations WHERE entry_id = ? AND reason = ?`)
|
|
304
|
+
.get(row.id, 'entry_end').time;
|
|
305
|
+
const time = reading && reading > row.end_time ? reading : row.end_time;
|
|
306
|
+
db.prepare(
|
|
307
|
+
`INSERT INTO events (entry_id, time, type, lat, lon, comment, payload, source, created_at)
|
|
308
|
+
VALUES (?, ?, 'stopover', ?, ?, ?, ?, 'auto', ?)`
|
|
309
|
+
).run(
|
|
310
|
+
row.id,
|
|
311
|
+
time,
|
|
312
|
+
row.end_lat,
|
|
313
|
+
row.end_lon,
|
|
314
|
+
row.end_place_name,
|
|
315
|
+
JSON.stringify({ placeName: row.end_place_name, placePending: row.end_place_pending === 1 }),
|
|
316
|
+
now
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
280
320
|
// The earlier entry survives and absorbs the later one, so the passage keeps
|
|
281
321
|
// its original id and departure.
|
|
282
322
|
function mergeEntries(db, id, otherId, now) {
|
|
@@ -309,15 +349,13 @@ function mergeEntries(db, id, otherId, now) {
|
|
|
309
349
|
);
|
|
310
350
|
}
|
|
311
351
|
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
.prepare(`SELECT MAX(time) AS time FROM observations WHERE entry_id = ? AND reason = ?`)
|
|
320
|
-
.get(earlier.id, 'entry_end').time;
|
|
352
|
+
// Before the reassignment below, while entry_id still tells earlier's own
|
|
353
|
+
// entry_end reading apart from later's.
|
|
354
|
+
recordStopover(db, earlier, now);
|
|
355
|
+
// Unioned rather than reassigned: carry-over (§4.11) means the two crews
|
|
356
|
+
// usually overlap, and a straight UPDATE would collide with the partial
|
|
357
|
+
// unique index on (entry_id, crew_member_id).
|
|
358
|
+
crew.absorbCrewOnMerge(db, earlier.id, later.id, now);
|
|
321
359
|
|
|
322
360
|
for (const table of CHILD_TABLES) {
|
|
323
361
|
db.prepare(`UPDATE ${table} SET entry_id = ? WHERE entry_id = ?`).run(earlier.id, later.id);
|
|
@@ -326,48 +364,19 @@ function mergeEntries(db, id, otherId, now) {
|
|
|
326
364
|
// index would otherwise reject the update below.
|
|
327
365
|
db.prepare('DELETE FROM log_entries WHERE id = ?').run(later.id);
|
|
328
366
|
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
// the whole reason a merge exists — would leave no trace on the timeline.
|
|
332
|
-
if (earlier.end_place_name !== null) {
|
|
333
|
-
// Placed no earlier than the entry_end reading, so it follows it on
|
|
334
|
-
// the timeline instead of appearing to predate the passage's own
|
|
335
|
-
// arrival. Falls back to the entry's end time when there is no
|
|
336
|
-
// reading to follow — closed by hand, or with no instrument data.
|
|
337
|
-
const time =
|
|
338
|
-
entryEndObservation && entryEndObservation > earlier.end_time
|
|
339
|
-
? entryEndObservation
|
|
340
|
-
: earlier.end_time;
|
|
341
|
-
// The comment carries the raw place name too, like an sk_alarm's
|
|
342
|
-
// message, so it still reads in a CSV export or before any
|
|
343
|
-
// translation is applied.
|
|
344
|
-
db.prepare(
|
|
345
|
-
`INSERT INTO events (entry_id, time, type, lat, lon, comment, payload, source, created_at)
|
|
346
|
-
VALUES (?, ?, 'stopover', ?, ?, ?, ?, 'auto', ?)`
|
|
347
|
-
).run(
|
|
348
|
-
earlier.id,
|
|
349
|
-
time,
|
|
350
|
-
earlier.end_lat,
|
|
351
|
-
earlier.end_lon,
|
|
352
|
-
earlier.end_place_name,
|
|
353
|
-
JSON.stringify({
|
|
354
|
-
placeName: earlier.end_place_name,
|
|
355
|
-
placePending: earlier.end_place_pending === 1
|
|
356
|
-
}),
|
|
357
|
-
now
|
|
358
|
-
);
|
|
359
|
-
}
|
|
360
|
-
|
|
367
|
+
// landmarks_pending: the merged passage now covers the later one's track
|
|
368
|
+
// as well, which may reach areas no landmarks have been fetched for.
|
|
361
369
|
db.prepare(
|
|
362
370
|
`UPDATE log_entries SET
|
|
363
|
-
state = ?, end_time = ?, stopped_since = ?,
|
|
371
|
+
state = ?, end_time = ?, stopped_since = ?, closed_by = ?,
|
|
364
372
|
end_lat = ?, end_lon = ?, end_place_id = ?, end_place_name = ?, end_place_pending = ?,
|
|
365
|
-
distance = ?, updated_at = ?
|
|
373
|
+
distance = ?, landmarks_pending = 1, updated_at = ?
|
|
366
374
|
WHERE id = ?`
|
|
367
375
|
).run(
|
|
368
376
|
later.state,
|
|
369
377
|
later.end_time,
|
|
370
378
|
later.stopped_since,
|
|
379
|
+
later.closed_by,
|
|
371
380
|
later.end_lat,
|
|
372
381
|
later.end_lon,
|
|
373
382
|
later.end_place_id,
|
|
@@ -405,6 +414,40 @@ function entryNearArrival(db, position) {
|
|
|
405
414
|
return stillThere ? last.id : null;
|
|
406
415
|
}
|
|
407
416
|
|
|
417
|
+
// The passage a departure at `time` carries on rather than starting a new
|
|
418
|
+
// one: the last one, if detection closed it (the crew closing one confirms the
|
|
419
|
+
// arrival) less than `closureMs` before -- or the crew cast off from it less
|
|
420
|
+
// than `closureMs` before, the boat taking a while to get going. Nothing may
|
|
421
|
+
// have started since, which also keeps a replay of the past from reopening a
|
|
422
|
+
// passage that live passages have since followed.
|
|
423
|
+
function reopenableEntry(db, time, closureMs) {
|
|
424
|
+
const last = db
|
|
425
|
+
.prepare(
|
|
426
|
+
`SELECT * FROM log_entries WHERE state = 'closed' AND end_time <= ?
|
|
427
|
+
ORDER BY end_time DESC, id DESC LIMIT 1`
|
|
428
|
+
)
|
|
429
|
+
.get(time);
|
|
430
|
+
if (!last || last.closed_by !== 'detection') {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
const later = db
|
|
434
|
+
.prepare('SELECT 1 FROM log_entries WHERE id <> ? AND start_time >= ? AND start_time <= ?')
|
|
435
|
+
.get(last.id, last.end_time, time);
|
|
436
|
+
if (later) {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
const castOff = db
|
|
440
|
+
.prepare(
|
|
441
|
+
`SELECT MAX(time) AS time FROM events
|
|
442
|
+
WHERE entry_id = ? AND type = 'manoeuvre' AND source = 'manual'
|
|
443
|
+
AND subtype IN (${DEPARTURE_MANOEUVRES.map(() => '?').join(', ')})
|
|
444
|
+
AND time >= ? AND time <= ?`
|
|
445
|
+
)
|
|
446
|
+
.get(last.id, ...DEPARTURE_MANOEUVRES, last.end_time, time).time;
|
|
447
|
+
const since = castOff ?? last.end_time;
|
|
448
|
+
return Date.parse(time) - Date.parse(since) < closureMs ? last : null;
|
|
449
|
+
}
|
|
450
|
+
|
|
408
451
|
// A passage opened by the crew casting off, before detection sees the vessel
|
|
409
452
|
// move. It starts stopped, so the first movement resumes it rather than opening
|
|
410
453
|
// another one, and a departure that never happens closes like any long stop.
|
|
@@ -432,7 +475,9 @@ function openEntryByHand(db, { time, position, placeMatchRadius, now }) {
|
|
|
432
475
|
now,
|
|
433
476
|
now
|
|
434
477
|
);
|
|
435
|
-
|
|
478
|
+
const entryId = Number(lastInsertRowid);
|
|
479
|
+
crew.copyCrewFromPreviousEntry(db, entryId, now);
|
|
480
|
+
return entryId;
|
|
436
481
|
}
|
|
437
482
|
|
|
438
483
|
function deleteEntry(db, id) {
|
|
@@ -453,5 +498,8 @@ module.exports = {
|
|
|
453
498
|
recomputeDurations,
|
|
454
499
|
activeEntryId,
|
|
455
500
|
entryNearArrival,
|
|
456
|
-
openEntryByHand
|
|
501
|
+
openEntryByHand,
|
|
502
|
+
recordStopover,
|
|
503
|
+
reopenableEntry,
|
|
504
|
+
DEPARTURE_MANOEUVRES
|
|
457
505
|
};
|
package/lib/event-watcher.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
const { createFreshnessTracker } = require('./detection');
|
|
2
2
|
const { activeEntryId, entryNearArrival } = require('./entries');
|
|
3
|
+
const { CRITICAL_STATES } = require('./events');
|
|
3
4
|
|
|
4
5
|
const METRES_PER_SECOND_PER_KNOT = 1852 / 3600;
|
|
5
6
|
|
|
6
7
|
const EVENT_DEFAULTS = { windSpeedThresholds: [20, 30], pressureDropThreshold: 4 };
|
|
7
8
|
|
|
8
9
|
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
10
|
const NOTIFICATION_METADATA_KEYS = new Set([
|
|
13
11
|
'meta',
|
|
14
12
|
'values',
|