signalk-underkeel 0.3.0 → 0.5.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,75 @@
2
2
 
3
3
  All notable changes to signalk-underkeel are documented here.
4
4
 
5
+ ## [0.5.0] - 2026-11-09
6
+
7
+ ### Changed
8
+
9
+ - **Breaking:** ENC cells come only from a chart provider service. **ENC cells directory**,
10
+ **ENC from Charts Provider** and **Ask Charts Provider for the ENC around the vessel** are
11
+ replaced by one pulldown, **Charts provider**, which chooses the service:
12
+ signalk-charts-provider-simple. Every ENC cell it shares is read, and the pulldown marks it
13
+ when it is not running and says what it shares. Cells of your own reach the index by
14
+ converting them in the charts provider with its **Keep the ENC cells of converted charts**
15
+ on.
16
+ - Each new index box asks the chosen charts provider for the ENC cells of its area, without a
17
+ setting of its own; its **Download the ENC cells around the vessel** decides whether it
18
+ downloads them (signalk-charts-provider-simple 2.18.0 or later).
19
+ - Where the vessel has no chart coverage, the reason adds what the charts provider answered
20
+ for the box: it is downloading the area, it has no ENC for this area (by the cells it has
21
+ stored there, with its reason), it is not running, or it is too old to be asked.
22
+ - The configuration moves to version 3. The migration drops the cells directory and the two
23
+ Charts Provider settings, chooses the charts provider when a collection was named, and
24
+ keeps the version 2 configuration as `config-v2.json` in the plugin data directory. When it
25
+ drops a cells directory and no charts provider takes its place, the server log says so, and
26
+ the setup check says no ENC is read until one is chosen.
27
+
28
+ - Of a cell two charts provider collections hold, the index reads the copy of the newest
29
+ edition and update, as the cell's DSID and its update files give them, rather than the first
30
+ in path order. A copy counts only the updates GDAL applies to it, and a copy GDAL cannot
31
+ read ranks below every copy it can.
32
+ - An index saved from one Charts Provider collection is the whole charts provider's, so it
33
+ stays in use after the upgrade while the charts provider's store is away.
34
+ - With no index yet, a failed box build's reason adds what the charts provider said of the
35
+ box, such as that it is downloading the area.
36
+
37
+ ### Fixed
38
+
39
+ - A failing area request to the charts provider is logged and no longer fails the box
40
+ refresh that made it, and is asked again after 5 minutes rather than when the box moves.
41
+ An area request that fails in the plugin itself is asked again after 5 minutes too.
42
+ - The no-coverage reason says the charts provider is not running once it stops, rather than
43
+ repeating its last answer; and with no index, the reason says so when it was not running at
44
+ start.
45
+
46
+ ## [0.4.0] - 2026-11-02
47
+
48
+ ### Added
49
+
50
+ - **ENC from Charts Provider** reads the ENC cells signalk-charts-provider-simple (2.16.0 or
51
+ later) shares in its ENC store: one chart set's or converted chart's cells by name, or all
52
+ of them with `*`, in place of **ENC cells directory**. The plugin restarts itself and
53
+ rebuilds the index when the charts provider writes new editions or updates of those cells,
54
+ and the admin form names the collections shared at the moment.
55
+ - When the Charts Provider collection in use has no cells, the reason says why, as
56
+ signalk-charts-provider-simple tells it: a chart set not downloaded yet or downloading, its
57
+ cells being written, or no such collection.
58
+ - **Ask Charts Provider for the ENC around the vessel** (expert, off by default): while all
59
+ of the charts provider's shared ENC is read, each new index box asks it to download the free
60
+ ENC cells of that area, when the charts provider allows such downloads. The cells arrive in
61
+ a chart set of their own and the index is rebuilt with them. An answer that may change
62
+ without the box changing is asked again later.
63
+
64
+ ### Changed
65
+
66
+ - The index records the ENC source it was built from: the cells directory, or the Charts
67
+ Provider collection by its id. A cells directory that is gone or empty keeps the saved index
68
+ only when that index was built from it, as an unmounted drive leaves it; switching to a
69
+ source with no cells yet no longer keeps using the previous source's index, and the reason
70
+ says why. Clearing the ENC source leaves no index in use rather than the previous source's,
71
+ again with the reason. An index saved by an earlier version, which records no source, is
72
+ kept as before.
73
+
5
74
  ## [0.3.0] - 2026-10-28
6
75
 
7
76
  ### Changed
package/README.md CHANGED
@@ -102,11 +102,25 @@ shows the RAM and CPUs it finds and says the same; it never changes a setting.
102
102
  ## Prepare the charts
103
103
 
104
104
  The plugin reads unencrypted S-57 ENC cells as they are delivered: each `.000` base cell with
105
- its `.001`, `.002`, … updates beside it. Copy the exchange set, for example its `ENC_ROOT`
106
- folder, under one directory and set **ENC cells directory** to it; `.000` files are found down
107
- to three folders deep, and down to six below a folder holding the exchange set's `CATALOG.031`,
108
- so an unzipped Inland ENC download (`<set>/ENC_ROOT/1R/7/1R7WAD07/1R7WAD07.000`) can be pointed
109
- at as it is. The cells are read in place with GDAL compiled to WebAssembly
105
+ its `.001`, `.002`, … updates beside it. It reads them from a chart provider service, chosen in
106
+ **Charts provider**: signalk-charts-provider-simple, which keeps the cells behind its ENC
107
+ charts in its ENC store (2.16.0 or later; 2.18.0 or later for the area requests below). Every
108
+ cell it shares is read: its ENC Charts sets, and the charts it converted from an upload or a
109
+ folder on the server while its **Keep the ENC cells of converted charts** is on, so an
110
+ exchange set or an Inland ENC download goes in by converting it there. The plugin keeps no
111
+ cells directory of its own. The pulldown marks a provider that is not running and says what
112
+ it shares now. When the charts provider writes new editions or updates of those cells, the
113
+ plugin restarts itself and rebuilds the index from them.
114
+
115
+ Each new index box asks the charts provider for the ENC cells of its area. With its
116
+ **Download the ENC cells around the vessel** on, it downloads the free ENC cells there into its
117
+ chart set "Around the vessel" and the index is rebuilt with them. Where the vessel has no
118
+ chart coverage, the reason says what the charts provider answered: it is downloading the
119
+ area, it has no ENC for this area (with its reason, such as no free ENC covering it), it is
120
+ not running, or it is too old to be asked. Traficom WFS depths (below) are downloaded
121
+ whatever ENC the charts provider has, and with none at all.
122
+ The cells are
123
+ read in place with GDAL compiled to WebAssembly
110
124
  (gdal3.js), which the plugin installs with it, and the updates are applied as they are read.
111
125
  Nothing is converted or written next to the cells. A cell without an `M_COVR` coverage layer
112
126
  is rejected. A cell's usage band comes from the third character of its name, else from its
@@ -147,7 +161,7 @@ rebuilds in 12–15 s instead of 30–34 s on x86. Deleting the folder is safe;
147
161
  read again.
148
162
 
149
163
  Folders of GeoJSON layers exported by `scripts/enc-to-geojson.sh` for earlier versions are no
150
- longer read: point **ENC cells directory** at the original `.000` cells instead. Such a folder
164
+ longer read: convert the original `.000` cells in the charts provider instead. Such a folder
151
165
  named like a cell is listed in `GET /status` as skipped.
152
166
 
153
167
  ## Settings
@@ -167,13 +181,13 @@ There are two places to set the plugin up, and both write the same configuration
167
181
 
168
182
  ![The setup steps: boat, water level and warnings](docs/images/how-it-works/setup-panel.png)
169
183
 
170
- | Setting | What it does |
171
- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
172
- | ENC cells directory | where the `.000` cells are |
173
- | Boat | draft (empty uses Signal K `design.draft.maximum`) and safety margin under the keel (0 m); only the keel's lowest point counts, and squat and waves are added on top (below) |
174
- | Water level | source (`none`, `manual`, `signalk-tide`, `fmi`, see below); only the chosen source's fields are shown |
175
- | Warnings | Early, Balanced or Late, in the table below |
176
- | Optional features | living chart (own soundings), Traficom WFS depths, grounding detector; each off until enabled |
184
+ | Setting | What it does |
185
+ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
186
+ | Charts provider | the chart provider service whose ENC cells are read: signalk-charts-provider-simple; empty: no ENC |
187
+ | Boat | draft (empty uses Signal K `design.draft.maximum`) and safety margin under the keel (0 m); only the keel's lowest point counts, and squat and waves are added on top (below) |
188
+ | Water level | source (`none`, `manual`, `signalk-tide`, `fmi`, see below); only the chosen source's fields are shown |
189
+ | Warnings | Early, Balanced or Late, in the table below |
190
+ | Optional features | living chart (own soundings), Traficom WFS depths, grounding detector; each off until enabled |
177
191
 
178
192
  | Warning style | Looks ahead | Alert at | Warn at, within | Alarm at, within | Alarm on negative mean UKC |
179
193
  | ------------------ | ----------- | -------- | --------------- | ---------------- | -------------------------- |
@@ -211,9 +225,12 @@ Expert settings, with their defaults:
211
225
  The saved configuration carries a version, `configVersion`. At the first start after an update
212
226
  from a configuration without one, the plugin keeps a copy as `config-v1.json` in its data
213
227
  directory, removes the look-ahead values the old admin form had filled in with their
214
- defaults, so the warning style can set them, and saves the result. The plugin then
215
- behaves exactly as before; a value that differed from its default stays and overrides the
216
- style.
228
+ defaults, so the warning style can set them, and saves the result. The warnings then
229
+ behave exactly as before; a value that differed from its default stays and overrides the
230
+ style. A configuration of version 2 is kept as `config-v2.json` and loses its ENC settings:
231
+ one that named a Charts Provider collection reads the whole charts provider from then on,
232
+ and one with an ENC cells directory reads no ENC until its cells are converted in the
233
+ charts provider and **Charts provider** is chosen, which the setup check asks for.
217
234
 
218
235
  ## Water level
219
236
 
@@ -337,13 +354,13 @@ datum mapping. Absolute paths in its reasons, in 5xx replies and in notification
337
354
  as `<path>` (an S-57 cell file keeps its name); the plugin status line and the server log keep
338
355
  them whole.
339
356
 
340
- | Reason shown | Cause and fix |
341
- | ------------------------------------------------------------- | ---------------------------------------------------------------------- |
342
- | `vessel draft not configured` | set **Draft** under Boat, or `design.draft.maximum` in Signal K |
343
- | `position stale` | no recent `navigation.position`; check the GNSS source |
344
- | `course or speed stale: only the current position is checked` | no recent COG or SOG |
345
- | `no chart index loaded` | set the cells directory; an index error is shown as the plugin's error |
346
- | `no chart coverage` | the boat or its footprints are outside every ENC cell |
357
+ | Reason shown | Cause and fix |
358
+ | ------------------------------------------------------------- | ------------------------------------------------------------------------- |
359
+ | `vessel draft not configured` | set **Draft** under Boat, or `design.draft.maximum` in Signal K |
360
+ | `position stale` | no recent `navigation.position`; check the GNSS source |
361
+ | `course or speed stale: only the current position is checked` | no recent COG or SOG |
362
+ | `no chart index loaded` | choose the charts provider; an index error is shown as the plugin's error |
363
+ | `no chart coverage` | the boat or its footprints are outside every ENC cell |
347
364
 
348
365
  # For developers
349
366
 
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CHART_PROVIDERS = void 0;
4
+ exports.chartFields = chartFields;
5
+ const shared_enc_1 = require("./shared-enc");
6
+ /** The services whose ENC store the plugin reads. */
7
+ exports.CHART_PROVIDERS = [
8
+ { id: shared_enc_1.CHARTS_PROVIDER_ID, name: "Charts Provider (signalk-charts-provider-simple)" }
9
+ ];
10
+ const DESCRIPTION = "The chart provider service whose ENC cells the index is built from. Every ENC cell it shares is read in place with GDAL, and the index is rebuilt when its cells change. Empty: no charts.";
11
+ /**
12
+ * The chart field: a pulldown of CHART_PROVIDERS. With `choices`, each is marked running or
13
+ * not, and the description says what Charts Provider shares now.
14
+ */
15
+ function chartFields(choices) {
16
+ const running = new Set(choices?.running ?? []);
17
+ return {
18
+ chartsProvider: {
19
+ type: "string",
20
+ title: "Charts provider",
21
+ description: choices?.shared === undefined ? DESCRIPTION : `${DESCRIPTION} ${choices.shared}`,
22
+ enum: exports.CHART_PROVIDERS.map((p) => p.id),
23
+ enumNames: exports.CHART_PROVIDERS.map((p) => choices === undefined || running.has(p.id) ? p.name : `${p.name}: not running`)
24
+ }
25
+ };
26
+ }
@@ -7,7 +7,8 @@
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.CONFIG_VERSION = void 0;
9
9
  exports.migrateConfig = migrateConfig;
10
- exports.CONFIG_VERSION = 2;
10
+ const shared_enc_1 = require("./shared-enc");
11
+ exports.CONFIG_VERSION = 3;
11
12
  /**
12
13
  * Values the version 1 form filled into every saved configuration as
13
14
  * defaults. Version 2 leaves these fields empty so the warning style sets
@@ -49,8 +50,29 @@ function v1ToV2(c, dropped) {
49
50
  }
50
51
  return out;
51
52
  }
53
+ /**
54
+ * Version 3 reads ENC only from a chart provider service, every cell it shares, and asks it
55
+ * for the cells around the vessel unasked: the cells directory and the asking switch are gone,
56
+ * and a Charts Provider collection chosen in version 2 becomes the choice of that service.
57
+ */
58
+ function v2ToV3(c, dropped) {
59
+ const { cellsDir, chartsProviderEnc, chartsProviderArea, ...out } = c;
60
+ if (cellsDir !== undefined) {
61
+ dropped.push("cellsDir");
62
+ }
63
+ if (chartsProviderArea !== undefined) {
64
+ dropped.push("chartsProviderArea");
65
+ }
66
+ if (chartsProviderEnc !== undefined) {
67
+ dropped.push("chartsProviderEnc");
68
+ if (typeof chartsProviderEnc === "string" && chartsProviderEnc.trim() !== "") {
69
+ out.chartsProvider ??= shared_enc_1.CHARTS_PROVIDER_ID;
70
+ }
71
+ }
72
+ return out;
73
+ }
52
74
  /** Step n takes version n to n + 1. */
53
- const STEPS = { 1: v1ToV2 };
75
+ const STEPS = { 1: v1ToV2, 2: v2ToV3 };
54
76
  function migrateConfig(raw) {
55
77
  const saved = record(raw);
56
78
  const stated = saved.configVersion;
@@ -5,6 +5,7 @@ exports.adminSchema = adminSchema;
5
5
  exports.flatten = flatten;
6
6
  exports.formsFollowDependencies = formsFollowDependencies;
7
7
  const lookahead_1 = require("./engine/lookahead");
8
+ const config_charts_1 = require("./config-charts");
8
9
  const ADVISORY = "Advisory navigation aid. Not for navigation; does not replace official charts or a proper lookout.";
9
10
  /** Shown under each field whose empty value follows a preset. */
10
11
  const FOLLOWS_STYLE = "Empty follows the warning style.";
@@ -301,11 +302,7 @@ function adminSchema(hints = {}) {
301
302
  type: "object",
302
303
  description: `${ADVISORY}${checks}`,
303
304
  properties: {
304
- cellsDir: {
305
- type: "string",
306
- title: "ENC cells directory",
307
- description: "S-57 ENC cells as delivered: .000 files with their .001, .002 … updates, also in sub-folders such as ENC_ROOT. Read directly with GDAL (WebAssembly); nothing is converted."
308
- },
305
+ ...(0, config_charts_1.chartFields)(hints.enc),
309
306
  vessel: {
310
307
  ...VESSEL_ESSENTIAL,
311
308
  properties: { ...VESSEL_ESSENTIAL.properties, draft: draftField(hints.signalkDraft) }
package/lib/config.js CHANGED
@@ -10,6 +10,7 @@ const route_request_1 = require("./api/route-request");
10
10
  const wfs_1 = require("./ingest/wfs");
11
11
  const config_presets_1 = require("./config-presets");
12
12
  const box_sizing_1 = require("./box-sizing");
13
+ const shared_enc_1 = require("./shared-enc");
13
14
  var config_schema_1 = require("./config-schema");
14
15
  Object.defineProperty(exports, "SCHEMA", { enumerable: true, get: function () { return config_schema_1.SCHEMA; } });
15
16
  exports.DEFAULT_ROUTE_CHECK = { speed: 2.5, xteLimit: 30, probability: 0.01 };
@@ -158,7 +159,7 @@ function parseConfig(raw) {
158
159
  const style = (0, config_presets_1.warningStyle)(r.warningStyle);
159
160
  return {
160
161
  indexDir: str(r.indexDir),
161
- cellsDir: str(r.cellsDir),
162
+ chartsProviderEnc: r.chartsProvider === shared_enc_1.CHARTS_PROVIDER_ID ? shared_enc_1.ALL_COLLECTIONS : undefined,
162
163
  rebuildIndex: r.rebuildIndex === true,
163
164
  leafZoom: Math.round(num(r.leafZoom, 20, 14, 22)),
164
165
  boxRadiusKm: boxRadius(r.boxRadiusKm),
@@ -41,18 +41,20 @@ function boxState(box) {
41
41
  }
42
42
  /**
43
43
  * `reason` with what the chart box has to do with it, when it is a no-coverage reason
44
- * (`verdict.ts`) and the footprint it names reaches the box edge; otherwise `reason`. The
45
- * footprint is the first at or after the time the reason gives.
44
+ * (`verdict.ts`) and the footprint it names reaches the box edge; with what the charts
45
+ * provider last answered when no box edge explains it; otherwise `reason`. The footprint is
46
+ * the first at or after the time the reason gives.
46
47
  */
47
48
  function boxCoverage(reason, fps, box) {
48
49
  const m = COVERAGE_REASON.exec(reason);
49
- if (!m || !box.bbox) {
50
+ if (!m) {
50
51
  return reason;
51
52
  }
52
53
  const t = m[1] === undefined ? 0 : Number(m[1]);
53
54
  const f = fps.find((x) => x.t >= t - 0.5) ?? fps[fps.length - 1];
54
- if (!f || !atEdge(f, box.bbox)) {
55
- return reason;
55
+ if (!f || !box.bbox || !atEdge(f, box.bbox)) {
56
+ // No box edge explains it: the charts have none there, and the provider may say why.
57
+ return box.providerNote === undefined ? reason : `${reason}: ${box.providerNote}`;
56
58
  }
57
59
  const where = insideBox(f.center, box.bbox) ? "at the chart box edge" : "outside the chart box";
58
60
  return `${reason}: ${where}, ${boxState(box)}`;
@@ -14,6 +14,8 @@ exports.savedLoad = savedLoad;
14
14
  exports.sourceKeys = sourceKeys;
15
15
  const fs_1 = __importDefault(require("fs"));
16
16
  const path_1 = __importDefault(require("path"));
17
+ const index_source_1 = require("./index-source");
18
+ const build_source_1 = require("./spatial/build-source");
17
19
  const cell_cache_1 = require("./ingest/cell-cache");
18
20
  const cells_1 = require("./ingest/cells");
19
21
  const cell_source_1 = require("./ingest/cell-source");
@@ -71,7 +73,7 @@ function skippedNote(cellsDir, scan) {
71
73
  return scan.skipped.length === 0
72
74
  ? []
73
75
  : [
74
- `skipped ${scan.skipped.join(", ")} in ${cellsDir ?? "cellsDir"}: ` +
76
+ `skipped ${scan.skipped.join(", ")} in ${cellsDir ?? "the charts provider's ENC"}: ` +
75
77
  "no S-57 cell (.000) in it, a dangling link, or a cell name met twice"
76
78
  ];
77
79
  }
@@ -139,7 +141,8 @@ async function settle(p) {
139
141
  cells.push(await (0, cell_source_1.readCell)(d, i));
140
142
  }
141
143
  const index = (0, chart_index_1.buildChartIndex)(cells, { leafZoom: p.build.leafZoom });
142
- (0, chart_index_1.saveChartIndex)(p.indexDir, index);
144
+ const { source } = p.build;
145
+ (0, chart_index_1.saveChartIndex)(p.indexDir, index, source ? (dir) => (0, build_source_1.writeSource)(dir, source) : undefined);
143
146
  return builtLoad(p, index);
144
147
  }
145
148
  catch (err) {
@@ -177,16 +180,16 @@ function scanCells(cellsDir, saved) {
177
180
  }
178
181
  const scan = (0, cell_source_1.scanCellSources)(cellsDir);
179
182
  return cellsMissing(cellsDir, scan.sources, saved)
180
- ? { away: `no S-57 cell found in ${cellsDir ?? "cellsDir"}` }
183
+ ? { away: `no S-57 cell found in ${cellsDir ?? "the charts provider's ENC"}` }
181
184
  : { scan };
182
185
  }
183
186
  function plan(cfg, indexDir, extraDirs, syncLoadBytes) {
184
187
  const { cells: saved, error } = readSaved(indexDir);
185
- const { stale, mismatch } = savedMatches(cfg, indexDir, saved, extraDirs);
186
- const fromSaved = (notes, unreadable) => stale
187
- ? { load: { indexDir, built: false, reason: missingReason(cfg, indexDir, true) } }
188
- : savedPlan(indexDir, notes, unreadable, syncLoadBytes);
189
- const found = scanCells(cfg.cellsDir, saved);
188
+ const { refused, mismatch } = savedMatches(cfg, indexDir, saved, extraDirs);
189
+ const fromSaved = (notes, unreadable) => refused === undefined
190
+ ? savedPlan(indexDir, notes, unreadable, syncLoadBytes)
191
+ : { load: { indexDir, built: false, reason: refused } };
192
+ const found = (0, index_source_1.cellsNow)(scanCells(cfg.cellsDir, saved), cfg, indexDir);
190
193
  if ("away" in found) {
191
194
  return fromSaved([`${found.away}; using the saved index`], (err) => ({
192
195
  load: (0, exports.failedLoad)(indexDir, err)
@@ -197,7 +200,8 @@ function plan(cfg, indexDir, extraDirs, syncLoadBytes) {
197
200
  let stamps;
198
201
  const encKeys = () => (stamps ??= scan.sources.map(sourceKey));
199
202
  const change = cellsChange(cfg.cellsDir, saved, encKeys);
200
- const notes = [...(change ? [change] : []), ...skippedNote(cfg.cellsDir, scan)];
203
+ const away = found.note === undefined ? [] : [found.note];
204
+ const notes = [...away, ...(change ? [change] : []), ...skippedNote(cfg.cellsDir, scan)];
201
205
  const sources = Boolean(cfg.cellsDir) || extraDirs.length > 0;
202
206
  const dirs = [...scan.sources, ...extraDirs];
203
207
  const keys = () => [...encKeys(), ...extraDirs.map(sourceKey)].sort();
@@ -220,7 +224,7 @@ function plan(cfg, indexDir, extraDirs, syncLoadBytes) {
220
224
  return boxPlan(cfg, indexDir, dirs, keys, {
221
225
  mismatch,
222
226
  fromSaved: (why) => fromSaved(why, unreadable),
223
- notes: skippedNote(cfg.cellsDir, scan)
227
+ notes: [...away, ...skippedNote(cfg.cellsDir, scan)]
224
228
  });
225
229
  }
226
230
  // With no index saved, `mismatch` says so.
@@ -235,7 +239,7 @@ function plan(cfg, indexDir, extraDirs, syncLoadBytes) {
235
239
  ]);
236
240
  }
237
241
  if (!saved) {
238
- return { load: { indexDir, built: false, reason: missingReason(cfg, indexDir, false) } };
242
+ return { load: { indexDir, built: false, reason: missingReason(cfg, indexDir) } };
239
243
  }
240
244
  return fromSaved(notes, unreadable);
241
245
  }
@@ -286,7 +290,7 @@ function boxPlan(cfg, indexDir, dirs, keys, p) {
286
290
  : (p.mismatch ??
287
291
  (sameCells
288
292
  ? `leaf zoom ${cfg.leafZoom} differs from the saved ${box.leafZoom ?? "?"}`
289
- : `cells in ${cfg.cellsDir ?? "cellsDir"} changed since it was saved`));
293
+ : `cells in ${cfg.cellsDir ?? "the charts provider's ENC"} changed since it was saved`));
290
294
  const notes = [...(why ? [`rebuilt the index: ${why}`] : []), ...p.notes];
291
295
  return buildPlan(cfg, indexDir, dirs, notes, { bbox: box.bbox, sources });
292
296
  }
@@ -304,7 +308,11 @@ function savedPlan(indexDir, notes, unreadable, syncLoadBytes) {
304
308
  }
305
309
  function buildPlan(cfg, indexDir, dirs, notes, box) {
306
310
  if (dirs.length === 0) {
307
- const none = { indexDir, built: false, reason: `no cells in ${cfg.cellsDir ?? "cellsDir"}` };
311
+ const none = {
312
+ indexDir,
313
+ built: false,
314
+ reason: `no cells in ${cfg.cellsDir ?? "the charts provider's ENC"}`
315
+ };
308
316
  return { load: withNotes(none, notes) };
309
317
  }
310
318
  // The build would be refused only once it is done: minutes on a Pi 4.
@@ -312,23 +320,24 @@ function buildPlan(cfg, indexDir, dirs, notes, box) {
312
320
  if (refused !== undefined) {
313
321
  return { load: (0, exports.failedLoad)(indexDir, new Error(refused)) };
314
322
  }
315
- const build = { dirs, leafZoom: cfg.leafZoom, indexDir };
323
+ const source = (0, index_source_1.indexSource)(cfg);
324
+ const build = { dirs, leafZoom: cfg.leafZoom, indexDir, ...(source ? { source } : {}) };
316
325
  return { build: box ? { ...build, box } : build, indexDir, notes };
317
326
  }
318
- function missingReason(cfg, indexDir, stale) {
319
- if (stale) {
320
- return "the saved index holds Traficom WFS depths and that source is off; set cellsDir";
321
- }
322
- return cfg.wfs ? exports.WFS_WAITING_REASON : `no chart index in ${indexDir}; set cellsDir`;
327
+ function missingReason(cfg, indexDir) {
328
+ return cfg.wfs ? exports.WFS_WAITING_REASON : `no chart index in ${indexDir}; choose the charts provider`;
323
329
  }
324
330
  /**
325
- * `stale`: the saved index holds WFS depths and the source is off. `mismatch`, why the saved
326
- * index is not used as is: none is saved, that, the source is on and the saved index lacks
327
- * its cell, or the index was saved by an older version of its pyramid or hazard file.
331
+ * `refused`, why the saved index is not used even with nothing to build another from: it holds
332
+ * WFS depths and that source is off, or it records an ENC source and none is set now.
333
+ * `mismatch`, why the saved index is not used as is: none is saved, those, the source is on
334
+ * and the saved index lacks its cell, or the index was saved by an older version of its
335
+ * pyramid or hazard file.
328
336
  */
329
337
  function savedMatches(cfg, indexDir, saved, extraDirs) {
330
338
  const holds = saved?.some((c) => c.source === wfs_1.WFS_SOURCE) ?? false;
331
339
  const stale = holds && !cfg.wfs;
340
+ const dropped = saved && (0, index_source_1.sourceDropped)(cfg, indexDir);
332
341
  const mismatch = saved === undefined
333
342
  ? `no index saved in ${indexDir}`
334
343
  : stale
@@ -337,8 +346,13 @@ function savedMatches(cfg, indexDir, saved, extraDirs) {
337
346
  ? "the saved one is from an older index version or its header is damaged"
338
347
  : extraDirs.length > 0 && !holds
339
348
  ? "the saved one lacks the Traficom WFS cell"
340
- : undefined;
341
- return { stale, mismatch };
349
+ : dropped;
350
+ const refused = stale
351
+ ? "the saved index holds Traficom WFS depths and that source is off; choose the charts provider"
352
+ : dropped && extraDirs.length === 0
353
+ ? `not using the saved index: ${dropped}; choose the charts provider`
354
+ : undefined;
355
+ return { refused, mismatch };
342
356
  }
343
357
  function savedCells(indexDir) {
344
358
  return (0, cells_1.parseCatalogue)(JSON.parse(fs_1.default.readFileSync(path_1.default.join(indexDir, "cells.json"), "utf8")))
@@ -0,0 +1,72 @@
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.indexSource = indexSource;
7
+ exports.sourceChanged = sourceChanged;
8
+ exports.cellsNow = cellsNow;
9
+ exports.sourceDropped = sourceDropped;
10
+ const path_1 = __importDefault(require("path"));
11
+ const build_source_1 = require("./spatial/build-source");
12
+ /**
13
+ * The ENC source an index was built from, as the settings name it: a cells directory, or a
14
+ * collection signalk-charts-provider-simple shares. Recorded with the index, so a source left
15
+ * without cells can be told apart from the source the index came from: a cells directory that
16
+ * is gone or empty most likely sits on a drive that is not mounted, and its saved index is
17
+ * used meanwhile, while a source the user chose since was never the saved index's.
18
+ *
19
+ * A collection is recorded by its id, not the directory it resolves to: the charts provider
20
+ * may keep its store elsewhere than the default path read while it is still starting. The
21
+ * whole store holds every collection, so an index of one collection, as saved before version
22
+ * 3 of the configuration, is the whole store's too.
23
+ */
24
+ const PROVIDER = "charts-provider:";
25
+ /** Whether an index saved from `saved` was built from the cells `now` names. */
26
+ function sameSource(saved, now) {
27
+ return saved === now || (now === `${PROVIDER}*` && saved.startsWith(PROVIDER));
28
+ }
29
+ /** The source `cfg` reads its S-57 cells from; undefined without one. */
30
+ function indexSource(cfg) {
31
+ if (cfg.chartsProviderEnc !== undefined) {
32
+ return `${PROVIDER}${cfg.chartsProviderEnc}`;
33
+ }
34
+ return cfg.cellsDir ? `dir:${path_1.default.resolve(cfg.cellsDir)}` : undefined;
35
+ }
36
+ /**
37
+ * Why the index in `indexDir` was not built from the source `cfg` reads, or undefined when it
38
+ * was, or records no source (an index saved by an older version).
39
+ */
40
+ function sourceChanged(cfg, indexDir) {
41
+ const saved = (0, build_source_1.readSource)(indexDir);
42
+ const now = indexSource(cfg);
43
+ return saved !== undefined && !sameSource(saved, now)
44
+ ? `the saved index was built from ${describe(saved)}, not ${now === undefined ? "the cells now set" : describe(now)}`
45
+ : undefined;
46
+ }
47
+ function describe(source) {
48
+ return source.startsWith(PROVIDER)
49
+ ? `Charts Provider's ${source.slice(PROVIDER.length)}`
50
+ : source.slice(source.indexOf(":") + 1);
51
+ }
52
+ /**
53
+ * `found`, a scan of the cells directory or why it says nothing of the saved index, except
54
+ * that cells away from a source the saved index was not built from are none: an unmounted
55
+ * drive keeps the saved index, but only one built from the cells now set.
56
+ */
57
+ function cellsNow(found, cfg, indexDir) {
58
+ const moved = "away" in found ? sourceChanged(cfg, indexDir) : undefined;
59
+ return "away" in found && moved !== undefined
60
+ ? {
61
+ scan: { sources: [], skipped: [] },
62
+ note: `${found.away}; not using the saved index: ${moved}`
63
+ }
64
+ : found;
65
+ }
66
+ /**
67
+ * Why the index in `indexDir` is not this configuration's when `cfg` sets no ENC source at
68
+ * all, as when the source is cleared, though the index records one; undefined otherwise.
69
+ */
70
+ function sourceDropped(cfg, indexDir) {
71
+ return indexSource(cfg) === undefined ? sourceChanged(cfg, indexDir) : undefined;
72
+ }
package/lib/index.js CHANGED
@@ -23,6 +23,7 @@ const block_keeper_1 = require("./block-keeper");
23
23
  const livechart_service_1 = require("./livechart-service");
24
24
  const wfs_service_1 = require("./wfs-service");
25
25
  const wfs_wiring_1 = require("./wfs-wiring");
26
+ const shared_enc_1 = require("./shared-enc");
26
27
  const fs_1 = __importDefault(require("fs"));
27
28
  const path_1 = __importDefault(require("path"));
28
29
  // lib/index.js and src/index.ts both sit one level below package.json.
@@ -61,6 +62,20 @@ function stopPlugin(app, clock, state) {
61
62
  ]
62
63
  });
63
64
  }
65
+ /**
66
+ * Restarts the plugin once the shared ENC cells it reads changed: a start compares them with the
67
+ * saved index and rebuilds it when they did. A server without a restart callback logs instead.
68
+ */
69
+ function watchShared(app, clock, collection, restart) {
70
+ return (0, shared_enc_1.watchSharedCells)(collection, clock, () => {
71
+ if (restart) {
72
+ restart();
73
+ }
74
+ else {
75
+ app.error("the shared ENC cells changed; restart the plugin to index them");
76
+ }
77
+ });
78
+ }
64
79
  /**
65
80
  * The admin form for this server: the essentials with expert settings
66
81
  * behind a checkbox where the admin UI forwards `dependencies`, every field
@@ -76,6 +91,8 @@ function formSchema(app, state) {
76
91
  catch (err) {
77
92
  app.error(`setup checks failed: ${err.message}`);
78
93
  }
94
+ // The chart pulldown lists what Charts Provider shares also while this plugin is off.
95
+ hints.enc ??= (0, shared_enc_1.runningProviders)();
79
96
  const schema = (0, config_schema_1.adminSchema)(hints);
80
97
  const version = app.config?.version;
81
98
  return (0, config_schema_1.formsFollowDependencies)(version) ? schema : (0, config_schema_1.flatten)(schema);
@@ -87,6 +104,7 @@ module.exports = function underkeel(app, clock = monitor_1.SYSTEM_CLOCK, fetch =
87
104
  requests: new route_service_1.RequestQueue()
88
105
  };
89
106
  let startup;
107
+ let shared;
90
108
  return {
91
109
  id: manifest_1.PLUGIN_ID,
92
110
  name: "Under-Keel Awareness",
@@ -96,10 +114,10 @@ module.exports = function underkeel(app, clock = monitor_1.SYSTEM_CLOCK, fetch =
96
114
  start(options, restart) {
97
115
  const migration = (0, config_migrate_1.migrateConfig)(options);
98
116
  (0, settings_service_1.persistMigration)(app, options, migration);
99
- const config = (0, config_1.parseConfig)(migration.config);
117
+ const config = (0, shared_enc_1.withSharedCells)((0, config_1.parseConfig)(migration.config), app.getDataDirPath());
100
118
  const wfs = (0, wfs_wiring_1.startBox)({ app, clock, state, config, fetch, build });
101
119
  const plan = (0, index_loader_1.planIndex)(config, app.getDataDirPath(), wfs?.savedCell() ? [wfs.cellDir] : []);
102
- const load = "load" in plan ? plan.load : (0, index_startup_1.pendingLoad)(plan);
120
+ const load = "load" in plan ? (0, shared_enc_1.noteSharedCells)(plan.load, config) : (0, index_startup_1.pendingLoad)(plan);
103
121
  const monitor = new monitor_1.Monitor(app, manifest_1.PLUGIN_ID, config, load.index, load.reason, clock, fetch);
104
122
  const acks = new ack_store_1.AckStore(path_1.default.join(app.getDataDirPath(), "acks.json"));
105
123
  if (acks.error) {
@@ -127,10 +145,15 @@ module.exports = function underkeel(app, clock = monitor_1.SYSTEM_CLOCK, fetch =
127
145
  state.zoneErrors = errors;
128
146
  errors.forEach((e) => app.error(e));
129
147
  });
148
+ shared = config.chartsProviderEnc
149
+ ? watchShared(app, clock, config.chartsProviderEnc, restart && (() => restart(migration.config)))
150
+ : undefined;
130
151
  (0, assets_1.mountAssets)(app);
131
152
  app.registerResourceProvider((0, manifest_1.manifestProvider)(VERSION));
132
153
  },
133
154
  stop() {
155
+ shared?.stop();
156
+ shared = undefined;
134
157
  startup?.abort();
135
158
  startup = undefined;
136
159
  stopPlugin(app, clock, state);