signalk-chiplog 2.1.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 +29 -1
- package/README.md +66 -29
- package/index.js +43 -17
- package/lib/api.js +15 -0
- package/lib/{forecast-schedule.js → background-schedule.js} +11 -11
- package/lib/database.js +44 -0
- package/lib/entries.js +7 -2
- package/lib/event-watcher.js +1 -3
- package/lib/events.js +38 -3
- package/lib/export.js +8 -0
- package/lib/landmark-finder.js +209 -0
- package/lib/landmarks.js +210 -0
- package/lib/logbook-pdf.js +45 -7
- package/package.json +2 -1
- package/public/app.css +121 -0
- package/public/entry/sw.js +1 -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/ExportView.mjs +3 -8
- package/public/js/components/PassageView.mjs +29 -8
- package/public/js/components/Timeline.mjs +30 -7
- package/public/js/context.mjs +7 -0
- package/public/js/days.mjs +10 -0
- package/public/js/format.mjs +11 -0
- package/public/js/i18n.mjs +129 -0
- package/public/js/landmarks.mjs +128 -0
- package/public/js/main.mjs +8 -5
- package/public/vendor/mediabunny-LICENSE +373 -0
- package/public/vendor/mediabunny.min.mjs +50 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,33 @@ All notable changes to Chiplog are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.2.0] - 2026-09-18
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Landmark bearings (amers)** — every position in the log is now also given the way a paper logbook gives one: a
|
|
14
|
+
distance and a bearing from the nearest landmark, "2,3 M ENE (065°) — Phare de Chauveau", under the coordinates in a
|
|
15
|
+
lighter grey, on the passage page and in the PDF logbook. The coordinates themselves are unchanged. Landmarks —
|
|
16
|
+
lighthouses, capes, named towers, harbours — are fetched from OpenStreetMap area by area and kept, so past passages
|
|
17
|
+
fill in as soon as their area is known and the same waters are never asked for twice; the bearing is computed when the
|
|
18
|
+
page is drawn, never stored. The landmark quoted is the one closest relative to its own range, so a lighthouse three
|
|
19
|
+
miles off wins over a marina alongside, and offshore the coordinates stay alone. A new setting, **Read each journal
|
|
20
|
+
line against the nearest landmark**, turns it off.
|
|
21
|
+
- **Animation** — a new page that replays passages on the map. Pick a start and an end date and every passage between
|
|
22
|
+
them plays in sequence, the time spent in port skipped, so a week's cruise takes seconds. The map follows the boat at
|
|
23
|
+
a scale chosen for each passage — a short hop stays readable instead of being magnified, a long crossing gets a wider
|
|
24
|
+
view without leaving the boat crawling across empty water — and a bubble on the chart shows the speed, the distance
|
|
25
|
+
covered since the animation began and the date and hour. Play, pause, four speeds (×0,5, ×1, ×2, ×4 — an hour of
|
|
26
|
+
sailing per second at ×1) and a slider over the animation's own time. A passage page links straight to it with its own
|
|
27
|
+
dates filled in.
|
|
28
|
+
- **Save an animation as an MP4** — Mobile (9:16), Portrait (3:4), Square (1:1), Landscape (4:3) or Widescreen (16:9),
|
|
29
|
+
encoded in the browser: nothing is uploaded, and the Signal K server renders nothing. Each frame's map is downloaded
|
|
30
|
+
just before that frame is drawn, so the film is the same whatever the connection was doing and no range is ever too
|
|
31
|
+
long to export. Needs a browser with WebCodecs (Chrome, Edge, Safari 17, Firefox 130 and later); without one the
|
|
32
|
+
animation still plays on screen.
|
|
33
|
+
- **Delete an alarm** — a logged alarm can now be removed from a passage's log, like a crew-entered line already could.
|
|
34
|
+
Deleting either the alarm or the line that later cleared it removes both, so no orphaned half is left behind.
|
|
35
|
+
|
|
9
36
|
## [2.1.0] - 2026-09-17
|
|
10
37
|
|
|
11
38
|
### Added
|
|
@@ -222,7 +249,8 @@ First release.
|
|
|
222
249
|
- REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
|
|
223
250
|
- Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
|
|
224
251
|
|
|
225
|
-
[Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.
|
|
252
|
+
[Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.2.0...HEAD
|
|
253
|
+
[2.2.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.1.0...v2.2.0
|
|
226
254
|
[2.1.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.0.0...v2.1.0
|
|
227
255
|
[2.0.0]: https://github.com/ricard33/signalk-chiplog/compare/v1.2.0...v2.0.0
|
|
228
256
|
[1.2.0]: https://github.com/ricard33/signalk-chiplog/compare/v1.1.0...v1.2.0
|
package/README.md
CHANGED
|
@@ -11,6 +11,8 @@ sensors cannot know from a tablet at the helm: manoeuvres, notes and handwriting
|
|
|
11
11
|
- **Automatic events**: alarms, autopilot changes, strong wind, falling barometer.
|
|
12
12
|
- **Departure and arrival names**, looked up online and corrected once for good.
|
|
13
13
|
- **Consultation webapp**: logbook by day, map, timeline, corrections, export.
|
|
14
|
+
- **Replay a range of passages** on the map — an hour of sailing per second — and save it as an MP4 for a phone, a
|
|
15
|
+
square post or a widescreen, rendered entirely in your browser.
|
|
14
16
|
- **Tablet entry app**: big buttons for gloves and wet fingers, stylus handwriting, night mode, works through Wi-Fi
|
|
15
17
|
dropouts.
|
|
16
18
|
- **Abandon-ship copy**: JSON, CSV and GPX, downloadable or written to a USB drive.
|
|
@@ -169,6 +171,26 @@ Departures and arrivals are named automatically:
|
|
|
169
171
|
3. **Corrections are remembered.** Renaming a departure or arrival in the webapp also renames that place for every later
|
|
170
172
|
passage starting or ending nearby. Past passages keep the name they recorded.
|
|
171
173
|
|
|
174
|
+
### Landmarks (amers)
|
|
175
|
+
|
|
176
|
+
Every position in the log is also given the way a paper logbook gives one — as a distance and a bearing **from a
|
|
177
|
+
landmark**, under the coordinates, in a lighter grey:
|
|
178
|
+
|
|
179
|
+
```text
|
|
180
|
+
46°08.88′N 001°12.90′W
|
|
181
|
+
2,3 M ENE (065°) — Phare de Chauveau
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
- **The landmarks come from OpenStreetMap**, fetched area by area through Overpass and kept: lighthouses and major
|
|
185
|
+
lights, capes, named towers and other seamark landmarks, minor lights, isolated-danger and safe-water beacons,
|
|
186
|
+
harbours and marinas. The numbered marks of a channel are left out — "6 c" says nothing in a logbook.
|
|
187
|
+
- **The nearest useful one wins**, not simply the nearest: each kind carries a range (15 nm for a lighthouse, 3 for a
|
|
188
|
+
harbour…), narrowed by the light's own range when known, and the landmark closest relative to its range is the one
|
|
189
|
+
quoted. Offshore, beyond them all, the coordinates stay alone.
|
|
190
|
+
- **Past passages fill in by themselves** once their area has been fetched — the bearing is worked out when the page or
|
|
191
|
+
the PDF is drawn, never stored.
|
|
192
|
+
- Turn **Read each journal line against the nearest landmark** off to keep the boat off Overpass entirely.
|
|
193
|
+
|
|
172
194
|
## The logbook webapp 💻
|
|
173
195
|
|
|
174
196
|
Open **Chiplog** from the Signal K webapps, or `/signalk-chiplog/`. Reading needs no more than read-only access.
|
|
@@ -189,13 +211,20 @@ Open **Chiplog** from the Signal K webapps, or `/signalk-chiplog/`. Reading need
|
|
|
189
211
|
current noted at departure), and the log: every reading and event in order, including handwritten notes. A passage in
|
|
190
212
|
progress refreshes every minute. Each line's comment can be edited (read/write access); a manoeuvre or note the crew
|
|
191
213
|
logged themselves can also be deleted — automatic lines (alarms, autopilot, weather, corrections) can only be
|
|
192
|
-
annotated.
|
|
214
|
+
annotated. Under each position, in grey, its bearing and distance from the nearest landmark.
|
|
193
215
|
- **Corrections** (read/write access):
|
|
194
216
|
- rename the departure, or the arrival once the passage is closed — a passage in progress has none yet to rename;
|
|
195
217
|
- switch an engine period to sail or back;
|
|
196
218
|
- close a passage in progress, e.g. to confirm an arrival;
|
|
197
219
|
- merge with the previous or next passage;
|
|
198
220
|
- delete a passage (admin).
|
|
221
|
+
- **Animation** — pick two dates and every passage between them replays on the map, one after another, the port time
|
|
222
|
+
skipped. The map follows the boat at a scale chosen for each passage — a short hop kept readable rather than
|
|
223
|
+
magnified, a long crossing allowed a wider view but never so wide the boat crawls across empty water — while a bubble
|
|
224
|
+
shows the speed, the distance covered since the start and the date. Play, pause and a slider over the animation's own
|
|
225
|
+
time, at ×0,5, ×1, ×2 or ×4. **Export MP4** saves it as a video in one of five shapes (Mobile 9:16, Portrait 3:4,
|
|
226
|
+
Square 1:1, Landscape 4:3, Widescreen 16:9). Everything happens in the browser: nothing is rendered or encoded on the
|
|
227
|
+
Signal K server, and the map tiles are the only thing downloaded.
|
|
199
228
|
- **Export** — download the whole logbook or a date range as a PDF logbook to print, JSON, CSV or GPX, and write the
|
|
200
229
|
abandon-ship copy to the USB drive now (admin). The PDF is written in the webapp's language and the device's time
|
|
201
230
|
zone.
|
|
@@ -291,33 +320,35 @@ a regular Signal K user account.
|
|
|
291
320
|
|
|
292
321
|
In the Signal K admin, **Apps & Plugins → Configuration → Chiplog**.
|
|
293
322
|
|
|
294
|
-
| Setting | Default | What it does
|
|
295
|
-
| ---------------------------------------------------------------- | --------------------------------------------- |
|
|
296
|
-
| Stop duration within which a new departure continues the passage | 30 min | A passage closes when the boat stops; leaving again sooner reopens it, the stop kept as a stopover.
|
|
297
|
-
| Under-way speed without navigation.state | 1 kn | Used only without signalk-autostate: under way above it, stopped below half of it.
|
|
298
|
-
| Propulsion assumed without engine data | sail | When nothing says whether the engine is running. Set to engine on a motorboat.
|
|
299
|
-
| Instrument snapshot interval | 60 min | Readings on this clock boundary during a passage.
|
|
300
|
-
| Track point interval | 15 s | A track point at least this often while moving.
|
|
301
|
-
| Place matching radius | 200 m | A departure or arrival this close to a known place takes its name.
|
|
302
|
-
| Name departures and arrivals with online geocoding | on | Turn off to never send positions online; places are then named after their coordinates until corrected.
|
|
303
|
-
| Geocoding service | `https://nominatim.openstreetmap.org` | Any Nominatim-compatible service, e.g. a self-hosted one.
|
|
304
|
-
|
|
|
305
|
-
|
|
|
306
|
-
| Fetch the
|
|
307
|
-
|
|
|
308
|
-
|
|
|
309
|
-
|
|
|
310
|
-
|
|
|
311
|
-
|
|
|
312
|
-
|
|
|
313
|
-
|
|
|
314
|
-
|
|
|
315
|
-
|
|
|
316
|
-
|
|
|
317
|
-
| InfluxDB
|
|
318
|
-
| InfluxDB
|
|
319
|
-
| InfluxDB
|
|
320
|
-
| InfluxDB
|
|
323
|
+
| Setting | Default | What it does |
|
|
324
|
+
| ---------------------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
325
|
+
| Stop duration within which a new departure continues the passage | 30 min | A passage closes when the boat stops; leaving again sooner reopens it, the stop kept as a stopover. |
|
|
326
|
+
| Under-way speed without navigation.state | 1 kn | Used only without signalk-autostate: under way above it, stopped below half of it. |
|
|
327
|
+
| Propulsion assumed without engine data | sail | When nothing says whether the engine is running. Set to engine on a motorboat. |
|
|
328
|
+
| Instrument snapshot interval | 60 min | Readings on this clock boundary during a passage. |
|
|
329
|
+
| Track point interval | 15 s | A track point at least this often while moving. |
|
|
330
|
+
| Place matching radius | 200 m | A departure or arrival this close to a known place takes its name. |
|
|
331
|
+
| Name departures and arrivals with online geocoding | on | Turn off to never send positions online; places are then named after their coordinates until corrected. |
|
|
332
|
+
| Geocoding service | `https://nominatim.openstreetmap.org` | Any Nominatim-compatible service, e.g. a self-hosted one. |
|
|
333
|
+
| Read each journal line against the nearest landmark | on | Fetches the landmarks of the areas sailed through from OpenStreetMap, so each position is also given as a bearing and distance from one. Turn off to keep the coordinates alone. |
|
|
334
|
+
| Landmark service (Overpass API) | `https://overpass-api.de/api/interpreter` | Any Overpass-compatible service, e.g. a self-hosted one. |
|
|
335
|
+
| Fetch the tide forecast at departure | on | Turn off to never send the departure position online; the passage page then shows no tide. |
|
|
336
|
+
| Marine service | `https://marine-api.open-meteo.com/v1/marine` | Any Open-Meteo Marine-compatible service, e.g. a self-hosted one. Serves the tide, and the sea state and current of the weather forecast. |
|
|
337
|
+
| Fetch the marine weather forecast at departure | on | Turn off to never send the departure position to the weather services; the passage page and the PDF then show no forecast. |
|
|
338
|
+
| Weather service | `https://api.open-meteo.com/v1/forecast` | Any Open-Meteo-compatible forecast service, e.g. a self-hosted one. |
|
|
339
|
+
| USB export directory | — | Where the abandon-ship copy is written, e.g. `/media/usb`. Empty turns the USB copy off. |
|
|
340
|
+
| Automatic USB copy interval | 15 min | How often the USB copy is brought up to date. 0 turns the periodic copy off. |
|
|
341
|
+
| Copy to the USB drive at each arrival | on | Brings the USB copy up to date as soon as a passage ends. |
|
|
342
|
+
| Logbook language (PDF) | en | Language of the PDF logbooks on the USB drive (English or French). |
|
|
343
|
+
| Ship's time zone (PDF) | the server's | Time zone of the PDF logbooks on the USB drive, e.g. `Europe/Paris`. |
|
|
344
|
+
| Wind speed thresholds | 20, 30 kn | Logged when the 2-minute average true wind crosses them. |
|
|
345
|
+
| Barometric drop warning | 4 hPa / 3 h | 0 turns it off. |
|
|
346
|
+
| InfluxDB host (retrospective analysis) | — | Local or remote host of the InfluxDB 1.x database signalk-to-influxdb writes to. Empty turns the retrospective analysis page off. |
|
|
347
|
+
| InfluxDB port | 8086 | |
|
|
348
|
+
| InfluxDB database | — | |
|
|
349
|
+
| InfluxDB username / password | — | Leave empty if the database needs none. |
|
|
350
|
+
| InfluxDB protocol | http | `http` or `https`. |
|
|
351
|
+
| InfluxDB vessel context | this server's own | Only needed running the replay from a different Signal K server than the one that wrote the history, e.g. development pointed at a production database. |
|
|
321
352
|
|
|
322
353
|
## Signal K data used 🔌
|
|
323
354
|
|
|
@@ -378,6 +409,8 @@ at the time.
|
|
|
378
409
|
- **Place names.** With geocoding on, the position of each departure and arrival that matches no known place is sent to
|
|
379
410
|
the geocoding service — OpenStreetMap's public Nominatim by default. Nothing else is sent, and nothing at all when it
|
|
380
411
|
is off.
|
|
412
|
+
- **Landmarks.** With them on, the area a passage sailed through — a half-degree box, not its track — is sent to the
|
|
413
|
+
Overpass service, OpenStreetMap's public instance by default, once per area ever. Nothing at all when it is off.
|
|
381
414
|
- **Tide forecast.** With it on, the departure position of each passage is sent to the tide service — the public
|
|
382
415
|
Open-Meteo by default — once, at departure. Nothing at all when it is off.
|
|
383
416
|
- **Weather forecast.** With it on, the departure position of each passage is sent to the weather service and to the
|
|
@@ -486,7 +519,11 @@ between attempts; nothing is lost, it just takes longer to resolve.
|
|
|
486
519
|
## Limitations 🚧
|
|
487
520
|
|
|
488
521
|
- **Not yet:** a places page, and editing manoeuvre shortcuts from the webapps.
|
|
489
|
-
- **Offline charts** are not provided.
|
|
522
|
+
- **Offline charts** are not provided; the animation draws the tracks on a blank sea when there is no connection.
|
|
523
|
+
- **The MP4 export needs a browser with WebCodecs** — Chrome, Edge, Safari 17 or Firefox 130 and later. Without it the
|
|
524
|
+
animation still plays on screen, and the export button is simply not offered.
|
|
525
|
+
- **A long animation takes a while to export**: every frame waits for its map before it is drawn, so the video is the
|
|
526
|
+
same whatever the connection was doing, but a long range means a lot of tiles. Turning the seamarks off halves them.
|
|
490
527
|
- **One vessel per Signal K server**, and no per-crew-member authorship.
|
|
491
528
|
|
|
492
529
|
## Development 🧑💻
|
package/index.js
CHANGED
|
@@ -2,9 +2,10 @@ const { registerRoutes } = require('./lib/api');
|
|
|
2
2
|
const { openDatabase } = require('./lib/database');
|
|
3
3
|
const { createPassageDetector, DETECTION_DEFAULTS, TICK_INTERVAL_MS } = require('./lib/detection');
|
|
4
4
|
const { ApiError } = require('./lib/errors');
|
|
5
|
-
const {
|
|
5
|
+
const { createBackgroundSchedule } = require('./lib/background-schedule');
|
|
6
6
|
const { createEventWatcher, CHECK_INTERVAL_MS, EVENT_DEFAULTS } = require('./lib/event-watcher');
|
|
7
7
|
const { INFLUX_DEFAULTS } = require('./lib/influx-history');
|
|
8
|
+
const { createLandmarkFinder, LANDMARK_DEFAULTS } = require('./lib/landmark-finder');
|
|
8
9
|
const { OBSERVATION_DEFAULTS } = require('./lib/observation-recorder');
|
|
9
10
|
const { createPlaceNamer, GEOCODING_DEFAULTS } = require('./lib/place-names');
|
|
10
11
|
const { PROPULSION_DEFAULTS } = require('./lib/propulsion-detector');
|
|
@@ -26,6 +27,9 @@ const DEFAULT_PLACE_MATCH_RADIUS = 200;
|
|
|
26
27
|
const FIRST_NAMING_DELAY_MS = 5 * 1000;
|
|
27
28
|
const NAMING_ERROR_RETRY_MS = 5 * 60 * 1000;
|
|
28
29
|
const FIRST_FORECAST_DELAY_MS = 5 * 1000;
|
|
30
|
+
// Landmarks are not needed for the passage under way, only for reading it back,
|
|
31
|
+
// so the first lookup waits for the busier start-up work to be done.
|
|
32
|
+
const FIRST_LANDMARK_DELAY_MS = 30 * 1000;
|
|
29
33
|
|
|
30
34
|
const MOTION_LABELS = { underway: 'Under way', stopped: 'Stopped', unknown: 'Waiting for data' };
|
|
31
35
|
const MODE_LABELS = { autostate: 'navigation.state', fallback: 'speed fallback' };
|
|
@@ -56,7 +60,7 @@ module.exports = function (app) {
|
|
|
56
60
|
let settings = null;
|
|
57
61
|
let detector = null;
|
|
58
62
|
let namer = null;
|
|
59
|
-
let
|
|
63
|
+
let schedules = [];
|
|
60
64
|
// The open passage detection last reported, to notice a new one.
|
|
61
65
|
let lastActiveEntryId = null;
|
|
62
66
|
let usbExport = null;
|
|
@@ -132,6 +136,19 @@ module.exports = function (app) {
|
|
|
132
136
|
description: 'The public OpenStreetMap instance by default, or a self-hosted Nominatim',
|
|
133
137
|
default: GEOCODING_DEFAULTS.geocodingUrl
|
|
134
138
|
},
|
|
139
|
+
landmarksEnabled: {
|
|
140
|
+
type: 'boolean',
|
|
141
|
+
title: 'Read each journal line against the nearest landmark',
|
|
142
|
+
description:
|
|
143
|
+
'Fetches the lighthouses, capes, towers and harbours of the areas sailed through from OpenStreetMap, so every position in the logbook is also given as a bearing and distance from the nearest one (© OpenStreetMap contributors, ODbL). Without it, only the coordinates are shown',
|
|
144
|
+
default: LANDMARK_DEFAULTS.landmarksEnabled
|
|
145
|
+
},
|
|
146
|
+
overpassUrl: {
|
|
147
|
+
type: 'string',
|
|
148
|
+
title: 'Landmark service (Overpass API)',
|
|
149
|
+
description: 'The public Overpass instance by default, or a self-hosted one',
|
|
150
|
+
default: LANDMARK_DEFAULTS.overpassUrl
|
|
151
|
+
},
|
|
135
152
|
tidesEnabled: {
|
|
136
153
|
type: 'boolean',
|
|
137
154
|
title: 'Fetch the tide forecast at departure',
|
|
@@ -252,10 +269,11 @@ module.exports = function (app) {
|
|
|
252
269
|
try {
|
|
253
270
|
const outcome = detector.tick();
|
|
254
271
|
usbExport.afterDetection(outcome);
|
|
255
|
-
// A passage just opened: fetch its forecasts
|
|
256
|
-
//
|
|
272
|
+
// A passage just opened: fetch its forecasts and the landmarks of where
|
|
273
|
+
// it is starting from now, not at the next idle poll or at the end of a
|
|
274
|
+
// retry delay left from an earlier failure.
|
|
257
275
|
if (outcome.activeEntryId !== null && outcome.activeEntryId !== lastActiveEntryId) {
|
|
258
|
-
|
|
276
|
+
schedules.forEach((schedule) => schedule.nudge());
|
|
259
277
|
}
|
|
260
278
|
lastActiveEntryId = outcome.activeEntryId;
|
|
261
279
|
const usbError = usbExport.status().lastError;
|
|
@@ -344,6 +362,8 @@ module.exports = function (app) {
|
|
|
344
362
|
placeMatchRadius: config.placeMatchRadius ?? DEFAULT_PLACE_MATCH_RADIUS,
|
|
345
363
|
geocodingEnabled: config.geocodingEnabled ?? GEOCODING_DEFAULTS.geocodingEnabled,
|
|
346
364
|
geocodingUrl: config.geocodingUrl || GEOCODING_DEFAULTS.geocodingUrl,
|
|
365
|
+
landmarksEnabled: config.landmarksEnabled ?? LANDMARK_DEFAULTS.landmarksEnabled,
|
|
366
|
+
overpassUrl: config.overpassUrl || LANDMARK_DEFAULTS.overpassUrl,
|
|
347
367
|
tidesEnabled: config.tidesEnabled ?? TIDE_DEFAULTS.tidesEnabled,
|
|
348
368
|
tideUrl: config.tideUrl || TIDE_DEFAULTS.tideUrl,
|
|
349
369
|
weatherEnabled: config.weatherEnabled ?? WEATHER_DEFAULTS.weatherEnabled,
|
|
@@ -411,23 +431,29 @@ module.exports = function (app) {
|
|
|
411
431
|
});
|
|
412
432
|
namingTimer = setTimeout(runNaming, FIRST_NAMING_DELAY_MS);
|
|
413
433
|
// Same idea as geocoding: network calls fetching the tide and weather
|
|
414
|
-
// forecasts near a recent departure,
|
|
415
|
-
// inside detection.
|
|
416
|
-
const
|
|
434
|
+
// forecasts near a recent departure, and the landmarks of the areas
|
|
435
|
+
// sailed through, each on its own chain rather than inside detection.
|
|
436
|
+
const networkOptions = { db: database, settings, userAgent: `signalk-chiplog/${version}` };
|
|
417
437
|
const log = (level, message) => (level === 'error' ? app.error(message) : app.debug(message));
|
|
418
438
|
lastActiveEntryId = null;
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
label: 'Tide',
|
|
422
|
-
|
|
439
|
+
schedules = [
|
|
440
|
+
createBackgroundSchedule({
|
|
441
|
+
label: 'Tide forecast',
|
|
442
|
+
resolver: createTideForecaster(networkOptions),
|
|
423
443
|
log,
|
|
424
444
|
firstDelayMs: FIRST_FORECAST_DELAY_MS
|
|
425
445
|
}),
|
|
426
|
-
|
|
427
|
-
label: 'Weather',
|
|
428
|
-
|
|
446
|
+
createBackgroundSchedule({
|
|
447
|
+
label: 'Weather forecast',
|
|
448
|
+
resolver: createWeatherForecaster(networkOptions),
|
|
429
449
|
log,
|
|
430
450
|
firstDelayMs: FIRST_FORECAST_DELAY_MS
|
|
451
|
+
}),
|
|
452
|
+
createBackgroundSchedule({
|
|
453
|
+
label: 'Landmark lookup',
|
|
454
|
+
resolver: createLandmarkFinder(networkOptions),
|
|
455
|
+
log,
|
|
456
|
+
firstDelayMs: FIRST_LANDMARK_DELAY_MS
|
|
431
457
|
})
|
|
432
458
|
];
|
|
433
459
|
lastStatus = null;
|
|
@@ -455,8 +481,8 @@ module.exports = function (app) {
|
|
|
455
481
|
clearTimeout(namingTimer);
|
|
456
482
|
namer?.stop();
|
|
457
483
|
namer = null;
|
|
458
|
-
|
|
459
|
-
|
|
484
|
+
schedules.forEach((schedule) => schedule.stop());
|
|
485
|
+
schedules = [];
|
|
460
486
|
usbExport?.stop();
|
|
461
487
|
usbExport = null;
|
|
462
488
|
replayJob?.cancel();
|
package/lib/api.js
CHANGED
|
@@ -4,6 +4,7 @@ const { ApiError, badRequest, conflict, notFound } = require('./errors');
|
|
|
4
4
|
const entries = require('./entries');
|
|
5
5
|
const events = require('./events');
|
|
6
6
|
const { renderExport } = require('./export');
|
|
7
|
+
const { listEntryLandmarks } = require('./landmarks');
|
|
7
8
|
const { isTimeZone, PDF_LANGUAGES } = require('./logbook-pdf');
|
|
8
9
|
const { toGeoJson, toGpx } = require('./formats');
|
|
9
10
|
const manoeuvreTypes = require('./manoeuvre-types');
|
|
@@ -229,6 +230,20 @@ function registerRoutes(router, { getContext, logError }) {
|
|
|
229
230
|
})
|
|
230
231
|
);
|
|
231
232
|
|
|
233
|
+
// The amers a line of this passage's journal can be read against (SPEC
|
|
234
|
+
// §4.13); which one each line takes, and the bearing, is worked out where
|
|
235
|
+
// the line is shown. Empty until the area's landmarks have been fetched.
|
|
236
|
+
readonly.get(
|
|
237
|
+
'/api/entries/:id/landmarks',
|
|
238
|
+
handle(({ db }, req) => {
|
|
239
|
+
const id = entryId(req);
|
|
240
|
+
entries.requireEntryRow(db, id);
|
|
241
|
+
const { limit, offset } = v.parsePagination(req.query);
|
|
242
|
+
const items = listEntryLandmarks(db, id);
|
|
243
|
+
return { total: items.length, limit, offset, items: items.slice(offset, offset + limit) };
|
|
244
|
+
})
|
|
245
|
+
);
|
|
246
|
+
|
|
232
247
|
readonly.get(
|
|
233
248
|
'/api/entries/:id/tide',
|
|
234
249
|
handle(({ db }, req) => {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
// Runs a
|
|
2
|
-
// each `resolveNext()` says when the next is due.
|
|
3
|
-
// when a passage opens, rather than leaving it to the
|
|
4
|
-
// delay. A nudge during a fetch runs it again as soon as
|
|
5
|
-
// never a second chain alongside.
|
|
1
|
+
// Runs a background resolver -- a departure forecast, a landmark lookup -- as
|
|
2
|
+
// its own chain of timeouts: each `resolveNext()` says when the next is due.
|
|
3
|
+
// `nudge()` runs it now -- when a passage opens, rather than leaving it to the
|
|
4
|
+
// idle poll or a retry delay. A nudge during a fetch runs it again as soon as
|
|
5
|
+
// that one ends, never a second chain alongside.
|
|
6
6
|
|
|
7
7
|
const ERROR_RETRY_MS = 5 * 60 * 1000;
|
|
8
8
|
|
|
9
|
-
function
|
|
9
|
+
function createBackgroundSchedule({ label, resolver, log, firstDelayMs }) {
|
|
10
10
|
let timer = null;
|
|
11
11
|
let running = false;
|
|
12
12
|
let nudged = false;
|
|
@@ -23,9 +23,9 @@ function createForecastSchedule({ label, forecaster, log, firstDelayMs }) {
|
|
|
23
23
|
nudged = false;
|
|
24
24
|
let result;
|
|
25
25
|
try {
|
|
26
|
-
result = await
|
|
26
|
+
result = await resolver.resolveNext();
|
|
27
27
|
} catch (err) {
|
|
28
|
-
log('error', `${label}
|
|
28
|
+
log('error', `${label} failed: ${err.stack ?? err}`);
|
|
29
29
|
result = { retryInMs: ERROR_RETRY_MS };
|
|
30
30
|
}
|
|
31
31
|
running = false;
|
|
@@ -36,7 +36,7 @@ function createForecastSchedule({ label, forecaster, log, firstDelayMs }) {
|
|
|
36
36
|
// Expected whenever the boat is out of reach of a network.
|
|
37
37
|
log(
|
|
38
38
|
'debug',
|
|
39
|
-
`${label}
|
|
39
|
+
`${label} unavailable, retrying in ${Math.round(result.retryInMs / 60000)} min: ${result.error.message}`
|
|
40
40
|
);
|
|
41
41
|
}
|
|
42
42
|
schedule(nudged ? 0 : result.retryInMs);
|
|
@@ -59,9 +59,9 @@ function createForecastSchedule({ label, forecaster, log, firstDelayMs }) {
|
|
|
59
59
|
stop() {
|
|
60
60
|
stopped = true;
|
|
61
61
|
clearTimeout(timer);
|
|
62
|
-
|
|
62
|
+
resolver.stop();
|
|
63
63
|
}
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
module.exports = {
|
|
67
|
+
module.exports = { createBackgroundSchedule };
|
package/lib/database.js
CHANGED
|
@@ -314,6 +314,50 @@ const MIGRATIONS = [
|
|
|
314
314
|
CREATE UNIQUE INDEX idx_log_entry_crew_unique_member
|
|
315
315
|
ON log_entry_crew (entry_id, crew_member_id)
|
|
316
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));
|
|
317
361
|
`
|
|
318
362
|
];
|
|
319
363
|
|
package/lib/entries.js
CHANGED
|
@@ -216,13 +216,16 @@ function updateEntry(db, id, patch, { placeMatchRadius, now }) {
|
|
|
216
216
|
next.distance = patch.distance;
|
|
217
217
|
}
|
|
218
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.
|
|
219
222
|
db.prepare(
|
|
220
223
|
`UPDATE log_entries SET
|
|
221
224
|
start_time = ?, end_time = ?,
|
|
222
225
|
start_lat = ?, start_lon = ?, end_lat = ?, end_lon = ?,
|
|
223
226
|
start_place_id = ?, end_place_id = ?, start_place_name = ?, end_place_name = ?,
|
|
224
227
|
start_place_pending = ?, end_place_pending = ?,
|
|
225
|
-
distance = ?, updated_at = ?
|
|
228
|
+
distance = ?, landmarks_pending = 1, updated_at = ?
|
|
226
229
|
WHERE id = ?`
|
|
227
230
|
).run(
|
|
228
231
|
next.start_time,
|
|
@@ -361,11 +364,13 @@ function mergeEntries(db, id, otherId, now) {
|
|
|
361
364
|
// index would otherwise reject the update below.
|
|
362
365
|
db.prepare('DELETE FROM log_entries WHERE id = ?').run(later.id);
|
|
363
366
|
|
|
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.
|
|
364
369
|
db.prepare(
|
|
365
370
|
`UPDATE log_entries SET
|
|
366
371
|
state = ?, end_time = ?, stopped_since = ?, closed_by = ?,
|
|
367
372
|
end_lat = ?, end_lon = ?, end_place_id = ?, end_place_name = ?, end_place_pending = ?,
|
|
368
|
-
distance = ?, updated_at = ?
|
|
373
|
+
distance = ?, landmarks_pending = 1, updated_at = ?
|
|
369
374
|
WHERE id = ?`
|
|
370
375
|
).run(
|
|
371
376
|
later.state,
|
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',
|
package/lib/events.js
CHANGED
|
@@ -27,6 +27,11 @@ const EVENT_TYPES = [
|
|
|
27
27
|
// or from corrections, never posted by a client.
|
|
28
28
|
const CLIENT_EVENT_TYPES = ['manoeuvre', 'text_annotation', 'handwritten_annotation'];
|
|
29
29
|
|
|
30
|
+
// A `sk_alarm` event reaching one of these states is the alarm itself; any
|
|
31
|
+
// other state (usually `normal`) is what cleared it. Shared with
|
|
32
|
+
// event-watcher.js, which decides what is worth logging in the first place.
|
|
33
|
+
const CRITICAL_STATES = new Set(['alarm', 'emergency']);
|
|
34
|
+
|
|
30
35
|
// An entry logged after the fact — typically replayed from the tablet's offline
|
|
31
36
|
// queue — takes the position the track recorded at its time.
|
|
32
37
|
const TRACK_POSITION_WINDOW_MS = 2 * 60 * 1000;
|
|
@@ -277,10 +282,35 @@ function updateEvent(db, id, patch) {
|
|
|
277
282
|
return toEvent(requireEventRow(db, id));
|
|
278
283
|
}
|
|
279
284
|
|
|
285
|
+
// An alarm is logged as two lines — the notification reaching `alarm` or
|
|
286
|
+
// `emergency`, and the one that later cleared it (SPEC §4.6) — so deleting
|
|
287
|
+
// either alone would leave the other referring to nothing. Its pair is the
|
|
288
|
+
// adjacent `sk_alarm` line for the same notification whose criticality
|
|
289
|
+
// differs: an escalation (`alarm` to `emergency`) is not a pair, both being
|
|
290
|
+
// critical, and is left alone.
|
|
291
|
+
function pairedAlarmEvent(db, row) {
|
|
292
|
+
if (row.type !== 'sk_alarm') {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
const siblings = db
|
|
296
|
+
.prepare(
|
|
297
|
+
`SELECT * FROM events WHERE entry_id = ? AND type = 'sk_alarm' AND subtype = ?
|
|
298
|
+
ORDER BY time, id`
|
|
299
|
+
)
|
|
300
|
+
.all(row.entry_id, row.subtype);
|
|
301
|
+
const index = siblings.findIndex((sibling) => sibling.id === row.id);
|
|
302
|
+
const critical = CRITICAL_STATES.has(JSON.parse(row.payload).state);
|
|
303
|
+
const neighbour = critical ? siblings[index + 1] : siblings[index - 1];
|
|
304
|
+
if (!neighbour || CRITICAL_STATES.has(JSON.parse(neighbour.payload).state) === critical) {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
return neighbour;
|
|
308
|
+
}
|
|
309
|
+
|
|
280
310
|
// Undoing the departure that opened a passage, before the vessel moved and
|
|
281
311
|
// before anything else was logged in it, takes the passage away too.
|
|
282
312
|
function deleteEvent(db, id) {
|
|
283
|
-
requireEventRow(db, id);
|
|
313
|
+
const row = requireEventRow(db, id);
|
|
284
314
|
withTransaction(db, () => {
|
|
285
315
|
const opened = db
|
|
286
316
|
.prepare(
|
|
@@ -291,15 +321,20 @@ function deleteEvent(db, id) {
|
|
|
291
321
|
.get(id, id);
|
|
292
322
|
if (opened) {
|
|
293
323
|
db.prepare('DELETE FROM log_entries WHERE id = ?').run(opened.id);
|
|
294
|
-
|
|
295
|
-
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const paired = pairedAlarmEvent(db, row);
|
|
327
|
+
if (paired) {
|
|
328
|
+
db.prepare('DELETE FROM events WHERE id = ?').run(paired.id);
|
|
296
329
|
}
|
|
330
|
+
db.prepare('DELETE FROM events WHERE id = ?').run(id);
|
|
297
331
|
});
|
|
298
332
|
}
|
|
299
333
|
|
|
300
334
|
module.exports = {
|
|
301
335
|
EVENT_TYPES,
|
|
302
336
|
CLIENT_EVENT_TYPES,
|
|
337
|
+
CRITICAL_STATES,
|
|
303
338
|
listEvents,
|
|
304
339
|
allEvents,
|
|
305
340
|
createEvent,
|
package/lib/export.js
CHANGED
|
@@ -7,6 +7,7 @@ const { conflict } = require('./errors');
|
|
|
7
7
|
const { toEntry } = require('./entries');
|
|
8
8
|
const { allEvents } = require('./events');
|
|
9
9
|
const { toCsv, toGpx } = require('./formats');
|
|
10
|
+
const { listEntryLandmarks } = require('./landmarks');
|
|
10
11
|
const { renderLogbookPdf } = require('./logbook-pdf');
|
|
11
12
|
const { allSegments } = require('./propulsion');
|
|
12
13
|
const { allTrackPoints, allObservations } = require('./track');
|
|
@@ -34,6 +35,7 @@ function bundleOf(db, row) {
|
|
|
34
35
|
observations: allObservations(db, row.id),
|
|
35
36
|
propulsion: allSegments(db, row.id),
|
|
36
37
|
events: allEvents(db, row.id),
|
|
38
|
+
landmarks: listEntryLandmarks(db, row.id),
|
|
37
39
|
tide: getTideForecast(db, row.id),
|
|
38
40
|
weather: getWeatherForecast(db, row.id),
|
|
39
41
|
crew: listEntryCrew(db, row.id)
|
|
@@ -137,6 +139,12 @@ function passageFingerprint(db, row, pdf) {
|
|
|
137
139
|
const forecast = db.prepare(`SELECT fetched_at FROM ${table} WHERE entry_id = ?`).get(row.id);
|
|
138
140
|
hash.update(JSON.stringify(forecast?.fetched_at ?? null));
|
|
139
141
|
}
|
|
142
|
+
// The landmarks a passage is read against arrive area by area, well after
|
|
143
|
+
// the sailing, and the PDF prints them: a copy already on the drive is worth
|
|
144
|
+
// rewriting once they do.
|
|
145
|
+
for (const landmark of listEntryLandmarks(db, row.id)) {
|
|
146
|
+
hash.update(JSON.stringify([landmark.id, landmark.updatedAt]));
|
|
147
|
+
}
|
|
140
148
|
for (const table of ['propulsion_segments', 'events']) {
|
|
141
149
|
for (const item of db
|
|
142
150
|
.prepare(`SELECT * FROM ${table} WHERE entry_id = ? ORDER BY id`)
|