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.
- package/CHANGELOG.md +44 -1
- package/README.md +50 -33
- package/docs/screenshots/01-logbook.png +0 -0
- package/docs/screenshots/02-passage-tide.png +0 -0
- package/docs/screenshots/03-passage-journal.png +0 -0
- package/docs/screenshots/04-entry-app.png +0 -0
- package/index.js +95 -0
- package/lib/api.js +40 -12
- package/lib/database.js +97 -7
- package/lib/detection.js +7 -1
- package/lib/entries.js +72 -4
- package/lib/event-watcher.js +1 -1
- package/lib/events.js +11 -3
- package/lib/export.js +20 -8
- package/lib/formats.js +27 -3
- package/lib/logbook-pdf.js +594 -0
- package/lib/observation-recorder.js +22 -9
- package/lib/pdf/helvetica.js +410 -0
- package/lib/pdf/winansi.js +65 -0
- package/lib/pdf/writer.js +160 -0
- package/lib/propulsion-detector.js +24 -1
- package/lib/propulsion.js +1 -1
- package/lib/tide-forecaster.js +155 -0
- package/lib/track-recorder.js +45 -4
- package/lib/track.js +14 -2
- package/lib/usb-scheduler.js +2 -1
- package/package.json +9 -2
- package/public/app.css +164 -0
- package/public/entry/entry.css +95 -0
- package/public/entry/js/components/Icons.mjs +136 -0
- package/public/entry/js/components/RecentList.mjs +17 -4
- package/public/entry/js/components/SketchPanel.mjs +184 -45
- package/public/entry/js/main.mjs +5 -3
- package/public/entry/js/strokes.mjs +85 -5
- package/public/entry/sw.js +2 -0
- package/public/js/components/ExportView.mjs +9 -5
- package/public/js/components/LogView.mjs +25 -3
- package/public/js/components/PassageView.mjs +112 -18
- package/public/js/components/TideCard.mjs +92 -0
- package/public/js/components/Timeline.mjs +97 -99
- package/public/js/format.mjs +39 -5
- package/public/js/i18n.mjs +123 -4
- package/public/js/log-lines.mjs +165 -0
- 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.
|
|
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,
|
|
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** —
|
|
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
|
|
185
|
-
| -------------------------------------------------- |
|
|
186
|
-
| Stop duration that ends a passage | 30 min
|
|
187
|
-
| Under-way speed without navigation.state | 1 kn
|
|
188
|
-
| Propulsion assumed without engine data | sail
|
|
189
|
-
| Instrument snapshot interval | 60 min
|
|
190
|
-
| Track point interval | 15 s
|
|
191
|
-
| Place matching radius | 200 m
|
|
192
|
-
| Name departures and arrivals with online geocoding | on
|
|
193
|
-
| Geocoding service | `https://nominatim.openstreetmap.org`
|
|
194
|
-
|
|
|
195
|
-
|
|
|
196
|
-
|
|
|
197
|
-
|
|
|
198
|
-
|
|
|
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.
|
|
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:**
|
|
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
|
|
Binary file
|
|
Binary file
|
|
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
|
|
251
|
-
// readings now say nothing about
|
|
252
|
-
const
|
|
253
|
-
if (created && event.type
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
394
|
-
|
|
413
|
+
const pdf = pdfOptions();
|
|
414
|
+
if (req.query.lang !== undefined) {
|
|
415
|
+
pdf.language = v.parseEnum(req.query.lang, 'lang', PDF_LANGUAGES);
|
|
395
416
|
}
|
|
396
|
-
|
|
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;
|