signalk-chiplog 2.2.0 → 2.3.1

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
@@ -6,6 +6,44 @@ All notable changes to Chiplog are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.3.1] - 2026-09-18
10
+
11
+ ### Added
12
+
13
+ - **Retrospective analysis now shows what has been reconstructed so far while it is still running**, not only once it
14
+ finishes: passages, distance, engine/sail time, track points and events update after every committed slice instead of
15
+ only the clock position. A run that fails partway — an InfluxDB query timing out on a slow host — keeps that same
16
+ summary next to the error, instead of leaving a bare error message with no way to tell what was saved.
17
+ - **A retrospective replay retries an InfluxDB query that times out** up to 3 times, 5 seconds apart, instead of failing
18
+ the whole run on what is often just a Raspberry Pi momentarily busy sharing its InfluxDB with Signal K itself; the
19
+ webapp shows which attempt is under way while it waits. The timeout covers the whole round trip, including a chunk's
20
+ JSON still streaming in after the connection answered, not just getting a connection in the first place. Each window
21
+ of history is also fetched in smaller, two-hour chunks instead of six, so a slow host has less to answer per request.
22
+
23
+ ### Fixed
24
+
25
+ - **A retrospective replay running alongside live tracking** could have a live detection tick, track sample or event
26
+ check land on the passage the replay was reconstructing — mistaking it for the current one, since both read the same
27
+ `active` row — and close it early or splice live position and instrument data into a past passage. Live detection,
28
+ track sampling and event watching now pause for as long as a replay is running.
29
+
30
+ ## [2.3.0] - 2026-09-18
31
+
32
+ ### Added
33
+
34
+ - **InfluxDB query timeout** setting (`influxQueryTimeoutSeconds`, 30 s by default): how long a retrospective replay
35
+ waits for the InfluxDB server to answer before giving up on it as unreachable or overloaded, now configurable instead
36
+ of a fixed 30 seconds — useful against a Raspberry Pi that is simply slow to answer a six-hour chunk.
37
+
38
+ ### Fixed
39
+
40
+ - **Retrospective replay backfilling a gap before passages already logged live** no longer dated every reconstructed
41
+ passage to the most recent existing passage's end time. Detection's guard against an out-of-order departure looked at
42
+ the latest `end_time` in the whole logbook rather than only at passages that actually preceded the new one, so filling
43
+ in an earlier gap — installing Chiplog after the fact, or after a stop — pinned every reconstructed departure to that
44
+ unrelated, later date, and the replay summary reported no passage found for the requested period even though entries
45
+ had been created.
46
+
9
47
  ## [2.2.0] - 2026-09-18
10
48
 
11
49
  ### Added
@@ -249,7 +287,9 @@ First release.
249
287
  - REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
250
288
  - Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
251
289
 
252
- [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.2.0...HEAD
290
+ [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.3.1...HEAD
291
+ [2.3.1]: https://github.com/ricard33/signalk-chiplog/compare/v2.3.0...v2.3.1
292
+ [2.3.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.2.0...v2.3.0
253
293
  [2.2.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.1.0...v2.2.0
254
294
  [2.1.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.0.0...v2.1.0
255
295
  [2.0.0]: https://github.com/ricard33/signalk-chiplog/compare/v1.2.0...v2.0.0
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Chiplog
2
2
 
3
- An automated logbook for [Signal K](https://signalk.org). Chiplog writes the logbook from the data already on your
4
- boat's Signal K server — passages, track, engine and sail, instrument readings, alarms — and lets the crew add what
3
+ An automated nautical logbook for [Signal K](https://signalk.org). Chiplog writes the logbook from the data already on
4
+ your boat's Signal K server — passages, track, engine and sail, instrument readings, alarms — and lets the crew add what
5
5
  sensors cannot know from a tablet at the helm: manoeuvres, notes and handwriting.
6
6
 
7
7
  - **One entry per passage**, opened when the boat leaves and closed as soon as it arrives, carrying on after short stops
@@ -348,6 +348,7 @@ In the Signal K admin, **Apps & Plugins → Configuration → Chiplog**.
348
348
  | InfluxDB database | — | |
349
349
  | InfluxDB username / password | — | Leave empty if the database needs none. |
350
350
  | InfluxDB protocol | http | `http` or `https`. |
351
+ | InfluxDB query timeout | 30 s | Each retrospective query gives up and reports an error past this, instead of hanging against an unreachable or overloaded database. |
351
352
  | 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. |
352
353
 
353
354
  ## Signal K data used 🔌
@@ -497,9 +498,10 @@ context** in the plugin configuration.
497
498
  ### A retrospective analysis takes minutes then fails with no clear reason
498
499
 
499
500
  The InfluxDB server did not answer — unreachable, overloaded, a firewall or a VPN not connected. Each query now gives up
500
- after 30 seconds with the connection problem it ran into, rather than hanging until some far longer, less informative
501
- failure; check that the server named in the plugin configuration is reachable from wherever Signal K runs, and that it
502
- is not overloaded.
501
+ after **InfluxDB query timeout** (30 seconds by default) with the connection problem it ran into, rather than hanging
502
+ until some far longer, less informative failure; check that the server named in the plugin configuration is reachable
503
+ from wherever Signal K runs, and that it is not overloaded — or raise the timeout if it is simply slow to answer a
504
+ six-hour chunk.
503
505
 
504
506
  ### A retrospective analysis over several days makes the InfluxDB server unresponsive
505
507
 
package/index.js CHANGED
@@ -256,6 +256,14 @@ module.exports = function (app) {
256
256
  enum: ['http', 'https'],
257
257
  default: INFLUX_DEFAULTS.influxProtocol
258
258
  },
259
+ influxQueryTimeoutSeconds: {
260
+ type: 'number',
261
+ title: 'InfluxDB query timeout (seconds)',
262
+ description:
263
+ 'Each retrospective query is given up on and reported as an error past this, rather than hanging indefinitely against an unreachable or overloaded database',
264
+ default: INFLUX_DEFAULTS.influxQueryTimeoutSeconds,
265
+ minimum: 1
266
+ },
259
267
  influxSelfContext: {
260
268
  type: 'string',
261
269
  title: 'InfluxDB vessel context',
@@ -266,6 +274,20 @@ module.exports = function (app) {
266
274
  };
267
275
 
268
276
  function runDetection() {
277
+ // A retrospective replay drives the same detector against a past window,
278
+ // holding its own passage 'active' in log_entries for as long as it takes
279
+ // to close it there — live detection, track sampling and event watching
280
+ // must not touch that row in the meantime, or a live tick would treat a
281
+ // reconstructed passage as the current one and close it early or splice
282
+ // live data into it.
283
+ if (replayJob?.status().running) {
284
+ const status = 'Retrospective replay in progress — live tracking paused';
285
+ if (status !== lastStatus) {
286
+ app.setPluginStatus(status);
287
+ lastStatus = status;
288
+ }
289
+ return;
290
+ }
269
291
  try {
270
292
  const outcome = detector.tick();
271
293
  usbExport.afterDetection(outcome);
@@ -384,6 +406,8 @@ module.exports = function (app) {
384
406
  influxUsername: config.influxUsername || null,
385
407
  influxPassword: config.influxPassword || null,
386
408
  influxProtocol: config.influxProtocol || INFLUX_DEFAULTS.influxProtocol,
409
+ influxQueryTimeoutSeconds:
410
+ config.influxQueryTimeoutSeconds ?? INFLUX_DEFAULTS.influxQueryTimeoutSeconds,
387
411
  influxSelfContext: config.influxSelfContext || null
388
412
  };
389
413
 
@@ -461,11 +485,19 @@ module.exports = function (app) {
461
485
  timers = [
462
486
  setInterval(runDetection, TICK_INTERVAL_MS),
463
487
  setInterval(
464
- guarded('Track recording', () => recorder.sample()),
488
+ guarded('Track recording', () => {
489
+ if (!replayJob?.status().running) {
490
+ recorder.sample();
491
+ }
492
+ }),
465
493
  SAMPLE_INTERVAL_MS
466
494
  ),
467
495
  setInterval(
468
- guarded('Event watching', () => watcher.check()),
496
+ guarded('Event watching', () => {
497
+ if (!replayJob?.status().running) {
498
+ watcher.check();
499
+ }
500
+ }),
469
501
  CHECK_INTERVAL_MS
470
502
  ),
471
503
  setInterval(
package/lib/detection.js CHANGED
@@ -314,11 +314,16 @@ function createPassageDetector({ db, readSelfPath, settings, clock = Date.now })
314
314
  }
315
315
 
316
316
  function openPassage(now, position, departure) {
317
+ const startIso = iso(departure.time);
318
+ // Only a passage that actually precedes this departure can push it later
319
+ // -- not just whichever entry happens to hold the latest end_time. A
320
+ // retrospective replay filling a gap before passages logged live (SPEC
321
+ // §4.10) would otherwise have every reconstructed departure clamped to
322
+ // the most recent (unrelated, later) passage's end.
317
323
  const previousEnd = db
318
- .prepare('SELECT MAX(end_time) AS endTime FROM log_entries')
319
- .get().endTime;
320
- const startTime =
321
- previousEnd && iso(departure.time) < previousEnd ? previousEnd : iso(departure.time);
324
+ .prepare('SELECT MAX(end_time) AS endTime FROM log_entries WHERE start_time < ?')
325
+ .get(startIso).endTime;
326
+ const startTime = previousEnd && startIso < previousEnd ? previousEnd : startIso;
322
327
  const start = departure.position ?? position;
323
328
  const place = initialPlaceName(db, start, settings.placeMatchRadius);
324
329
 
@@ -12,17 +12,25 @@ const { AUTOSTATE_SOURCE_PREFIX, MAX_AGE_MS, UNDERWAY_STATES } = require('./dete
12
12
  // packets, a VPN not connected) can otherwise hang far longer than this --
13
13
  // Node's fetch has no default timeout of its own -- for an error that is no
14
14
  // clearer once it finally arrives, so `query` bounds every request itself.
15
- const QUERY_TIMEOUT_MS = 30 * 1000;
15
+ // Configurable (`influxQueryTimeoutSeconds`): a Raspberry Pi under load can
16
+ // need longer than the default to answer a six-hour chunk.
17
+ const DEFAULT_QUERY_TIMEOUT_SECONDS = 30;
16
18
 
17
19
  // A resource-constrained host (a Raspberry Pi running both Signal K and
18
20
  // InfluxDB) can be brought down by one query spanning weeks across every
19
- // path at once; six hours at a time keeps each request's result small
21
+ // path at once; two hours at a time keeps each request's result small
20
22
  // regardless of how long the requested range is, with a short pause between
21
23
  // them so the database is never asked for the next one while still
22
24
  // recovering from the last.
23
- const CHUNK_MS = 6 * 60 * 60 * 1000;
25
+ const CHUNK_MS = 2 * 60 * 60 * 1000;
24
26
  const CHUNK_PAUSE_MS = 200;
25
27
 
28
+ // A query that times out is retried rather than failing the whole replay
29
+ // outright -- a Raspberry Pi sharing its InfluxDB with Signal K itself is
30
+ // often just busy for a moment, not actually unreachable.
31
+ const QUERY_MAX_RETRIES = 3;
32
+ const QUERY_RETRY_DELAY_MS = 5 * 1000;
33
+
26
34
  // The motion scan asks for one mean per minute, light enough to cover a week
27
35
  // per request.
28
36
  const SCAN_BUCKET_MS = 60 * 1000;
@@ -34,7 +42,8 @@ const INFLUX_DEFAULTS = {
34
42
  influxPort: 8086,
35
43
  influxDatabase: '',
36
44
  influxUsername: '',
37
- influxPassword: ''
45
+ influxPassword: '',
46
+ influxQueryTimeoutSeconds: DEFAULT_QUERY_TIMEOUT_SECONDS
38
47
  };
39
48
 
40
49
  // Everything detection, track recording and observations read live (SPEC
@@ -107,9 +116,13 @@ function createInfluxHistory({
107
116
  username,
108
117
  password,
109
118
  selfContext,
119
+ queryTimeoutSeconds = INFLUX_DEFAULTS.influxQueryTimeoutSeconds,
110
120
  fetch = globalThis.fetch,
111
- signal
121
+ signal,
122
+ onRetry = () => {},
123
+ retryDelayMs = QUERY_RETRY_DELAY_MS
112
124
  }) {
125
+ const queryTimeoutMs = queryTimeoutSeconds * 1000;
113
126
  // path -> [{ time, node }], ascending by time; `node` is what readSelfPath
114
127
  // answers, built once here rather than on every read.
115
128
  const series = new Map();
@@ -131,36 +144,59 @@ function createInfluxHistory({
131
144
  epoch: 'ms',
132
145
  q: statements.join(';')
133
146
  });
134
- const timeout = AbortSignal.timeout(QUERY_TIMEOUT_MS);
135
- let response;
136
- try {
137
- response = await fetch(url, {
138
- method: 'POST',
139
- headers,
140
- body,
141
- signal: signal ? AbortSignal.any([timeout, signal]) : timeout
142
- });
143
- } catch (err) {
144
- if (signal?.aborted) {
145
- throw err;
146
- }
147
- if (err.name === 'TimeoutError') {
147
+
148
+ // One full round trip: the timeout signal covers reading the response
149
+ // body too, not just getting the headers back, so a database that is slow
150
+ // to stream a large chunk's JSON times out here just as it would waiting
151
+ // for the connection -- both must retry the same way.
152
+ async function attempt() {
153
+ const timeout = AbortSignal.timeout(queryTimeoutMs);
154
+ let response;
155
+ try {
156
+ response = await fetch(url, {
157
+ method: 'POST',
158
+ headers,
159
+ body,
160
+ signal: signal ? AbortSignal.any([timeout, signal]) : timeout
161
+ });
162
+ } catch (err) {
163
+ if (signal?.aborted || err.name === 'TimeoutError') {
164
+ throw err;
165
+ }
166
+ // Node's fetch wraps a connection failure (wrong host, refused,
167
+ // certificate…) as a bare "fetch failed"; the actual reason is here.
148
168
  throw new Error(
149
- `InfluxDB at ${protocol}://${host}:${port} did not answer within ${QUERY_TIMEOUT_MS / 1000}s`,
169
+ `Could not reach InfluxDB at ${protocol}://${host}:${port}: ${err.cause?.message ?? err.message}`,
150
170
  { cause: err }
151
171
  );
152
172
  }
153
- // Node's fetch wraps a connection failure (wrong host, refused,
154
- // certificate…) as a bare "fetch failed"; the actual reason is here.
155
- throw new Error(
156
- `Could not reach InfluxDB at ${protocol}://${host}:${port}: ${err.cause?.message ?? err.message}`,
157
- { cause: err }
158
- );
173
+ if (!response.ok) {
174
+ throw new Error(`InfluxDB query failed: ${response.status} ${await response.text()}`);
175
+ }
176
+ return response.json();
159
177
  }
160
- if (!response.ok) {
161
- throw new Error(`InfluxDB query failed: ${response.status} ${await response.text()}`);
178
+
179
+ let payload;
180
+ // Each attempt gets its own full timeout budget, not a shared one left
181
+ // over from the last -- a query that timed out once is retried outright,
182
+ // not with less time to answer than before.
183
+ for (let retries = 0; ; retries += 1) {
184
+ try {
185
+ payload = await attempt();
186
+ onRetry(null);
187
+ break;
188
+ } catch (err) {
189
+ if (signal?.aborted || err.name !== 'TimeoutError') {
190
+ throw err;
191
+ }
192
+ const message = `InfluxDB at ${protocol}://${host}:${port} did not answer within ${queryTimeoutSeconds}s`;
193
+ if (retries >= QUERY_MAX_RETRIES) {
194
+ throw new Error(message, { cause: err });
195
+ }
196
+ onRetry(retries + 1, QUERY_MAX_RETRIES, message);
197
+ await sleep(retryDelayMs);
198
+ }
162
199
  }
163
- const payload = await response.json();
164
200
  return payload.results.map((result) => {
165
201
  if (result.error) {
166
202
  throw new Error(`InfluxDB query error: ${result.error}`);
@@ -477,4 +513,4 @@ function createInfluxHistory({
477
513
  return { preload, clear, scanMotion, readSelfPath };
478
514
  }
479
515
 
480
- module.exports = { createInfluxHistory, INFLUX_DEFAULTS };
516
+ module.exports = { createInfluxHistory, INFLUX_DEFAULTS, CHUNK_MS, QUERY_MAX_RETRIES };
package/lib/replay-job.js CHANGED
@@ -13,7 +13,8 @@ function createReplayJob({
13
13
  clock = Date.now,
14
14
  log = () => {},
15
15
  fetch = globalThis.fetch,
16
- onDone = () => {}
16
+ onDone = () => {},
17
+ retryDelayMs
17
18
  }) {
18
19
  let current = null;
19
20
  let lastResult = null;
@@ -71,8 +72,21 @@ function createReplayJob({
71
72
  username: settings.influxUsername,
72
73
  password: settings.influxPassword,
73
74
  selfContext: settings.influxSelfContext || app.selfContext,
75
+ queryTimeoutSeconds: settings.influxQueryTimeoutSeconds,
74
76
  fetch,
75
- signal
77
+ signal,
78
+ retryDelayMs,
79
+ onRetry: (attempt, of, message) => {
80
+ if (attempt !== null) {
81
+ log(
82
+ 'error',
83
+ `Retrospective replay: InfluxDB query timed out, retrying (attempt ${attempt}/${of}): ${message}`
84
+ );
85
+ }
86
+ if (current) {
87
+ current.retry = attempt === null ? null : { attempt, of, message };
88
+ }
89
+ }
76
90
  });
77
91
  try {
78
92
  await runWindowedReplay({
@@ -90,6 +104,11 @@ function createReplayJob({
90
104
  onProgress: (nowMs) => {
91
105
  if (current) {
92
106
  current.now = iso(nowMs);
107
+ // Recomputed each slice so the webapp can show what has actually
108
+ // been committed so far, not just how far the clock has got --
109
+ // the only way to know anything was saved if the run then times
110
+ // out or otherwise fails.
111
+ current.summary = summarise(afterId, to);
93
112
  }
94
113
  }
95
114
  });
@@ -104,7 +123,13 @@ function createReplayJob({
104
123
  summary: summarise(afterId, to)
105
124
  };
106
125
  } else {
107
- lastError = { at: iso(clock()), from, to, message: err.message };
126
+ lastError = {
127
+ at: iso(clock()),
128
+ from,
129
+ to,
130
+ message: err.message,
131
+ summary: summarise(afterId, to)
132
+ };
108
133
  log('error', `Retrospective replay failed (${from} to ${to}): ${err.message}`);
109
134
  }
110
135
  } finally {
@@ -140,7 +165,15 @@ function createReplayJob({
140
165
  );
141
166
  }
142
167
  const controller = new AbortController();
143
- current = { from, to, startedAt: iso(clock()), now: from, phase: 'scanning', controller };
168
+ current = {
169
+ from,
170
+ to,
171
+ startedAt: iso(clock()),
172
+ now: from,
173
+ phase: 'scanning',
174
+ retry: null,
175
+ controller
176
+ };
144
177
  lastError = null;
145
178
  perform(from, to, controller.signal);
146
179
  return { from, to };
@@ -162,7 +195,9 @@ function createReplayJob({
162
195
  from: current.from,
163
196
  to: current.to,
164
197
  now: current.now,
165
- phase: current.phase
198
+ phase: current.phase,
199
+ summary: current.summary ?? null,
200
+ retry: current.retry
166
201
  },
167
202
  lastResult,
168
203
  lastError
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "signalk-chiplog",
3
- "version": "2.2.0",
4
- "description": "Automated digital logbook plugin for Signal K, with keyboard/handwritten entry on tablet",
3
+ "version": "2.3.1",
4
+ "description": "Nautical logbook for Signal K: automatic entries, handwritten notes",
5
5
  "main": "index.js",
6
6
  "files": [
7
7
  "index.js",
@@ -36,7 +36,8 @@
36
36
  "./docs/screenshots/04-entry-app.png"
37
37
  ],
38
38
  "recommends": [
39
- "signalk-to-influxdb"
39
+ "signalk-to-influxdb",
40
+ "signalk-autostate"
40
41
  ]
41
42
  },
42
43
  "author": "Cedric RICARD <ricard33@gmail.com>",
@@ -26,19 +26,35 @@ function Progress({ progress }) {
26
26
  time: `${format.shortDate(progress.now)} ${format.time(progress.now)}`
27
27
  })}
28
28
  </p>
29
+ ${
30
+ progress.retry &&
31
+ html`<p class="notice">
32
+ ${t('replay.retrying', {
33
+ attempt: progress.retry.attempt,
34
+ of: progress.retry.of,
35
+ message: progress.retry.message
36
+ })}
37
+ </p>`
38
+ }
39
+ ${
40
+ progress.phase === 'replaying' &&
41
+ html`<${Summary} summary=${progress.summary} live=${true} />`
42
+ }
29
43
  </div>
30
44
  `;
31
45
  }
32
46
 
33
- // What the latest run added -- also after a cancellation, since what was
34
- // reconstructed up to that point stays on record.
35
- function Summary({ summary }) {
47
+ // What a run added, live while it is still going (so a timeout partway
48
+ // through still leaves something to show for it) and again for the final
49
+ // outcome -- also after a cancellation, since what was reconstructed up to
50
+ // that point stays on record either way.
51
+ function Summary({ summary, live = false }) {
36
52
  const { t, format } = useLocale();
37
53
  if (!summary) {
38
54
  return null;
39
55
  }
40
56
  if (summary.passages === 0) {
41
- return html`<p class="muted">${t('replay.summaryEmpty')}</p>`;
57
+ return live ? null : html`<p class="muted">${t('replay.summaryEmpty')}</p>`;
42
58
  }
43
59
  const facts = [
44
60
  ['replay.summaryPassages', format.count(summary.passages)],
@@ -66,12 +82,15 @@ function Outcome({ status }) {
66
82
  const { t, format } = useLocale();
67
83
  if (status.lastError) {
68
84
  const { lastError } = status;
69
- return html`<p class="notice notice-error">
70
- ${t('replay.failed', {
71
- time: format.time(lastError.at),
72
- message: lastError.message
73
- })}
74
- </p>`;
85
+ return html`
86
+ <p class="notice notice-error">
87
+ ${t('replay.failed', {
88
+ time: format.time(lastError.at),
89
+ message: lastError.message
90
+ })}
91
+ </p>
92
+ <${Summary} summary=${lastError.summary} />
93
+ `;
75
94
  }
76
95
  if (status.lastResult) {
77
96
  const { lastResult } = status;
@@ -351,6 +351,7 @@ export const MESSAGES = {
351
351
  'replay.start': 'Reconstruct',
352
352
  'replay.cancel': 'Cancel',
353
353
  'replay.progress': '{percent}% — at {time}',
354
+ 'replay.retrying': 'Retrying after a timeout (attempt {attempt}/{of}): {message}',
354
355
  'replay.done': 'Reconstruction finished at {time}.',
355
356
  'replay.cancelled': 'Reconstruction cancelled at {time}.',
356
357
  'replay.failed': 'Reconstruction failed at {time}: {message}',
@@ -802,6 +803,8 @@ export const MESSAGES = {
802
803
  'replay.start': 'Reconstruire',
803
804
  'replay.cancel': 'Annuler',
804
805
  'replay.progress': '{percent} % — à {time}',
806
+ 'replay.retrying':
807
+ 'Nouvelle tentative après un délai dépassé (essai {attempt}/{of}) : {message}',
805
808
  'replay.done': 'Reconstruction terminée à {time}.',
806
809
  'replay.cancelled': 'Reconstruction annulée à {time}.',
807
810
  'replay.failed': 'Échec de la reconstruction à {time} : {message}',