signalk-chiplog 1.0.0 → 1.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -33
  3. package/docs/screenshots/01-logbook.png +0 -0
  4. package/docs/screenshots/02-passage-tide.png +0 -0
  5. package/docs/screenshots/03-passage-journal.png +0 -0
  6. package/docs/screenshots/04-entry-app.png +0 -0
  7. package/index.js +95 -0
  8. package/lib/api.js +40 -12
  9. package/lib/database.js +97 -7
  10. package/lib/detection.js +7 -1
  11. package/lib/entries.js +72 -4
  12. package/lib/event-watcher.js +1 -1
  13. package/lib/events.js +11 -3
  14. package/lib/export.js +20 -8
  15. package/lib/formats.js +27 -3
  16. package/lib/logbook-pdf.js +594 -0
  17. package/lib/observation-recorder.js +22 -9
  18. package/lib/pdf/helvetica.js +410 -0
  19. package/lib/pdf/winansi.js +65 -0
  20. package/lib/pdf/writer.js +160 -0
  21. package/lib/propulsion-detector.js +24 -1
  22. package/lib/propulsion.js +1 -1
  23. package/lib/tide-forecaster.js +155 -0
  24. package/lib/track-recorder.js +45 -4
  25. package/lib/track.js +14 -2
  26. package/lib/usb-scheduler.js +2 -1
  27. package/package.json +9 -2
  28. package/public/app.css +164 -0
  29. package/public/entry/entry.css +95 -0
  30. package/public/entry/js/components/Icons.mjs +136 -0
  31. package/public/entry/js/components/RecentList.mjs +17 -4
  32. package/public/entry/js/components/SketchPanel.mjs +184 -45
  33. package/public/entry/js/main.mjs +5 -3
  34. package/public/entry/js/strokes.mjs +85 -5
  35. package/public/entry/sw.js +2 -0
  36. package/public/js/components/ExportView.mjs +9 -5
  37. package/public/js/components/LogView.mjs +25 -3
  38. package/public/js/components/PassageView.mjs +112 -18
  39. package/public/js/components/TideCard.mjs +92 -0
  40. package/public/js/components/Timeline.mjs +97 -99
  41. package/public/js/format.mjs +39 -5
  42. package/public/js/i18n.mjs +123 -4
  43. package/public/js/log-lines.mjs +165 -0
  44. package/public/js/tide.mjs +40 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,47 @@ All notable changes to Chiplog are documented here. The format follows [Keep a C
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.2.0] - 2026-09-15
8
+
9
+ ### Added
10
+
11
+ - The tablet app's handwriting pad now fills the whole screen and has a toolbar: fine pen, thick pen, highlighter, eraser, undo and a choice of colour (kept to the theme's colour in night mode). The eraser removes only the points it touches, splitting a stroke instead of deleting all of it; undo now steps back through erasing too, not just strokes. A stroke's colour and tool travel with it to the webapp's timeline and the PDF export, not just the tablet.
12
+
13
+ ### Fixed
14
+
15
+ - The tablet app's stylus canvas now prevents the default action on every contact, not just the pen's — a resting palm's touch was left to the browser, which could hijack it as a gesture and cancel the pen's in-progress stroke, or show a native text-selection highlight over the canvas. iOS Safari's long-press selection callout on the canvas needed the whole entry app, not just the canvas, to opt out of selection to reliably stay away, plus blocking `selectstart`/`contextmenu`/`dragstart` directly since the CSS alone is unreliable on some iOS versions.
16
+ - Quickly lifting and reapplying the pen could have its next stroke silently dropped: the previous contact's pointerup can arrive after the next one's pointerdown, which read as "still drawing" and refused to start the new stroke.
17
+ - Worked around an iPadOS Safari/Scribble bug that could swallow a pen's pointer events mid-stroke, dropping strokes or having them mistakenly typed into the comment field, by also preventing the canvas's underlying touch events directly, not just the pointer ones.
18
+ - An autopilot engagement, disengagement or mode change now takes an instrument snapshot like every other automatic event, instead of logging the change with no conditions attached.
19
+
20
+ ## [1.1.0] - 2026-09-15
21
+
22
+ ### Added
23
+
24
+ - A summary above the logbook's day-grouped list: number of passages, total distance and total time, across every passage logged rather than just the pages currently loaded (`GET /entries/stats`).
25
+ - Tide forecast: fetched near the departure position for the next 24 hours when a passage opens (Open-Meteo Marine, free and keyless, configurable and can be turned off), shown on the passage page with the departure's place, the date, time and height of each high/low tide, and the water height curve. Sits next to the engine/sail card, each taking about half the width on a wide screen instead of the full width. Heights are relative to mean sea level, not the chart datum nautical tide tables use, and the app says so (`datum: "msl"` in the API).
26
+ - Editing and deleting logbook lines from the webapp's passage page: any line's comment can be corrected, and a manoeuvre or note the crew logged themselves can be deleted (automatic lines — alarms, autopilot, weather, corrections — can only be annotated).
27
+ - Highest speed and wind seen on a passage, shown alongside the average speed on the passage page (`maxSpeed`/`maxWindSpeed` in the API). Wind (true and apparent) and heading now ride along with every track point, not just the hourly instrument snapshot, so a gust between snapshots is no longer missed.
28
+ - Automatic engine/sail switches now show as a line in the passage log, not just on the engine/sail strip, with the conditions at that moment (`propulsion_change` event).
29
+ - Facsimile PDF logbook: A4 landscape, a page per day in ship's time, with time, position, course, speed, wind, barometer, depth, engine or sail and remarks; departure and arrival lines with passage totals, day totals, handwritten notes drawn. Downloadable from the export page in the webapp's language and the device's time zone.
30
+ - Engine hours of every engine: each engine's hour counter is recorded in readings, shown at departure and arrival with the hours run on the passage page and in the PDF, and exported as one CSV column per engine and in the JSON (`engineRuntimes`).
31
+ - One PDF per passage in the USB copy, in the new logbook language and ship's time zone settings. Existing copies gain their PDFs at the next copy.
32
+ - Screenshots for the Signal K App Store listing (`signalk.screenshots` in `package.json`).
33
+
34
+ ### Changed
35
+
36
+ - The tablet app's comment and delete actions on a recent entry are now icon buttons, keeping the same touch target size.
37
+ - Times are shown on the 24-hour clock in English too.
38
+
39
+ ### Fixed
40
+
41
+ - The log reading in instrument snapshots now comes from `navigation.log` (the total, non-resettable distance log), not `navigation.trip.log`, which a crew resetting the trip counter could zero out mid-passage.
42
+ - Renaming a departure or arrival now also renames that place on every later passage that already reused it, as documented; an earlier passage keeps the name it recorded.
43
+ - An alarm's message is no longer repeated as its comment in the passage log.
44
+ - The arrival correction field no longer appears, and is refused by the API (`409 entry_active`), on a passage still in progress — it has no arrival yet, only a moving last-seen position.
45
+ - A note or handwritten sketch logged live now takes an instrument snapshot too, like a manoeuvre already did, so the conditions it was written in show in the log.
46
+ - The App Store icon (`signalk.appIcon`) pointed at a non-existent `icon.svg` at the package root; the icon has always lived at `public/icon.svg`.
47
+
7
48
  ## [1.0.0] - 2026-09-13
8
49
 
9
50
  First release.
@@ -48,5 +89,7 @@ First release.
48
89
  - REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
49
90
  - Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
50
91
 
51
- [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v1.0.0...HEAD
92
+ [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v1.2.0...HEAD
93
+ [1.2.0]: https://github.com/ricard33/signalk-chiplog/compare/v1.1.0...v1.2.0
94
+ [1.1.0]: https://github.com/ricard33/signalk-chiplog/compare/v1.0.0...v1.1.0
52
95
  [1.0.0]: https://github.com/ricard33/signalk-chiplog/releases/tag/v1.0.0
package/README.md CHANGED
@@ -83,11 +83,11 @@ Each passage is split into engine and sail periods covering the time under way.
83
83
  3. `navigation.state` (`motoring` / `sailing`);
84
84
  4. the configured default, **sail**.
85
85
 
86
- A wrong period can be corrected in the webapp. A correction to the period in progress holds until the engine data actually changes.
86
+ A wrong period can be corrected in the webapp. A correction to the period in progress holds until the engine data actually changes. Each actual switch is also logged as a line in the passage's timeline.
87
87
 
88
88
  ### Instrument readings
89
89
 
90
- Readings are taken at departure, **every hour on the hour** during the passage (configurable), at arrival, and with each manoeuvre logged live, so a reef appears with the wind that called for it. Each reading holds whatever is available among position, speed and course over ground, heading, speed through water, true and apparent wind, depth, barometer, air and water temperature, trip log and engine hours. A sensor that has gone silent is left blank rather than repeating an old value.
90
+ Readings are taken at departure, **every hour on the hour** during the passage (configurable), at arrival, and with each manoeuvre, note or sketch logged live, so a reef appears with the wind that called for it and a note with the conditions when it was written. Each reading holds whatever is available among position, speed and course over ground, heading, speed through water, true and apparent wind, depth, barometer, air and water temperature, the log and the hour counter of each engine — both engines of a twin-engine boat. A sensor that has gone silent is left blank rather than repeating an old value.
91
91
 
92
92
  ### Automatic events
93
93
 
@@ -100,6 +100,12 @@ Added to the timeline without anyone touching anything:
100
100
 
101
101
  An alarm at anchor between two passages goes to the passage that ended there, as long as the boat is within 1 nautical mile of that arrival.
102
102
 
103
+ ### Tide forecast
104
+
105
+ When a passage opens, Chiplog fetches the predicted water height near the departure for the next 24 hours (configurable service, on by default) and shows it on the passage page: the departure's place, the high and low tide times and heights, and the height curve. Fetched once, at departure — not kept up to date afterwards. Offline is handled the same way as geocoding: retried for a while, then given up on quietly if the boat stays out of reach, or if the position simply has no tide (an inland lake). Hourly data, so times are accurate to within about half an hour — enough for a logbook reference, not for timing a lock or a bar crossing to the minute.
106
+
107
+ **Heights are relative to mean sea level, not a charted "hauteur d'eau".** The free tide service used has no notion of chart datum (the lowest-astronomical-tide reference SHOM and other official tide tables use), so a reading here can be several metres off what a nautical chart or an official tide table would say for the same moment — the app says so under the chart. Tide _times_ are unaffected by this: a vertical offset does not move when high or low water falls.
108
+
103
109
  ### Place names
104
110
 
105
111
  Departures and arrivals are named automatically:
@@ -113,15 +119,15 @@ Departures and arrivals are named automatically:
113
119
  Open **Chiplog** from the Signal K webapps, or `/signalk-chiplog/`. Reading needs no more than read-only access.
114
120
 
115
121
  - **Status bar** — under way under sail or engine, stopped, or waiting for data, with a link to the passage in progress. A warning shows when detection works from speed alone because signalk-autostate is missing.
116
- - **Logbook** — passages grouped by day, newest first, with times, departure and arrival, distance, duration and an engine/sail bar. A passage across midnight appears on both days. Provisional place names are shown as such.
117
- - **Passage page** — summary, map of the track (OpenStreetMap with OpenSeaMap seamarks, which can be hidden), the engine and sail periods, and the log: every reading and event in order, including handwritten notes. A passage in progress refreshes every minute.
122
+ - **Logbook** — a summary above the list (number of passages, total distance, total time, across every passage logged, not just what is loaded), then passages grouped by day, newest first, with times, departure and arrival, distance, duration and an engine/sail bar. A passage across midnight appears on both days. Provisional place names are shown as such.
123
+ - **Passage page** — summary (distance, duration, average speed, and the highest speed and wind seen), map of the track (OpenStreetMap with OpenSeaMap seamarks, which can be hidden), the tide forecast near the departure (place, high/low times and heights, height curve) when one was fetched, the engine and sail periods with each engine's hour counter at departure and arrival and the hours run, and the log: every reading and event in order, including handwritten notes. A passage in progress refreshes every minute. Each line's comment can be edited (read/write access); a manoeuvre or note the crew logged themselves can also be deleted — automatic lines (alarms, autopilot, weather, corrections) can only be annotated.
118
124
  - **Corrections** (read/write access):
119
- - rename the departure or arrival;
125
+ - rename the departure, or the arrival once the passage is closed — a passage in progress has none yet to rename;
120
126
  - switch an engine period to sail or back;
121
127
  - close a passage in progress, e.g. to confirm an arrival;
122
128
  - merge with the previous or next passage;
123
129
  - delete a passage (admin).
124
- - **Export** — download the whole logbook or a date range as JSON, CSV or GPX, and write the abandon-ship copy to the USB drive now (admin).
130
+ - **Export** — download the whole logbook or a date range as a PDF logbook to print, JSON, CSV or GPX, and write the abandon-ship copy to the USB drive now (admin). The PDF is written in the webapp's language and the device's time zone.
125
131
 
126
132
  **Helm entry** in the top bar opens the tablet entry app.
127
133
 
@@ -147,9 +153,9 @@ Other entries made with no passage open go to the last passage if the boat is st
147
153
  ### Notes and handwriting
148
154
 
149
155
  - **Note** — type and tap **Log it**.
150
- - **Handwriting** — write or draw on the pad with a stylus or a finger, optionally add a comment, and tap **Log it**. Pen pressure sets the line width. **Undo stroke** and **Clear** fix mistakes. Once a stylus has touched the pad, fingers are ignored, so a palm resting on the screen does not draw.
156
+ - **Handwriting** — takes over the whole screen, with a toolbar above the pad: fine pen, thick pen, highlighter, eraser, undo, and a choice of colour (kept to the theme's colour in night mode, to spare night vision). Pen pressure also sets the line width. The eraser removes only what it actually touches, splitting a stroke rather than deleting all of it; undo steps back through strokes and erasing alike. Once a stylus has touched the pad, fingers are ignored, so a palm resting on the screen does not draw. Add a comment and tap **Log it** to send.
151
157
 
152
- Handwritten notes appear as drawn in the logbook's timeline.
158
+ Handwritten notes appear as drawn — colour, pen or highlighter included — in the logbook's timeline and in the PDF export, not just on the tablet.
153
159
 
154
160
  ### Latest entries
155
161
 
@@ -181,37 +187,41 @@ To revoke it, delete the device under **Security → Devices**: the tablet asks
181
187
 
182
188
  In the Signal K admin, **Apps & Plugins → Configuration → Chiplog**.
183
189
 
184
- | Setting | Default | What it does |
185
- | -------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------- |
186
- | Stop duration that ends a passage | 30 min | Shorter stops stay within the same passage. |
187
- | Under-way speed without navigation.state | 1 kn | Used only without signalk-autostate: under way above it, stopped below half of it. |
188
- | Propulsion assumed without engine data | sail | When nothing says whether the engine is running. Set to engine on a motorboat. |
189
- | Instrument snapshot interval | 60 min | Readings on this clock boundary during a passage. |
190
- | Track point interval | 15 s | A track point at least this often while moving. |
191
- | Place matching radius | 200 m | A departure or arrival this close to a known place takes its name. |
192
- | Name departures and arrivals with online geocoding | on | Turn off to never send positions online; places are then named after their coordinates until corrected. |
193
- | Geocoding service | `https://nominatim.openstreetmap.org` | Any Nominatim-compatible service, e.g. a self-hosted one. |
194
- | USB export directory | | Where the abandon-ship copy is written, e.g. `/media/usb`. Empty turns the USB copy off. |
195
- | Automatic USB copy interval | 15 min | How often the USB copy is brought up to date. 0 turns the periodic copy off. |
196
- | Copy to the USB drive at each arrival | on | Brings the USB copy up to date as soon as a passage ends. |
197
- | Wind speed thresholds | 20, 30 kn | Logged when the 2-minute average true wind crosses them. |
198
- | Barometric drop warning | 4 hPa / 3 h | 0 turns it off. |
190
+ | Setting | Default | What it does |
191
+ | -------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
192
+ | Stop duration that ends a passage | 30 min | Shorter stops stay within the same passage. |
193
+ | Under-way speed without navigation.state | 1 kn | Used only without signalk-autostate: under way above it, stopped below half of it. |
194
+ | Propulsion assumed without engine data | sail | When nothing says whether the engine is running. Set to engine on a motorboat. |
195
+ | Instrument snapshot interval | 60 min | Readings on this clock boundary during a passage. |
196
+ | Track point interval | 15 s | A track point at least this often while moving. |
197
+ | Place matching radius | 200 m | A departure or arrival this close to a known place takes its name. |
198
+ | Name departures and arrivals with online geocoding | on | Turn off to never send positions online; places are then named after their coordinates until corrected. |
199
+ | Geocoding service | `https://nominatim.openstreetmap.org` | Any Nominatim-compatible service, e.g. a self-hosted one. |
200
+ | Fetch the tide forecast at departure | on | Turn off to never send the departure position online; the passage page then shows no tide. |
201
+ | Tide service | `https://marine-api.open-meteo.com/v1/marine` | Any Open-Meteo Marine-compatible service, e.g. a self-hosted one. |
202
+ | USB export directory | | Where the abandon-ship copy is written, e.g. `/media/usb`. Empty turns the USB copy off. |
203
+ | Automatic USB copy interval | 15 min | How often the USB copy is brought up to date. 0 turns the periodic copy off. |
204
+ | Copy to the USB drive at each arrival | on | Brings the USB copy up to date as soon as a passage ends. |
205
+ | Logbook language (PDF) | en | Language of the PDF logbooks on the USB drive (English or French). |
206
+ | Ship's time zone (PDF) | the server's | Time zone of the PDF logbooks on the USB drive, e.g. `Europe/Paris`. |
207
+ | Wind speed thresholds | 20, 30 kn | Logged when the 2-minute average true wind crosses them. |
208
+ | Barometric drop warning | 4 hPa / 3 h | 0 turns it off. |
199
209
 
200
210
  ## Signal K data used
201
211
 
202
212
  None of these is required except position and speed over ground; each feature uses what the boat has.
203
213
 
204
- | Purpose | Signal K paths |
205
- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
206
- | Passages, track, distance | `navigation.position`, `navigation.speedOverGround`, `navigation.courseOverGroundTrue`, `navigation.state` |
207
- | Engine or sail | `propulsion.*.revolutions`, `propulsion.*.state`, `navigation.state` |
208
- | Readings | `navigation.headingTrue` (or `headingMagnetic` + `magneticVariation`), `navigation.speedThroughWater`, `environment.wind.*`, `environment.depth.belowSurface` (or `belowTransducer`), `environment.outside.pressure`, `environment.outside.temperature`, `environment.water.temperature`, `navigation.trip.log`, `propulsion.*.runTime` |
209
- | Events | `notifications.*`, `steering.autopilot.state`, `.mode`, `.engaged`, `.target`, `environment.wind.speedTrue`, `environment.outside.pressure` |
214
+ | Purpose | Signal K paths |
215
+ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
216
+ | Passages, track, distance | `navigation.position`, `navigation.speedOverGround`, `navigation.courseOverGroundTrue`, `navigation.state` |
217
+ | Engine or sail | `propulsion.*.revolutions`, `propulsion.*.state`, `navigation.state` |
218
+ | Readings | `navigation.headingTrue` (or `headingMagnetic` + `magneticVariation`), `navigation.speedThroughWater`, `environment.wind.*`, `environment.depth.belowSurface` (or `belowTransducer`), `environment.outside.pressure`, `environment.outside.temperature`, `environment.water.temperature`, `navigation.log`, `propulsion.*.runTime` |
219
+ | Events | `notifications.*`, `steering.autopilot.state`, `.mode`, `.engaged`, `.target`, `environment.wind.speedTrue`, `environment.outside.pressure` |
210
220
 
211
221
  ## Backups and abandon ship
212
222
 
213
- - **Download** — Export page → JSON (the complete record, including tracks and handwriting), CSV (logbook lines in nautical units, for a spreadsheet) or GPX (tracks).
214
- - **USB drive** — leave a USB drive plugged into the server and set the USB export directory. Chiplog then keeps a copy on it by itself: every 15 minutes and as soon as a passage ends (both configurable). **Write to the USB drive now** on the Export page makes a copy immediately. The copy fills a `chiplog/` folder on the drive with one JSON, CSV and GPX file per passage, named so that sorting by name sorts by date — e.g. `2026-09-13_0612Z_La-Rochelle_Les-Sables-d-Olonne.csv` (times in UTC; a passage in progress ends in `underway`).
223
+ - **Download** — Export page → PDF (a paper-style logbook: a page per day with time, position, course, speed, wind, barometer, depth, engine or sail and remarks, handwritten notes included), JSON (the complete record, including tracks and handwriting), CSV (logbook lines in nautical units, for a spreadsheet) or GPX (tracks).
224
+ - **USB drive** — leave a USB drive plugged into the server and set the USB export directory. Chiplog then keeps a copy on it by itself: every 15 minutes and as soon as a passage ends (both configurable). **Write to the USB drive now** on the Export page makes a copy immediately. The copy fills a `chiplog/` folder on the drive with one PDF, JSON, CSV and GPX file per passage, named so that sorting by name sorts by date — e.g. `2026-09-13_0612Z_La-Rochelle_Les-Sables-d-Olonne.csv` (times in UTC; a passage in progress ends in `underway`).
215
225
  - Each export writes only passages that are new or changed since the last one, and removes the files of passages deleted, merged or renamed. Other files in the folder are left alone.
216
226
  - Each file is flushed to the drive before it appears, so pulling the drive out never leaves a half-written file.
217
227
  - The Export page shows the schedule, the last copy, the next one, and the last failure if any.
@@ -220,10 +230,11 @@ None of these is required except position and speed over ground; each feature us
220
230
  ## Privacy and online services
221
231
 
222
232
  - **Place names.** With geocoding on, the position of each departure and arrival that matches no known place is sent to the geocoding service — OpenStreetMap's public Nominatim by default. Nothing else is sent, and nothing at all when it is off.
233
+ - **Tide forecast.** With it on, the departure position of each passage is sent to the tide service — the public Open-Meteo by default — once, at departure. Nothing at all when it is off.
223
234
  - **Maps.** The logbook webapp loads map tiles from OpenStreetMap and OpenSeaMap while the device viewing it is online. Offline, the track is still drawn, on a blank background.
224
235
  - **Nothing else** leaves the boat. There is no account, analytics or cloud service.
225
236
 
226
- Map data and place names © OpenStreetMap contributors (ODbL); seamarks © OpenSeaMap.
237
+ Map data and place names © OpenStreetMap contributors (ODbL); seamarks © OpenSeaMap; tide data © [Open-Meteo.com](https://open-meteo.com/) (CC BY 4.0).
227
238
 
228
239
  ## Troubleshooting
229
240
 
@@ -268,9 +279,13 @@ The USB drive is not mounted at the configured directory, or cannot be written.
268
279
 
269
280
  The server's clock is wrong — common on a Raspberry Pi without a real-time clock. Set it from GPS with `signalk-set-system-time`.
270
281
 
282
+ ### Handwriting strokes are dropped or turn into typed text
283
+
284
+ On an iPad, this is Apple's **Scribble** intercepting the Apple Pencil before the page sees it — a known iPadOS/Safari limitation with no web-page-level fix (Scribble runs beneath the browser). If it happens often, turn Scribble off under **Settings → Apple Pencil → Scribble**; a tablet dedicated to Chiplog does not need it.
285
+
271
286
  ## Limitations
272
287
 
273
- - **Not yet:** the PDF logbook facsimile (planned for V1.1), a places page, and editing manoeuvre shortcuts from the webapps.
288
+ - **Not yet:** a places page, and editing manoeuvre shortcuts from the webapps.
274
289
  - **Offline charts** are not provided.
275
290
  - **One vessel per Signal K server**, and no per-crew-member authorship.
276
291
 
@@ -285,6 +300,8 @@ npm run demo:seed -- /tmp/chiplog-demo # a demo logbook to try the webapps wit
285
300
 
286
301
  The functional specification is in [docs/SPEC.md](docs/SPEC.md), the data model in [docs/DATA_MODEL.md](docs/DATA_MODEL.md), and the REST API in [docs/API.md](docs/API.md). [CLAUDE.md](CLAUDE.md) describes the code layout and conventions.
287
302
 
303
+ `docs/screenshots/` holds the images the Signal K App Store shows for this plugin (`signalk.screenshots` in `package.json`), taken against a demo logbook (`npm run demo:seed`) with a real browser, e.g. `google-chrome --headless --window-size=1280,800 --screenshot=docs/screenshots/01-logbook.png http://localhost:3000/signalk-chiplog/?lang=en`. Retake them after a visible UI change.
304
+
288
305
  ## License
289
306
 
290
307
  MIT — see [LICENSE](LICENSE). Changes are listed in [CHANGELOG.md](CHANGELOG.md).
Binary file
package/index.js CHANGED
@@ -6,6 +6,7 @@ const { createEventWatcher, CHECK_INTERVAL_MS, EVENT_DEFAULTS } = require('./lib
6
6
  const { OBSERVATION_DEFAULTS } = require('./lib/observation-recorder');
7
7
  const { createPlaceNamer, GEOCODING_DEFAULTS } = require('./lib/place-names');
8
8
  const { PROPULSION_DEFAULTS } = require('./lib/propulsion-detector');
9
+ const { createTideForecaster, TIDE_DEFAULTS } = require('./lib/tide-forecaster');
9
10
  const { createTrackRecorder, SAMPLE_INTERVAL_MS, TRACK_DEFAULTS } = require('./lib/track-recorder');
10
11
  const {
11
12
  createUsbExportScheduler,
@@ -13,11 +14,15 @@ const {
13
14
  USB_EXPORT_DEFAULTS
14
15
  } = require('./lib/usb-scheduler');
15
16
 
17
+ const { isTimeZone, PDF_LANGUAGES } = require('./lib/logbook-pdf');
18
+
16
19
  const { version } = require('./package.json');
17
20
 
18
21
  const DEFAULT_PLACE_MATCH_RADIUS = 200;
19
22
  const FIRST_NAMING_DELAY_MS = 5 * 1000;
20
23
  const NAMING_ERROR_RETRY_MS = 5 * 60 * 1000;
24
+ const FIRST_TIDE_DELAY_MS = 5 * 1000;
25
+ const TIDE_ERROR_RETRY_MS = 5 * 60 * 1000;
21
26
 
22
27
  const MOTION_LABELS = { underway: 'Under way', stopped: 'Stopped', unknown: 'Waiting for data' };
23
28
  const MODE_LABELS = { autostate: 'navigation.state', fallback: 'speed fallback' };
@@ -29,6 +34,13 @@ function readVesselPosition(app) {
29
34
  : null;
30
35
  }
31
36
 
37
+ // Signal K keeps the vessel name as a plain value, not a {value, timestamp} node.
38
+ function readVesselName(app) {
39
+ const name = app.getSelfPath('name');
40
+ const text = typeof name === 'string' ? name : name?.value;
41
+ return typeof text === 'string' && text.trim() !== '' ? text.trim() : null;
42
+ }
43
+
32
44
  function describeDetection({ mode, motion, propulsion, activeEntryId }) {
33
45
  const under = propulsion === null ? '' : ` under ${propulsion}`;
34
46
  const passage = activeEntryId === null ? '' : `, passage ${activeEntryId} open`;
@@ -41,8 +53,10 @@ module.exports = function (app) {
41
53
  let settings = null;
42
54
  let detector = null;
43
55
  let namer = null;
56
+ let tideForecaster = null;
44
57
  let usbExport = null;
45
58
  let namingTimer = null;
59
+ let tideTimer = null;
46
60
  let timers = [];
47
61
  let lastStatus = null;
48
62
 
@@ -113,6 +127,19 @@ module.exports = function (app) {
113
127
  description: 'The public OpenStreetMap instance by default, or a self-hosted Nominatim',
114
128
  default: GEOCODING_DEFAULTS.geocodingUrl
115
129
  },
130
+ tidesEnabled: {
131
+ type: 'boolean',
132
+ title: 'Fetch the tide forecast at departure',
133
+ description:
134
+ 'Sends the departure position to the tide service below for the next 24 hours of predicted water height. Data comes from Open-Meteo (CC BY 4.0)',
135
+ default: TIDE_DEFAULTS.tidesEnabled
136
+ },
137
+ tideUrl: {
138
+ type: 'string',
139
+ title: 'Tide service (Open-Meteo Marine-compatible)',
140
+ description: 'The public Open-Meteo instance by default, or a self-hosted one',
141
+ default: TIDE_DEFAULTS.tideUrl
142
+ },
116
143
  usbExportPath: {
117
144
  type: 'string',
118
145
  title: 'USB export directory',
@@ -132,6 +159,20 @@ module.exports = function (app) {
132
159
  title: 'Copy to the USB drive at each arrival',
133
160
  default: USB_EXPORT_DEFAULTS.usbExportOnArrival
134
161
  },
162
+ logbookLanguage: {
163
+ type: 'string',
164
+ title: 'Logbook language (PDF)',
165
+ description:
166
+ 'Language of the PDF logbook written to the USB drive; downloads from the webapp use the webapp language',
167
+ enum: PDF_LANGUAGES,
168
+ default: 'en'
169
+ },
170
+ logbookTimeZone: {
171
+ type: 'string',
172
+ title: 'Ship’s time zone (PDF)',
173
+ description:
174
+ 'IANA time zone the PDF logbook on the USB drive is kept in, e.g. Europe/Paris. Empty uses the server’s time zone; downloads from the webapp use the browser’s'
175
+ },
135
176
  windSpeedThresholds: {
136
177
  type: 'array',
137
178
  title: 'Wind speed thresholds (knots)',
@@ -191,6 +232,29 @@ module.exports = function (app) {
191
232
  namingTimer = setTimeout(runNaming, result.retryInMs);
192
233
  }
193
234
 
235
+ // Same idea as geocoding: a network call fetching the tide forecast near a
236
+ // recent departure, on its own chain rather than inside detection.
237
+ async function runTides() {
238
+ const current = tideForecaster;
239
+ let result;
240
+ try {
241
+ result = await current.resolveNext();
242
+ } catch (err) {
243
+ app.error(`Tide forecast failed: ${err.stack ?? err}`);
244
+ result = { retryInMs: TIDE_ERROR_RETRY_MS };
245
+ }
246
+ if (tideForecaster !== current || result.outcome === 'stopped') {
247
+ return;
248
+ }
249
+ if (result.outcome === 'failed') {
250
+ // Expected whenever the boat is out of reach of a network.
251
+ app.debug(
252
+ `Tide forecast unavailable, retrying in ${Math.round(result.retryInMs / 60000)} min: ${result.error.message}`
253
+ );
254
+ }
255
+ tideTimer = setTimeout(runTides, result.retryInMs);
256
+ }
257
+
194
258
  // For work that runs every second: log a failure once, not on every run.
195
259
  function guarded(label, work) {
196
260
  let failing = false;
@@ -207,6 +271,13 @@ module.exports = function (app) {
207
271
  };
208
272
  }
209
273
 
274
+ const pdfOptions = () => ({
275
+ language: settings.logbookLanguage,
276
+ timeZone: settings.logbookTimeZone ?? undefined,
277
+ vesselName: readVesselName(app),
278
+ version
279
+ });
280
+
210
281
  plugin.start = function (config = {}) {
211
282
  try {
212
283
  const { db, migrated } = openDatabase(app.getDataDirPath());
@@ -222,14 +293,27 @@ module.exports = function (app) {
222
293
  placeMatchRadius: config.placeMatchRadius ?? DEFAULT_PLACE_MATCH_RADIUS,
223
294
  geocodingEnabled: config.geocodingEnabled ?? GEOCODING_DEFAULTS.geocodingEnabled,
224
295
  geocodingUrl: config.geocodingUrl || GEOCODING_DEFAULTS.geocodingUrl,
296
+ tidesEnabled: config.tidesEnabled ?? TIDE_DEFAULTS.tidesEnabled,
297
+ tideUrl: config.tideUrl || TIDE_DEFAULTS.tideUrl,
225
298
  usbExportPath: config.usbExportPath || null,
226
299
  usbExportIntervalMinutes:
227
300
  config.usbExportIntervalMinutes ?? USB_EXPORT_DEFAULTS.usbExportIntervalMinutes,
228
301
  usbExportOnArrival: config.usbExportOnArrival ?? USB_EXPORT_DEFAULTS.usbExportOnArrival,
302
+ logbookLanguage: PDF_LANGUAGES.includes(config.logbookLanguage)
303
+ ? config.logbookLanguage
304
+ : 'en',
305
+ logbookTimeZone: config.logbookTimeZone || null,
229
306
  windSpeedThresholds: config.windSpeedThresholds ?? EVENT_DEFAULTS.windSpeedThresholds,
230
307
  pressureDropThreshold: config.pressureDropThreshold ?? EVENT_DEFAULTS.pressureDropThreshold
231
308
  };
232
309
 
310
+ if (settings.logbookTimeZone && !isTimeZone(settings.logbookTimeZone)) {
311
+ app.error(
312
+ `Unknown time zone "${settings.logbookTimeZone}" for the PDF logbook; using the server's`
313
+ );
314
+ settings.logbookTimeZone = null;
315
+ }
316
+
233
317
  if (migrated.to > migrated.from) {
234
318
  app.debug(`Database schema migrated from version ${migrated.from} to ${migrated.to}`);
235
319
  }
@@ -252,12 +336,19 @@ module.exports = function (app) {
252
336
  settings,
253
337
  userAgent: `signalk-chiplog/${version}`
254
338
  });
339
+ tideForecaster = createTideForecaster({
340
+ db: database,
341
+ settings,
342
+ userAgent: `signalk-chiplog/${version}`
343
+ });
255
344
  usbExport = createUsbExportScheduler({
256
345
  db: database,
257
346
  settings,
347
+ pdfOptions,
258
348
  log: (level, message) => (level === 'error' ? app.error(message) : app.debug(message))
259
349
  });
260
350
  namingTimer = setTimeout(runNaming, FIRST_NAMING_DELAY_MS);
351
+ tideTimer = setTimeout(runTides, FIRST_TIDE_DELAY_MS);
261
352
  lastStatus = null;
262
353
  runDetection();
263
354
  timers = [
@@ -283,6 +374,9 @@ module.exports = function (app) {
283
374
  clearTimeout(namingTimer);
284
375
  namer?.stop();
285
376
  namer = null;
377
+ clearTimeout(tideTimer);
378
+ tideForecaster?.stop();
379
+ tideForecaster = null;
286
380
  usbExport?.stop();
287
381
  usbExport = null;
288
382
  detector = null;
@@ -311,6 +405,7 @@ module.exports = function (app) {
311
405
  vesselPosition: () => readVesselPosition(app),
312
406
  observeEvent: (entryId, time) => detector.observeEvent(entryId, time),
313
407
  usbExport,
408
+ pdfOptions,
314
409
  detection: () => ({
315
410
  mode: detector.mode(),
316
411
  motion: detector.motion(),
package/lib/api.js CHANGED
@@ -1,12 +1,14 @@
1
1
  const { getSchemaVersion } = require('./database');
2
- const { ApiError, badRequest, conflict } = require('./errors');
2
+ const { ApiError, badRequest, conflict, notFound } = require('./errors');
3
3
  const entries = require('./entries');
4
4
  const events = require('./events');
5
5
  const { renderExport } = require('./export');
6
+ const { isTimeZone, PDF_LANGUAGES } = require('./logbook-pdf');
6
7
  const { toGeoJson, toGpx } = require('./formats');
7
8
  const manoeuvreTypes = require('./manoeuvre-types');
8
9
  const places = require('./places');
9
10
  const propulsion = require('./propulsion');
11
+ const { getTideForecast } = require('./tide-forecaster');
10
12
  const track = require('./track');
11
13
  const v = require('./validation');
12
14
 
@@ -122,6 +124,11 @@ function registerRoutes(router, { getContext, logError }) {
122
124
  )
123
125
  );
124
126
 
127
+ readonly.get(
128
+ '/api/entries/stats',
129
+ handle(({ db, now }, req) => entries.getStats(db, { ...parseRange(req.query), now: now() }))
130
+ );
131
+
125
132
  readonly.get(
126
133
  '/api/entries/:id',
127
134
  handle(({ db }, req) => entries.getEntry(db, entryId(req)))
@@ -220,6 +227,19 @@ function registerRoutes(router, { getContext, logError }) {
220
227
  })
221
228
  );
222
229
 
230
+ readonly.get(
231
+ '/api/entries/:id/tide',
232
+ handle(({ db }, req) => {
233
+ const id = entryId(req);
234
+ entries.requireEntryRow(db, id);
235
+ const forecast = getTideForecast(db, id);
236
+ if (!forecast) {
237
+ throw notFound('tide', id);
238
+ }
239
+ return forecast;
240
+ })
241
+ );
242
+
223
243
  // Events
224
244
 
225
245
  readonly.get(
@@ -247,10 +267,10 @@ function registerRoutes(router, { getContext, logError }) {
247
267
  clientRef: optional(body, 'clientRef', requiredString(100))
248
268
  });
249
269
 
250
- // Conditions at a manoeuvre belong in the log — but only as it happens;
251
- // readings now say nothing about a manoeuvre logged after the fact.
252
- const observeLiveManoeuvre = (observeEvent, input, { event, created }) => {
253
- if (created && event.type === 'manoeuvre' && input.time === undefined) {
270
+ // Conditions at a manoeuvre, note or sketch belong in the log — but only as
271
+ // it happens; readings now say nothing about one logged after the fact.
272
+ const observeLiveEvent = (observeEvent, input, { event, created }) => {
273
+ if (created && events.CLIENT_EVENT_TYPES.includes(event.type) && input.time === undefined) {
254
274
  observeEvent(event.entryId, event.time);
255
275
  }
256
276
  };
@@ -264,7 +284,7 @@ function registerRoutes(router, { getContext, logError }) {
264
284
  now: now(),
265
285
  vesselPosition: vesselPosition()
266
286
  });
267
- observeLiveManoeuvre(observeEvent, input, outcome);
287
+ observeLiveEvent(observeEvent, input, outcome);
268
288
  res.status(outcome.created ? 201 : 200);
269
289
  return outcome.event;
270
290
  })
@@ -280,7 +300,7 @@ function registerRoutes(router, { getContext, logError }) {
280
300
  vesselPosition: vesselPosition(),
281
301
  placeMatchRadius: config.placeMatchRadius
282
302
  });
283
- observeLiveManoeuvre(observeEvent, input, outcome);
303
+ observeLiveEvent(observeEvent, input, outcome);
284
304
  res.status(outcome.created ? 201 : 200);
285
305
  return { ...outcome.event, openedEntry: outcome.openedEntry };
286
306
  })
@@ -383,21 +403,29 @@ function registerRoutes(router, { getContext, logError }) {
383
403
 
384
404
  readonly.get(
385
405
  '/api/export',
386
- handle(({ db, now }, req, res) => {
406
+ handle(async ({ db, now, pdfOptions }, req, res) => {
387
407
  const format = v.parseEnum(req.query.format ?? 'json', 'format', [
388
408
  'json',
389
409
  'csv',
390
410
  'gpx',
391
411
  'pdf'
392
412
  ]);
393
- if (format === 'pdf') {
394
- throw new ApiError(501, 'not_implemented', 'PDF export arrives in V1.1');
413
+ const pdf = pdfOptions();
414
+ if (req.query.lang !== undefined) {
415
+ pdf.language = v.parseEnum(req.query.lang, 'lang', PDF_LANGUAGES);
395
416
  }
396
- const { contentType, filename, body } = renderExport(
417
+ if (req.query.tz !== undefined) {
418
+ if (!isTimeZone(req.query.tz)) {
419
+ throw badRequest('tz must be an IANA time zone, such as Europe/Paris');
420
+ }
421
+ pdf.timeZone = req.query.tz;
422
+ }
423
+ const { contentType, filename, body } = await renderExport(
397
424
  db,
398
425
  format,
399
426
  parseRange(req.query),
400
- now()
427
+ now(),
428
+ pdf
401
429
  );
402
430
  res.attachment(filename).type(contentType).send(body);
403
431
  return null;