@meri-imperiumi/signalk-dead-reckoning 0.3.0 → 0.4.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +36 -0
  3. package/package.json +1 -1
  4. package/plugin/index.js +116 -0
  5. package/plugin/plotterext.js +194 -0
  6. package/plugin/shadow-vessel.js +179 -0
  7. package/plugin/statustilesexamples.js +109 -0
  8. package/public/dr-app.js +4 -3
  9. package/public/dr-ext-model.js +84 -0
  10. package/public/dr-ext-widget.html +29 -0
  11. package/public/dr-ext-widget.js +275 -0
  12. package/public/dr-map-view.js +239 -26
  13. package/public/dr-sight-panel.js +11 -3
  14. package/public/dr-viewmodel.js +370 -2
  15. package/public/index.html +5 -0
  16. package/public/vendor/maplibre-gl/LICENSE-leaflet-maplibre-gl.txt +15 -0
  17. package/public/vendor/maplibre-gl/LICENSE-maplibre-gl.txt +116 -0
  18. package/public/vendor/maplibre-gl/README.md +19 -0
  19. package/public/vendor/maplibre-gl/leaflet-maplibre-gl.js +234 -0
  20. package/public/vendor/maplibre-gl/maplibre-gl.css +1 -0
  21. package/public/vendor/maplibre-gl/maplibre-gl.js +59 -0
  22. package/public/vendor/plotterext-bus/LICENSE +21 -0
  23. package/public/vendor/plotterext-bus/README.md +12 -0
  24. package/public/vendor/plotterext-bus/chunk-4W6N34SD.js +318 -0
  25. package/public/vendor/plotterext-bus/chunk-7XRFPDQL.js +267 -0
  26. package/public/vendor/plotterext-bus/extension.js +31 -0
  27. package/status-tiles-examples.json +120 -0
  28. package/tests/dr-ext-model.test.js +96 -0
  29. package/tests/fake-app.js +21 -0
  30. package/tests/plotterext.test.js +188 -0
  31. package/tests/shadow-vessel-integration.test.js +201 -0
  32. package/tests/shadow-vessel.test.js +209 -0
  33. package/tests/statustilesexamples.test.js +174 -0
  34. package/tests/vector-charts.test.js +516 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,93 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-08-28
9
+
10
+ ### Added
11
+ - **Example Status Tiles set (work doc #22)** — the plugin now ships a
12
+ ready-made Status Tiles set (`status-tiles-examples.json`) that a
13
+ boat owner copies into their panel with one tap — no JSON editing,
14
+ no hand-authoring predicates. The set exposes the underway DR
15
+ integrity tile: the sensor-health and divergence-advisory
16
+ notifications, the DR-override (`navigation.deadReckoning.active`)
17
+ state, and banded checks on the uncertainty radius (1 nm warn / 3 nm
18
+ crit) and time-since-fix (3 h / 6 h), with footer readouts for
19
+ uncertainty, DR–GPS divergence, and the DR method. It is advertised
20
+ through the standard resources API as a read-only
21
+ `statusTileExamples` provider (keyed by the plugin id, returning `{}`
22
+ when stopped so a disabled plugin contributes no stale sets),
23
+ which the signalk-status-tiles webapp discovers and offers as a
24
+ one-tap "Add" that merges into the user's config (skipping existing
25
+ ids — idempotent, never overwrites user edits). Mirrors the existing
26
+ plotter-extension provider pattern; no running server is required to
27
+ ship or consume it.
28
+ - **Shadow vessel on chart plotters (work doc #21)** — the DR
29
+ shadow-boat position is now published as a synthetic vessel on a
30
+ stable `vessels.<uuid>` context, so chart plotters that source
31
+ vessels from the Signal K stream (Freeboard-SK's AIS layer) render
32
+ it on the chart alongside the own vessel and AIS traffic, with a
33
+ projected COG line. Freeboard-SK draws it as a green `ais_buddy`
34
+ target — the only per-target data-driven visual lever in its AIS
35
+ style decision tree is the `buddy` flag, which the plugin sets on the
36
+ root-value delta. The gap between the shadow's heading (bow) and its
37
+ COG line visualizes set + leeway, mirroring how the plotter treats
38
+ the own vessel. Opt-in via *Shadow Vessel* in the plugin settings
39
+ (off by default); the configurable label defaults to "DR Shadow".
40
+ The plugin stays the source of truth — the shadow is an additional
41
+ chart-visible target, not a navigational authority. The context UUID
42
+ is generated once and persisted so the plotter's target id is
43
+ continuous across restarts. While DR is idle (moored, no speed or
44
+ heading) the shadow keeps publishing the last position at SOG 0 so
45
+ it doesn't vanish or go stale, and the COG line clears. No
46
+ Freeboard-SK change is required.
47
+ - **Vector chart tiles in the webapp map (work doc #20)** — charts
48
+ with `format: 'pbf'` in the server's configured charts resource
49
+ (NOAA ENC-derived S-57 MBTiles, an Open Waters passage cache, any
50
+ vector MBTiles served by `signalk-charts-provider-simple`) now
51
+ render instead of failing silently as broken image tiles. MapLibre
52
+ GL JS 5.24.0 and the official Leaflet bridge are vendored under
53
+ `public/vendor/maplibre-gl/` (same policy as Leaflet — no CDN, no
54
+ build step) and mounted via `L.maplibreGL`, so all DR overlays stay
55
+ plain Leaflet. When the corridor downloader's asset manifest
56
+ (`/plugins/signalk-corridor-tile-downloader/assets/manifest.json`)
57
+ advertises a mirrored Open Waters `style` URL, that style is mounted
58
+ wholesale — full marine symbology (buoys by shape and colour,
59
+ lights, restricted-area hatching), bathymetry contours and soundings
60
+ with labels, base map and hillshade, online and offline alike —
61
+ while terrarium-DEM (`webp`) stores stay mirror internals and never
62
+ appear as overlays. For sources without a manifest the style is
63
+ composed client-side by the pure `maplibreStyleFor` (view-model):
64
+ one vector source on the chart's absolute tile URL (MapLibre's
65
+ blob-URL workers can't resolve relative paths) with native max zoom
66
+ — vector charts keep rendering past the raster maxNativeZoom
67
+ ceiling — plus a dark sea background and geometry-only
68
+ fill/line/circle layers with marine styling for known source-layer
69
+ families (S-57 LNDARE/DEPARE/DEPCNT/COALNE/SOUNDG ids and the
70
+ OSM-marine names the corridor cache carries: seamark, waterway,
71
+ wetland, sea_area, light as amber dots). Chart-mount failures are
72
+ logged instead of vanishing. Right-clicking a charted symbol
73
+ (lighthouse, seamark, peak, island) resolves its charted name and
74
+ seeds the sight form's *Object* field — bearings are taken to
75
+ identified objects, not bare coordinates.
76
+ - **Plotter-extension status tile for Freeboard-SK (work doc #19)** —
77
+ the plugin now registers a `plotterExtensions` resource provider
78
+ (Plotter Extensions API v1) so chart plotters that host the
79
+ mechanism (Freeboard-SK ≥ 3.0) offer a "Dead Reckoning" widget:
80
+ a 2×1 glanceable tile showing GPS↔DR divergence, the DR uncertainty
81
+ radius, fix cadence and DR health (tracking / maneuver / fouled /
82
+ stale / warm), colored by severity like the webapp status line, with
83
+ an OVERRIDE badge when DR is authoritative and an amber night-mode
84
+ palette following the host's `nightMode` capability. Live values
85
+ arrive through the host's multiplexed Signal K relay (the tile opens
86
+ no connection of its own). The iframe assets are served from a new
87
+ publicly readable `/plotterext/signalk-dead-reckoning/` route (a
88
+ minimal in-repo static handler — the plugin stays zero-dependency);
89
+ discovery is the authenticated resources API, enablement is the
90
+ plugin's own enable switch. The bus client
91
+ (`signalk-plotterext-bus` 0.11.0, MIT) is vendored under
92
+ `public/vendor/plotterext-bus/` like Leaflet. The standalone webapp
93
+ is unaffected.
94
+
8
95
  ## [0.3.0] - 2026-08-27
9
96
 
10
97
  ### Added
package/README.md CHANGED
@@ -3,3 +3,39 @@
3
3
  An offline-first dead reckoning and sensor fusion engine for Signal K that maintains a continuously computed "shadow boat" position from water-track sensors (speed through water, compass heading, and learned leeway and current corrections), so you always have a navigational fallback when GPS becomes unreliable — whether from jamming, spoofing, or plain receiver failure. While GPS is trusted, the engine learns vessel-specific calibration corrections against ground truth and watches for GPS anomalies; when it isn't, the same learned model keeps the dead-reckoned position, its uncertainty polygon, and a water-track log going. Fixes from celestial sights, compass bearings, and vertical angles are entered through a unified pipeline and can snap dead reckoning back on track, with optional write-through to `signalk-logbook`.
4
4
 
5
5
  **Note:** This is just a toy. Make your own navigation calculations and decisions.
6
+
7
+ ## Requirements
8
+
9
+ * This plugin installed and configured
10
+ * Some chart source available to Signal K
11
+ * Hand bearing compass and/or sextant
12
+
13
+ ## Getting started
14
+
15
+ 1. Install the plugin from the Signal K Appstore (or `npm install @meri-imperiumi/signalk-dead-reckoning`) and enable it. The defaults are sane — you only need to revisit the configuration if you want to change intervals or integrations.
16
+ 2. Make sure the required data feeds are available on the vessel network:
17
+ * `navigation.position` (GPS) — the training baseline and anomaly reference
18
+ * `navigation.speedThroughWater` (paddlewheel log) and `navigation.headingMagnetic` (compass + variation) — the water-track inputs
19
+ * Wind data — used by the calibration model and the polar speed fallback
20
+ 3. Just sail. While GPS is reliable and you're under sail (propulsion stopped), the engine continuously learns vessel-specific leeway and speed-loss corrections against GPS ground truth. There is nothing to activate: the "shadow boat" runs at all times, and the uncertainty polygon tightens over a season as the calibration bins fill up. Early on, expect conservative uncertainty estimates.
21
+ 4. Optional integrations, configured in the plugin settings:
22
+ * `signalk-logbook` — write confirmed fixes and maneuvers to the vessel's logbook
23
+ * `signalk-polar-performance-plugin` — polar-derived speed when the paddlewheel is fouled or silent
24
+ * `@meri-imperiumi/signalk-autostate` — keeps the navigation state (anchored, sailing, motoring) accurate, which gates training
25
+ * Signal K Weather API — point-forecast current vectors for the DR solution
26
+
27
+ ## Daily use
28
+
29
+ Open the webapp from your Signal K server's *Web apps* menu. It shows the vessel and the shadow boat on the chart, GPS↔DR divergence, the uncertainty polygon, and the water-track log. A status tile is also available for Freeboard-SK ≥ 3.0.
30
+
31
+ **Entering fixes.** Open *⊕ Sight / LOP* and pick the method:
32
+
33
+ * **Bearing** — compass bearing to a known object (lighthouse, tower) gives a line of position
34
+ * **Vert. Angle** — vertical angle to a known object of known height gives a circular position line
35
+ * **Celestial** — sextant sight of the Sun, Moon, or an almanac star
36
+
37
+ You can right-click an object on the map to pre-fill its coordinates. Sights can be entered with a stopwatch delay ("minutes ago") instead of clock time. Observations accumulate in the pending list; select two or more, *Preview selected* to resolve the candidate fix, then *Confirm fix* to snap dead reckoning to it. A single line of position can also be advanced to a later sight (running fix).
38
+
39
+ **When GPS looks wrong.** The plugin raises notifications for GPS anomalies, growing DR divergence, and paddlewheel fouling — but it never switches navigational authority by itself. If you decide to trust DR over GPS, press *Engage OVERRIDE*; the shadow boat takes over `navigation.position` instantly (it has been running all along). Release it when position is re-established, e.g. after confirming a fix.
40
+
41
+ **Other controls.** *≋ Current* sets a manual set-and-drift override with a TTL, which outranks the automatic current sources while it lasts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
package/plugin/index.js CHANGED
@@ -79,6 +79,12 @@ const {
79
79
  const { resolveCandidateFix, confirmFix } = require("./fix-pipeline.js");
80
80
  const { reduceSight, reduceNoonSight } = require("./celestial.js");
81
81
  const starAlmanac = require("./star-almanac.js");
82
+ const { registerPlotterExtension } = require("./plotterext.js");
83
+ const { registerStatusTileExamples } = require("./statustilesexamples.js");
84
+ const {
85
+ createShadowVesselPublisher,
86
+ resolveVelocity: resolveShadowVelocity,
87
+ } = require("./shadow-vessel.js");
82
88
  const { computeRadius } = require("./uncertainty.js");
83
89
  const {
84
90
  DEFAULT_FACTOR,
@@ -297,6 +303,18 @@ const DEFAULT_CONFIG = {
297
303
  baseUrl: "", // empty → http://localhost:<server port>
298
304
  intervalMs: 1800000,
299
305
  },
306
+ /**
307
+ * Shadow vessel (work doc #21): publish the DR position as a synthetic
308
+ * `vessels.<id>` target so chart plotters (Freeboard-SK's AIS layer)
309
+ * render the shadow boat on the chart. Opt-in — off by default. The
310
+ * `buddy` flag is hardcoded on (it's the distinctness mechanism, not a
311
+ * preference): Freeboard-SK's only per-target data-driven visual lever
312
+ * is the buddy flag → the green ais_buddy glyph.
313
+ */
314
+ shadowVessel: {
315
+ enabled: false,
316
+ name: "DR Shadow",
317
+ },
300
318
  };
301
319
 
302
320
  /**
@@ -357,6 +375,8 @@ const deps = {
357
375
  createPolarSpeedState,
358
376
  polarSpeedSample,
359
377
  polarSpeedAverage,
378
+ createShadowVesselPublisher,
379
+ resolveShadowVelocity,
360
380
  distanceNm,
361
381
  bearingDeg,
362
382
  };
@@ -397,6 +417,15 @@ module.exports = (app) => {
397
417
  /** @type {WeatherCurrentClient|null} §6.2 tier-3 weather current poller */
398
418
  let weatherClient = null;
399
419
 
420
+ /** @type {(() => void)|null} plotter-extension provider teardown (work doc #19) */
421
+ let plotterExtTeardown = null;
422
+
423
+ /** @type {(() => void)|null} status-tiles examples provider teardown (work doc #22) */
424
+ let statusTileExamplesTeardown = null;
425
+
426
+ /** @type {{publish: Function, stop: Function}|null} shadow vessel publisher (work doc #21) */
427
+ let shadow = null;
428
+
400
429
  /**
401
430
  * Manual set-and-drift override (§6.2 tier 1): watchstander input,
402
431
  * honored while its TTL lasts. Set/cleared via
@@ -585,6 +614,18 @@ module.exports = (app) => {
585
614
  title: "Polar speed staleness cutoff (s)",
586
615
  default: DEFAULT_CONFIG.polar.staleS,
587
616
  },
617
+ "shadowVessel.enabled": {
618
+ type: "boolean",
619
+ title: "Show the DR shadow boat on chart plotters",
620
+ description:
621
+ "Publishes the DR position as a synthetic vessel (a green 'buddy' target on Freeboard-SK) so chart plotters that source vessels from the Signal K stream render the shadow boat on the chart alongside your own vessel, with a projected COG line. The plugin stays the source of truth; this only adds a chart-visible target.",
622
+ default: DEFAULT_CONFIG.shadowVessel.enabled,
623
+ },
624
+ "shadowVessel.name": {
625
+ type: "string",
626
+ title: "Shadow vessel label",
627
+ default: DEFAULT_CONFIG.shadowVessel.name,
628
+ },
588
629
  },
589
630
  },
590
631
 
@@ -615,6 +656,10 @@ module.exports = (app) => {
615
656
  ...DEFAULT_CONFIG.polar,
616
657
  ...(options?.polar ?? {}),
617
658
  };
659
+ config.shadowVessel = {
660
+ ...DEFAULT_CONFIG.shadowVessel,
661
+ ...(options?.shadowVessel ?? {}),
662
+ };
618
663
 
619
664
  dbPath = join(app.getDataDirPath(), "dead-reckoning.sqlite");
620
665
  db = deps.openDatabase(dbPath);
@@ -700,6 +745,24 @@ module.exports = (app) => {
700
745
  const logSinceOrigin = deps.getState(db, "dr_log_since_origin");
701
746
  if (logSinceOrigin) engine.logNmSinceOrigin = Number(logSinceOrigin) || 0;
702
747
 
748
+ // Shadow vessel (work doc #21): publish the DR position as a synthetic
749
+ // vessels.<id> target so chart plotters render it. The context is a
750
+ // stable UUID persisted across restarts so the plotter's target id
751
+ // is continuous (no flicker/age-out on restart). Generated once and
752
+ // reused — never collides with a real AIS target.
753
+ if (config.shadowVessel.enabled) {
754
+ let shadowCtx = deps.getState(db, "shadow_vessel_context");
755
+ if (!shadowCtx) {
756
+ shadowCtx = `vessels.urn:mrn:signalk:uuid:${crypto.randomUUID()}`;
757
+ deps.setState(db, "shadow_vessel_context", shadowCtx);
758
+ }
759
+ shadow = deps.createShadowVesselPublisher({
760
+ app,
761
+ context: shadowCtx,
762
+ name: config.shadowVessel.name,
763
+ });
764
+ }
765
+
703
766
  initLogbook();
704
767
 
705
768
  // Subscribe to the sensor inputs the engine needs.
@@ -738,6 +801,22 @@ module.exports = (app) => {
738
801
  publishMeta();
739
802
  setStatus("Dead reckoning started");
740
803
 
804
+ // Plotter-extension host integration (work doc #19): advertise
805
+ // the status-tile manifest to chart plotters (Freeboard-SK ≥3.0)
806
+ // and serve the iframe assets at a public, non-admin-gated route.
807
+ plotterExtTeardown = registerPlotterExtension(app, {
808
+ id: PLUGIN_ID,
809
+ });
810
+
811
+ // Status Tiles example-set provider (work doc #22): advertise
812
+ // the DR position tile set so the Status Tiles webapp offers it
813
+ // as a one-tap "Add" that merges into the user's panel. Read-only,
814
+ // running-gated — returns {} when stopped so a disabled plugin
815
+ // contributes no stale sets.
816
+ statusTileExamplesTeardown = registerStatusTileExamples(app, {
817
+ id: PLUGIN_ID,
818
+ });
819
+
741
820
  // Public config endpoint (CONFIG_PATH): mounted on the app so
742
821
  // anonymous / read-only clients can read the plugin config
743
822
  // (incl. positionFormat) without admin auth. Mirrors
@@ -793,6 +872,12 @@ module.exports = (app) => {
793
872
  weatherClient?.stop();
794
873
  weatherClient = null;
795
874
  training = null;
875
+ plotterExtTeardown?.();
876
+ plotterExtTeardown = null;
877
+ statusTileExamplesTeardown?.();
878
+ statusTileExamplesTeardown = null;
879
+ shadow?.stop();
880
+ shadow = null;
796
881
  // Hygiene: clear a live advisory so it doesn't linger after the
797
882
  // plugin stops monitoring.
798
883
  if (divergence?.active) {
@@ -1067,6 +1152,14 @@ module.exports = (app) => {
1067
1152
  [PATHS.elapsedSinceFix]: engine.elapsedSinceOriginS,
1068
1153
  [PATHS.divergenceDistance]: null,
1069
1154
  });
1155
+ // Shadow vessel (work doc #21): keep the shadow on the chart at the
1156
+ // last position so it doesn't vanish or go stale while DR is idle.
1157
+ // SOG=0 clears any previously-drawn COG line so it doesn't linger
1158
+ // stale; heading/COG are omitted (the plotter retains the last
1159
+ // values, which is honest — the shadow isn't moving).
1160
+ if (shadow && engine.origin) {
1161
+ shadow.publish({ position: engine.origin, sogMs: 0 });
1162
+ }
1070
1163
  return;
1071
1164
  }
1072
1165
 
@@ -1348,6 +1441,29 @@ module.exports = (app) => {
1348
1441
  ? dvg.distance_nm * METRES_PER_NM
1349
1442
  : null,
1350
1443
  });
1444
+
1445
+ // Shadow vessel (work doc #21): emit the DR position + a derived
1446
+ // COG/SOG so the plotter draws the shadow with a projected COG
1447
+ // line. The velocity vector mirrors the engine's motion model
1448
+ // (water-track + current) so the line projects consistently. Units
1449
+ // are Signal K SI (rad, m/s), matching how the plugin publishes
1450
+ // the own-vessel current vector above.
1451
+ if (shadow && pos) {
1452
+ const vel = deps.resolveShadowVelocity({
1453
+ stwKn: effectiveStwKn,
1454
+ headingTrueDeg,
1455
+ leewayDeg: corrections.leeway_angle,
1456
+ speedLoss: corrections.speed_loss,
1457
+ current,
1458
+ });
1459
+ shadow.publish({
1460
+ position: pos,
1461
+ headingTrueRad:
1462
+ headingTrueDeg != null ? degToRad(headingTrueDeg) : null,
1463
+ cogRad: vel ? degToRad(vel.cogDeg) : null,
1464
+ sogMs: vel ? knotsToMs(vel.sogKn) : null,
1465
+ });
1466
+ }
1351
1467
  }
1352
1468
  }
1353
1469
 
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Plotter-extension host integration (Freeboard-SK and any Plotter
3
+ * Extensions API v1 chart plotter).
4
+ *
5
+ * Two server-side responsibilities (see freeboard-sk
6
+ * docs/api/plotter_extension_provider_plugins.md):
7
+ *
8
+ * 1. Register a read-only `plotterExtensions` resource provider whose
9
+ * single entry is this plugin's manifest — the host discovers the
10
+ * extension there. Presence in the collection *is* the enablement
11
+ * signal: hosts must not add a second per-extension gate, and the
12
+ * provider goes empty on plugin stop so the host tears the contexts
13
+ * down.
14
+ * 2. Serve the extension's iframe assets at a publicly readable,
15
+ * non-admin-gated route. `/plugins/*` is admin-only on the server, so
16
+ * the manifest URLs point at a self-mounted namespaced prefix instead.
17
+ * The assets are inert UI code — all data flows over the host bus.
18
+ * Served by a minimal in-repo static handler rather than
19
+ * `express.static`: the plugin is zero-dependency by design (SPEC §2)
20
+ * and `express` is not resolvable from a plugin's own tree — only
21
+ * from inside the server. The handler only needs to serve the small,
22
+ * known-good set of files under `public/`.
23
+ *
24
+ * @file plotterext.js
25
+ */
26
+
27
+ const path = require("node:path");
28
+ const fs = require("node:fs");
29
+ const pkg = require("../package.json");
30
+
31
+ /**
32
+ * Directory holding the extension's iframe assets. The same `public/`
33
+ * the standalone webapp is served from (the webapp keyword mounts it at
34
+ * the package path); the extension entry pages live alongside the ES
35
+ * modules they import, so relative imports keep working under either
36
+ * mount.
37
+ */
38
+ const PUBLIC_DIR = path.join(__dirname, "..", "public");
39
+
40
+ /** Content types for the extension's asset kinds (inert UI code). */
41
+ const MIME = {
42
+ ".html": "text/html; charset=utf-8",
43
+ ".js": "text/javascript; charset=utf-8",
44
+ ".mjs": "text/javascript; charset=utf-8",
45
+ ".css": "text/css; charset=utf-8",
46
+ ".png": "image/png",
47
+ ".svg": "image/svg+xml",
48
+ ".ico": "image/x-icon",
49
+ ".json": "application/json; charset=utf-8",
50
+ ".txt": "text/plain; charset=utf-8",
51
+ };
52
+
53
+ /**
54
+ * Builds the extension manifest.
55
+ *
56
+ * `requires` lists only what the tile genuinely cannot run without: the
57
+ * widget grid and the Signal K relay (a status tile with no data is dead
58
+ * weight). Night mode is used when present and guarded with
59
+ * `hasCapability` at runtime.
60
+ *
61
+ * @param {string} assetBase - server-relative URL prefix for assets
62
+ * @param {string} version - plugin version for display metadata
63
+ * @returns {object} manifest per the Plotter Extensions API v1
64
+ */
65
+ function buildManifest(assetBase, version) {
66
+ return {
67
+ name: "Dead Reckoning",
68
+ description:
69
+ "Dead reckoning status tile: GPS↔DR divergence, uncertainty radius and DR health at a glance.",
70
+ version,
71
+ apiVersion: "1",
72
+ requires: ["widgets", "signalk.stream"],
73
+ optional: ["nightMode"],
74
+ widgets: [
75
+ {
76
+ id: "dr-status",
77
+ title: "Dead Reckoning",
78
+ type: "iframe",
79
+ url: `${assetBase}/dr-ext-widget.html`,
80
+ size: "2x1",
81
+ lifecycle: "whileEnabled",
82
+ },
83
+ ],
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Minimal static-asset middleware for the extension's files. Not a
89
+ * general file server: only regular files directly under `root` are
90
+ * served (directory indexes and anything escaping `root` fall through).
91
+ *
92
+ * @param {string} root - absolute directory to serve
93
+ * @param {string} assetBase - mount prefix, stripped when the host did
94
+ * not already strip it (Express strips it for `app.use(path, fn)`;
95
+ * other hosts may pass the full path)
96
+ * @returns {(req: object, res: object, next: Function) => void}
97
+ */
98
+ function staticAssetHandler(root, assetBase) {
99
+ const rootPrefix = root.endsWith(path.sep) ? root : root + path.sep;
100
+ return (req, res, next) => {
101
+ let urlPath = req.path ?? req.url ?? "/";
102
+ const q = urlPath.indexOf("?");
103
+ if (q >= 0) urlPath = urlPath.slice(0, q);
104
+ if (urlPath.startsWith(assetBase)) {
105
+ urlPath = urlPath.slice(assetBase.length);
106
+ }
107
+ if (!urlPath.startsWith("/")) urlPath = `/${urlPath}`;
108
+
109
+ let resolved;
110
+ try {
111
+ resolved = path.normalize(
112
+ path.join(rootPrefix, decodeURIComponent(urlPath)),
113
+ );
114
+ } catch {
115
+ next(); // malformed percent-encoding
116
+ return;
117
+ }
118
+ // Traversal guard: the resolved path must stay inside root.
119
+ if (!resolved.startsWith(rootPrefix)) {
120
+ next();
121
+ return;
122
+ }
123
+
124
+ fs.stat(resolved, (err, st) => {
125
+ if (err || !st.isFile()) {
126
+ next();
127
+ return;
128
+ }
129
+ res.statusCode = 200;
130
+ res.setHeader(
131
+ "Content-Type",
132
+ MIME[path.extname(resolved)] ?? "application/octet-stream",
133
+ );
134
+ res.setHeader("Cache-Control", "public, max-age=3600");
135
+ fs.readFile(resolved, (readErr, data) => {
136
+ if (readErr) {
137
+ next();
138
+ return;
139
+ }
140
+ res.end(data);
141
+ });
142
+ });
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Registers the plotter-extension provider and mounts the asset route.
148
+ *
149
+ * @param {import("@signalk/server-api").ServerAPI} app - Signal K server API
150
+ * @param {{id: string, version?: string}} opts - plugin id (manifest key)
151
+ * and optional version override (defaults to the package version)
152
+ * @returns {() => void} teardown — empties the provider's listing so
153
+ * hosts unload the extension's contexts. The static asset mount stays
154
+ * (Express has no unmount, and the files are inert); that matches the
155
+ * webapp keyword's behaviour for a disabled plugin.
156
+ */
157
+ function registerPlotterExtension(app, opts) {
158
+ const { id } = opts;
159
+ const version = opts.version ?? pkg.version;
160
+ const assetBase = `/plotterext/${id}`;
161
+ const manifest = buildManifest(assetBase, version);
162
+ let running = true;
163
+
164
+ app.registerResourceProvider({
165
+ type: "plotterExtensions",
166
+ methods: {
167
+ listResources: async () => (running ? { [id]: manifest } : {}),
168
+ getResource: async (resourceId) => {
169
+ if (!running || resourceId !== id) {
170
+ throw new Error(`No such plotterExtensions resource: ${resourceId}`);
171
+ }
172
+ return manifest;
173
+ },
174
+ setResource: async () => {
175
+ throw new Error(`${id} is a read-only provider`);
176
+ },
177
+ deleteResource: async () => {
178
+ throw new Error(`${id} is a read-only provider`);
179
+ },
180
+ },
181
+ });
182
+
183
+ app.use(assetBase, staticAssetHandler(PUBLIC_DIR, assetBase));
184
+
185
+ return () => {
186
+ running = false;
187
+ };
188
+ }
189
+
190
+ module.exports = {
191
+ buildManifest,
192
+ registerPlotterExtension,
193
+ staticAssetHandler,
194
+ };