signalk-chiplog 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ All notable changes to Chiplog are documented here. The format follows [Keep a C
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.0.0] - 2026-09-16
8
+
9
+ ### Added
10
+
11
+ - The passage page has a **Boat status** card: each engine's hour counter, at departure and arrival, and every tank's level (and volume) and every battery's charge, voltage and current as noted at departure. Tanks and batteries are noted once on the passage as it opens (`startTanks`, `startBatteries`), whether detection or the crew opens it, and included in the JSON export.
12
+ - Merging two entries now keeps a record of the stop between them: a `stopover` line on the surviving passage's timeline, naming the place and its position — previously that information was silently lost once the merge took the later entry's arrival as its own.
13
+ - The passage page's track map now shows a small boat marker at the selected point, pointing along its heading, and a scrubber under the map to step back and forth through the track's history — it defaults to the latest point, doubling as the current position on a passage in progress. A band below it shows that point's time, SOG, COG, STW, TWS, TWD, TWA and AWA. Speed through water now rides along with every track point like wind and heading already did, not just the hourly instrument snapshot.
14
+ - Retrospective analysis: a new **Retrospective** page reconstructs past passages for a date range from a [signalk-to-influxdb](https://github.com/tkurki/signalk-to-influxdb) history (InfluxDB 1.x, local or remote — a new recommended companion plugin), through the exact same detection pipeline used live rather than a separate implementation. Runs in the background with a progress bar, refuses a range that overlaps a passage already on record, refuses to run at all while a passage is under way (it would be driving that same live passage through the replay's detector at the same time), and can be cancelled mid-way without losing what it already reconstructed. Data is matched to this server's own vessel identity by default, overridable (`influxSelfContext`) for running it from a different Signal K server than the one that wrote the history; a mismatch fails with the vessel contexts actually found, rather than reconstructing nothing with no explanation. An InfluxDB that never answers — unreachable, or overloaded — fails after 30 seconds with the actual connection problem, rather than hanging indefinitely on a generic "fetch failed". A quick first pass reads one mean speed per minute to find when the boat moved, and only those stretches are then fetched — one value per track interval — and replayed, committing once per ten simulated minutes, and the page sums up what a run added (passages, distance, engine and sail time, track points, events): a month now takes seconds rather than the best part of an hour, and no longer holds every raw reading of the range in memory. Requests stay bounded (a week for the scan, six hours for a stretch) with a short pause between them, so a multi-week reconstruction cannot overwhelm a database sharing a resource-constrained host (a Raspberry Pi) with Signal K itself; cancelling works at any stage. A replay wakes the place-naming lookup immediately once it finishes, rather than leaving newly-reconstructed departures and arrivals waiting out whatever backoff that chain was already in. Signal K alarms are not reconstructed, since a typical InfluxDB history does not archive notifications the way it does a numeric reading, nor are weather events while the boat lay still between passages, nor the extra track points live recording adds on turns and speed changes.
15
+
16
+ ### Fixed
17
+
18
+ - The arrival instrument snapshot (`entry_end`) is now dated from the moment the passage actually ended, not from the later tick that found out about it once the stop had held past the closure threshold (up to `stopClosureMinutes`) — it could otherwise sort after an hourly reading taken during that wait, even though the passage had already ended before that reading was taken.
19
+
20
+ ## [1.2.0] - 2026-09-15
21
+
22
+ ### Added
23
+
24
+ - 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.
25
+
26
+ ### Fixed
27
+
28
+ - 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.
29
+ - 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.
30
+ - 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.
31
+ - 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.
32
+
7
33
  ## [1.1.0] - 2026-09-15
8
34
 
9
35
  ### Added
@@ -76,6 +102,8 @@ First release.
76
102
  - REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
77
103
  - Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
78
104
 
79
- [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v1.1.0...HEAD
105
+ [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.0.0...HEAD
106
+ [2.0.0]: https://github.com/ricard33/signalk-chiplog/compare/v1.2.0...v2.0.0
107
+ [1.2.0]: https://github.com/ricard33/signalk-chiplog/compare/v1.1.0...v1.2.0
80
108
  [1.1.0]: https://github.com/ricard33/signalk-chiplog/compare/v1.0.0...v1.1.0
81
109
  [1.0.0]: https://github.com/ricard33/signalk-chiplog/releases/tag/v1.0.0
package/README.md CHANGED
@@ -23,6 +23,7 @@ English and French, chosen from the browser's language.
23
23
  - [Configuration](#configuration)
24
24
  - [Signal K data used](#signal-k-data-used)
25
25
  - [Backups and abandon ship](#backups-and-abandon-ship)
26
+ - [Retrospective analysis](#retrospective-analysis)
26
27
  - [Privacy and online services](#privacy-and-online-services)
27
28
  - [Troubleshooting](#troubleshooting)
28
29
  - [Limitations](#limitations)
@@ -68,7 +69,7 @@ A **passage** is one logbook entry: from leaving a berth or anchorage to arrivin
68
69
  - **Power cuts and restarts.** If the server comes back after the boat has been still for longer than the tolerance, the passage is closed at its last movement. A short restart carries on with the same passage.
69
70
  - **Under way or stopped** comes from `navigation.state` when signalk-autostate provides it. Otherwise Chiplog averages speed over ground over 3 minutes: under way above 1 knot, stopped below half a knot. This keeps a boat swinging at anchor from starting passages.
70
71
 
71
- A passage that was split in two — a stop just longer than the tolerance, for instance — can be merged back from the logbook webapp.
72
+ A passage that was split in two — a stop just longer than the tolerance, for instance — can be merged back from the logbook webapp. The stop the merge folds away is kept on the timeline as its own line, naming the place, since it would otherwise leave no trace once the merge takes the later passage's arrival as its own.
72
73
 
73
74
  ### Track and distance
74
75
 
@@ -120,7 +121,7 @@ Open **Chiplog** from the Signal K webapps, or `/signalk-chiplog/`. Reading need
120
121
 
121
122
  - **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.
122
123
  - **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.
124
+ - **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) with a small boat marker at the selected point, a scrubber under the map to step back and forth through its history (defaulting to the latest point, so it shows the current position on a passage in progress) with a band of that point's time, SOG, COG, STW, TWS, TWD, TWA and AWA, the tide forecast near the departure (place, high/low times and heights, height curve) when one was fetched, the engine and sail periods, the boat's status (each engine's hour counter at departure and arrival and the hours run, and the tank levels and battery charge, voltage and current noted at departure), 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.
124
125
  - **Corrections** (read/write access):
125
126
  - rename the departure, or the arrival once the passage is closed — a passage in progress has none yet to rename;
126
127
  - switch an engine period to sail or back;
@@ -128,6 +129,7 @@ Open **Chiplog** from the Signal K webapps, or `/signalk-chiplog/`. Reading need
128
129
  - merge with the previous or next passage;
129
130
  - delete a passage (admin).
130
131
  - **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.
132
+ - **Retrospective** (admin) — reconstruct past passages for a date range from an InfluxDB history (see [Retrospective analysis](#retrospective-analysis)).
131
133
 
132
134
  **Helm entry** in the top bar opens the tablet entry app.
133
135
 
@@ -153,9 +155,9 @@ Other entries made with no passage open go to the last passage if the boat is st
153
155
  ### Notes and handwriting
154
156
 
155
157
  - **Note** — type and tap **Log it**.
156
- - **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.
158
+ - **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.
157
159
 
158
- Handwritten notes appear as drawn in the logbook's timeline.
160
+ 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.
159
161
 
160
162
  ### Latest entries
161
163
 
@@ -187,25 +189,31 @@ To revoke it, delete the device under **Security → Devices**: the tablet asks
187
189
 
188
190
  In the Signal K admin, **Apps & Plugins → Configuration → Chiplog**.
189
191
 
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. |
192
+ | Setting | Default | What it does |
193
+ | -------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
194
+ | Stop duration that ends a passage | 30 min | Shorter stops stay within the same passage. |
195
+ | Under-way speed without navigation.state | 1 kn | Used only without signalk-autostate: under way above it, stopped below half of it. |
196
+ | Propulsion assumed without engine data | sail | When nothing says whether the engine is running. Set to engine on a motorboat. |
197
+ | Instrument snapshot interval | 60 min | Readings on this clock boundary during a passage. |
198
+ | Track point interval | 15 s | A track point at least this often while moving. |
199
+ | Place matching radius | 200 m | A departure or arrival this close to a known place takes its name. |
200
+ | Name departures and arrivals with online geocoding | on | Turn off to never send positions online; places are then named after their coordinates until corrected. |
201
+ | Geocoding service | `https://nominatim.openstreetmap.org` | Any Nominatim-compatible service, e.g. a self-hosted one. |
202
+ | Fetch the tide forecast at departure | on | Turn off to never send the departure position online; the passage page then shows no tide. |
203
+ | Tide service | `https://marine-api.open-meteo.com/v1/marine` | Any Open-Meteo Marine-compatible service, e.g. a self-hosted one. |
204
+ | USB export directory | — | Where the abandon-ship copy is written, e.g. `/media/usb`. Empty turns the USB copy off. |
205
+ | Automatic USB copy interval | 15 min | How often the USB copy is brought up to date. 0 turns the periodic copy off. |
206
+ | Copy to the USB drive at each arrival | on | Brings the USB copy up to date as soon as a passage ends. |
207
+ | Logbook language (PDF) | en | Language of the PDF logbooks on the USB drive (English or French). |
208
+ | Ship's time zone (PDF) | the server's | Time zone of the PDF logbooks on the USB drive, e.g. `Europe/Paris`. |
209
+ | Wind speed thresholds | 20, 30 kn | Logged when the 2-minute average true wind crosses them. |
210
+ | Barometric drop warning | 4 hPa / 3 h | 0 turns it off. |
211
+ | 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. |
212
+ | InfluxDB port | 8086 | |
213
+ | InfluxDB database | — | |
214
+ | InfluxDB username / password | — | Leave empty if the database needs none. |
215
+ | InfluxDB protocol | http | `http` or `https`. |
216
+ | 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. |
209
217
 
210
218
  ## Signal K data used
211
219
 
@@ -216,6 +224,7 @@ None of these is required except position and speed over ground; each feature us
216
224
  | Passages, track, distance | `navigation.position`, `navigation.speedOverGround`, `navigation.courseOverGroundTrue`, `navigation.state` |
217
225
  | Engine or sail | `propulsion.*.revolutions`, `propulsion.*.state`, `navigation.state` |
218
226
  | 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` |
227
+ | Boat status | `tanks.*.*.currentLevel`, `.currentVolume`, `.capacity`, `.name`, `electrical.batteries.*.voltage`, `.current`, `.capacity.stateOfCharge`, `.temperature`, `.name` |
219
228
  | Events | `notifications.*`, `steering.autopilot.state`, `.mode`, `.engaged`, `.target`, `environment.wind.speedTrue`, `environment.outside.pressure` |
220
229
 
221
230
  ## Backups and abandon ship
@@ -227,11 +236,22 @@ None of these is required except position and speed over ground; each feature us
227
236
  - The Export page shows the schedule, the last copy, the next one, and the last failure if any.
228
237
  - **The database** — `chiplog.sqlite` in the plugin's data folder can be copied while the plugin is stopped.
229
238
 
239
+ ## Retrospective analysis
240
+
241
+ Already have a history of the boat's Signal K data before Chiplog was installed, or from a period the plugin was stopped? The **Retrospective** page (admin access) reconstructs those passages from it, using the exact same detection Chiplog runs live — the same thresholds, so a reconstructed passage is one Chiplog would have logged had it been running at the time.
242
+
243
+ - **Requires [signalk-to-influxdb](https://github.com/tkurki/signalk-to-influxdb)** (a recommended companion plugin) already having written the boat's data into an InfluxDB 1.x database — local or on another machine, set in **Apps & Plugins → Configuration**: host, port, database, and a username/password if it needs one.
244
+ - Pick a **from** and **to** date on the Retrospective page and start it. It runs in the background — the page shows its progress — and can be cancelled at any point; a first quick pass finds when the boat moved, and only those stretches are then fetched and reconstructed, so weeks in port take next to no time. Once done, the page sums up what it added: passages, distance, time under engine and sail, track points and events; what was already reconstructed up to that point stays on record.
245
+ - **Refuses to run while a passage is under way**, whatever the date range asked for — it would be reconstructing history through the same detection that is simultaneously tracking the live passage.
246
+ - **Refuses a range that overlaps a passage already logged**, to avoid a duplicate or a conflicting one. Reconstruction only ever adds passages; it does not edit or merge into an existing one.
247
+ - **What is not reconstructed**: Signal K alarms and emergencies (`sk_alarm` events), since a typical InfluxDB history does not archive notifications the way it does a numeric reading; strong-wind and falling-barometer events while the boat lay still between passages; and the extra track points recorded live on turns and speed changes — a reconstructed track has one point per **Track point interval**. Everything else read from a continuously published path — position, speed, wind, engine, autopilot, depth, barometer — is reconstructed the same as live.
248
+
230
249
  ## Privacy and online services
231
250
 
232
251
  - **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
252
  - **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.
234
253
  - **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.
254
+ - **Retrospective analysis.** Running one queries the InfluxDB database set in the plugin configuration — the boat's own, local or remote, never a third party — for the Signal K history in the requested range.
235
255
  - **Nothing else** leaves the boat. There is no account, analytics or cloud service.
236
256
 
237
257
  Map data and place names © OpenStreetMap contributors (ODbL); seamarks © OpenSeaMap; tide data © [Open-Meteo.com](https://open-meteo.com/) (CC BY 4.0).
@@ -279,6 +299,26 @@ The USB drive is not mounted at the configured directory, or cannot be written.
279
299
 
280
300
  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`.
281
301
 
302
+ ### Handwriting strokes are dropped or turn into typed text
303
+
304
+ 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.
305
+
306
+ ### A retrospective analysis finishes but reconstructs nothing
307
+
308
+ Signal K tags historical data with the vessel it came from; a replay only reads data tagged for its own vessel. This shows up running the replay from a different Signal K server than the one that wrote the history — a development instance pointed at a production database, typically — since each server has its own vessel identity by default. The replay's error names the vessel contexts it actually found in the database; set the matching one as **InfluxDB vessel context** in the plugin configuration.
309
+
310
+ ### A retrospective analysis takes minutes then fails with no clear reason
311
+
312
+ The InfluxDB server did not answer — unreachable, overloaded, a firewall or a VPN not connected. Each query now gives up after 30 seconds with the connection problem it ran into, rather than hanging until some far longer, less informative failure; check that the server named in the plugin configuration is reachable from wherever Signal K runs, and that it is not overloaded.
313
+
314
+ ### A retrospective analysis over several days makes the InfluxDB server unresponsive
315
+
316
+ The replay first reads one mean speed per minute, a week at a time, then fetches only the stretches where the boat moved, six hours at a time and already reduced to one value per track interval, with a short pause between requests, specifically so this does not happen — a boat's InfluxDB often shares a resource-constrained host (a Raspberry Pi) with Signal K itself, and one query spanning weeks across every path at once can overwhelm it. If it still struggles on a very small or busy host, run the reconstruction over shorter date ranges instead of the whole history at once.
317
+
318
+ ### Reconstructed passages keep their provisional place names for a while
319
+
320
+ A replay wakes the geocoding lookup as soon as it finishes, but the lookup itself still needs internet access to succeed — the passage page shows the raw coordinates until it does. If the boat (or the Signal K server running the replay) has no internet access at the time, naming is retried on the same backoff as any other departure or arrival, up to an hour between attempts; nothing is lost, it just takes longer to resolve.
321
+
282
322
  ## Limitations
283
323
 
284
324
  - **Not yet:** a places page, and editing manoeuvre shortcuts from the webapps.
package/index.js CHANGED
@@ -3,9 +3,11 @@ 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
5
  const { createEventWatcher, CHECK_INTERVAL_MS, EVENT_DEFAULTS } = require('./lib/event-watcher');
6
+ const { INFLUX_DEFAULTS } = require('./lib/influx-history');
6
7
  const { OBSERVATION_DEFAULTS } = require('./lib/observation-recorder');
7
8
  const { createPlaceNamer, GEOCODING_DEFAULTS } = require('./lib/place-names');
8
9
  const { PROPULSION_DEFAULTS } = require('./lib/propulsion-detector');
10
+ const { createReplayJob } = require('./lib/replay-job');
9
11
  const { createTideForecaster, TIDE_DEFAULTS } = require('./lib/tide-forecaster');
10
12
  const { createTrackRecorder, SAMPLE_INTERVAL_MS, TRACK_DEFAULTS } = require('./lib/track-recorder');
11
13
  const {
@@ -55,6 +57,7 @@ module.exports = function (app) {
55
57
  let namer = null;
56
58
  let tideForecaster = null;
57
59
  let usbExport = null;
60
+ let replayJob = null;
58
61
  let namingTimer = null;
59
62
  let tideTimer = null;
60
63
  let timers = [];
@@ -188,6 +191,43 @@ module.exports = function (app) {
188
191
  'The log records a pressure fall of at least this much over three hours; 0 disables it',
189
192
  default: EVENT_DEFAULTS.pressureDropThreshold,
190
193
  minimum: 0
194
+ },
195
+ influxHost: {
196
+ type: 'string',
197
+ title: 'InfluxDB host (retrospective analysis)',
198
+ description:
199
+ 'For reconstructing past passages from a signalk-to-influxdb history (InfluxDB 1.x), local or remote. Leave empty to turn that feature off'
200
+ },
201
+ influxPort: {
202
+ type: 'number',
203
+ title: 'InfluxDB port',
204
+ default: INFLUX_DEFAULTS.influxPort
205
+ },
206
+ influxDatabase: {
207
+ type: 'string',
208
+ title: 'InfluxDB database'
209
+ },
210
+ influxUsername: {
211
+ type: 'string',
212
+ title: 'InfluxDB username',
213
+ description: 'Leave empty if the database needs none'
214
+ },
215
+ influxPassword: {
216
+ type: 'string',
217
+ title: 'InfluxDB password',
218
+ format: 'password'
219
+ },
220
+ influxProtocol: {
221
+ type: 'string',
222
+ title: 'InfluxDB protocol',
223
+ enum: ['http', 'https'],
224
+ default: INFLUX_DEFAULTS.influxProtocol
225
+ },
226
+ influxSelfContext: {
227
+ type: 'string',
228
+ title: 'InfluxDB vessel context',
229
+ description:
230
+ 'Only needed running the replay from a different Signal K server than the one that wrote the history — e.g. a development instance pointed at a boat’s production database. The vessel context the data was tagged with, such as "vessels.urn:mrn:imo:mmsi:123456789"; a failed replay names the contexts actually found. Leave empty to use this server’s own (Signal K → Server → Vessel Identity)'
191
231
  }
192
232
  }
193
233
  };
@@ -209,6 +249,18 @@ module.exports = function (app) {
209
249
  }
210
250
  }
211
251
 
252
+ // A retrospective replay can create newly-pending place names; wake the
253
+ // naming chain immediately rather than leave it to whatever backoff it
254
+ // was already in. Guarded against the plugin having stopped in the
255
+ // meantime, since a replay runs in the background and isn't awaited.
256
+ function nudgeNaming() {
257
+ if (!namer) {
258
+ return;
259
+ }
260
+ clearTimeout(namingTimer);
261
+ namingTimer = setTimeout(runNaming, 0);
262
+ }
263
+
212
264
  // Geocoding is a network call, so it runs as its own chain of timeouts rather
213
265
  // than inside detection: each lookup says when the next one is due.
214
266
  async function runNaming() {
@@ -304,7 +356,14 @@ module.exports = function (app) {
304
356
  : 'en',
305
357
  logbookTimeZone: config.logbookTimeZone || null,
306
358
  windSpeedThresholds: config.windSpeedThresholds ?? EVENT_DEFAULTS.windSpeedThresholds,
307
- pressureDropThreshold: config.pressureDropThreshold ?? EVENT_DEFAULTS.pressureDropThreshold
359
+ pressureDropThreshold: config.pressureDropThreshold ?? EVENT_DEFAULTS.pressureDropThreshold,
360
+ influxHost: config.influxHost || null,
361
+ influxPort: config.influxPort ?? INFLUX_DEFAULTS.influxPort,
362
+ influxDatabase: config.influxDatabase || null,
363
+ influxUsername: config.influxUsername || null,
364
+ influxPassword: config.influxPassword || null,
365
+ influxProtocol: config.influxProtocol || INFLUX_DEFAULTS.influxProtocol,
366
+ influxSelfContext: config.influxSelfContext || null
308
367
  };
309
368
 
310
369
  if (settings.logbookTimeZone && !isTimeZone(settings.logbookTimeZone)) {
@@ -347,6 +406,13 @@ module.exports = function (app) {
347
406
  pdfOptions,
348
407
  log: (level, message) => (level === 'error' ? app.error(message) : app.debug(message))
349
408
  });
409
+ replayJob = createReplayJob({
410
+ db: database,
411
+ settings,
412
+ app,
413
+ log: (level, message) => (level === 'error' ? app.error(message) : app.debug(message)),
414
+ onDone: nudgeNaming
415
+ });
350
416
  namingTimer = setTimeout(runNaming, FIRST_NAMING_DELAY_MS);
351
417
  tideTimer = setTimeout(runTides, FIRST_TIDE_DELAY_MS);
352
418
  lastStatus = null;
@@ -379,6 +445,8 @@ module.exports = function (app) {
379
445
  tideForecaster = null;
380
446
  usbExport?.stop();
381
447
  usbExport = null;
448
+ replayJob?.cancel();
449
+ replayJob = null;
382
450
  detector = null;
383
451
  if (database) {
384
452
  database.close();
@@ -404,7 +472,9 @@ module.exports = function (app) {
404
472
  now: () => new Date().toISOString(),
405
473
  vesselPosition: () => readVesselPosition(app),
406
474
  observeEvent: (entryId, time) => detector.observeEvent(entryId, time),
475
+ noteDeparture: (entryId) => detector.noteDeparture(entryId),
407
476
  usbExport,
477
+ replayJob,
408
478
  pdfOptions,
409
479
  detection: () => ({
410
480
  mode: detector.mode(),
package/lib/api.js CHANGED
@@ -269,22 +269,27 @@ function registerRoutes(router, { getContext, logError }) {
269
269
 
270
270
  // Conditions at a manoeuvre, note or sketch belong in the log — but only as
271
271
  // it happens; readings now say nothing about one logged after the fact.
272
- const observeLiveEvent = (observeEvent, input, { event, created }) => {
272
+ // Likewise the boat's state, for a passage the crew opened by casting off.
273
+ const observeLiveEvent = (context, input, { event, created, openedEntry }) => {
273
274
  if (created && events.CLIENT_EVENT_TYPES.includes(event.type) && input.time === undefined) {
274
- observeEvent(event.entryId, event.time);
275
+ context.observeEvent(event.entryId, event.time);
276
+ if (openedEntry) {
277
+ context.noteDeparture(event.entryId);
278
+ }
275
279
  }
276
280
  };
277
281
 
278
282
  readwrite.post(
279
283
  '/api/entries/:id/events',
280
- handle(({ db, now, vesselPosition, observeEvent }, req, res) => {
284
+ handle((context, req, res) => {
285
+ const { db, now, vesselPosition } = context;
281
286
  const id = entryId(req);
282
287
  const input = parseEventInput(v.requireBody(req.body, EVENT_FIELDS));
283
288
  const outcome = events.createEvent(db, id, input, {
284
289
  now: now(),
285
290
  vesselPosition: vesselPosition()
286
291
  });
287
- observeLiveEvent(observeEvent, input, outcome);
292
+ observeLiveEvent(context, input, outcome);
288
293
  res.status(outcome.created ? 201 : 200);
289
294
  return outcome.event;
290
295
  })
@@ -293,14 +298,15 @@ function registerRoutes(router, { getContext, logError }) {
293
298
  // What the tablet posts: the server finds the passage the entry belongs to.
294
299
  readwrite.post(
295
300
  '/api/events',
296
- handle(({ db, config, now, vesselPosition, observeEvent }, req, res) => {
301
+ handle((context, req, res) => {
302
+ const { db, config, now, vesselPosition } = context;
297
303
  const input = parseEventInput(v.requireBody(req.body, EVENT_FIELDS));
298
304
  const outcome = events.logCrewEvent(db, input, {
299
305
  now: now(),
300
306
  vesselPosition: vesselPosition(),
301
307
  placeMatchRadius: config.placeMatchRadius
302
308
  });
303
- observeLiveEvent(observeEvent, input, outcome);
309
+ observeLiveEvent(context, input, outcome);
304
310
  res.status(outcome.created ? 201 : 200);
305
311
  return { ...outcome.event, openedEntry: outcome.openedEntry };
306
312
  })
@@ -450,6 +456,32 @@ function registerRoutes(router, { getContext, logError }) {
450
456
  return usbExport.run('manual');
451
457
  })
452
458
  );
459
+
460
+ readonly.get(
461
+ '/api/replay',
462
+ handle(({ replayJob }) => replayJob.status())
463
+ );
464
+
465
+ admin.post(
466
+ '/api/replay',
467
+ handle(({ replayJob }, req) => {
468
+ const from = v.parseTimestamp(req.body.from, 'from');
469
+ const to = v.parseTimestamp(req.body.to, 'to');
470
+ if (to <= from) {
471
+ throw badRequest('to must be after from');
472
+ }
473
+ return replayJob.start(from, to);
474
+ })
475
+ );
476
+
477
+ admin.post(
478
+ '/api/replay/cancel',
479
+ handle(({ replayJob }) => {
480
+ if (!replayJob.cancel()) {
481
+ throw conflict('replay_not_running', 'No retrospective replay is running');
482
+ }
483
+ })
484
+ );
453
485
  }
454
486
 
455
487
  module.exports = { registerRoutes };
package/lib/database.js CHANGED
@@ -239,6 +239,23 @@ const MIGRATIONS = [
239
239
  fetched_at TEXT NOT NULL,
240
240
  points TEXT NOT NULL
241
241
  );
242
+ `,
243
+ `
244
+ -- Speed through water at each track point, for the passage page's position
245
+ -- scrubber (SPEC §4.1) -- true wind angle is derived from twd and heading
246
+ -- instead of stored, since it needs no sensor of its own.
247
+ ALTER TABLE track_points ADD COLUMN stw REAL;
248
+ `,
249
+ // Another CHECK-widening rebuild, same reason and same shape as
250
+ // addPropulsionChangeEventType.
251
+ addStopoverEventType,
252
+ `
253
+ -- The boat's state noted as the passage opened (SPEC §4.5.1), as JSON
254
+ -- arrays: every tank's level, volume and capacity, every battery's voltage,
255
+ -- current, state of charge and temperature, in SI units. NULL when the boat
256
+ -- publishes none, or the passage was opened after the fact.
257
+ ALTER TABLE log_entries ADD COLUMN start_tanks TEXT;
258
+ ALTER TABLE log_entries ADD COLUMN start_batteries TEXT;
242
259
  `
243
260
  ];
244
261
 
@@ -290,6 +307,56 @@ function addPropulsionChangeEventType(db, version) {
290
307
  db.exec('PRAGMA foreign_keys = ON');
291
308
  }
292
309
 
310
+ function addStopoverEventType(db, version) {
311
+ db.exec('PRAGMA foreign_keys = OFF');
312
+ withTransaction(db, () => {
313
+ db.exec(`
314
+ -- Merging two entries (SPEC §3.1) folds the earlier one's arrival into
315
+ -- the middle of the surviving passage, which would otherwise silently
316
+ -- drop the only record of where that stop was.
317
+ CREATE TABLE events_new (
318
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
319
+ entry_id INTEGER NOT NULL REFERENCES log_entries (id) ON DELETE CASCADE,
320
+ time TEXT NOT NULL,
321
+ type TEXT NOT NULL CHECK (
322
+ type IN (
323
+ 'manoeuvre',
324
+ 'text_annotation',
325
+ 'handwritten_annotation',
326
+ 'sk_alarm',
327
+ 'autopilot',
328
+ 'weather_threshold',
329
+ 'manual_correction',
330
+ 'propulsion_change',
331
+ 'stopover'
332
+ )
333
+ ),
334
+ subtype TEXT,
335
+ lat REAL,
336
+ lon REAL,
337
+ comment TEXT,
338
+ payload TEXT,
339
+ source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('auto', 'manual')),
340
+ created_at TEXT NOT NULL,
341
+ client_ref TEXT
342
+ );
343
+ INSERT INTO events_new (
344
+ id, entry_id, time, type, subtype, lat, lon, comment, payload, source, created_at, client_ref
345
+ )
346
+ SELECT id, entry_id, time, type, subtype, lat, lon, comment, payload, source, created_at, client_ref
347
+ FROM events;
348
+ DROP TABLE events;
349
+ ALTER TABLE events_new RENAME TO events;
350
+ CREATE INDEX idx_events_entry_time ON events (entry_id, time);
351
+ CREATE INDEX idx_events_type ON events (type);
352
+ CREATE UNIQUE INDEX idx_events_client_ref ON events (client_ref)
353
+ WHERE client_ref IS NOT NULL;
354
+ `);
355
+ db.exec(`PRAGMA user_version = ${version + 1}`);
356
+ });
357
+ db.exec('PRAGMA foreign_keys = ON');
358
+ }
359
+
293
360
  function seedBuiltinManoeuvreTypes(db) {
294
361
  const insert = db.prepare(
295
362
  `INSERT INTO manoeuvre_types (key, label, sort_order, builtin)
@@ -301,8 +368,24 @@ function seedBuiltinManoeuvreTypes(db) {
301
368
  }
302
369
  }
303
370
 
371
+ // Open transactions per connection. A call made inside another joins it
372
+ // rather than starting its own -- the retrospective replay wraps many
373
+ // detection ticks in one, so a long replay commits (and syncs) once per slice
374
+ // instead of once per tick. Only the outermost call commits or rolls back.
375
+ const transactionDepth = new WeakMap();
376
+
304
377
  function withTransaction(db, fn) {
378
+ const depth = transactionDepth.get(db) ?? 0;
379
+ if (depth > 0) {
380
+ transactionDepth.set(db, depth + 1);
381
+ try {
382
+ return fn();
383
+ } finally {
384
+ transactionDepth.set(db, depth);
385
+ }
386
+ }
305
387
  db.exec('BEGIN');
388
+ transactionDepth.set(db, 1);
306
389
  try {
307
390
  const result = fn();
308
391
  db.exec('COMMIT');
@@ -310,6 +393,8 @@ function withTransaction(db, fn) {
310
393
  } catch (err) {
311
394
  db.exec('ROLLBACK');
312
395
  throw err;
396
+ } finally {
397
+ transactionDepth.delete(db);
313
398
  }
314
399
  }
315
400
 
package/lib/detection.js CHANGED
@@ -360,7 +360,11 @@ function createPassageDetector({ db, readSelfPath, settings, clock = Date.now })
360
360
  closePassage(entry, now);
361
361
  // Closed well after the tolerance ran out means the plugin was not
362
362
  // running: current conditions say nothing about that arrival.
363
- closed = { id: entry.id, late: stoppedFor > closureMs + LATE_CLOSURE_MARGIN_MS };
363
+ closed = {
364
+ id: entry.id,
365
+ endTime: entry.stopped_since,
366
+ late: stoppedFor > closureMs + LATE_CLOSURE_MARGIN_MS
367
+ };
364
368
  entry = null;
365
369
  } else if (current === 'underway') {
366
370
  resumedAt = resumeTime(entry, now);
@@ -400,11 +404,16 @@ function createPassageDetector({ db, readSelfPath, settings, clock = Date.now })
400
404
  stateIssue: () => stateIssue,
401
405
  motion: () => motion,
402
406
  propulsion: () => propulsionType,
403
- observeEvent: (entryId, time) => observations.recordEvent(entryId, time, clock())
407
+ observeEvent: (entryId, time) => observations.recordEvent(entryId, time, clock()),
408
+ noteDeparture: (entryId) => observations.noteDeparture(entryId, clock())
404
409
  };
405
410
  }
406
411
 
407
412
  module.exports = {
413
+ AUTOSTATE_SOURCE_PREFIX,
414
+ MAX_AGE_MS,
415
+ METRES_PER_SECOND_PER_KNOT,
416
+ UNDERWAY_STATES,
408
417
  createFreshnessTracker,
409
418
  createPassageDetector,
410
419
  DETECTION_DEFAULTS,
package/lib/entries.js CHANGED
@@ -29,6 +29,8 @@ function toEntry(row) {
29
29
  engineDuration: row.engine_duration,
30
30
  sailDuration: row.sail_duration,
31
31
  openedByEventId: row.opened_by_event_id,
32
+ startTanks: row.start_tanks === null ? null : JSON.parse(row.start_tanks),
33
+ startBatteries: row.start_batteries === null ? null : JSON.parse(row.start_batteries),
32
34
  createdAt: row.created_at,
33
35
  updatedAt: row.updated_at
34
36
  };
@@ -307,6 +309,16 @@ function mergeEntries(db, id, otherId, now) {
307
309
  );
308
310
  }
309
311
 
312
+ // The "entry_end" observation is dated the same as the entry's own end,
313
+ // so this is normally a no-op; it only matters for an entry closed
314
+ // before that was true, when the reading could be up to
315
+ // `stopClosureMinutes` later than end_time. Read before the reassignment
316
+ // below, while entry_id still tells earlier's own snapshot apart from
317
+ // later's.
318
+ const entryEndObservation = db
319
+ .prepare(`SELECT MAX(time) AS time FROM observations WHERE entry_id = ? AND reason = ?`)
320
+ .get(earlier.id, 'entry_end').time;
321
+
310
322
  for (const table of CHILD_TABLES) {
311
323
  db.prepare(`UPDATE ${table} SET entry_id = ? WHERE entry_id = ?`).run(earlier.id, later.id);
312
324
  }
@@ -314,6 +326,38 @@ function mergeEntries(db, id, otherId, now) {
314
326
  // index would otherwise reject the update below.
315
327
  db.prepare('DELETE FROM log_entries WHERE id = ?').run(later.id);
316
328
 
329
+ // The merge below overwrites the earlier entry's end with the later
330
+ // entry's: without this, the place where the passage actually stopped —
331
+ // the whole reason a merge exists — would leave no trace on the timeline.
332
+ if (earlier.end_place_name !== null) {
333
+ // Placed no earlier than the entry_end reading, so it follows it on
334
+ // the timeline instead of appearing to predate the passage's own
335
+ // arrival. Falls back to the entry's end time when there is no
336
+ // reading to follow — closed by hand, or with no instrument data.
337
+ const time =
338
+ entryEndObservation && entryEndObservation > earlier.end_time
339
+ ? entryEndObservation
340
+ : earlier.end_time;
341
+ // The comment carries the raw place name too, like an sk_alarm's
342
+ // message, so it still reads in a CSV export or before any
343
+ // translation is applied.
344
+ db.prepare(
345
+ `INSERT INTO events (entry_id, time, type, lat, lon, comment, payload, source, created_at)
346
+ VALUES (?, ?, 'stopover', ?, ?, ?, ?, 'auto', ?)`
347
+ ).run(
348
+ earlier.id,
349
+ time,
350
+ earlier.end_lat,
351
+ earlier.end_lon,
352
+ earlier.end_place_name,
353
+ JSON.stringify({
354
+ placeName: earlier.end_place_name,
355
+ placePending: earlier.end_place_pending === 1
356
+ }),
357
+ now
358
+ );
359
+ }
360
+
317
361
  db.prepare(
318
362
  `UPDATE log_entries SET
319
363
  state = ?, end_time = ?, stopped_since = ?,