signalk-underkeel 0.4.0 → 0.5.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
@@ -2,6 +2,54 @@
2
2
 
3
3
  All notable changes to signalk-underkeel are documented here.
4
4
 
5
+ ## [0.5.1] - 2026-11-10
6
+
7
+ ### Changed
8
+
9
+ - The package is listed in the Signal K plugin registry's **Plotter Extensions** category
10
+ (keyword `signalk-category-plotter-extensions`).
11
+
12
+ ## [0.5.0] - 2026-11-09
13
+
14
+ ### Changed
15
+
16
+ - **Breaking:** ENC cells come only from a chart provider service. **ENC cells directory**,
17
+ **ENC from Charts Provider** and **Ask Charts Provider for the ENC around the vessel** are
18
+ replaced by one pulldown, **Charts provider**, which chooses the service:
19
+ signalk-charts-provider-simple. Every ENC cell it shares is read, and the pulldown marks it
20
+ when it is not running and says what it shares. Cells of your own reach the index by
21
+ converting them in the charts provider with its **Keep the ENC cells of converted charts**
22
+ on.
23
+ - Each new index box asks the chosen charts provider for the ENC cells of its area, without a
24
+ setting of its own; its **Download the ENC cells around the vessel** decides whether it
25
+ downloads them (signalk-charts-provider-simple 2.18.0 or later).
26
+ - Where the vessel has no chart coverage, the reason adds what the charts provider answered
27
+ for the box: it is downloading the area, it has no ENC for this area (by the cells it has
28
+ stored there, with its reason), it is not running, or it is too old to be asked.
29
+ - The configuration moves to version 3. The migration drops the cells directory and the two
30
+ Charts Provider settings, chooses the charts provider when a collection was named, and
31
+ keeps the version 2 configuration as `config-v2.json` in the plugin data directory. When it
32
+ drops a cells directory and no charts provider takes its place, the server log says so, and
33
+ the setup check says no ENC is read until one is chosen.
34
+
35
+ - Of a cell two charts provider collections hold, the index reads the copy of the newest
36
+ edition and update, as the cell's DSID and its update files give them, rather than the first
37
+ in path order. A copy counts only the updates GDAL applies to it, and a copy GDAL cannot
38
+ read ranks below every copy it can.
39
+ - An index saved from one Charts Provider collection is the whole charts provider's, so it
40
+ stays in use after the upgrade while the charts provider's store is away.
41
+ - With no index yet, a failed box build's reason adds what the charts provider said of the
42
+ box, such as that it is downloading the area.
43
+
44
+ ### Fixed
45
+
46
+ - A failing area request to the charts provider is logged and no longer fails the box
47
+ refresh that made it, and is asked again after 5 minutes rather than when the box moves.
48
+ An area request that fails in the plugin itself is asked again after 5 minutes too.
49
+ - The no-coverage reason says the charts provider is not running once it stops, rather than
50
+ repeating its last answer; and with no index, the reason says so when it was not running at
51
+ start.
52
+
5
53
  ## [0.4.0] - 2026-11-02
6
54
 
7
55
  ### Added
package/README.md CHANGED
@@ -102,23 +102,24 @@ 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.
110
-
111
- Cells downloaded with signalk-charts-provider-simple (2.16.0 or later) are read where that
112
- plugin shares them, in its ENC store: set **ENC from Charts Provider** to one of the chart sets
113
- or converted charts the admin form lists as shared, or to `*` for all of them. It is used
114
- instead of **ENC cells directory**. When the charts provider writes new editions or updates of
115
- those cells, the plugin restarts itself and rebuilds the index from them. When the collection
116
- has no cells yet, the reason says why, as the charts provider tells it: a chart set not
117
- downloaded or still downloading, cells being written, or no such collection. With `*` and
118
- **Ask Charts Provider for the ENC around the vessel** on (an expert setting, off by default),
119
- each new index box asks the charts provider to download the free ENC cells of that area, when
120
- its **Download the ENC cells around the vessel** is on too; they arrive in its chart set
121
- "Around the vessel" and the index is rebuilt with them. The cells are
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
122
123
  read in place with GDAL compiled to WebAssembly
123
124
  (gdal3.js), which the plugin installs with it, and the updates are applied as they are read.
124
125
  Nothing is converted or written next to the cells. A cell without an `M_COVR` coverage layer
@@ -160,7 +161,7 @@ rebuilds in 12–15 s instead of 30–34 s on x86. Deleting the folder is safe;
160
161
  read again.
161
162
 
162
163
  Folders of GeoJSON layers exported by `scripts/enc-to-geojson.sh` for earlier versions are no
163
- 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
164
165
  named like a cell is listed in `GET /status` as skipped.
165
166
 
166
167
  ## Settings
@@ -180,14 +181,13 @@ There are two places to set the plugin up, and both write the same configuration
180
181
 
181
182
  ![The setup steps: boat, water level and warnings](docs/images/how-it-works/setup-panel.png)
182
183
 
183
- | Setting | What it does |
184
- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
185
- | ENC cells directory | where the `.000` cells are |
186
- | ENC from Charts Provider | or a chart set or converted chart whose cells signalk-charts-provider-simple shares, `*` for all of them; used instead of the directory |
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 |
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 |
191
191
 
192
192
  | Warning style | Looks ahead | Alert at | Warn at, within | Alarm at, within | Alarm on negative mean UKC |
193
193
  | ------------------ | ----------- | -------- | --------------- | ---------------- | -------------------------- |
@@ -214,7 +214,7 @@ Expert settings, with their defaults:
214
214
 
215
215
  | Group | Settings |
216
216
  | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
217
- | Chart index | index directory, rebuild at start, index leaf zoom (20), box around the vessel (10 km), ask Charts Provider for the ENC around the vessel |
217
+ | Chart index | index directory, rebuild at start, index leaf zoom (20), box around the vessel (10 km) |
218
218
  | Boat | length, beam (fallback: Signal K `design.*`); draft σ (0.05 m); block coefficient and heel effect (none, fin keel or a table); double squat in narrow channels; wave allowance factor (0.4 × Hs) |
219
219
  | Look-ahead | minimum horizon (60 s); position buffer (10 m, 2σ); fan spread (COG 5°, rate of turn 0.1°/s); escape-heading rate of turn (3°/s); and the distance, probabilities and windows of the warning style, each overriding the style when set |
220
220
  | Water level | manual height and σ; FMI refresh (600 s); chart-datum mappings for FMI |
@@ -225,9 +225,12 @@ Expert settings, with their defaults:
225
225
  The saved configuration carries a version, `configVersion`. At the first start after an update
226
226
  from a configuration without one, the plugin keeps a copy as `config-v1.json` in its data
227
227
  directory, removes the look-ahead values the old admin form had filled in with their
228
- defaults, so the warning style can set them, and saves the result. The plugin then
229
- behaves exactly as before; a value that differed from its default stays and overrides the
230
- 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.
231
234
 
232
235
  ## Water level
233
236
 
@@ -351,13 +354,13 @@ datum mapping. Absolute paths in its reasons, in 5xx replies and in notification
351
354
  as `<path>` (an S-57 cell file keeps its name); the plugin status line and the server log keep
352
355
  them whole.
353
356
 
354
- | Reason shown | Cause and fix |
355
- | ------------------------------------------------------------- | ---------------------------------------------------------------------- |
356
- | `vessel draft not configured` | set **Draft** under Boat, or `design.draft.maximum` in Signal K |
357
- | `position stale` | no recent `navigation.position`; check the GNSS source |
358
- | `course or speed stale: only the current position is checked` | no recent COG or SOG |
359
- | `no chart index loaded` | set the cells directory; an index error is shown as the plugin's error |
360
- | `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 |
361
364
 
362
365
  # For developers
363
366
 
@@ -1,34 +1,26 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CHART_AREA_FIELD = void 0;
3
+ exports.CHART_PROVIDERS = void 0;
4
4
  exports.chartFields = chartFields;
5
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.";
6
11
  /**
7
- * The admin form's chart fields: a directory of S-57 cells, or the cells
8
- * signalk-charts-provider-simple shares (`shared-enc.ts`).
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.
9
14
  */
10
- function chartFields(sharedNow) {
15
+ function chartFields(choices) {
16
+ const running = new Set(choices?.running ?? []);
11
17
  return {
12
- cellsDir: {
18
+ chartsProvider: {
13
19
  type: "string",
14
- title: "ENC cells directory",
15
- 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."
16
- },
17
- chartsProviderEnc: {
18
- type: "string",
19
- pattern: shared_enc_1.COLLECTION_CHOICE_PATTERN,
20
- title: "ENC from Charts Provider",
21
- description: "Or read the ENC cells signalk-charts-provider-simple keeps: the name of an ENC Charts set there (Gulf of Finland is Gulf_of_Finland) or of a converted chart whose cells it keeps, or * for all of them. Used instead of the ENC cells directory; the index is rebuilt when the cells change." +
22
- (sharedNow ? ` ${sharedNow}` : "")
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`)
23
24
  }
24
25
  };
25
26
  }
26
- /** The expert field asking Charts Provider for the cells around the vessel (`shared-enc.ts`). */
27
- exports.CHART_AREA_FIELD = {
28
- chartsProviderArea: {
29
- type: "boolean",
30
- title: "Ask Charts Provider for the ENC around the vessel",
31
- description: 'With ENC from Charts Provider set to * and Charts Provider\'s "Download the ENC cells around the vessel" on, each new index box asks it to download the free ENC cells of that area; they arrive in its chart set "Around the vessel" and the index is rebuilt with them. Off: only the cells already shared are read.',
32
- default: false
33
- }
34
- };
@@ -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;
@@ -265,8 +265,7 @@ const CHART_INDEX = {
265
265
  maximum: 22
266
266
  },
267
267
  boxRadiusKm: boxKm("Index box around the vessel (km)", 10, "Half-width of the area the index is built in around the vessel; it is rebuilt as the vessel moves. Outside it the plugin reports no coverage, and the reason says the vessel is outside the chart box. At speed the box grows past this radius, up to the largest index box, so the vessel cannot reach its edge, less the look-ahead, before the next box is built: a box must hold 1.5 times the distance run during the last build (grown with the build area) plus the look-ahead and a 2.4 km band. It grows at once when too small, and shrinks only at the next rebuild once the speed needs under 70% of it. 0 indexes every cell (slow for large chart sets); a radius under 2 km is raised to 2. With the Traficom WFS on, its radius is used, and the box grows to 25 km at most."),
268
- boxMaxRadiusKm: boxKm("Largest index box at speed (km)", 0, "The box around the vessel grows up to this half-width at speed. 0 sizes it from the RAM the system reports: 10 km on a 1 GB Raspberry Pi or Cerbo GX, about 19 km on a 4 GB Raspberry Pi 4, about 27 km with 8 GB. The status says when even this box is too small for the speed and build time."),
269
- ...config_charts_1.CHART_AREA_FIELD
268
+ boxMaxRadiusKm: boxKm("Largest index box at speed (km)", 0, "The box around the vessel grows up to this half-width at speed. 0 sizes it from the RAM the system reports: 10 km on a 1 GB Raspberry Pi or Cerbo GX, about 19 km on a 4 GB Raspberry Pi 4, about 27 km with 8 GB. The status says when even this box is too small for the speed and build time.")
270
269
  };
271
270
  const EXPERT = {
272
271
  ...CHART_INDEX,
@@ -303,7 +302,7 @@ function adminSchema(hints = {}) {
303
302
  type: "object",
304
303
  description: `${ADVISORY}${checks}`,
305
304
  properties: {
306
- ...(0, config_charts_1.chartFields)(hints.encCollections),
305
+ ...(0, config_charts_1.chartFields)(hints.enc),
307
306
  vessel: {
308
307
  ...VESSEL_ESSENTIAL,
309
308
  properties: { ...VESSEL_ESSENTIAL.properties, draft: draftField(hints.signalkDraft) }
package/lib/config.js CHANGED
@@ -23,14 +23,6 @@ function optionalPositive(v) {
23
23
  function str(v) {
24
24
  return typeof v === "string" && v.trim() !== "" ? v : undefined;
25
25
  }
26
- /**
27
- * A charts provider collection id or `*`, trimmed. Anything else, a path out of the store
28
- * among them, is no choice, and the ENC cells directory stays in use.
29
- */
30
- function collectionChoice(v) {
31
- const s = str(v)?.trim();
32
- return s !== undefined && (0, shared_enc_1.isCollectionChoice)(s) ? s : undefined;
33
- }
34
26
  function record(v) {
35
27
  return typeof v === "object" && v !== null ? v : {};
36
28
  }
@@ -167,9 +159,7 @@ function parseConfig(raw) {
167
159
  const style = (0, config_presets_1.warningStyle)(r.warningStyle);
168
160
  return {
169
161
  indexDir: str(r.indexDir),
170
- cellsDir: str(r.cellsDir),
171
- chartsProviderEnc: collectionChoice(r.chartsProviderEnc),
172
- chartsProviderArea: r.chartsProviderArea === true,
162
+ chartsProviderEnc: r.chartsProvider === shared_enc_1.CHARTS_PROVIDER_ID ? shared_enc_1.ALL_COLLECTIONS : undefined,
173
163
  rebuildIndex: r.rebuildIndex === true,
174
164
  leafZoom: Math.round(num(r.leafZoom, 20, 14, 22)),
175
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)}`;
@@ -73,7 +73,7 @@ function skippedNote(cellsDir, scan) {
73
73
  return scan.skipped.length === 0
74
74
  ? []
75
75
  : [
76
- `skipped ${scan.skipped.join(", ")} in ${cellsDir ?? "cellsDir"}: ` +
76
+ `skipped ${scan.skipped.join(", ")} in ${cellsDir ?? "the charts provider's ENC"}: ` +
77
77
  "no S-57 cell (.000) in it, a dangling link, or a cell name met twice"
78
78
  ];
79
79
  }
@@ -180,7 +180,7 @@ function scanCells(cellsDir, saved) {
180
180
  }
181
181
  const scan = (0, cell_source_1.scanCellSources)(cellsDir);
182
182
  return cellsMissing(cellsDir, scan.sources, saved)
183
- ? { away: `no S-57 cell found in ${cellsDir ?? "cellsDir"}` }
183
+ ? { away: `no S-57 cell found in ${cellsDir ?? "the charts provider's ENC"}` }
184
184
  : { scan };
185
185
  }
186
186
  function plan(cfg, indexDir, extraDirs, syncLoadBytes) {
@@ -290,7 +290,7 @@ function boxPlan(cfg, indexDir, dirs, keys, p) {
290
290
  : (p.mismatch ??
291
291
  (sameCells
292
292
  ? `leaf zoom ${cfg.leafZoom} differs from the saved ${box.leafZoom ?? "?"}`
293
- : `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`));
294
294
  const notes = [...(why ? [`rebuilt the index: ${why}`] : []), ...p.notes];
295
295
  return buildPlan(cfg, indexDir, dirs, notes, { bbox: box.bbox, sources });
296
296
  }
@@ -308,7 +308,11 @@ function savedPlan(indexDir, notes, unreadable, syncLoadBytes) {
308
308
  }
309
309
  function buildPlan(cfg, indexDir, dirs, notes, box) {
310
310
  if (dirs.length === 0) {
311
- 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
+ };
312
316
  return { load: withNotes(none, notes) };
313
317
  }
314
318
  // The build would be refused only once it is done: minutes on a Pi 4.
@@ -321,7 +325,7 @@ function buildPlan(cfg, indexDir, dirs, notes, box) {
321
325
  return { build: box ? { ...build, box } : build, indexDir, notes };
322
326
  }
323
327
  function missingReason(cfg, indexDir) {
324
- return cfg.wfs ? exports.WFS_WAITING_REASON : `no chart index in ${indexDir}; set cellsDir`;
328
+ return cfg.wfs ? exports.WFS_WAITING_REASON : `no chart index in ${indexDir}; choose the charts provider`;
325
329
  }
326
330
  /**
327
331
  * `refused`, why the saved index is not used even with nothing to build another from: it holds
@@ -344,9 +348,9 @@ function savedMatches(cfg, indexDir, saved, extraDirs) {
344
348
  ? "the saved one lacks the Traficom WFS cell"
345
349
  : dropped;
346
350
  const refused = stale
347
- ? "the saved index holds Traficom WFS depths and that source is off; set cellsDir"
351
+ ? "the saved index holds Traficom WFS depths and that source is off; choose the charts provider"
348
352
  : dropped && extraDirs.length === 0
349
- ? `not using the saved index: ${dropped}; set cellsDir`
353
+ ? `not using the saved index: ${dropped}; choose the charts provider`
350
354
  : undefined;
351
355
  return { refused, mismatch };
352
356
  }
@@ -17,12 +17,19 @@ const build_source_1 = require("./spatial/build-source");
17
17
  * used meanwhile, while a source the user chose since was never the saved index's.
18
18
  *
19
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.
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.
21
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
+ }
22
29
  /** The source `cfg` reads its S-57 cells from; undefined without one. */
23
30
  function indexSource(cfg) {
24
31
  if (cfg.chartsProviderEnc !== undefined) {
25
- return `charts-provider:${cfg.chartsProviderEnc}`;
32
+ return `${PROVIDER}${cfg.chartsProviderEnc}`;
26
33
  }
27
34
  return cfg.cellsDir ? `dir:${path_1.default.resolve(cfg.cellsDir)}` : undefined;
28
35
  }
@@ -33,13 +40,13 @@ function indexSource(cfg) {
33
40
  function sourceChanged(cfg, indexDir) {
34
41
  const saved = (0, build_source_1.readSource)(indexDir);
35
42
  const now = indexSource(cfg);
36
- return saved !== undefined && saved !== now
43
+ return saved !== undefined && !sameSource(saved, now)
37
44
  ? `the saved index was built from ${describe(saved)}, not ${now === undefined ? "the cells now set" : describe(now)}`
38
45
  : undefined;
39
46
  }
40
47
  function describe(source) {
41
- return source.startsWith("charts-provider:")
42
- ? `Charts Provider's ${source.slice("charts-provider:".length)}`
48
+ return source.startsWith(PROVIDER)
49
+ ? `Charts Provider's ${source.slice(PROVIDER.length)}`
43
50
  : source.slice(source.indexOf(":") + 1);
44
51
  }
45
52
  /**
package/lib/index.js CHANGED
@@ -91,6 +91,8 @@ function formSchema(app, state) {
91
91
  catch (err) {
92
92
  app.error(`setup checks failed: ${err.message}`);
93
93
  }
94
+ // The chart pulldown lists what Charts Provider shares also while this plugin is off.
95
+ hints.enc ??= (0, shared_enc_1.runningProviders)();
94
96
  const schema = (0, config_schema_1.adminSchema)(hints);
95
97
  const version = app.config?.version;
96
98
  return (0, config_schema_1.formsFollowDependencies)(version) ? schema : (0, config_schema_1.flatten)(schema);
@@ -8,6 +8,7 @@ exports.sourceName = sourceName;
8
8
  exports.currentInfo = currentInfo;
9
9
  exports.sourceIdentity = sourceIdentity;
10
10
  exports.readCell = readCell;
11
+ exports.cellVersionRank = cellVersionRank;
11
12
  exports.scanCellSources = scanCellSources;
12
13
  const crypto_1 = require("crypto");
13
14
  const fs_1 = __importDefault(require("fs"));
@@ -64,6 +65,46 @@ function readCell(source, cellRef) {
64
65
  ? (0, enc_wasm_1.readEncCell)(source, cellRef)
65
66
  : Promise.resolve((0, cell_reader_1.readCellDirectory)(source, cellRef));
66
67
  }
68
+ /**
69
+ * A copy's edition and update as one comparable number, as GDAL's S-57 reader (3.8) applies
70
+ * its updates: from `.001` on, one file after another until one is missing or does not open as
71
+ * ISO 8211, whatever update the base cell's DSID carries. Each update's DSID must name the edition the
72
+ * reader holds, or 0 (as NOAA's do), and the update after the one it holds; otherwise the
73
+ * reader gives up on the whole cell, as it does on a cell re-issued at update 3 with a `.001`
74
+ * beside it. -1 when the copy would not be read so, or its edition or files cannot be read.
75
+ */
76
+ function cellVersionRank(file) {
77
+ try {
78
+ const version = (0, enc_wasm_1.baseVersion)(file);
79
+ if (version === undefined) {
80
+ return -1;
81
+ }
82
+ const updates = new Map((0, enc_wasm_1.encFiles)(file)
83
+ .slice(1)
84
+ .map((f) => [Number(path_1.default.extname(f).slice(1)), f]));
85
+ let { edition, update } = version;
86
+ for (let n = 1;; n++) {
87
+ const f = updates.get(n);
88
+ // A file missing, or one that does not open as ISO 8211, ends the updates GDAL applies.
89
+ if (f === undefined || !(0, enc_wasm_1.isIso8211)(f)) {
90
+ break;
91
+ }
92
+ // One that opens but whose DSID gives no edition and update in step makes it give up.
93
+ const own = (0, enc_wasm_1.dsidVersion)(f);
94
+ if (own.edition === undefined ||
95
+ own.update !== update + 1 ||
96
+ (own.edition !== 0 && own.edition !== edition)) {
97
+ return -1;
98
+ }
99
+ edition = own.edition;
100
+ update = own.update;
101
+ }
102
+ return version.edition * 100_000 + update;
103
+ }
104
+ catch {
105
+ return -1;
106
+ }
107
+ }
67
108
  /** How deep below the cells directory S-57 cells are looked for: ENC_ROOT/<cell>/<cell>.000. */
68
109
  const MAX_DEPTH = 4;
69
110
  /**
@@ -85,8 +126,10 @@ function isDirectory(p) {
85
126
  /**
86
127
  * The S-57 cells under `cellsDir`: every `.000` file down to MAX_DEPTH, such
87
128
  * as in an ENC_ROOT exchange set, and down to EXCHANGE_SET_DEPTH below any
88
- * directory holding a CATALOG.031 within it. A name met twice keeps its first
89
- * file in path order; one file met again through a link counts once. A
129
+ * directory holding a CATALOG.031 within it. A name met twice, as one cell in two charts
130
+ * provider collections, keeps the copy of the newest edition and update; between copies of
131
+ * the same version, or whose edition cannot be read, the first in path order. One file met
132
+ * again through a link counts once. A
90
133
  * directory is read again only when reached with more depth left than
91
134
  * before, and never from within itself, so a linked loop ends where it comes
92
135
  * back.
@@ -96,7 +139,8 @@ function scanCellSources(cellsDir) {
96
139
  if (!cellsDir) {
97
140
  return scan;
98
141
  }
99
- const seen = new Set();
142
+ /** Each cell name's index in `scan.sources`, and the version of the copy kept there. */
143
+ const seen = new Map();
100
144
  const files = new Set();
101
145
  /** Folders named like a cell, by real path: skipped unless a cell is read from under one. */
102
146
  const named = new Map();
@@ -110,11 +154,21 @@ function scanCellSources(cellsDir) {
110
154
  }
111
155
  files.add(real);
112
156
  const name = sourceName(source);
113
- if (seen.has(name)) {
114
- scan.skipped.push(path_1.default.relative(cellsDir, source));
157
+ const kept = seen.get(name);
158
+ if (kept) {
159
+ // Versions are read only for a name met twice: one header each, not a scan of every cell.
160
+ // A copy that cannot be read ranks -1 and never displaces one that can.
161
+ kept.rank ??= cellVersionRank(scan.sources[kept.at]);
162
+ const rank = cellVersionRank(source);
163
+ const older = rank > kept.rank ? scan.sources[kept.at] : source;
164
+ if (older !== source) {
165
+ scan.sources[kept.at] = source;
166
+ kept.rank = rank;
167
+ }
168
+ scan.skipped.push(path_1.default.relative(cellsDir, older));
115
169
  return;
116
170
  }
117
- seen.add(name);
171
+ seen.set(name, { at: scan.sources.length });
118
172
  scan.sources.push(source);
119
173
  };
120
174
  /** Depth left when each real directory was read, and those being read now. */
@@ -8,7 +8,10 @@ exports.encReaderId = encReaderId;
8
8
  exports.sharedLayers = sharedLayers;
9
9
  exports.readEncLayers = readEncLayers;
10
10
  exports.readEncParts = readEncParts;
11
+ exports.baseVersion = baseVersion;
11
12
  exports.baseEdition = baseEdition;
13
+ exports.isIso8211 = isIso8211;
14
+ exports.dsidVersion = dsidVersion;
12
15
  exports.encCell = encCell;
13
16
  exports.readEncCell = readEncCell;
14
17
  exports.encFiles = encFiles;
@@ -226,13 +229,16 @@ async function readEncParts(file) {
226
229
  parts.set(s57_geojson_1.POSITION_LAYER, Buffer.from(JSON.stringify(positionLayer(primitives))));
227
230
  return parts;
228
231
  }
232
+ /** The highest edition and update numbers read as known: an update has a 3-digit file. */
233
+ const MAX_EDITION = 9_999;
234
+ const MAX_UPDATE = 999;
229
235
  /**
230
- * The edition number in the base cell's DSID record, read from the ISO 8211
231
- * file itself. GDAL reports the last update's DSID_EDTN once updates are
232
- * applied, and NOAA updates carry 0 there: US5FL65M, edition 38, read as
233
- * edition 0 with its three updates.
236
+ * The EDTN and UPDN fields of a cell file's DSID record, read from the ISO 8211 file itself;
237
+ * each undefined when it is missing, not all digits, or out of range, and both when the record
238
+ * is not laid out as S-57 sets it, as a separator lost or misplaced leaves it. EDTN may be 0,
239
+ * as NOAA update files carry it.
234
240
  */
235
- function baseEdition(file) {
241
+ function dsidFields(file) {
236
242
  const fd = fs_1.default.openSync(file, "r");
237
243
  try {
238
244
  const head = Buffer.alloc(65_536);
@@ -241,26 +247,150 @@ function baseEdition(file) {
241
247
  // The data descriptive record comes first; the DSID is in the first data record after it.
242
248
  const dr = num(0, 5);
243
249
  if (!(dr > 0) || dr + 24 > size) {
244
- return undefined;
250
+ return {};
245
251
  }
246
252
  const base = dr + num(dr + 12, 5);
247
253
  const [lenSize, posSize, tagSize] = [num(dr + 20, 1), num(dr + 21, 1), num(dr + 23, 1)];
248
- for (let e = dr + 24; head[e] !== 0x1e && e < base; e += tagSize + lenSize + posSize) {
254
+ // A size of 0, or not a digit, would leave the directory walk where it is.
255
+ if (![lenSize, posSize, tagSize].every((n) => Number.isInteger(n) && n > 0)) {
256
+ return {};
257
+ }
258
+ const entry = tagSize + lenSize + posSize;
259
+ for (let e = dr + 24; head[e] !== 0x1e && e < base; e += entry) {
249
260
  if (head.toString("latin1", e, e + tagSize) === "DSID") {
250
- // RCNM (1 byte), RCID (4), EXPP (1), INTU (1), then DSNM and EDTN, each ended by 0x1f.
251
- const dsnm = base + num(e + tagSize + lenSize, posSize) + 7;
252
- const edtn = head.indexOf(0x1f, dsnm) + 1;
253
- const end = head.indexOf(0x1f, edtn);
254
- const n = edtn > 0 && end > edtn ? Number(head.toString("latin1", edtn, end)) : NaN;
255
- return Number.isInteger(n) && n > 0 ? n : undefined;
261
+ // RCNM (1 byte), RCID (4), EXPP (1), INTU (1), then DSNM, EDTN and UPDN, each ended by
262
+ // 0x1f, then UADT and ISDT, 8 characters each with no separator, all within the field.
263
+ const start = base + num(e + tagSize + lenSize, posSize);
264
+ const stop = Math.min(size, start + num(e + tagSize, lenSize));
265
+ const after = (from) => {
266
+ const at = head.indexOf(0x1f, from);
267
+ return at >= 0 && at < stop ? at : -1;
268
+ };
269
+ const dsnmEnd = after(start + 7);
270
+ const edtnEnd = dsnmEnd < 0 ? -1 : after(dsnmEnd + 1);
271
+ const updnEnd = edtnEnd < 0 ? -1 : after(edtnEnd + 1);
272
+ // A separator lost or misplaced leaves no two dates where they belong.
273
+ const dates = head.toString("latin1", updnEnd + 1, updnEnd + 17);
274
+ if (updnEnd < 0 || updnEnd + 17 > stop || !/^[\d ]{16}$/.test(dates)) {
275
+ return {};
276
+ }
277
+ const field = (from, to, max) => {
278
+ const text = head.toString("latin1", from, to);
279
+ const n = /^\d{1,5}$/.test(text) ? Number(text) : NaN;
280
+ return n <= max ? n : undefined;
281
+ };
282
+ const edition = field(dsnmEnd + 1, edtnEnd, MAX_EDITION);
283
+ const update = field(edtnEnd + 1, updnEnd, MAX_UPDATE);
284
+ return {
285
+ ...(edition === undefined ? {} : { edition }),
286
+ ...(update === undefined ? {} : { update })
287
+ };
256
288
  }
257
289
  }
258
- return undefined;
290
+ return {};
259
291
  }
260
292
  finally {
261
293
  fs_1.default.closeSync(fd);
262
294
  }
263
295
  }
296
+ /**
297
+ * The edition and update numbers in the base cell's DSID record. GDAL reports the last
298
+ * update's DSID_EDTN once updates are applied, and NOAA updates carry 0 there: US5FL65M,
299
+ * edition 38, read as edition 0 with its three updates. A base cell re-issued with its updates
300
+ * applied carries their number in UPDN. Undefined unless both read as numbers in range, as a
301
+ * version that cannot be read is not known.
302
+ */
303
+ function baseVersion(file) {
304
+ const { edition, update } = dsidFields(file);
305
+ return edition !== undefined && edition > 0 && update !== undefined
306
+ ? { edition, update }
307
+ : undefined;
308
+ }
309
+ /** The edition number in the base cell's DSID record, whatever its UPDN holds. */
310
+ function baseEdition(file) {
311
+ const { edition } = dsidFields(file);
312
+ return edition !== undefined && edition > 0 ? edition : undefined;
313
+ }
314
+ /**
315
+ * Whether `file` opens as ISO 8211 as GDAL's `DDFModule::Open` (3.8) checks it: a 24-byte
316
+ * leader of printable characters with an interchange level of 1 to 3, the leader identifier L,
317
+ * a version of 1 or blank, and sizes in range; then the whole data descriptive record, each of
318
+ * its directory entries naming a field that lies within it. GDAL stops looking for updates at
319
+ * a file that does not open.
320
+ */
321
+ function isIso8211(file) {
322
+ let fd;
323
+ try {
324
+ // GDAL's Open refuses a directory, and a file it cannot open ends the updates too.
325
+ if (!fs_1.default.statSync(file).isFile()) {
326
+ return false;
327
+ }
328
+ fd = fs_1.default.openSync(file, "r");
329
+ }
330
+ catch {
331
+ return false;
332
+ }
333
+ try {
334
+ const leader = Buffer.alloc(LEADER);
335
+ const sizes = fs_1.default.readSync(fd, leader, 0, LEADER, 0) === LEADER ? leaderSizes(leader) : undefined;
336
+ if (!sizes) {
337
+ return false;
338
+ }
339
+ const record = Buffer.alloc(sizes.recLength);
340
+ return (fs_1.default.readSync(fd, record, 0, sizes.recLength, 0) === sizes.recLength &&
341
+ directoryFits(record, sizes));
342
+ }
343
+ catch {
344
+ return false;
345
+ }
346
+ finally {
347
+ fs_1.default.closeSync(fd);
348
+ }
349
+ }
350
+ const LEADER = 24;
351
+ /** DDFScanInt: atoi over a fixed width, 0 where no digits lead. */
352
+ const scanInt = (bytes, at, len) => Number.parseInt(bytes.toString("latin1", at, at + len), 10) || 0;
353
+ /** The record and entry sizes a leader gives, or undefined where GDAL refuses it. */
354
+ function leaderSizes(leader) {
355
+ const char = (at) => String.fromCharCode(leader[at]);
356
+ const valid = leader.every((b) => b >= 32 && b <= 126) &&
357
+ "123".includes(char(5)) &&
358
+ char(6) === "L" &&
359
+ "1 ".includes(char(8));
360
+ if (!valid) {
361
+ return undefined;
362
+ }
363
+ const sizes = {
364
+ recLength: scanInt(leader, 0, 5),
365
+ areaStart: scanInt(leader, 12, 5),
366
+ lenSize: scanInt(leader, 20, 1),
367
+ posSize: scanInt(leader, 21, 1),
368
+ tagSize: scanInt(leader, 23, 1)
369
+ };
370
+ const inRange = sizes.recLength >= LEADER &&
371
+ scanInt(leader, 10, 2) > 0 &&
372
+ sizes.areaStart >= LEADER &&
373
+ sizes.lenSize > 0 &&
374
+ sizes.posSize > 0 &&
375
+ sizes.tagSize > 0;
376
+ return inRange ? sizes : undefined;
377
+ }
378
+ /** Whether every directory entry of the data descriptive record names a field within it. */
379
+ function directoryFits(record, s) {
380
+ const width = s.lenSize + s.posSize + s.tagSize;
381
+ for (let e = LEADER; e + width <= s.recLength && record[e] !== 0x1e; e += width) {
382
+ const length = scanInt(record, e + s.tagSize, s.lenSize);
383
+ const position = scanInt(record, e + s.tagSize + s.lenSize, s.posSize);
384
+ if (position < 0 || length < 2 || s.recLength - (s.areaStart + position) < length) {
385
+ return false;
386
+ }
387
+ }
388
+ return true;
389
+ }
390
+ /** The EDTN and UPDN of any cell file's DSID record, an update file's among them; EDTN may be 0. */
391
+ function dsidVersion(file) {
392
+ return dsidFields(file);
393
+ }
264
394
  /** The cell `readEncLayers` read from `file`, parsed as `readCellDirectory` parses an export. */
265
395
  function encCell(file, layers, cellRef, bandOverride) {
266
396
  const name = path_1.default.basename(file, path_1.default.extname(file));
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.V1_BACKUP = void 0;
7
+ exports.backupName = backupName;
7
8
  exports.persistMigration = persistMigration;
8
9
  exports.settingsReply = settingsReply;
9
10
  exports.patchErrors = patchErrors;
@@ -18,17 +19,26 @@ const vessel_1 = require("./model/vessel");
18
19
  const setup_checks_1 = require("./setup-checks");
19
20
  /** Where the version 1 configuration is kept when it is first migrated. */
20
21
  exports.V1_BACKUP = "config-v1.json";
22
+ /** Where a configuration of version `from` is kept when it is first migrated. */
23
+ function backupName(from) {
24
+ return `config-v${from}.json`;
25
+ }
21
26
  function isObject(v) {
22
27
  return typeof v === "object" && v !== null && !Array.isArray(v);
23
28
  }
24
- /** Saves a migrated configuration, after keeping a version 1 one beside the plugin data. */
29
+ /** Saves a migrated configuration, after keeping the one it replaces beside the plugin data. */
25
30
  function persistMigration(app, saved, m) {
26
31
  if (!m.changed) {
27
32
  return;
28
33
  }
29
- const backup = path_1.default.join(app.getDataDirPath(), exports.V1_BACKUP);
34
+ const dir = isObject(saved) ? saved.cellsDir : undefined;
35
+ if (typeof dir === "string" && dir.trim() !== "" && m.config.chartsProvider === undefined) {
36
+ app.error("the ENC cells directory setting is gone: ENC now comes only from a charts provider; " +
37
+ `choose it under Charts provider (the earlier settings are kept in ${backupName(m.from)})`);
38
+ }
39
+ const backup = path_1.default.join(app.getDataDirPath(), backupName(m.from));
30
40
  try {
31
- if (m.from === 1 && !fs_1.default.existsSync(backup)) {
41
+ if (!fs_1.default.existsSync(backup)) {
32
42
  fs_1.default.writeFileSync(backup, JSON.stringify(saved ?? {}, null, 2));
33
43
  }
34
44
  }
@@ -62,13 +62,18 @@ function suggestedSource(datums) {
62
62
  }
63
63
  function chartsCheck(s, facts) {
64
64
  if (facts.cells > 0) {
65
- return { id: "charts", level: "ok", text: `Charts: ${facts.cells} cells indexed` };
65
+ // With no provider chosen no ENC is read, as a cells directory dropped by the version 3
66
+ // migration leaves it: said for as long as none is chosen.
67
+ const only = s.config?.chartsProviderEnc
68
+ ? ""
69
+ : "; no charts provider chosen, so no ENC is read: choose the charts provider";
70
+ return { id: "charts", level: "ok", text: `Charts: ${facts.cells} cells indexed${only}` };
66
71
  }
67
- if (!s.config?.cellsDir && !s.config?.wfs) {
72
+ if (!s.config?.chartsProviderEnc && !s.config?.wfs) {
68
73
  return {
69
74
  id: "charts",
70
75
  level: "todo",
71
- text: "Charts: set the ENC cells directory or ENC from Charts Provider"
76
+ text: "Charts: choose the charts provider"
72
77
  };
73
78
  }
74
79
  const reason = (0, public_text_1.publicReason)(s.load?.reason) ?? "no chart index loaded";
@@ -183,6 +188,6 @@ function schemaHints(app, s) {
183
188
  checks: setupChecks(s, facts).map((c) => `${MARK[c.level]} ${c.text}`),
184
189
  signalkDraft: facts.signalk.draft,
185
190
  source: sourceHint(facts),
186
- encCollections: (0, shared_enc_1.collectionsHint)()
191
+ enc: (0, shared_enc_1.runningProviders)()
187
192
  };
188
193
  }
package/lib/shared-enc.js CHANGED
@@ -3,15 +3,17 @@ 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.COLLECTION_CHOICE_PATTERN = exports.ALL_COLLECTIONS = exports.CHARTS_PROVIDER_ID = void 0;
7
- exports.isCollectionChoice = isCollectionChoice;
6
+ exports.AREA_ERROR_RETRY_MS = exports.ALL_COLLECTIONS = exports.CHARTS_PROVIDER_ID = void 0;
8
7
  exports.sharedEncApi = sharedEncApi;
9
8
  exports.sharedEncRoot = sharedEncRoot;
10
9
  exports.sharedCellsDir = sharedCellsDir;
11
10
  exports.withSharedCells = withSharedCells;
11
+ exports.runningProviders = runningProviders;
12
+ exports.sharedCollections = sharedCollections;
12
13
  exports.collectionsHint = collectionsHint;
13
14
  exports.sharedCellsNote = sharedCellsNote;
14
15
  exports.noteSharedCells = noteSharedCells;
16
+ exports.sharedCellsIn = sharedCellsIn;
15
17
  exports.requestSharedArea = requestSharedArea;
16
18
  exports.watchSharedCells = watchSharedCells;
17
19
  const path_1 = __importDefault(require("path"));
@@ -20,19 +22,12 @@ const path_1 = __importDefault(require("path"));
20
22
  * cells behind its ENC charts as delivered, one folder per collection (an
21
23
  * ENC Charts set's cells, or a converted chart's):
22
24
  * `<root>/<collection>/<CELL>/<CELL>.000` with the updates beside it. The
23
- * plugin reads a collection, or the whole store, as its cells directory,
25
+ * plugin reads a collection, or the whole store, as its only cells directory,
24
26
  * and is told when the cells change. See that plugin's docs/enc-store.md.
25
27
  */
26
28
  exports.CHARTS_PROVIDER_ID = "signalk-charts-provider-simple";
27
29
  /** Every collection the charts provider shares. */
28
30
  exports.ALL_COLLECTIONS = "*";
29
- const COLLECTION_ID = /^[A-Za-z0-9_-]{1,80}$/;
30
- /** A collection choice for the admin form, with the space around it that parsing trims. */
31
- exports.COLLECTION_CHOICE_PATTERN = "^\\s*(\\*|[A-Za-z0-9_-]{1,80})?\\s*$";
32
- /** A collection's id, or ALL_COLLECTIONS. */
33
- function isCollectionChoice(value) {
34
- return value === exports.ALL_COLLECTIONS || COLLECTION_ID.test(value);
35
- }
36
31
  /** The charts provider's ENC store API, while that plugin runs in this server. */
37
32
  function sharedEncApi(scope = globalThis) {
38
33
  const api = scope.__signalk_chartsProviderEnc;
@@ -59,24 +54,43 @@ function sharedCellsDir(dataDir, collection, api = sharedEncApi()) {
59
54
  const root = sharedEncRoot(dataDir, api);
60
55
  return collection === exports.ALL_COLLECTIONS ? root : path_1.default.join(root, collection);
61
56
  }
62
- /** `cfg` reading its cells from the charts provider's collection when it names one. */
57
+ /** `cfg` reading its cells from the charts provider's collection it names; none without one. */
63
58
  function withSharedCells(cfg, dataDir, api = sharedEncApi()) {
59
+ const { cellsDir: _none, ...rest } = cfg;
64
60
  return cfg.chartsProviderEnc === undefined
65
- ? cfg
66
- : { ...cfg, cellsDir: sharedCellsDir(dataDir, cfg.chartsProviderEnc, api) };
61
+ ? rest
62
+ : { ...rest, cellsDir: sharedCellsDir(dataDir, cfg.chartsProviderEnc, api) };
67
63
  }
68
- /** One line naming the collections the charts provider shares, for the admin form. */
69
- function collectionsHint(api = sharedEncApi()) {
64
+ /**
65
+ * The chart provider services running in this server, by id, and what Charts Provider shares
66
+ * now, for the admin form.
67
+ */
68
+ function runningProviders(scope = globalThis) {
69
+ const api = sharedEncApi(scope);
70
+ const shared = collectionsHint(api);
71
+ return {
72
+ running: api ? [exports.CHARTS_PROVIDER_ID] : [],
73
+ ...(shared === undefined ? {} : { shared })
74
+ };
75
+ }
76
+ /** The collections the charts provider shares now; undefined while it cannot tell. */
77
+ function sharedCollections(api = sharedEncApi()) {
70
78
  if (!api) {
71
79
  return undefined;
72
80
  }
73
- let collections;
74
81
  try {
75
- collections = api.collections();
82
+ return api.collections();
76
83
  }
77
84
  catch {
78
85
  return undefined;
79
86
  }
87
+ }
88
+ /** One line naming the collections the charts provider shares. */
89
+ function collectionsHint(api = sharedEncApi()) {
90
+ const collections = sharedCollections(api);
91
+ if (collections === undefined) {
92
+ return undefined;
93
+ }
80
94
  return collections.length === 0
81
95
  ? "Charts Provider shares no ENC cells yet: download an ENC Charts set there."
82
96
  : `Shared now: ${collections
@@ -84,12 +98,13 @@ function collectionsHint(api = sharedEncApi()) {
84
98
  .join(", ")}.`;
85
99
  }
86
100
  /**
87
- * Why the charts provider's `collection` holds no cells to read, as it tells it; undefined
88
- * when it is ready, or the provider cannot tell.
101
+ * Why the charts provider's `collection` holds no cells to read, as it tells it, or that it is
102
+ * 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.
89
104
  */
90
105
  function sharedCellsNote(collection, api = sharedEncApi()) {
91
106
  if (!api) {
92
- return undefined;
107
+ return "Charts Provider is not running";
93
108
  }
94
109
  if (collection === exports.ALL_COLLECTIONS) {
95
110
  return collectionsHint(api)?.startsWith("Charts Provider shares no")
@@ -111,7 +126,7 @@ function sharedCellsNote(collection, api = sharedEncApi()) {
111
126
  ? `Charts Provider is downloading ${collection}; the index is built once its cells are shared`
112
127
  : `${collection} is a Charts Provider chart set not downloaded yet: download it in its ENC Charts tab`;
113
128
  case "absent":
114
- return `Charts Provider shares no ${collection}: check ENC from Charts Provider`;
129
+ return `Charts Provider shares no ${collection}: check the charts it shares`;
115
130
  default:
116
131
  return undefined;
117
132
  }
@@ -123,13 +138,36 @@ function noteSharedCells(load, cfg, api = sharedEncApi()) {
123
138
  : undefined;
124
139
  return note === undefined ? load : { ...load, reason: `${load.reason ?? ""}; ${note}` };
125
140
  }
126
- /** Asks the charts provider for the cells around `bbox`; undefined when it cannot be asked. */
141
+ /**
142
+ * The stored cells of the charts provider reaching `bbox`; undefined when it cannot tell: not
143
+ * running, too old, or failing.
144
+ */
145
+ function sharedCellsIn(bbox, api = sharedEncApi()) {
146
+ try {
147
+ return api?.cellsIn?.(bbox);
148
+ }
149
+ catch {
150
+ return undefined;
151
+ }
152
+ }
153
+ /** When to ask again after the charts provider failed to answer, ms: it may recover. */
154
+ exports.AREA_ERROR_RETRY_MS = 5 * 60_000;
155
+ /**
156
+ * Asks the charts provider for the cells around `bbox`; undefined when it cannot be asked. A
157
+ * request that throws is a refusal asked again after AREA_ERROR_RETRY_MS, unlike one it
158
+ * refuses on purpose, which stays until the area changes.
159
+ */
127
160
  function requestSharedArea(bbox, api = sharedEncApi()) {
128
161
  try {
129
162
  return api?.requestArea?.(bbox, "signalk-underkeel");
130
163
  }
131
164
  catch (err) {
132
- return { accepted: false, started: false, reason: err.message };
165
+ return {
166
+ accepted: false,
167
+ started: false,
168
+ reason: err.message,
169
+ retryAfterMs: exports.AREA_ERROR_RETRY_MS
170
+ };
133
171
  }
134
172
  }
135
173
  /**
@@ -12,6 +12,7 @@ const box_sizing_1 = require("./box-sizing");
12
12
  const box_status_1 = require("./box-status");
13
13
  const cell_reader_1 = require("./ingest/cell-reader");
14
14
  const wfs_1 = require("./ingest/wfs");
15
+ const shared_enc_1 = require("./shared-enc");
15
16
  const build_box_1 = require("./spatial/build-box");
16
17
  const chart_position_1 = require("./spatial/chart-position");
17
18
  const chart_index_1 = require("./spatial/chart-index");
@@ -53,7 +54,7 @@ class WfsService {
53
54
  indexed;
54
55
  /** The box of the last index this service handed over in this run. */
55
56
  handed;
56
- /** The box `onBox` is to be told of again, and when. */
57
+ /** The box the charts provider is to be asked about again, and when. */
57
58
  again;
58
59
  /** The saved WFS cell was missing or unreadable at start: the box is downloaded again. */
59
60
  cellLost = false;
@@ -136,12 +137,14 @@ class WfsService {
136
137
  view() {
137
138
  const bbox = this.handed ?? this.box?.bbox;
138
139
  const lastMs = this.box?.durationMs;
140
+ const note = this.deps.area?.note();
139
141
  return {
140
142
  ...(bbox ? { bbox } : {}),
141
143
  rebuilding: this.busy,
142
144
  failed: !this.busy && this.lastError !== undefined,
143
145
  ...(lastMs === undefined ? {} : { lastMs }),
144
- summary: this.status().summary
146
+ summary: this.status().summary,
147
+ ...(note === undefined ? {} : { providerNote: note })
145
148
  };
146
149
  }
147
150
  /** The cap the box grows to, never below the configured radius. */
@@ -265,8 +268,19 @@ class WfsService {
265
268
  this.ceilingKm = Math.max(min, Math.floor((radiusKm * 0.75) / box_sizing_1.STEP_KM) * box_sizing_1.STEP_KM);
266
269
  }
267
270
  }
271
+ /**
272
+ * Tells the charts provider of the box; advisory, so a failure never stops the refresh. A
273
+ * failed ask is tried again later, as a provider that failed to answer may recover.
274
+ */
268
275
  announce(bbox, now) {
269
- const ms = this.deps.onBox?.(bbox);
276
+ let ms;
277
+ try {
278
+ ms = this.deps.area?.ask(bbox);
279
+ }
280
+ catch (err) {
281
+ this.deps.error(`${this.label}: asking for the area's cells failed: ${err.message}`);
282
+ ms = shared_enc_1.AREA_ERROR_RETRY_MS;
283
+ }
270
284
  this.again = ms === undefined ? undefined : { bbox, at: now + ms };
271
285
  }
272
286
  announceAgain(now) {
@@ -315,6 +329,8 @@ class WfsService {
315
329
  }
316
330
  : undefined,
317
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.
318
334
  cellsRequired: !("url" in config) || cells === true,
319
335
  box: inBox ? bbox : undefined,
320
336
  build: {
package/lib/wfs-wiring.js CHANGED
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.AREA_ASK_MAX_MS = exports.AREA_ASK_MIN_MS = void 0;
7
7
  exports.startBox = startBox;
8
8
  exports.areaRequests = areaRequests;
9
+ exports.areaNote = areaNote;
9
10
  exports.startWfs = startWfs;
10
11
  const fs_1 = __importDefault(require("fs"));
11
12
  const path_1 = __importDefault(require("path"));
@@ -84,47 +85,84 @@ function startBox(w) {
84
85
  : undefined;
85
86
  }
86
87
  /**
87
- * What each new box tells the charts provider: asks for its cells with `chartsProviderArea`
88
- * on, and only while the source is `*`, as they arrive in a chart set of their own.
88
+ * The asking a configuration does: every new box asks the chosen charts provider for its
89
+ * cells, which it downloads into a chart set of its own; none without a provider.
89
90
  */
90
91
  function areaRequests(config, app) {
91
- return config.chartsProviderArea && config.chartsProviderEnc === shared_enc_1.ALL_COLLECTIONS
92
- ? askForArea(app)
93
- : undefined;
92
+ return config.chartsProviderEnc === shared_enc_1.ALL_COLLECTIONS ? askForArea(app) : undefined;
94
93
  }
95
94
  /** How soon, and how late, a box is asked about again: Charts Provider starting, or busy. */
96
95
  exports.AREA_ASK_MIN_MS = 60_000;
97
96
  exports.AREA_ASK_MAX_MS = 30 * 60_000;
98
97
  const ASK_STARTING_MS = 5 * 60_000;
98
+ /**
99
+ * What the provider said of a box, as the status gives it when the vessel has no chart
100
+ * coverage: from its answer to the area request and the cells it has stored there (`cells`,
101
+ * undefined when it cannot tell). Undefined when it has cells there and is not downloading:
102
+ * a gap at the vessel is then the charts'.
103
+ */
104
+ function areaNote(answer, cells, running = (0, shared_enc_1.sharedEncApi)() !== undefined) {
105
+ if (answer === undefined) {
106
+ return unaskedNote(running);
107
+ }
108
+ if (answer.started) {
109
+ return "Charts Provider is downloading the ENC around the vessel";
110
+ }
111
+ if (cells === undefined) {
112
+ return `Charts Provider: ${answer.reason}`;
113
+ }
114
+ if (cells.length > 0) {
115
+ return undefined;
116
+ }
117
+ // Accepted and not started: no free ENC covers the area, or its download waits for the
118
+ // internet connection; the provider's reason says which.
119
+ return answer.accepted
120
+ ? `Charts Provider has no ENC for this area: ${answer.reason}`
121
+ : `Charts Provider has no ENC for this area and does not download it: ${answer.reason}`;
122
+ }
123
+ /** Why the charts provider could not be asked: not running, or a version without `requestArea`. */
124
+ function unaskedNote(running) {
125
+ return running
126
+ ? "Charts Provider is too old to be asked for the ENC around the vessel: update it"
127
+ : "Charts Provider is not running";
128
+ }
99
129
  /**
100
130
  * Asks the charts provider for each box's cells, logging its answer when it changes, and says
101
131
  * when to ask again: when the provider says so, or is not running yet.
102
132
  */
103
133
  function askForArea(app) {
104
134
  let last;
105
- return (bbox) => {
106
- const answer = (0, shared_enc_1.requestSharedArea)(bbox);
107
- const line = answer === undefined
108
- ? "Charts Provider cannot be asked for the ENC around the vessel: it is not running, or too old"
109
- : `Charts Provider, asked for the ENC around the vessel: ${answer.reason}`;
110
- if (line !== last) {
111
- last = line;
112
- if (answer?.accepted === true) {
113
- app.debug(line);
114
- }
115
- else {
116
- app.error(line);
135
+ let note;
136
+ 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),
139
+ ask: (bbox) => {
140
+ const api = (0, shared_enc_1.sharedEncApi)();
141
+ const answer = (0, shared_enc_1.requestSharedArea)(bbox, api);
142
+ note = areaNote(answer, answer === undefined ? undefined : (0, shared_enc_1.sharedCellsIn)(bbox, api), !!api);
143
+ const line = answer === undefined
144
+ ? unaskedNote(!!api)
145
+ : `Charts Provider, asked for the ENC around the vessel: ${answer.reason}`;
146
+ if (line !== last) {
147
+ last = line;
148
+ if (answer?.accepted === true) {
149
+ app.debug(line);
150
+ }
151
+ else {
152
+ app.error(line);
153
+ }
117
154
  }
155
+ const ms = answer === undefined ? ASK_STARTING_MS : answer.retryAfterMs;
156
+ return typeof ms === "number" && Number.isFinite(ms)
157
+ ? Math.min(exports.AREA_ASK_MAX_MS, Math.max(exports.AREA_ASK_MIN_MS, ms))
158
+ : undefined;
118
159
  }
119
- const ms = answer === undefined ? ASK_STARTING_MS : answer.retryAfterMs;
120
- return typeof ms === "number" && Number.isFinite(ms)
121
- ? Math.min(exports.AREA_ASK_MAX_MS, Math.max(exports.AREA_ASK_MIN_MS, ms))
122
- : undefined;
123
160
  };
124
161
  }
125
162
  function startWfs(w, config, dirName = "wfs") {
126
163
  const dir = path_1.default.join(w.app.getDataDirPath(), dirName);
127
164
  fs_1.default.mkdirSync(dir, { recursive: true });
165
+ const area = areaRequests(w.config, w.app);
128
166
  return new wfs_service_1.WfsService({
129
167
  config,
130
168
  dir,
@@ -132,7 +170,7 @@ function startWfs(w, config, dirName = "wfs") {
132
170
  cacheDir: (0, index_loader_1.cellCacheDir)(w.app.getDataDirPath()),
133
171
  leafZoom: w.config.leafZoom,
134
172
  source: (0, index_source_1.indexSource)(w.config),
135
- onBox: areaRequests(w.config, w.app),
173
+ area,
136
174
  cellsDir: w.config.cellsDir,
137
175
  inBox: w.config.boxRadiusKm > 0,
138
176
  index: () => w.state.load?.index,
@@ -147,11 +185,14 @@ function startWfs(w, config, dirName = "wfs") {
147
185
  onIndex: (index, changed) => useIndex(w, index, changed),
148
186
  error: (m) => {
149
187
  w.app.error(m);
150
- // With no index in use the failure is why the look-ahead is off: an error, not "waiting".
188
+ // With no index in use the failure is why the look-ahead is off: an error, not "waiting",
189
+ // with what the charts provider said of the box, as when it is downloading its cells.
151
190
  const { load, monitor } = w.state;
152
191
  if (load && !load.index) {
153
- w.state.load = { ...load, reason: m };
154
- monitor?.noIndex(m);
192
+ const note = area?.note();
193
+ const reason = note === undefined ? m : `${m}; ${note}`;
194
+ w.state.load = { ...load, reason };
195
+ monitor?.noIndex(reason);
155
196
  }
156
197
  }
157
198
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-underkeel",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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": {