speedandcurrent 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,213 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+
7
+ ## [2.6.0] - 2026-11-05
8
+
9
+ ### Added
10
+ - Current estimates are also published as an `environment.current` object, with magnetic set calculated from `navigation.magneticVariation` and published at `environment.current.setMagnetic`.
11
+ - The webapp now shows magnetic variation as an estimation input and magnetic current set as an output.
12
+
13
+ ### Fixed
14
+ - Magnetic variation is no longer marked idle or stale, since it may update too infrequently for telemetry staleness checks.
15
+
16
+ ## [2.5.0] - 2026-10-26
17
+
18
+ ### Added
19
+ - Correction-table files now use version 2 with per-cell update timestamps, capped time-based aging, and silent migration of existing version-1 files.
20
+ - The webapp now polls at the plugin's actual sample rate instead of a fixed 1s interval, reported via a new `pollIntervalMs` field on `/api/report`.
21
+ - Correction estimates now fuse mature nearby table cells using their full covariance and normalized distance, with an automatically estimated spatial variance, a two-cell search radius, and fusion-weight diagnostics in the webapp.
22
+ - The correction table now overlays the current uncorrected (raw) and corrected speed/heel as red and blue dots positioned directly on the grid, with a matching legend entry.
23
+
24
+ ### Changed
25
+ - The npm package now includes this changelog.
26
+ - Updated the `signalkutilities` dependency to `^3.2.0`, including non-finite sample protection, explicit-null smoother resets, and subscription-manager-controlled startup values.
27
+ - Requires signalk-server >=2.35.0 for the upstream metadata-first subscription fix ([SignalK/signalk-server#3216](https://github.com/SignalK/signalk-server/pull/3216)).
28
+ - Read-only Signal K users can now access live reports, status, and metadata; settings and correction-table listing or changes remain administrator-only.
29
+ - Correction-table learning no longer incorporates the estimated current, and the related setting has been removed.
30
+ - Correction-table process noise is now configured as a user-facing correction drift rate in knots/month, with a conservative default of 0.3 and an SI conversion internally. Legacy stability settings migrate silently.
31
+ - Correction interpolation now includes mature cells within 2.5 normalized grid-cell units.
32
+ - Correction learning now always uses a moving-average window of at least 5 seconds, with a one-second gap between observation windows. Older smoother selections migrate silently.
33
+ - Cells become eligible after one accepted observation; the explicit maturity gate remains in place at zero for future tuning.
34
+
35
+ ### Fixed
36
+ - Removed idle-triggered input resubscriptions, including boat-speed inputs, to avoid replaying cached values during sensor outages. Idle/stale warnings remain, and existing subscriptions receive data when the source resumes. Synchronous startup safeguards are preserved.
37
+ - The boat-speed estimation inputs now prioritize Boatspeed unless groundspeed fallback is selected, and the correction table marks its most recently updated cell with a thin black border reflected in the legend.
38
+ - The learning status Reason row is now hidden when the last observation was accepted, instead of showing a redundant "Observation recorded" message.
39
+ - `navigation.state` no longer misreports as "not ready" a few seconds after each update; it is an event path that only publishes on transition, so the generic telemetry staleness timer no longer applies to it. The Inputs tab now also displays its current value.
40
+ - Learning and leeway are now gated on the vessel's own SOG-based moving/not-moving state, rather than solely on `navigation.state` freshness. Previously, an infrequent `navigation.state` update could go stale within seconds, silently letting learning continue while anchored or moored. `navigation.state`, when known and `suspendLearningOnNavigationState` is enabled, can still override to "not moving" (anchored/moored/motoring), but can no longer force "moving" against the vessel's own SOG.
41
+ - Webapp report parsing failures now remain visible and still refresh plugin status instead of aborting the polling tick.
42
+ - Disabled current estimation no longer publishes a zero-current value during plugin startup.
43
+ - Malformed correction tables now recover safely at startup, while explicit loads reject invalid files without replacing the active table.
44
+ - The correction-table Kalman filter now uses each observation's own measurement-noise covariance (already computed from live sensor variances) instead of a fixed identity matrix, so anisotropic and rotated observation uncertainty is correctly reflected in cell covariances. Persisted tables store a serializable model descriptor rather than the runtime model; existing table files still load unchanged.
45
+
46
+ ## [2.3.4] - 2026-09-17
47
+
48
+ ### Fixed
49
+ - Boat-speed startup now preserves synchronously replayed cached values, avoids restart errors, and keeps current estimation behind the startup stabilization period regardless of table-learning settings.
50
+ - Attitude roll handling now treats incomplete or stale values as invalid during correction-table learning, preventing fabricated heel-based corrections while the input is not yet usable.
51
+ - Webapp values and warnings now share per-path lifecycle status, distinguish subscribed and computed data, report polar magnitude and angle independently, and show stale raw inputs consistently across all pages.
52
+
53
+ ## [2.3.3] - 2026-09-06
54
+
55
+ ### Fixed
56
+ - `navigation.leewayAngle` no longer reports a correction-table artifact when the vessel is stationary. The lateral component of the table correction is now suppressed below the table speed step and while `navigation.state` is `anchored` or `moored`, so leeway is published as 0 instead of an angle derived from a near-zero speed. The longitudinal speed correction, and leeway while motoring, are unaffected.
57
+ - Updated `signalkutilities` to 3.1.2, which completes the 3.1.1 recovery fix for polar-backed inputs. Smoothed heading and the ground-speed vector are built on `Polar`/`PolarSmoother`, where the idle timer was still cleared rather than re-armed on each delta, so those inputs did not recover after going silent following a healthy start.
58
+
59
+ ## [2.3.2] - 2026-09-04
60
+
61
+ ### Fixed
62
+ - Updated `signalkutilities` to 3.1.1, so an input that goes silent *after* having delivered data is now detected and resubscribed. Previously recovery only ran for subscriptions that had never received a delta, meaning an upstream outage after a healthy start could leave the plugin dead until it was restarted.
63
+
64
+ ## [2.3.1] - 2026-08-18
65
+
66
+ ### Fixed
67
+ - Signal K admin styling now loads from standalone CSS records in current Vite manifests while retaining support for entry-associated CSS and older server fallbacks.
68
+
69
+ ## [2.3.0] - 2026-08-16
70
+
71
+ ### Added
72
+ - Learning status now distinguishes between active learning and observations that were skipped, rejected, or invalid, and the webapp reports the current reason more clearly.
73
+
74
+ ### Changed
75
+ - Correction-table learning now uses automatic gates based on `navigation.state`, with `anchored` and `moored` always suspending learning when available and `motoring` optionally suspending it.
76
+ - The learning threshold for both STW and SOG now follows the active table speed-step size.
77
+ - The webapp no longer exposes a separate minimum-SOG learning setting.
78
+
79
+ ### Fixed
80
+ - When learning is off or suspended, the webapp no longer keeps showing a stale previous observation result.
81
+
82
+ ## [2.2.1] - 2026-08-14
83
+
84
+ ### Fixed
85
+ - Input subscriptions now recover automatically after idle or stale periods, and the webapp exposes lifecycle warnings for affected paths.
86
+ - Corrected output paths are now explicitly cleared with `null` when estimation is disabled or when the plugin stops, preventing stale values from lingering on the Signal K bus.
87
+ - The webapp-facing status and report endpoints now reflect the plugin's current runtime state more directly.
88
+ - SOG fallback now checks raw STW (`rawBoatSpeed`) directly when detecting a zero paddlewheel reading, avoiding dependence on the derived corrected speed value.
89
+
90
+ ## [2.1.2] - 2026-07-23
91
+
92
+ ### Fixed
93
+ - App Store icon was missing (monogram fallback shown): added `icon.png` at the package root so the App Store CDN can fetch it from the npm tarball via unpkg.com. The runtime `signalk.appIcon: ./icon.png` path is unchanged - the server still serves it from `public/icon.png` via the webapp mount.
94
+ - Updated `signalkutilities` to 3.0.1, fixing continuous 401 log flooding on SK servers with security enabled and anonymous read access disabled.
95
+
96
+ ## [2.1.1] - 2026-07-20
97
+
98
+ ### Fixed
99
+ - Sidebar toggler was unreachable on mobile viewports.
100
+
101
+ ### Changed
102
+ - Updated `signalkutilities` dependency to `^3.0.0` (removes `PolarTable` from the shared library; no behaviour change for this plugin).
103
+
104
+ ## [2.1.0] - 2026-07-10
105
+
106
+ ### Added
107
+ - `null` is written to corrected boat speed (`navigation.speedThroughWater`, `navigation.leewayAngle`) and current (`environment.current.drift`, `environment.current.setTrue`) paths when `estimateBoatSpeed` is toggled off.
108
+ - `null` is written to all active output paths (current, corrected boat speed) when the plugin stops, preventing stale values on the SK bus.
109
+
110
+ ## [2.0.2] - 2026-6-18
111
+ ### Fixed
112
+ - The plugin icon was not displayed in the Signal K App Store because the icon path in the package metadata was incorrect.
113
+ - Test files are no longer included in the published npm package.
114
+
115
+ ## [2.0.1] - 2026-6-17
116
+ ### Fixed
117
+ - Correction table reporting was doing unnecessary work on every update, causing higher CPU use especially on low-power hardware such as a Raspberry Pi.
118
+ - The variance values used for interpolating between correction table cells were calculated with an incorrect formula, causing them to be overestimated. This made the Kalman outlier gate slightly less accurate.
119
+
120
+ ## [2.0.0] - 2026-6-17
121
+ ### Changed
122
+ - Moving average smoother is now O(1) in both CPU and memory: it no longer slows down or uses more memory as the window size grows.
123
+
124
+ - **Breaking:** Requires `signalkutilities` v2.0.0. Source-selection and pass-through machinery has been removed from the library; Signal K Server now handles source priorities natively.
125
+ - Per-path source settings (`headingSource`, `boatSpeedSource`, `SOGSource`, `attitudeSource`) have been removed. Use Signal K Server's built-in source priority configuration instead.
126
+ - The **Prevent Speed Duplication** setting has been removed. The server's `excludeSelf` behaviour and source priorities replace it.
127
+
128
+ ### Removed
129
+ - Source dropdowns in the webapp Inputs section.
130
+ - Source-related fields from persisted plugin configuration. A one-time migration strips these fields automatically on first start with the new version.
131
+
132
+ ### Added
133
+ - Documentation explaining how to configure Signal K source priorities so that the corrected `navigation.speedThroughWater` is delivered to all consumers.
134
+
135
+ ## [1.7.10] - 2026-5-14
136
+ ### Fixed
137
+ - Removed incorrect `self.` prefix from Signal K paths.
138
+
139
+ ## [1.7.9] - 2026-5-14
140
+ ### Added
141
+ - Learning status indicator in the webapp showing whether the correction table is active, stabilising, or off, and whether the last observation was accepted, rejected, invalid, or below threshold.
142
+ - Outlier rejection using Mahalanobis distance: bad observations are discarded before being fed into the Kalman filter, preventing a single bad sample from corrupting a cell.
143
+
144
+ ### Fixed
145
+ - Smoothed learning inputs are now always registered in the reporter regardless of whether `updateCorrectionTable` is enabled, so warnings clear correctly when learning is toggled on at runtime.
146
+
147
+ ## [1.7.8] - 2026-5-14
148
+ ### Added
149
+ - Legend below the correction table explaining factor / leeway values, background colour coding, stripe direction, active cell, and neighbour cell indicators.
150
+
151
+ ### Fixed
152
+ - Correction table learning never started when `assumeCurrent` is off.
153
+ - Active cell highlighting (most recently updated cell) was not visible.
154
+
155
+ ## [1.7.7] - 2026-5-13
156
+ ### Added
157
+ - Optional staleness detection for inputs.
158
+ - Precise warnings on state of inputs.
159
+
160
+ ## [1.7.5] - 2025-11-19
161
+ ### Added
162
+ - New webApp.
163
+ - WebApp allows to change plugin configuration while the plugin is running.
164
+ - Support for multiple correction tables (new, copy, delete).
165
+ - Support for altering the dimensions of a correction table (alter).
166
+ - Better edge case handling.
167
+ - User selectable options for smoothing method and smoothing parameters.
168
+ - Support for unitPreferences.
169
+
170
+ ### Fixed
171
+ - Bug where navigation.speedThroughWater was removed from the signalk bus.
172
+
173
+ ### Removed
174
+ - Possibility to set options via the signalk plugin configuration.
175
+
176
+ ## [1.6.0] - 2025-11-19
177
+ ### Added
178
+ - Option to copy navigation.speedOverGround to navigation.speedThroughWater when speedThroughWater is not properly measured. Thanks to Jean-Laurent Girod for the suggestion.
179
+
180
+ ## [1.5.1] - 2025-10-06
181
+ ### Fixed
182
+ - Bug preventing current to be displayed in KIP.
183
+
184
+ ## [1.5.0] - 2025-10-06
185
+ ### Changed
186
+ - Separate loops for correcting speed and for updating correction factors.
187
+ - Speed correction loop uses raw values.
188
+ - Updating correction factors using moving averages.
189
+ - Unified cell rendering driven by "Show" selections; removed the previous Table style toggle.
190
+
191
+ ### Added
192
+ - Option to set the source for input paths.
193
+ - Full integration of uncertainty estimations (variance) in estimations and corrections.
194
+ - Ability to resize the correction table without losing all correction data.
195
+ - WebApp: "Show" multi-select to choose per-cell metrics (correction X/Y, factor, leeway, trace, N) for the correction table.
196
+ - WebApp: Color mode options for the correction table.
197
+ - WebApp: Persistence for speed unit, angle unit, color mode, and shown metrics via localStorage.
198
+
199
+ ### Fixed
200
+ - Angles are now reported in the appropriate range, 0 to 2 * PI or -PI to PI, depending on path.
201
+
202
+ ## [1.1.0] - 2025-8-20
203
+ ### Changed
204
+ - Path names for current. It now is self.environment.current.setTrue and self.environment.current.drift.
205
+
206
+ ### Fixed
207
+ - Case error in filename that prevented the webapp from displaying the correction table.
208
+ - Corrected speed through water is now calculated when the correction table is fixed.
209
+
210
+ ## [1.0.1] - 2025-1-27
211
+ ### Added
212
+ - Basic functionality to estimate speed, leeway and current using Kalman filters.
213
+ - Basic functionality for a webapp that shows what is going on.
package/README.md CHANGED
@@ -6,7 +6,9 @@ Most paddle wheel logs carry a systematic error that varies with boat speed and
6
6
 
7
7
  This plugin builds and maintains a **2-dimensional correction table** — indexed by speed and heel — that is populated automatically from real sailing observations. No calibration runs, no spreadsheets, no manual entry. Once the table has enough coverage the plugin applies per-point corrections to `navigation.speedThroughWater`, derives leeway from the lateral component of the correction, and estimates water current by subtracting the corrected boat speed vector from the GPS ground speed vector.
8
8
 
9
- In the current implementation the plugin is also explicit about runtime state: if an input goes idle or stale, the webapp shows a warning and the handler resubscribes; when estimation is disabled or the plugin stops, the active output paths are cleared with `null` so downstream consumers do not keep stale values.
9
+ Requires signalk-server >=2.35.0 for reliable metadata-first input subscriptions.
10
+
11
+ If an input goes idle or stale, the webapp shows a warning while the subscription stays attached, ready for the next delta; when estimation is disabled or the plugin stops, the active output paths are cleared with `null` so downstream consumers do not keep stale values.
10
12
 
11
13
  In broad terms the plugin:
12
14
 
@@ -31,6 +33,12 @@ The plugin requires no further configuration in the Signal K admin UI. All setti
31
33
 
32
34
  ---
33
35
 
36
+ ## HTTP endpoint access
37
+
38
+ Signal K read-only users can access the live report (`/api/report`), metadata (`/api/meta`), and status (`/api/status`) endpoints. Settings (`/api/settings`) and correction-table listing or management (`/api/tables...`) require administrator access. Anonymous requests still follow the server's configured security policy; this plugin does not enable anonymous access.
39
+
40
+ ---
41
+
34
42
  ## Configuring Signal K source priorities
35
43
 
36
44
  Signal K Server manages source priorities natively. When multiple devices publish the same path, the server delivers the highest-priority source to all subscribers — including this plugin. No per-path source selection is needed inside the plugin itself.
@@ -54,6 +62,7 @@ Once configured, all consumers on the Signal K bus — KIP, OpenCPN, Instrument
54
62
  | `navigation.courseOverGroundTrue` | GPS ground speed direction |
55
63
  | `navigation.headingTrue` | True heading — used to rotate the boat-frame correction into the ground frame |
56
64
  | `navigation.attitude` | Roll angle — provides the heel index into the correction table |
65
+ | `navigation.magneticVariation` | Magnetic variation — used to convert estimated current set to magnetic north |
57
66
 
58
67
  ---
59
68
 
@@ -65,6 +74,8 @@ Once configured, all consumers on the Signal K bus — KIP, OpenCPN, Instrument
65
74
  | `navigation.leewayAngle` | rad | Leeway angle (starboard positive). Derived from the lateral correction component. |
66
75
  | `environment.current.drift` | m/s | Estimated current speed. |
67
76
  | `environment.current.setTrue` | rad | Estimated current direction (the direction the water moves *toward*). |
77
+ | `environment.current.setMagnetic` | rad | Estimated current direction relative to magnetic north, calculated from `environment.current.setTrue` and `navigation.magneticVariation`. |
78
+ | `environment.current` | object | Current estimate containing `drift`, `setTrue`, and, when magnetic variation is available, `setMagnetic`. |
68
79
 
69
80
  A 60-second stabilisation period applies after startup. Corrected boatspeed may be published during this window, but current estimation and table learning wait until it ends.
70
81
 
@@ -99,6 +110,7 @@ The **Inputs** section shows live readings from the raw sensors as they arrive f
99
110
  | Boat speed | `navigation.speedThroughWater` |
100
111
  | Ground speed | `navigation.speedOverGround` + `navigation.courseOverGroundTrue` |
101
112
  | Attitude (heel) | `navigation.attitude` (roll) |
113
+ | Magnetic variation | `navigation.magneticVariation` |
102
114
 
103
115
  ### Settings
104
116
 
@@ -116,7 +128,7 @@ The **Boatspeed Estimation** section is enabled with the toggle in the panel hea
116
128
 
117
129
  The panel is organised into three groups:
118
130
 
119
- **Inputs** — the raw sensor values used for correction: heading, boat speed, ground speed, and attitude. Warnings appear here if any are unavailable.
131
+ **Inputs** — the raw signals used by the estimation calculations: heading, boat speed, ground speed, attitude, and magnetic variation. Warnings appear here if any are unavailable.
120
132
 
121
133
  **Intermediates** — computed vectors that give insight into what the plugin is doing:
122
134
  - *Speed correction* — the correction vector currently being applied (longitudinal + lateral components).
@@ -126,6 +138,7 @@ The panel is organised into three groups:
126
138
  **Outputs** — what is being published to Signal K:
127
139
  - *Corrected boatspeed / Leeway* — corrected STW magnitude and leeway angle.
128
140
  - *Current* — estimated current drift and set.
141
+ - *Magnetic set* — estimated current direction relative to magnetic north, calculated using magnetic variation.
129
142
 
130
143
  ### Settings
131
144
 
@@ -289,7 +302,7 @@ Every time conditions are right — plugin running for >60 seconds, smoothers se
289
302
  observation = R(ψ)⁻¹ · V_SOG − R(ψ)⁻¹ · V_current − V_STW
290
303
  ```
291
304
 
292
- where `R(ψ)` rotates from ground frame to boat frame using true heading `ψ`. In plain terms: rotate GPS velocity into the boat frame, subtract the raw paddle wheel velocity. The residual is the implied sensor error for the current speed and heel.
305
+ where `R(ψ)` rotates from ground frame to boat frame using true heading `ψ`. In plain terms: rotate GPS velocity into the boat frame, subtract the current estimate (also rotated), subtract the raw paddle wheel velocity. The residual is the implied sensor error for the current speed and heel.
293
306
 
294
307
  The Kalman update combines this observation with the cell's existing belief:
295
308
 
@@ -298,11 +311,11 @@ K = P · (P + R_obs)⁻¹
298
311
  x_new = x_old + K · (observation − x_old)
299
312
  ```
300
313
 
301
- where P is the cell's current covariance and R_obs is the observation covariance derived from the measurement uncertainty of all contributing signals (SOG variance + STW variance, rotated appropriately). It also includes heading uncertainty in radians squared. For $u = R(-heading)(groundSpeed - current)$, the heading contribution is $J \sigma_h^2 J^T$, where $J = [u_y, -u_x]^T$. This is applied once to the combined ground-speed-minus-current vector because both vectors share the same heading error. **Noisy observations produce a smaller gain and move the cell estimate less.**
314
+ where P is the cell's current covariance and R_obs is the observation covariance derived from the measurement uncertainty of all contributing signals (SOG variance + current variance + STW variance, rotated appropriately). It also includes heading uncertainty in radians squared. For $u = R(-heading)(groundSpeed - current)$, the heading contribution is $J \sigma_h^2 J^T$, where $J = [u_y, -u_x]^T$. This is applied once to the combined ground-speed-minus-current vector because both vectors share the same heading error. **Noisy observations produce a smaller gain and move the cell estimate less.**
302
315
 
303
316
  ### Time-scaled aging and correction drift
304
317
 
305
- Each cell has a small **process-noise rate** that allows it to drift slowly over time, reflecting that a paddle wheel's error can change. For an elapsed interval `dt`, the process covariance added to each diagonal is:
318
+ Each cell has a small **process-noise rate** that allows it to drift slowly over time, reflecting that a paddle wheel's error can change with fouling, recalibration, or crew weight distribution. For an elapsed interval `dt`, the process covariance added to each diagonal is:
306
319
 
307
320
  ```text
308
321
  effectiveDt = min(dt, 90 days)
package/index.js CHANGED
@@ -295,6 +295,9 @@ module.exports = function (app) {
295
295
  let smoothedAttitude = null;
296
296
  let rawCurrent = null;
297
297
  let smoothedCurrent = null;
298
+ let magneticVariationHandler = null;
299
+ let setMagneticHandler = null;
300
+ let setMagneticPublished = false;
298
301
  let smoothedBoatSpeed = null;
299
302
  let correctedBoatSpeed = null;
300
303
  let lrnBoatSpeed = null;
@@ -385,7 +388,7 @@ module.exports = function (app) {
385
388
  function setLifecycleWarning(id, status, path) {
386
389
  const safePath = path || 'unknown path';
387
390
  const message = status === 'idle'
388
- ? `Input ${id} is idle on ${safePath}; resubscribing`
391
+ ? `Input ${id} is idle on ${safePath}`
389
392
  : status === 'incomplete'
390
393
  ? `Input ${id} is missing ${safePath}`
391
394
  : `Input ${id} is stale on ${safePath}`;
@@ -407,7 +410,7 @@ module.exports = function (app) {
407
410
  .sort((a, b) => b.updatedAt - a.updatedAt);
408
411
  }
409
412
 
410
- function buildLifecycleCallbacks(id, getPath, resubscribe) {
413
+ function buildLifecycleCallbacks(id, getPath) {
411
414
  return {
412
415
  onDelta: () => {
413
416
  clearLifecycleWarning(id);
@@ -421,13 +424,8 @@ module.exports = function (app) {
421
424
  onIdle: () => {
422
425
  if (!isRunning) return;
423
426
  const path = getPath();
424
- app.debug(`[${plugin.id}] idle input ${id} on ${path}; resubscribing`);
427
+ app.debug(`[${plugin.id}] idle input ${id} on ${path}`);
425
428
  setLifecycleWarning(id, 'idle', path);
426
- try {
427
- resubscribe();
428
- } catch (e) {
429
- app.debug(`[${plugin.id}] resubscribe failed for ${id}: ${e.message}`);
430
- }
431
429
  }
432
430
  };
433
431
  }
@@ -448,7 +446,7 @@ module.exports = function (app) {
448
446
  app.debug('registerWithRouter');
449
447
  readOptions(); // pre-load so /api/settings works before start()
450
448
 
451
- router.get('/api/report', (req, res) => {
449
+ router.access('readonly').get('/api/report', (req, res) => {
452
450
  if (!isRunning) {
453
451
  res.status(503).json({ error: "Plugin is not running" });
454
452
  } else {
@@ -462,7 +460,7 @@ module.exports = function (app) {
462
460
  }
463
461
  });
464
462
 
465
- router.get('/api/meta', (req, res) => {
463
+ router.access('readonly').get('/api/meta', (req, res) => {
466
464
  if (!isRunning) {
467
465
  res.status(503).json({ error: "Plugin is not running" });
468
466
  } else {
@@ -472,7 +470,7 @@ module.exports = function (app) {
472
470
 
473
471
 
474
472
 
475
- router.get('/api/status', (req, res) => {
473
+ router.access('readonly').get('/api/status', (req, res) => {
476
474
  res.json({ status: pluginStatus, isRunning, lifecycleWarnings, learningState: getLearningStatePayload() });
477
475
  });
478
476
 
@@ -653,8 +651,7 @@ module.exports = function (app) {
653
651
  smootherOptions,
654
652
  ...buildLifecycleCallbacks(
655
653
  'heading.angle',
656
- () => smoothedHeading?.handler?.path || 'navigation.headingTrue',
657
- () => { smoothedHeading?.unsubscribe(); smoothedHeading?.subscribe(false, true); }
654
+ () => smoothedHeading?.handler?.path || 'navigation.headingTrue'
658
655
  )
659
656
  });
660
657
  rawHeading = smoothedHeading.handler;
@@ -669,8 +666,7 @@ module.exports = function (app) {
669
666
  smootherOptions,
670
667
  ...buildLifecycleCallbacks(
671
668
  'attitude.smoothed',
672
- () => smoothedAttitude?.handler?.path || 'navigation.attitude',
673
- () => { smoothedAttitude?.unsubscribe(); smoothedAttitude?.subscribe(); }
669
+ () => smoothedAttitude?.handler?.path || 'navigation.attitude'
674
670
  )
675
671
  });
676
672
  rawAttitude = smoothedAttitude.handler;
@@ -691,6 +687,25 @@ module.exports = function (app) {
691
687
  // send metadata for current
692
688
  MessageHandler.setMeta(app, plugin.id, "environment.current.drift", {units: "m/s", type: "number", description: "Speed of the current"});
693
689
  MessageHandler.setMeta(app, plugin.id, "environment.current.setTrue", { units: "rad", type: "number", description: "Direction of the current" });
690
+ MessageHandler.setMeta(app, plugin.id, "environment.current", { type: "object", description: "Estimated water current" });
691
+ MessageHandler.setMeta(app, plugin.id, "environment.current.setMagnetic", {
692
+ units: "rad",
693
+ type: "number",
694
+ description: "Direction of the current relative to magnetic north",
695
+ displayName: "Magnetic set",
696
+ displayUnits: { category: "angle" }
697
+ });
698
+ magneticVariationHandler = new MessageHandler(app, plugin.id, "magneticVariation");
699
+ magneticVariationHandler.configure("navigation.magneticVariation");
700
+ magneticVariationHandler.idlePeriod = 0;
701
+ magneticVariationHandler.stalePeriod = 0;
702
+ magneticVariationHandler.onDelta = () => {
703
+ clearLifecycleWarning("magneticVariation");
704
+ publishCurrentObject();
705
+ };
706
+ magneticVariationHandler.subscribe();
707
+ setMagneticHandler = new MessageHandler(app, plugin.id, "setMagnetic");
708
+ setMagneticHandler.configure("environment.current.setMagnetic");
694
709
  rawCurrent = new Polar(app, plugin.id, "current");
695
710
  rawCurrent.configureMagnitude("environment.current.drift");
696
711
  rawCurrent.configureAngle("environment.current.setTrue");
@@ -776,10 +791,8 @@ module.exports = function (app) {
776
791
  onIdle: () => {
777
792
  if (!isRunning) return;
778
793
  const path = smoothedBoatSpeed?.handler?.path || 'navigation.speedThroughWater';
779
- app.debug(`[${plugin.id}] idle input boatSpeed.smoothed on ${path}; resubscribing`);
794
+ app.debug(`[${plugin.id}] idle input boatSpeed.smoothed on ${path}`);
780
795
  setLifecycleWarning('boatSpeed.smoothed', 'idle', path);
781
- smoothedBoatSpeed?.unsubscribe();
782
- smoothedBoatSpeed?.subscribe();
783
796
  },
784
797
  onStale: () => {
785
798
  if (!isRunning) return;
@@ -820,8 +833,7 @@ module.exports = function (app) {
820
833
  smootherOptions,
821
834
  ...buildLifecycleCallbacks(
822
835
  'groundSpeed.smoothed',
823
- () => `${smoothedGroundSpeed?.polar?.pathMagnitude || 'navigation.speedOverGround'}, ${smoothedGroundSpeed?.polar?.pathAngle || 'navigation.courseOverGroundTrue'}`,
824
- () => { smoothedGroundSpeed?.unsubscribe(); smoothedGroundSpeed?.subscribe(true, true); }
836
+ () => `${smoothedGroundSpeed?.polar?.pathMagnitude || 'navigation.speedOverGround'}, ${smoothedGroundSpeed?.polar?.pathAngle || 'navigation.courseOverGroundTrue'}`
825
837
  )
826
838
  });
827
839
  rawGroundSpeed = smoothedGroundSpeed.polar;
@@ -853,6 +865,8 @@ module.exports = function (app) {
853
865
  reportFull.addPolar(residual);
854
866
  reportFull.addPolar(smoothedResidual);
855
867
  }
868
+ reportFull.addDelta(magneticVariationHandler);
869
+ reportFull.addDelta(setMagneticHandler);
856
870
  // Smoothed learning inputs are always subscribed regardless of updateCorrectionTable,
857
871
  // so always add them to the report — otherwise warnings never clear when
858
872
  // learning was disabled at startup and later toggled on.
@@ -884,12 +898,14 @@ module.exports = function (app) {
884
898
  }
885
899
  // Clear all active output paths from the SK bus before teardown.
886
900
  if (smoothedCurrent) PolarSmoother.clear(app, plugin.id, [smoothedCurrent]);
901
+ clearCurrentObjectOutputs();
887
902
  if (options.estimateBoatSpeed && correctedBoatSpeed) Polar.clear(app, plugin.id, [correctedBoatSpeed]);
888
903
  smoothedHeading = smoothedHeading?.terminate();
889
904
  smoothedAttitude = smoothedAttitude?.terminate();
890
905
  rawCurrent = rawCurrent?.terminate();
891
906
  smoothedCurrent = smoothedCurrent?.terminate?.();
892
907
  navigationStateHandler = navigationStateHandler?.terminate();
908
+ magneticVariationHandler = magneticVariationHandler?.terminate();
893
909
  smoothedBoatSpeed = smoothedBoatSpeed?.terminate();
894
910
  correctedBoatSpeed = correctedBoatSpeed?.terminate();
895
911
  lrnBoatSpeed = lrnBoatSpeed?.terminate();
@@ -905,6 +921,8 @@ module.exports = function (app) {
905
921
  noCurrent = null;
906
922
  rawBoatSpeed = null;
907
923
  rawGroundSpeed = null;
924
+ setMagneticHandler = null;
925
+ setMagneticPublished = false;
908
926
  lastNavigationStateValue = null;
909
927
  learningStabilizingUntil = 0;
910
928
  learningStabilizingReason = 'startup';
@@ -996,10 +1014,50 @@ module.exports = function (app) {
996
1014
  }
997
1015
 
998
1016
  PolarSmoother.send(app, plugin.id, [smoothedCurrent]);
1017
+ publishCurrentObject();
999
1018
 
1000
1019
  Polar.send(app, plugin.id, [correctedBoatSpeed]);
1001
1020
  }
1002
1021
 
1022
+ function publishCurrentObject() {
1023
+ if (!options.estimateBoatSpeed || !smoothedCurrent?.ready) return;
1024
+ const drift = smoothedCurrent.magnitude;
1025
+ const setTrue = smoothedCurrent.angle;
1026
+ if (!Number.isFinite(drift) || !Number.isFinite(setTrue)) return;
1027
+
1028
+ const current = { drift, setTrue };
1029
+ const values = [{ path: "environment.current", value: current }];
1030
+ const magneticVariation = magneticVariationHandler?.value;
1031
+ if (magneticVariationHandler?.ready && Number.isFinite(magneticVariation)) {
1032
+ const setMagnetic = ((setTrue - magneticVariation) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI);
1033
+ current.setMagnetic = setMagnetic;
1034
+ setMagneticHandler.value = setMagnetic;
1035
+ values.push({ path: "environment.current.setMagnetic", value: setMagnetic });
1036
+ setMagneticPublished = true;
1037
+ } else {
1038
+ setMagneticHandler.invalidate();
1039
+ if (setMagneticPublished) {
1040
+ values.push({ path: "environment.current.setMagnetic", value: null });
1041
+ }
1042
+ setMagneticPublished = false;
1043
+ }
1044
+
1045
+ app.handleMessage(plugin.id, { updates: [{ values }] });
1046
+ }
1047
+
1048
+ function clearCurrentObjectOutputs() {
1049
+ setMagneticHandler?.invalidate();
1050
+ setMagneticPublished = false;
1051
+ app.handleMessage(plugin.id, {
1052
+ updates: [{
1053
+ values: [
1054
+ { path: "environment.current", value: null },
1055
+ { path: "environment.current.setMagnetic", value: null }
1056
+ ]
1057
+ }]
1058
+ });
1059
+ }
1060
+
1003
1061
  /**
1004
1062
  * Updates the correction table from the current smoothed inputs.
1005
1063
  * Reads minSpeed from module-level state;
@@ -1198,6 +1256,7 @@ module.exports = function (app) {
1198
1256
  if (key === 'estimateBoatSpeed' && !value && correctedBoatSpeed) {
1199
1257
  Polar.clear(app, plugin.id, [correctedBoatSpeed]);
1200
1258
  PolarSmoother.clear(app, plugin.id, [smoothedCurrent]);
1259
+ clearCurrentObjectOutputs();
1201
1260
  }
1202
1261
 
1203
1262
  delete changedOptions[key];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "speedandcurrent",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "A plugin that automatically corrects boat speed and calculates current and leeway using sensor fusion.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -18,7 +18,7 @@
18
18
  "homepage": "https://github.com/Asw1n/speedandcurrent#readme",
19
19
  "engines": {
20
20
  "node": ">=22.0.0",
21
- "signalk-server": ">=2.28.0"
21
+ "signalk-server": ">=2.35.0"
22
22
  },
23
23
  "keywords": [
24
24
  "signalk-webapp",
@@ -41,9 +41,10 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "kalman-filter": "^2.3.0",
44
- "signalkutilities": "^3.1.6"
44
+ "signalkutilities": "^3.2.0"
45
45
  },
46
46
  "files": [
47
+ "CHANGELOG.md",
47
48
  "icon.png",
48
49
  "index.js",
49
50
  "correctionTable.js",
package/public/app.js CHANGED
@@ -118,7 +118,7 @@ async function loadMeta() {
118
118
 
119
119
  const computedItemIds = new Set([
120
120
  'current.smoothed', 'boatSpeedRefGround', 'speedCorrection',
121
- 'correctedBoatSpeed', 'residual', 'residual.smoothed'
121
+ 'correctedBoatSpeed', 'residual', 'residual.smoothed', 'setMagnetic'
122
122
  ]);
123
123
 
124
124
  function getPathStatus(pathState, hasValue, computed = false, computedReady = false) {
@@ -564,6 +564,8 @@ function renderLiveSections() {
564
564
  const inputAttitudes = filterById(state.attitudesAll, ['attitude']);
565
565
  const fallbackInputPolars = inputPolars.length ? inputPolars : filterById(state.polarsAll, ['groundSpeed.smoothed']);
566
566
  const fallbackInputDeltas = inputDeltas.length ? inputDeltas : filterById(state.deltasAll, ['heading.smoothed', 'boatSpeed.smoothed']);
567
+ const magneticVariationInput = filterById(state.deltasAll, ['magneticVariation']);
568
+ fallbackInputDeltas.push(...magneticVariationInput);
567
569
  const fallbackInputAttitudes = inputAttitudes.length ? inputAttitudes : filterById(state.attitudesAll, ['attitude.smoothed']);
568
570
  const displayedInputs = [...fallbackInputPolars, ...fallbackInputDeltas, ...fallbackInputAttitudes];
569
571
 
@@ -584,7 +586,7 @@ function renderLiveSections() {
584
586
  ? [...groundSpeedInputs, ...boatSpeedInputs]
585
587
  : [...boatSpeedInputs, ...groundSpeedInputs];
586
588
  estimationInputs.push(
587
- ...filterById(state.deltasAll, ['heading.angle']),
589
+ ...filterById(state.deltasAll, ['heading.angle', 'magneticVariation']),
588
590
  ...filterById(state.attitudesAll, ['attitude'])
589
591
  );
590
592
  renderItemsInto('estimation-inputs', estimationInputs);
@@ -599,9 +601,10 @@ function renderLiveSections() {
599
601
  renderGroupInto('estimation-intermediates', estimationIntermediates, [], [], statisticRows);
600
602
  // Estimation — outputs
601
603
  const estimationOutputs = filterById(state.polarsAll, ['correctedBoatSpeed', 'current.smoothed']);
602
- renderGroupInto('estimation-outputs', estimationOutputs, [], []);
604
+ const estimationOutputDeltas = filterById(state.deltasAll, ['setMagnetic']);
605
+ renderGroupInto('estimation-outputs', estimationOutputs, estimationOutputDeltas, []);
603
606
  // Estimation — warnings
604
- renderWarnings('estimation-warnings', [...estimationInputs, ...estimationIntermediates, ...estimationOutputs]);
607
+ renderWarnings('estimation-warnings', [...estimationInputs, ...estimationIntermediates, ...estimationOutputs, ...estimationOutputDeltas]);
605
608
 
606
609
  // Learning — inputs
607
610
  const learningPolars = filterById(state.polarsAll, ['groundSpeed.smoothed']);