signalk-underkeel 0.5.0 → 0.6.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
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to signalk-underkeel are documented here.
4
4
 
5
+ ## [0.6.0] - 2026-11-10
6
+
7
+ ### Changed
8
+
9
+ - Charts Provider sharing its store after this plugin started, as it does at every server
10
+ start once its charts are loaded, no longer restarts the plugin when the store holds the
11
+ cells read at start; only the note that it was not running goes.
12
+ - The first chart box is built as soon as the first position arrives, not at the next 10 s
13
+ check. Until it is in use the reason says the box is being built and how far it has come,
14
+ for example `building the chart box around the vessel: reading cells 3/7`, and the cells
15
+ phase counts each cell as it is read instead of jumping from 0 to all.
16
+ - A charts provider still starting, or one that does not download an area, goes to the debug
17
+ log instead of the error log; one too old to be asked, or whose answer failed, stays an
18
+ error.
19
+ - Once a chart box is in use, the cells the next box can reach are read into the cell cache by
20
+ a child process at the lowest CPU priority, so moving the box over them reads none during the
21
+ rebuild: after a 6 km move the Waddenzee box rebuilt in 8.6 s against 34 s for its first
22
+ build (x86).
23
+
24
+ ## [0.5.1] - 2026-11-10
25
+
26
+ ### Changed
27
+
28
+ - The package is listed in the Signal K plugin registry's **Plotter Extensions** category
29
+ (keyword `signalk-category-plotter-extensions`).
30
+
5
31
  ## [0.5.0] - 2026-11-09
6
32
 
7
33
  ### Changed
package/README.md CHANGED
@@ -144,8 +144,10 @@ phase, start and the last build's duration. At speed the box grows past its radi
144
144
  **Largest index box at speed** (0 sizes it from the RAM), so the vessel cannot reach the edge
145
145
  before the next box is built (docs/HOW-IT-WORKS.md §12). A radius under 2 km is raised to 2.
146
146
  Until the first position arrives the engine is `off`
147
- with the reason `waiting for the vessel position to build the chart box`. Set the box to 0 to
148
- index every cell.
147
+ with the reason `waiting for the vessel position to build the chart box`; the box is built
148
+ as soon as one does, and meanwhile the reason says how far it has come, for example
149
+ `building the chart box around the vessel: reading cells 3/7`. Set the box to 0 to index every
150
+ cell.
149
151
 
150
152
  At the first start the plugin builds a chart index from the cells in a child process and saves
151
153
  it under the plugin data directory, `~/.signalk/plugin-config-data/signalk-underkeel/index`
@@ -157,8 +159,11 @@ changed; `GET /status` names the cells that caused the rebuild. **Rebuild index
157
159
  forces a rebuild on every start. A cell folder may be a symbolic link. What the build read
158
160
  from each cell is kept, compressed, in `cell-cache` in the plugin data directory (at most 512 MB), so a
159
161
  chart box refresh reads again only the cells whose files changed: the 10 km Waddenzee box
160
- rebuilds in 12–15 s instead of 30–34 s on x86. Deleting the folder is safe; the cells are then
161
- read again.
162
+ rebuilds in 12–15 s instead of 30–34 s on x86. Once a box is in use, a child process at the
163
+ lowest CPU priority reads into it the cells the next box can reach, so a box moved over cells it
164
+ had not met reads none during the rebuild: after a 6 km move the Waddenzee box was rebuilt in
165
+ 8.6 s against 34 s for its first build (x86). A rebuild ends that process first. Deleting the
166
+ folder is safe; the cells are then read again.
162
167
 
163
168
  Folders of GeoJSON layers exported by `scripts/enc-to-geojson.sh` for earlier versions are no
164
169
  longer read: convert the original `.000` cells in the charts provider instead. Such a folder
package/lib/box-status.js CHANGED
@@ -1,9 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isPending = void 0;
3
4
  exports.rebuildStatus = rebuildStatus;
5
+ exports.noIndexReason = noIndexReason;
6
+ exports.phaseText = phaseText;
4
7
  exports.boxSummary = boxSummary;
5
8
  const box_sizing_1 = require("./box-sizing");
6
9
  const chart_box_1 = require("./engine/chart-box");
10
+ const index_loader_1 = require("./index-loader");
7
11
  function rebuildStatus(r, now) {
8
12
  return {
9
13
  startedAt: new Date(r.startedAt).toISOString(),
@@ -13,6 +17,21 @@ function rebuildStatus(r, now) {
13
17
  ...(r.estimateMs === undefined ? {} : { estimateMs: r.estimateMs })
14
18
  };
15
19
  }
20
+ /** Whether `reason` is one of PENDING_REASONS, maybe with a note (`; …`) or detail (`: …`). */
21
+ const isPending = (reason) => [...index_loader_1.PENDING_REASONS].some((p) => reason.startsWith(p) && /^(?:$|[;:] )/.test(reason.slice(p.length)));
22
+ exports.isPending = isPending;
23
+ /**
24
+ * Why there is no index: `reason`, or what the first chart box build is doing while it runs,
25
+ * as the vessel position it waited for has come.
26
+ */
27
+ function noIndexReason(reason, box) {
28
+ const why = reason ?? "no chart index loaded";
29
+ if (box?.rebuilding && why.startsWith(index_loader_1.BOX_WAITING_REASON)) {
30
+ return box.phase ? `${index_loader_1.BOX_BUILDING_REASON}: ${box.phase}` : index_loader_1.BOX_BUILDING_REASON;
31
+ }
32
+ return why;
33
+ }
34
+ /** What a box build in progress is doing, with its count where it gave one. */
16
35
  function phaseText(r) {
17
36
  const of = r.done !== undefined && r.total !== undefined ? ` ${r.done}/${r.total}` : "";
18
37
  switch (r.phase) {
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.warmCells = warmCells;
7
+ exports.startWarming = startWarming;
8
+ exports.boxWarmer = boxWarmer;
9
+ const os_1 = __importDefault(require("os"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const worker_threads_1 = require("worker_threads");
12
+ const child_process_1 = require("./foundation/child-process");
13
+ const index_loader_1 = require("./index-loader");
14
+ const cell_cache_1 = require("./ingest/cell-cache");
15
+ const cell_source_1 = require("./ingest/cell-source");
16
+ const enc_read_process_1 = require("./ingest/enc-read-process");
17
+ const build_box_1 = require("./spatial/build-box");
18
+ /** Reads the cells of `job.around` the cache lacks into it; resolves with how many were read. */
19
+ async function warmCells(job, reader = enc_read_process_1.readInProcess) {
20
+ const cells = (0, index_loader_1.cellSources)(job.cellsDir).filter(cell_source_1.isEncFile);
21
+ const cache = cell_cache_1.LayerCache.open(job.cacheDir, cells);
22
+ if (!cache) {
23
+ return 0;
24
+ }
25
+ cache.hold((0, build_box_1.boxCells)(cells, job.hold, job.cacheDir));
26
+ const want = (0, build_box_1.boxCells)(cells, job.around, job.cacheDir).filter((f) => !cache.has(f));
27
+ let read = 0;
28
+ for (const file of want) {
29
+ const layers = (await reader([file])).get(file);
30
+ if (layers) {
31
+ cache.put(file, layers);
32
+ read++;
33
+ }
34
+ // One cell's layers held at a time.
35
+ await cache.flush();
36
+ }
37
+ return read;
38
+ }
39
+ /**
40
+ * Runs `job` in a child process from the compiled `script`, its heap capped at `heapMb`;
41
+ * `done` gets its reply, or why it ended without one, unless it was stopped.
42
+ */
43
+ function startWarming(job, heapMb, done, script = path_1.default.join(__dirname, "cell-warm.js")) {
44
+ const { child, stderr } = (0, child_process_1.forkLimited)(script, heapMb, true);
45
+ let stopped = false;
46
+ let reply;
47
+ const closed = new Promise((resolve) => {
48
+ child.once("close", (code) => {
49
+ if (!stopped) {
50
+ done(reply ?? { error: `cell cache warming exited with ${code ?? "a signal"}: ${stderr()}` });
51
+ }
52
+ resolve();
53
+ });
54
+ });
55
+ child.once("error", () => undefined);
56
+ child.on("message", (m) => (reply = m));
57
+ child.send(job, () => undefined);
58
+ return {
59
+ stop: () => {
60
+ stopped = true;
61
+ (0, child_process_1.killGroup)(child);
62
+ return closed;
63
+ }
64
+ };
65
+ }
66
+ /** One run at a time through `start`; a run that cannot start is reported to `error`. */
67
+ function boxWarmer(start, error) {
68
+ let running;
69
+ let stopped = false;
70
+ const end = () => {
71
+ const r = running;
72
+ running = undefined;
73
+ return r ? r.stop() : Promise.resolve();
74
+ };
75
+ return {
76
+ boxInUse: (bbox, reachKm) => {
77
+ if (stopped) {
78
+ return;
79
+ }
80
+ void end();
81
+ try {
82
+ running = start((0, build_box_1.growBbox)(bbox, reachKm), bbox);
83
+ }
84
+ catch (err) {
85
+ error(`filling the cell cache failed: ${err.message}`);
86
+ }
87
+ },
88
+ beforeBuild: end,
89
+ stop: () => {
90
+ stopped = true;
91
+ void end();
92
+ }
93
+ };
94
+ }
95
+ if (worker_threads_1.isMainThread && require.main === module && process.send) {
96
+ (0, child_process_1.exitWithParent)();
97
+ try {
98
+ // Behind the server and the look-ahead, whatever the cores.
99
+ os_1.default.setPriority(19);
100
+ }
101
+ catch {
102
+ // Not permitted: it runs at the server's priority.
103
+ }
104
+ process.once("message", (job) => {
105
+ warmCells(job)
106
+ .then((read) => ({ read }), (err) => ({ error: err.message }))
107
+ .then(child_process_1.sendToParent)
108
+ .finally(() => process.exit(0));
109
+ });
110
+ }
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.failedLoad = exports.cellCacheDir = exports.SYNC_LOAD_BYTES = exports.PENDING_REASONS = exports.BOX_WAITING_REASON = exports.WFS_WAITING_REASON = exports.LOADING_REASON = exports.BUILDING_REASON = void 0;
6
+ exports.failedLoad = exports.cellCacheDir = exports.SYNC_LOAD_BYTES = exports.PENDING_REASONS = exports.BOX_BUILDING_REASON = exports.BOX_WAITING_REASON = exports.WFS_WAITING_REASON = exports.LOADING_REASON = exports.BUILDING_REASON = void 0;
7
7
  exports.cellSources = cellSources;
8
8
  exports.cellsMissing = cellsMissing;
9
9
  exports.savedCatalogue = savedCatalogue;
@@ -27,12 +27,15 @@ exports.BUILDING_REASON = "building chart index";
27
27
  exports.LOADING_REASON = "loading chart index";
28
28
  exports.WFS_WAITING_REASON = "waiting for the first Traficom WFS download";
29
29
  exports.BOX_WAITING_REASON = "waiting for the vessel position to build the chart box";
30
+ /** Why there is no index while the first chart box around the vessel is built. */
31
+ exports.BOX_BUILDING_REASON = "building the chart box around the vessel";
30
32
  /** Reasons that mean the index is on its way, not that loading it failed. */
31
33
  exports.PENDING_REASONS = new Set([
32
34
  exports.BUILDING_REASON,
33
35
  exports.LOADING_REASON,
34
36
  exports.WFS_WAITING_REASON,
35
- exports.BOX_WAITING_REASON
37
+ exports.BOX_WAITING_REASON,
38
+ exports.BOX_BUILDING_REASON
36
39
  ]);
37
40
  /**
38
41
  * A saved pyramid.bin larger than this is read and inflated off the event
package/lib/index.js CHANGED
@@ -66,7 +66,7 @@ function stopPlugin(app, clock, state) {
66
66
  * Restarts the plugin once the shared ENC cells it reads changed: a start compares them with the
67
67
  * saved index and rebuilds it when they did. A server without a restart callback logs instead.
68
68
  */
69
- function watchShared(app, clock, collection, restart) {
69
+ function watchShared(app, clock, collection, restart, sameCells) {
70
70
  return (0, shared_enc_1.watchSharedCells)(collection, clock, () => {
71
71
  if (restart) {
72
72
  restart();
@@ -74,7 +74,64 @@ function watchShared(app, clock, collection, restart) {
74
74
  else {
75
75
  app.error("the shared ENC cells changed; restart the plugin to index them");
76
76
  }
77
- });
77
+ }, sameCells ? { sameCells } : {});
78
+ }
79
+ /** The stamps of the S-57 cells under `dir`, as one string; undefined when it cannot be read. */
80
+ function cellsStamp(dir) {
81
+ try {
82
+ return (0, index_loader_1.sourceKeys)((0, index_loader_1.cellSources)(dir)).join("\n");
83
+ }
84
+ catch {
85
+ return undefined;
86
+ }
87
+ }
88
+ /**
89
+ * Watches the charts provider's cells in use (`watchShared`). A provider publishing later the
90
+ * cells read at start restarts nothing: the reason shown loses its note that it had not started.
91
+ */
92
+ function watchAtStart(app, clock, state, plan, restart) {
93
+ const { config, load } = state;
94
+ const collection = config?.chartsProviderEnc;
95
+ if (!config || collection === undefined) {
96
+ return undefined;
97
+ }
98
+ const same = sameCellsAsStart(config.cellsDir, collection);
99
+ const renote = () => {
100
+ if ("load" in plan && state.load === load && load?.reason !== undefined) {
101
+ const fresh = (0, shared_enc_1.noteSharedCells)(plan.load, config);
102
+ state.load = fresh;
103
+ if (!fresh.index && fresh.reason !== undefined) {
104
+ state.monitor?.noIndex(fresh.reason);
105
+ }
106
+ }
107
+ };
108
+ const quiet = same &&
109
+ ((root) => {
110
+ const unchanged = same(root);
111
+ if (unchanged) {
112
+ renote();
113
+ }
114
+ return unchanged;
115
+ });
116
+ return watchShared(app, clock, collection, restart, quiet);
117
+ }
118
+ /**
119
+ * Whether a charts provider that publishes its store later than this plugin starts, as it does
120
+ * once its charts are loaded, publishes the cells read at start: the same directory, the same
121
+ * cells. Undefined when the provider was there at start, or nothing was read.
122
+ */
123
+ function sameCellsAsStart(cellsDir, collection) {
124
+ if ((0, shared_enc_1.sharedEncApi)() !== undefined || cellsDir === undefined) {
125
+ return undefined;
126
+ }
127
+ const stamp = cellsStamp(cellsDir);
128
+ if (stamp === undefined) {
129
+ return undefined;
130
+ }
131
+ return (root) => {
132
+ const dir = collection === shared_enc_1.ALL_COLLECTIONS ? root : path_1.default.join(root, collection);
133
+ return path_1.default.resolve(dir) === path_1.default.resolve(cellsDir) && cellsStamp(dir) === stamp;
134
+ };
78
135
  }
79
136
  /**
80
137
  * The admin form for this server: the essentials with expert settings
@@ -145,9 +202,7 @@ module.exports = function underkeel(app, clock = monitor_1.SYSTEM_CLOCK, fetch =
145
202
  state.zoneErrors = errors;
146
203
  errors.forEach((e) => app.error(e));
147
204
  });
148
- shared = config.chartsProviderEnc
149
- ? watchShared(app, clock, config.chartsProviderEnc, restart && (() => restart(migration.config)))
150
- : undefined;
205
+ shared = watchAtStart(app, clock, state, plan, restart && (() => restart(migration.config)));
151
206
  (0, assets_1.mountAssets)(app);
152
207
  app.registerResourceProvider((0, manifest_1.manifestProvider)(VERSION));
153
208
  },
@@ -68,8 +68,8 @@ function writeQuietly(file, data) {
68
68
  fs_1.default.rmSync(tmp, { force: true });
69
69
  }
70
70
  }
71
- /** The layers in `bytes` filed under `key`; "stale" for another key, "corrupt" for a damaged entry. */
72
- function decodeEntry(bytes, key) {
71
+ /** The entry's header and where its body lies; "corrupt" for a damaged entry. */
72
+ function entryHead(bytes) {
73
73
  const end = bytes.length - SUM_BYTES;
74
74
  const headAt = MAGIC.length + 4;
75
75
  if (end < headAt || !bytes.subarray(0, MAGIC.length).equals(MAGIC)) {
@@ -79,16 +79,27 @@ function decodeEntry(bytes, key) {
79
79
  return "corrupt";
80
80
  }
81
81
  const bodyAt = headAt + bytes.readUInt32LE(MAGIC.length);
82
- let head;
82
+ try {
83
+ return bodyAt > end
84
+ ? "corrupt"
85
+ : { head: JSON.parse(bytes.toString("utf8", headAt, bodyAt)), bodyAt, end };
86
+ }
87
+ catch {
88
+ return "corrupt";
89
+ }
90
+ }
91
+ /** The layers in `bytes` filed under `key`; "stale" for another key, "corrupt" for a damaged entry. */
92
+ function decodeEntry(bytes, key) {
93
+ const entry = entryHead(bytes);
94
+ if (entry === "corrupt") {
95
+ return entry;
96
+ }
97
+ const { head, bodyAt, end } = entry;
98
+ if (head.key !== key) {
99
+ return "stale";
100
+ }
83
101
  let raw;
84
102
  try {
85
- if (bodyAt > end) {
86
- return "corrupt";
87
- }
88
- head = JSON.parse(bytes.toString("utf8", headAt, bodyAt));
89
- if (head.key !== key) {
90
- return "stale";
91
- }
92
103
  raw = zlib_1.default.brotliDecompressSync(bytes.subarray(bodyAt, end));
93
104
  }
94
105
  catch {
@@ -188,6 +199,36 @@ class LayerCache {
188
199
  }
189
200
  return typeof layers === "string" ? undefined : layers;
190
201
  }
202
+ /**
203
+ * Whether an entry for `file` as its files are now is kept, checked whole but not
204
+ * decompressed; a damaged one is removed.
205
+ */
206
+ has(file) {
207
+ try {
208
+ const { name, key } = this.identify(file, (0, enc_wasm_1.encStamp)(file));
209
+ const entry = path_1.default.join(this.dir, name);
210
+ const found = entryHead(fs_1.default.readFileSync(entry));
211
+ if (found === "corrupt") {
212
+ fs_1.default.rmSync(entry, { force: true });
213
+ return false;
214
+ }
215
+ return found.head.key === key;
216
+ }
217
+ catch {
218
+ return false;
219
+ }
220
+ }
221
+ /** Keeps the entries of `files` from being dropped to make room, as if this build used them. */
222
+ hold(files) {
223
+ for (const file of files) {
224
+ try {
225
+ this.used.add(entryName(fs_1.default.realpathSync(file)));
226
+ }
227
+ catch {
228
+ // Gone: nothing to keep.
229
+ }
230
+ }
231
+ }
191
232
  /**
192
233
  * Files the layers just read from `file`, compressed off the event loop; `flush` waits for
193
234
  * the write. Layers of a cell whose files changed while it was read are not kept: they are
@@ -276,19 +317,20 @@ exports.LayerCache = LayerCache;
276
317
  * from, and filing those it reads. With every cell kept, gdal3.js is not started.
277
318
  */
278
319
  function cachedReader(reader, cache) {
279
- return async (files) => {
320
+ return async (files, onRead) => {
280
321
  const out = new Map();
281
322
  const read = [];
282
323
  for (const file of new Set(files)) {
283
324
  const layers = cache.get(file);
284
325
  if (layers) {
285
326
  out.set(file, layers);
327
+ onRead?.(file);
286
328
  }
287
329
  else {
288
330
  read.push(file);
289
331
  }
290
332
  }
291
- for (const [file, layers] of await reader(read)) {
333
+ for (const [file, layers] of await reader(read, onRead)) {
292
334
  out.set(file, layers);
293
335
  cache.put(file, layers);
294
336
  }
@@ -6,10 +6,11 @@ const worker_threads_1 = require("worker_threads");
6
6
  const child_process_1 = require("../foundation/child-process");
7
7
  const enc_wasm_1 = require("./enc-wasm");
8
8
  /** Reads on the calling thread; the WebAssembly memory stays with it. */
9
- const readInProcess = async (files) => {
9
+ const readInProcess = async (files, onRead) => {
10
10
  const out = new Map();
11
- for (const file of files) {
11
+ for (const file of new Set(files)) {
12
12
  out.set(file, await (0, enc_wasm_1.readEncLayers)(file));
13
+ onRead?.(file);
13
14
  }
14
15
  return out;
15
16
  };
@@ -20,7 +21,7 @@ exports.readInProcess = readInProcess;
20
21
  * that exits before every cell has come back fails the read.
21
22
  */
22
23
  function readInChild(heapMb, script = __filename) {
23
- return (files) => {
24
+ return (files, onRead) => {
24
25
  const want = [...new Set(files)];
25
26
  if (want.length === 0) {
26
27
  return Promise.resolve(new Map());
@@ -35,6 +36,7 @@ function readInChild(heapMb, script = __filename) {
35
36
  }
36
37
  else if (m.file !== undefined && m.layers) {
37
38
  out.set(m.file, (0, enc_wasm_1.sharedLayers)(new Map(Object.entries(m.layers))));
39
+ onRead?.(m.file);
38
40
  }
39
41
  });
40
42
  child.once("error", (err) => (failure ??= err));
package/lib/monitor.js CHANGED
@@ -11,7 +11,7 @@ const notify_1 = require("./engine/notify");
11
11
  const state_1 = require("./foundation/state");
12
12
  const integrity_1 = require("./model/integrity");
13
13
  const vessel_1 = require("./model/vessel");
14
- const index_loader_1 = require("./index-loader");
14
+ const box_status_1 = require("./box-status");
15
15
  const wfs_1 = require("./ingest/wfs");
16
16
  const water_level_service_1 = require("./water-level-service");
17
17
  const notifications_1 = require("./notifications");
@@ -262,7 +262,7 @@ class Monitor {
262
262
  if (!this.index) {
263
263
  return {
264
264
  state: "off",
265
- reason: this.indexReason ?? "no chart index loaded",
265
+ reason: (0, box_status_1.noIndexReason)(this.indexReason, this.chartBox?.()),
266
266
  clearance: null,
267
267
  clearanceSigma: null,
268
268
  risk: null,
@@ -331,8 +331,8 @@ class Monitor {
331
331
  const now = this.clock.now();
332
332
  if (!this.index) {
333
333
  // The engine is off either way; a pending reason is a status, any other an error.
334
- const reason = this.indexReason ?? "no chart index loaded";
335
- if (index_loader_1.PENDING_REASONS.has(reason)) {
334
+ const reason = (0, box_status_1.noIndexReason)(this.indexReason, this.chartBox?.());
335
+ if ((0, box_status_1.isPending)(reason)) {
336
336
  this.status.status(boxNote ? `${reason} · ${boxNote}` : reason, now);
337
337
  }
338
338
  else {
package/lib/shared-enc.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.AREA_ERROR_RETRY_MS = exports.ALL_COLLECTIONS = exports.CHARTS_PROVIDER_ID = void 0;
6
+ exports.AREA_ERROR_RETRY_MS = exports.PROVIDER_ABSENT = exports.ALL_COLLECTIONS = exports.CHARTS_PROVIDER_ID = void 0;
7
7
  exports.sharedEncApi = sharedEncApi;
8
8
  exports.sharedEncRoot = sharedEncRoot;
9
9
  exports.sharedCellsDir = sharedCellsDir;
@@ -97,14 +97,20 @@ function collectionsHint(api = sharedEncApi()) {
97
97
  .map((c) => `${c.id} (${c.kind === "chart-set" ? "chart set" : "converted chart"}, ${c.cellCount} cells)`)
98
98
  .join(", ")}.`;
99
99
  }
100
+ /**
101
+ * What is said of a charts provider whose store is not published: at a server start it is
102
+ * published only once the provider's charts are loaded, after this plugin has started.
103
+ */
104
+ exports.PROVIDER_ABSENT = "Charts Provider is not running or still starting";
100
105
  /**
101
106
  * Why the charts provider's `collection` holds no cells to read, as it tells it, or that it is
102
107
  * not running; undefined when it is ready, or cannot tell. A provider started later restarts
103
- * the plugin (`watchShared`), so the note is read again then.
108
+ * the plugin (`watchShared`) unless it shares the cells read at start; then only this note is
109
+ * read again (`watchAtStart` in index.ts).
104
110
  */
105
111
  function sharedCellsNote(collection, api = sharedEncApi()) {
106
112
  if (!api) {
107
- return "Charts Provider is not running";
113
+ return exports.PROVIDER_ABSENT;
108
114
  }
109
115
  if (collection === exports.ALL_COLLECTIONS) {
110
116
  return collectionsHint(api)?.startsWith("Charts Provider shares no")
@@ -166,18 +172,28 @@ function requestSharedArea(bbox, api = sharedEncApi()) {
166
172
  accepted: false,
167
173
  started: false,
168
174
  reason: err.message,
169
- retryAfterMs: exports.AREA_ERROR_RETRY_MS
175
+ retryAfterMs: exports.AREA_ERROR_RETRY_MS,
176
+ failed: true
170
177
  };
171
178
  }
172
179
  }
180
+ /** A comparison that throws counts as cells changed, so the plugin restarts rather than keep them. */
181
+ function safely(f) {
182
+ try {
183
+ return f();
184
+ }
185
+ catch {
186
+ return false;
187
+ }
188
+ }
173
189
  /**
174
190
  * Calls `onChange` once the cells of `collection` (any, for ALL_COLLECTIONS) changed and
175
191
  * settled. While the charts provider has not published its API, looks for it every `pollMs`,
176
192
  * and after `waitMs` every `slowPollMs` for as long as the watch runs, since it may start any
177
193
  * time; meanwhile the cells are read as found at start. An API that appears later counts as
178
- * a change: the provider may have written cells meanwhile, or keep its store elsewhere than
179
- * the default path the cells were read from, and the restart rebuilds the index only when
180
- * the cells it finds differ.
194
+ * a change, unless `sameCells` finds its store holds the cells read at start: the provider
195
+ * may have written cells meanwhile, or keep its store elsewhere than the default path the
196
+ * cells were read from. It is asked once subscribed, so a write after it is heard.
181
197
  */
182
198
  function watchSharedCells(collection, clock, onChange, opts = {}) {
183
199
  const settleMs = opts.settleMs ?? 5_000;
@@ -185,6 +201,7 @@ function watchSharedCells(collection, clock, onChange, opts = {}) {
185
201
  const pollMs = opts.pollMs ?? 5_000;
186
202
  const slowPollMs = opts.slowPollMs ?? 60_000;
187
203
  const started = clock.now();
204
+ const sameAsStart = (root) => safely(() => opts.sameCells?.(root) ?? false);
188
205
  let unsubscribe;
189
206
  let changedAt;
190
207
  let timer;
@@ -212,7 +229,9 @@ function watchSharedCells(collection, clock, onChange, opts = {}) {
212
229
  });
213
230
  if (late) {
214
231
  stopTimer();
215
- changed();
232
+ if (!sameAsStart(api.root)) {
233
+ changed();
234
+ }
216
235
  }
217
236
  return true;
218
237
  };
@@ -28,9 +28,14 @@ class EncReads {
28
28
  if (files.length === 0) {
29
29
  return;
30
30
  }
31
- const done = this.layers.size;
32
- (0, build_progress_1.reportProgress)({ phase: "cells", done, total: done + files.length });
33
- for (const [file, layers] of await this.reader(files)) {
31
+ const total = this.layers.size + new Set(files).size;
32
+ let done = this.layers.size;
33
+ (0, build_progress_1.reportProgress)({ phase: "cells", done, total });
34
+ const read = await this.reader(files, () => {
35
+ done = Math.min(total, done + 1);
36
+ (0, build_progress_1.reportProgress)({ phase: "cells", done, total });
37
+ });
38
+ for (const [file, layers] of read) {
34
39
  this.layers.set(file, layers);
35
40
  }
36
41
  (0, build_progress_1.reportProgress)({ phase: "cells", done: this.layers.size, total: this.layers.size });
@@ -3,12 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.statusReport = statusReport;
4
4
  const public_text_1 = require("./foundation/public-text");
5
5
  /** Body of `GET /status`: engine state, index provenance and look-ahead summary; `?summary` without the per-cell lists. */
6
- function indexSection(load, summary) {
6
+ function indexSection(load, monitor, summary) {
7
7
  const index = load?.index;
8
+ // Without an index the engine's reason is why, and follows a first box build as it runs.
9
+ const reason = !index && monitor?.last.state === "off" ? monitor.last.reason : load?.reason;
8
10
  return {
9
11
  loaded: index !== undefined,
10
12
  built: load?.built ?? false,
11
- reason: (0, public_text_1.publicReason)(load?.reason),
13
+ reason: (0, public_text_1.publicReason)(reason),
12
14
  nodes: index?.pyramid.count ?? 0,
13
15
  hazards: index?.hazards.size ?? 0,
14
16
  ...(summary ? {} : { cells: index?.catalogue.cells ?? [] })
@@ -38,7 +40,7 @@ function statusReport(s) {
38
40
  const wfs = s.wfs?.status();
39
41
  return {
40
42
  ...(s.monitor ? monitorSection(s.monitor, summary) : { engine: s.stopped, vessel: [] }),
41
- index: indexSection(s.load, summary),
43
+ index: indexSection(s.load, s.monitor, summary),
42
44
  livechart: s.livechart?.status() ?? null,
43
45
  wfs: wfs && s.wfs?.downloads ? { ...wfs, lastError: (0, public_text_1.publicReason)(wfs.lastError) } : null,
44
46
  chartBox: s.wfs ? boxSection(s.wfs) : null,
@@ -9,6 +9,7 @@ const path_1 = __importDefault(require("path"));
9
9
  const fan_1 = require("./engine/fan");
10
10
  const geo_1 = require("./foundation/geo");
11
11
  const box_sizing_1 = require("./box-sizing");
12
+ const wfs_worker_1 = require("./wfs-worker");
12
13
  const box_status_1 = require("./box-status");
13
14
  const cell_reader_1 = require("./ingest/cell-reader");
14
15
  const wfs_1 = require("./ingest/wfs");
@@ -24,6 +25,8 @@ Object.defineProperty(exports, "workerHeapLimitMb", { enumerable: true, get: fun
24
25
  Object.defineProperty(exports, "workerRefresher", { enumerable: true, get: function () { return build_process_1.workerRefresher; } });
25
26
  /** Position checks; a download is due only when the vessel has left the inner half of the box. */
26
27
  const CHECK_EVERY_MS = 10_000;
28
+ /** How often a service started before the first position looks for it, ms. */
29
+ const FIRST_FIX_EVERY_MS = 1000;
27
30
  /** No two downloads start closer together than this, and after a failure the wait is longer. */
28
31
  const MIN_INTERVAL_MS = 60_000;
29
32
  const RETRY_AFTER_ERROR_MS = 300_000;
@@ -62,6 +65,8 @@ class WfsService {
62
65
  lastAttempt = -Infinity;
63
66
  lastError;
64
67
  timer;
68
+ /** Looks for the first position while none has come. */
69
+ firstFix;
65
70
  controller;
66
71
  rebuild;
67
72
  /** Lowered for this run when a box ran its build out of memory. */
@@ -108,14 +113,41 @@ class WfsService {
108
113
  start() {
109
114
  const check = () => void this.check().catch((err) => this.deps.error(`${this.label}: ${err.message}`));
110
115
  this.timer = this.deps.clock.setInterval(check, CHECK_EVERY_MS);
116
+ // A server starts this plugin before the position arrives: the first box is checked once
117
+ // one does, not up to CHECK_EVERY_MS later.
118
+ if (!this.hasPosition()) {
119
+ this.firstFix = this.deps.clock.setInterval(() => {
120
+ if (this.hasPosition()) {
121
+ this.stopFirstFix();
122
+ check();
123
+ }
124
+ }, FIRST_FIX_EVERY_MS);
125
+ }
111
126
  check();
112
127
  }
128
+ /** Whether a position is known; one that cannot be read is reported by the check. */
129
+ hasPosition() {
130
+ try {
131
+ return this.deps.position() !== undefined;
132
+ }
133
+ catch {
134
+ return true;
135
+ }
136
+ }
137
+ stopFirstFix() {
138
+ if (this.firstFix !== undefined) {
139
+ this.deps.clock.clearInterval(this.firstFix);
140
+ this.firstFix = undefined;
141
+ }
142
+ }
113
143
  stop() {
114
144
  if (this.timer !== undefined) {
115
145
  this.deps.clock.clearInterval(this.timer);
116
146
  this.timer = undefined;
117
147
  }
148
+ this.stopFirstFix();
118
149
  this.controller?.abort();
150
+ this.deps.warm?.stop();
119
151
  }
120
152
  status() {
121
153
  const rebuild = this.rebuild && (0, box_status_1.rebuildStatus)(this.rebuild, this.deps.clock.now());
@@ -138,9 +170,11 @@ class WfsService {
138
170
  const bbox = this.handed ?? this.box?.bbox;
139
171
  const lastMs = this.box?.durationMs;
140
172
  const note = this.deps.area?.note();
173
+ const run = this.rebuild;
141
174
  return {
142
175
  ...(bbox ? { bbox } : {}),
143
176
  rebuilding: this.busy,
177
+ ...(this.busy && run ? { phase: (0, box_status_1.phaseText)((0, box_status_1.rebuildStatus)(run, this.deps.clock.now())) } : {}),
144
178
  failed: !this.busy && this.lastError !== undefined,
145
179
  ...(lastMs === undefined ? {} : { lastMs }),
146
180
  summary: this.status().summary,
@@ -311,37 +345,11 @@ class WfsService {
311
345
  const run = this.begin(now, controller);
312
346
  const { radiusKm } = run;
313
347
  try {
348
+ await this.deps.warm?.beforeBuild();
314
349
  const centre = this.centreAhead(pos, radiusKm, run.estimateMs ?? this.refreshMs());
315
350
  const bbox = (0, wfs_1.boxAround)(centre, radiusKm);
316
351
  this.announce(bbox, now);
317
- const config = this.deps.config;
318
- const { inBox, cellsDir } = this.deps;
319
- // With WFS depths alone in use an empty cells directory loses nothing.
320
- const cells = this.deps.index()?.catalogue.cells.some((c) => c.source !== wfs_1.WFS_SOURCE);
321
- const { index, features } = await this.deps.refresh({
322
- download: "url" in config
323
- ? {
324
- url: config.url,
325
- bbox,
326
- dir: this.cellDir,
327
- now: new Date(now).toISOString(),
328
- catzoc: config.catzoc
329
- }
330
- : undefined,
331
- cellsDir,
332
- // WFS depths are built whatever ENC the charts provider has, but an index of
333
- // charted hazards is kept while its cells are away, not replaced by depths alone.
334
- cellsRequired: !("url" in config) || cells === true,
335
- box: inBox ? bbox : undefined,
336
- build: {
337
- dirs: "url" in config ? [this.cellDir] : [],
338
- leafZoom: this.deps.leafZoom,
339
- indexDir: this.deps.indexDir,
340
- ...(this.deps.cacheDir === undefined ? {} : { cacheDir: this.deps.cacheDir }),
341
- source: this.deps.source,
342
- ...(inBox ? {} : { changed: [this.cellDir] })
343
- }
344
- }, controller.signal, (progress) => {
352
+ const { index, features } = await this.deps.refresh((0, wfs_worker_1.refreshJob)(this.deps, this.cellDir, bbox, now), controller.signal, (progress) => {
345
353
  if (this.rebuild === run) {
346
354
  run.progress = progress;
347
355
  }
@@ -359,6 +367,8 @@ class WfsService {
359
367
  this.box = box;
360
368
  this.lastError = undefined;
361
369
  await this.handOver(index, bbox);
370
+ // The next box is centred inside this one, at most MAX_LEAD of its radius ahead.
371
+ this.deps.warm?.boxInUse(bbox, radiusKm * (1 + MAX_LEAD));
362
372
  }
363
373
  catch (err) {
364
374
  if (controller.signal.aborted) {
package/lib/wfs-wiring.js CHANGED
@@ -18,6 +18,7 @@ const wfs_1 = require("./ingest/wfs");
18
18
  const livechart_service_1 = require("./livechart-service");
19
19
  const rebind_1 = require("./livechart/rebind");
20
20
  const box_sizing_1 = require("./box-sizing");
21
+ const cell_warm_1 = require("./cell-warm");
21
22
  const wfs_service_1 = require("./wfs-service");
22
23
  const pause = () => new Promise((resolve) => setImmediate(resolve));
23
24
  /** The catalogues list the same cells in the same order, the WFS cell aside. */
@@ -94,7 +95,9 @@ function areaRequests(config, app) {
94
95
  /** How soon, and how late, a box is asked about again: Charts Provider starting, or busy. */
95
96
  exports.AREA_ASK_MIN_MS = 60_000;
96
97
  exports.AREA_ASK_MAX_MS = 30 * 60_000;
97
- const ASK_STARTING_MS = 5 * 60_000;
98
+ /** A charts provider not running yet is most likely starting: at a server start it publishes
99
+ * its store once its charts are loaded, after this plugin started. */
100
+ const ASK_STARTING_MS = exports.AREA_ASK_MIN_MS;
98
101
  /**
99
102
  * What the provider said of a box, as the status gives it when the vessel has no chart
100
103
  * coverage: from its answer to the area request and the cells it has stored there (`cells`,
@@ -124,7 +127,7 @@ function areaNote(answer, cells, running = (0, shared_enc_1.sharedEncApi)() !==
124
127
  function unaskedNote(running) {
125
128
  return running
126
129
  ? "Charts Provider is too old to be asked for the ENC around the vessel: update it"
127
- : "Charts Provider is not running";
130
+ : shared_enc_1.PROVIDER_ABSENT;
128
131
  }
129
132
  /**
130
133
  * Asks the charts provider for each box's cells, logging its answer when it changes, and says
@@ -133,11 +136,18 @@ function unaskedNote(running) {
133
136
  function askForArea(app) {
134
137
  let last;
135
138
  let note;
139
+ /** Whether the last ask found no charts provider running. */
140
+ let askedAbsent = false;
136
141
  return {
137
- // A provider stopped since its answer leaves that answer stale: it is not running now.
138
- note: () => ((0, shared_enc_1.sharedEncApi)() === undefined ? unaskedNote(false) : note),
142
+ // A provider stopped since its answer leaves that answer stale: it is not running now. One
143
+ // started since it was found absent has not been asked yet: it is asked again shortly.
144
+ note: () => {
145
+ const running = (0, shared_enc_1.sharedEncApi)() !== undefined;
146
+ return !running ? unaskedNote(false) : askedAbsent ? undefined : note;
147
+ },
139
148
  ask: (bbox) => {
140
149
  const api = (0, shared_enc_1.sharedEncApi)();
150
+ askedAbsent = api === undefined;
141
151
  const answer = (0, shared_enc_1.requestSharedArea)(bbox, api);
142
152
  note = areaNote(answer, answer === undefined ? undefined : (0, shared_enc_1.sharedCellsIn)(bbox, api), !!api);
143
153
  const line = answer === undefined
@@ -145,11 +155,15 @@ function askForArea(app) {
145
155
  : `Charts Provider, asked for the ENC around the vessel: ${answer.reason}`;
146
156
  if (line !== last) {
147
157
  last = line;
148
- if (answer?.accepted === true) {
149
- app.debug(line);
158
+ // A refusal follows the provider's own setting, and a provider not running yet is most
159
+ // likely starting; the status says both where the vessel lacks coverage. A provider too
160
+ // old to be asked, or one whose answer threw, needs the user.
161
+ const fault = answer === undefined ? api !== undefined : answer.failed === true;
162
+ if (fault) {
163
+ app.error(line);
150
164
  }
151
165
  else {
152
- app.error(line);
166
+ app.debug(line);
153
167
  }
154
168
  }
155
169
  const ms = answer === undefined ? ASK_STARTING_MS : answer.retryAfterMs;
@@ -159,15 +173,28 @@ function askForArea(app) {
159
173
  }
160
174
  };
161
175
  }
176
+ /** Fills the cell cache around the box, saying in the debug log how many cells it read. */
177
+ function warmAround(w, job) {
178
+ return (0, cell_warm_1.startWarming)(job, (0, wfs_service_1.workerHeapLimitMb)(), (reply) => {
179
+ if (reply.error !== undefined) {
180
+ w.app.debug(`cell cache: filling it around the chart box failed: ${reply.error}`);
181
+ }
182
+ else if (reply.read) {
183
+ w.app.debug(`cell cache: read ${reply.read} cells around the chart box`);
184
+ }
185
+ });
186
+ }
162
187
  function startWfs(w, config, dirName = "wfs") {
163
188
  const dir = path_1.default.join(w.app.getDataDirPath(), dirName);
164
189
  fs_1.default.mkdirSync(dir, { recursive: true });
165
190
  const area = areaRequests(w.config, w.app);
191
+ const cacheDir = (0, index_loader_1.cellCacheDir)(w.app.getDataDirPath());
192
+ const { cellsDir } = w.config;
166
193
  return new wfs_service_1.WfsService({
167
194
  config,
168
195
  dir,
169
196
  indexDir: w.config.indexDir ?? path_1.default.join(w.app.getDataDirPath(), "index"),
170
- cacheDir: (0, index_loader_1.cellCacheDir)(w.app.getDataDirPath()),
197
+ cacheDir,
171
198
  leafZoom: w.config.leafZoom,
172
199
  source: (0, index_source_1.indexSource)(w.config),
173
200
  area,
@@ -183,6 +210,12 @@ function startWfs(w, config, dirName = "wfs") {
183
210
  lookahead: w.config.lookahead,
184
211
  maxRadiusKm: w.config.boxMaxRadiusKm || (0, box_sizing_1.autoCapKm)((0, wfs_service_1.workerHeapLimitMb)()),
185
212
  onIndex: (index, changed) => useIndex(w, index, changed),
213
+ // In processes of their own, as the builds are; an injected builder, as in the tests, gets none.
214
+ ...(w.build === wfs_service_1.workerBuilder && cellsDir !== undefined && w.config.boxRadiusKm > 0
215
+ ? {
216
+ warm: (0, cell_warm_1.boxWarmer)((around, hold) => warmAround(w, { cellsDir, cacheDir, around, hold }), (m) => w.app.error(m))
217
+ }
218
+ : {}),
186
219
  error: (m) => {
187
220
  w.app.error(m);
188
221
  // With no index in use the failure is why the look-ahead is off: an error, not "waiting",
package/lib/wfs-worker.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.NO_FEATURES = void 0;
4
+ exports.refreshJob = refreshJob;
4
5
  exports.refreshCells = refreshCells;
5
6
  exports.refreshBuild = refreshBuild;
6
7
  exports.runWfsJob = runWfsJob;
@@ -10,6 +11,35 @@ const wfs_1 = require("./ingest/wfs");
10
11
  const build_box_1 = require("./spatial/build-box");
11
12
  const build_progress_1 = require("./spatial/build-progress");
12
13
  const build_worker_1 = require("./spatial/build-worker");
14
+ /** The refresh of the box `bbox`, started at `now`, with its WFS cell kept in `cellDir`. */
15
+ function refreshJob(deps, cellDir, bbox, now) {
16
+ const { config, inBox, cellsDir } = deps;
17
+ const wfs = "url" in config ? config : undefined;
18
+ // With WFS depths alone in use an empty cells directory loses nothing.
19
+ const cells = deps.index()?.catalogue.cells.some((c) => c.source !== wfs_1.WFS_SOURCE);
20
+ return {
21
+ download: wfs && {
22
+ url: wfs.url,
23
+ bbox,
24
+ dir: cellDir,
25
+ now: new Date(now).toISOString(),
26
+ catzoc: wfs.catzoc
27
+ },
28
+ cellsDir,
29
+ // WFS depths are built whatever ENC the charts provider has, but an index of charted
30
+ // hazards is kept while its cells are away, not replaced by depths alone.
31
+ cellsRequired: !wfs || cells === true,
32
+ box: inBox ? bbox : undefined,
33
+ build: {
34
+ dirs: wfs ? [cellDir] : [],
35
+ leafZoom: deps.leafZoom,
36
+ indexDir: deps.indexDir,
37
+ ...(deps.cacheDir === undefined ? {} : { cacheDir: deps.cacheDir }),
38
+ source: deps.source,
39
+ ...(inBox ? {} : { changed: [cellDir] })
40
+ }
41
+ };
42
+ }
13
43
  exports.NO_FEATURES = { depthAreas: 0, dredgedAreas: 0, soundings: 0 };
14
44
  /**
15
45
  * The S-57 cells under `cellsDir`, found before the download. Scanning and stamping the 698
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-underkeel",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Under-keel clearance awareness for Signal K: look-ahead grounding risk, route checks and a living chart.",
5
5
  "main": "lib/index.js",
6
6
  "files": [
@@ -19,6 +19,7 @@
19
19
  "keywords": [
20
20
  "signalk-node-server-plugin",
21
21
  "signalk-category-notifications",
22
+ "signalk-category-plotter-extensions",
22
23
  "signalk-category-chart-plotters"
23
24
  ],
24
25
  "signalk": {