signalk-performance-processor 0.0.0-stage → 0.0.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 ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.0.1 — 2026-10-29
4
+
5
+ First release, a proof of concept. Run it in Shadow mode alongside your instruments before relying on it.
6
+
7
+ - Corrections, each with an on/off switch: boat speed (linearity by speed plus heel correction), leeway (K × heel ÷ STW²), and H5000-style TWA and TWS tables with upwind, reach and downwind rows by TWS.
8
+ - True wind from apparent wind minus boat velocity including leeway; TWD from heading and variation; current from SOG/COG.
9
+ - Shadow mode publishes `…Corrected` paths; Replace mode publishes the standard paths under the plugin's own source for Source Priorities.
10
+ - Webapp with Live, Calibration and Settings pages. Configuration is saved as the plugin's options and validated before it is applied.
11
+ - Ships with this boat's defaults: the owner's STW table and leeway K, and TWA/TWS tables fitted to the 2025 race logs.
12
+ - Loop guard, input plausibility checks, a choice of source per input, and a test suite.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ghotihook
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,115 @@
1
- # Temporary Holding Version
1
+ <img src="public/icon.svg" width="72" align="right" alt="">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # signalk-performance-processor
4
+
5
+ > **Proof of concept (0.0.1).** Run it in Shadow mode alongside your instruments and compare before relying on it.
6
+
7
+ Signal K plugin and webapp that does the calibration job of an instrument processor such as a B&G H5000: it corrects boat speed and leeway, works out true wind from apparent wind, applies H5000-style TWA and TWS correction tables, and publishes true wind and current.
8
+
9
+ Each correction can be switched on or off and edited in the webapp. A fresh install starts with values fitted to this boat's 2025 race logs.
10
+
11
+ See the [changelog](CHANGELOG.md) for what changed in each release.
12
+
13
+ ## What it does
14
+
15
+ The corrections run in this order, each on the output of the one before:
16
+
17
+ | Step | What | Default |
18
+ |---|---|---|
19
+ | Boat speed (STW) | Knots added to the paddlewheel: a **linearity** row by speed plus a **heel correction** row by heel, the same split as the H5000 | On |
20
+ | Leeway | Leeway = K × heel ÷ STW², turning the boat's velocity to leeward | On, K = 7.9446 |
21
+ | True wind | Apparent wind minus the boat's velocity through the water | Always |
22
+ | TWA table | Degrees added to \|TWA\| by TWS, with upwind, reach and downwind rows blended by TWA (70° / 100° / 130°) | On |
23
+ | TWS table | Percent applied to TWS by TWS, same rows and blending | Off |
24
+ | TWD, current | TWD from heading and corrected TWA; current from SOG/COG minus water velocity | Always, when heading and GPS are available |
25
+
26
+ ### Inputs
27
+
28
+ | Input | Signal K path |
29
+ |---|---|
30
+ | Apparent wind | `environment.wind.angleApparent`, `environment.wind.speedApparent` |
31
+ | Speed through water | `navigation.speedThroughWater` |
32
+ | Heading | `navigation.headingTrue`, or `navigation.headingMagnetic` + the variation in Settings |
33
+ | Heel | `navigation.attitude` (roll) |
34
+ | SOG and COG | `navigation.speedOverGround`, `navigation.courseOverGroundTrue` |
35
+
36
+ True wind is worked out on every apparent wind update. Inputs older than 3 seconds are not used. Without STW there is no true wind. Without heel, the heel correction and leeway are skipped. Without heading, there is no TWD or current.
37
+
38
+ Feed it **measured** apparent wind and **raw** paddlewheel speed. If another plugin already corrects STW (for example `signalk-stw-heel-correction`), choose the instrument as the STW source in Settings, or the correction is applied twice.
39
+
40
+ ### Outputs
41
+
42
+ | Output | Replace mode | Shadow mode |
43
+ |---|---|---|
44
+ | True wind angle | `environment.wind.angleTrueWater` | `environment.wind.angleTrueWaterCorrected` |
45
+ | True wind speed | `environment.wind.speedTrue` | `environment.wind.speedTrueCorrected` |
46
+ | True wind direction | `environment.wind.directionTrue` | `environment.wind.directionTrueCorrected` |
47
+ | Speed through water (when on) | `navigation.speedThroughWater` | `navigation.speedThroughWaterCorrected` |
48
+ | Leeway (when on) | `navigation.leewayAngle` | `navigation.leewayAngleCorrected` |
49
+ | Current | `environment.current` (`drift`, `setTrue`) | `environment.currentCorrected` |
50
+
51
+ Everything is published under this plugin's own `$source`. Corrected STW goes out each time the paddlewheel reports, while the STW correction is on; the rest goes out with each apparent wind update. Shadow paths carry `meta` with units, since they are not in the Signal K schema.
52
+
53
+ ## Modes
54
+
55
+ - **Shadow** (default) publishes to separate `…Corrected` paths. Your instruments and displays carry on unchanged, and you can log both side by side.
56
+ - **Replace** publishes on the standard paths. Then:
57
+ 1. In **Server → Source Priorities**, rank `signalk-performance-processor` above the instrument for each output path.
58
+ 2. Zero the instrument's own TWA/TWS correction tables and leeway, so nothing is corrected twice. Keep the masthead unit alignment and the boat speed calibration factor in the instrument.
59
+
60
+ ## The webapp
61
+
62
+ Open **Webapps → Performance Processor**.
63
+
64
+ - **Live**: the outputs next to their uncorrected values, what each step is doing right now, and which source and how fresh each input is.
65
+ - **Calibration**: every correction in pipeline order with an on/off switch. Select one to edit its values, then **Save**. **Reset to defaults** puts back this boat's fitted values for that correction.
66
+ - **Settings**: Shadow or Replace, magnetic variation, and which source to read for each input.
67
+
68
+ Saving needs write access when Signal K security is on. The settings are stored as the plugin's normal configuration, so they are also visible (and editable as a fallback) under **Server → Plugin Config**.
69
+
70
+ ## Defaults for this boat
71
+
72
+ Fitted to the 2025 race logs and checked on Jan–Mar 2026 races not used in fitting, by how little true wind direction and current jump through tacks, gybes and roundings.
73
+
74
+ - **STW:** linearity +0.58 kn at 0.5 kn falling to 0 at 8.5 kn (tapering to 0 below 0.5 kn); heel 0 to −0.29 kn at 30°. This is the owner's measured heel × STW table, reduced to two rows within 0.03 kn of every cell.
75
+ - **Leeway:** K = 7.9446, one value for every sail.
76
+ - **TWA table (°):**
77
+
78
+ | TWS (kn) | 5 | 10 | 15 | 20 | 25 | 30 |
79
+ |---|---|---|---|---|---|---|
80
+ | Upwind | −12.0 | −13.4 | −5.7 | −4.3 | −4.3 | −4.3 |
81
+ | Reach | −10.7 | −10.7 | −10.7 | −10.7 | −10.7 | −10.7 |
82
+ | Downwind | −4.2 | −3.5 | −1.6 | −1.3 | −1.3 | −1.3 |
83
+
84
+ The reach row is provisional: it rests on roundings only.
85
+ - **TWS table (%):** off. Downwind −11.1% at 15 kn and −14.4% from 20 kn; light air and upwind left at 0.
86
+ - **Variation:** 12.8° E.
87
+
88
+ ## Adding a correction
89
+
90
+ Corrections live in `lib/processor.js` as entries in `CORRECTIONS`, each with a stage (`input` before true wind, `trueWind` after) and an `apply` function. Their defaults go in `lib/defaults.js`. The pipeline, the Live view and the enable switch pick a new entry up from there; the Calibration page needs an editor for its values in `public/app.js`.
91
+
92
+ ## API
93
+
94
+ Under `/plugins/signalk-performance-processor`:
95
+
96
+ - `GET /config` returns the configuration in use and the defaults.
97
+ - `PUT /config` validates, saves and restarts with a new configuration. It returns 400 with the reasons if the configuration is invalid.
98
+ - `GET /live` returns the latest result, each step, input ages and the sources seen.
99
+
100
+ ## Development
101
+
102
+ ```bash
103
+ npm test # node --test
104
+ ```
105
+
106
+ To run it from a working copy on a Signal K server:
107
+
108
+ ```bash
109
+ cd ~/.signalk && npm install /path/to/signalk-performance-processor
110
+ sudo systemctl restart signalk
111
+ ```
112
+
113
+ ## Licence
114
+
115
+ MIT
package/index.js ADDED
@@ -0,0 +1,368 @@
1
+ 'use strict'
2
+
3
+ const { DEFAULTS } = require('./lib/defaults')
4
+ const { process, correctInputs, normalizeConfig, CORRECTIONS } = require('./lib/processor')
5
+
6
+ const MS_TO_KN = 1.94384
7
+ const RAD_TO_DEG = 180 / Math.PI
8
+ const STALE_MS = 3000 // an input older than this is not used
9
+ const STATUS_INTERVAL_MS = 1000
10
+ // Bounds for sanity-checking sensor input. Anything outside these is a fault, not a reading.
11
+ const MAX_PLAUSIBLE_STW_KN = 60
12
+ const MAX_PLAUSIBLE_AWS_KN = 120
13
+ const MAX_PLAUSIBLE_HEEL_DEG = 90
14
+ // See signalk-stw-heel-correction: below this a correction is float noise, and recording
15
+ // it as an output would make the next identical raw sample look like an echo.
16
+ const ECHO_MIN_DELTA_MS = 1e-6
17
+
18
+ // Inputs, grouped the way they are chosen in Settings.
19
+ const INPUTS = {
20
+ apparentWind: ['environment.wind.angleApparent', 'environment.wind.speedApparent'],
21
+ stw: ['navigation.speedThroughWater'],
22
+ heading: ['navigation.headingMagnetic', 'navigation.headingTrue'],
23
+ attitude: ['navigation.attitude'],
24
+ gps: ['navigation.speedOverGround', 'navigation.courseOverGroundTrue']
25
+ }
26
+ const INPUT_OF = Object.fromEntries(Object.entries(INPUTS).flatMap(([k, ps]) => ps.map(p => [p, k])))
27
+
28
+ // Outputs on their standard paths. Shadow mode appends SHADOW_SUFFIX so nothing
29
+ // already on the boat is displaced while the numbers are being checked.
30
+ const OUTPUTS = {
31
+ twa: { path: 'environment.wind.angleTrueWater', units: 'rad', description: 'True wind angle, corrected' },
32
+ tws: { path: 'environment.wind.speedTrue', units: 'm/s', description: 'True wind speed, corrected' },
33
+ twd: { path: 'environment.wind.directionTrue', units: 'rad', description: 'True wind direction, corrected' },
34
+ stw: { path: 'navigation.speedThroughWater', units: 'm/s', description: 'Speed through water, corrected' },
35
+ leeway: { path: 'navigation.leewayAngle', units: 'rad', description: 'Leeway angle, + to starboard' },
36
+ current: { path: 'environment.current', description: 'Current: drift (m/s) and setTrue (rad)' }
37
+ }
38
+ const SHADOW_SUFFIX = 'Corrected'
39
+
40
+ module.exports = function (app) {
41
+ const plugin = {
42
+ id: 'signalk-performance-processor',
43
+ name: 'gh - Performance Processor',
44
+ description: 'H5000-style calibration: corrects STW and leeway, then true wind angle and speed, and publishes true wind and current'
45
+ }
46
+
47
+ let config = null
48
+ let unsubscribes = []
49
+ let retryTimer = null
50
+ let running = false
51
+
52
+ // Latest value per input path, and every source seen on each input group.
53
+ let latest = {}
54
+ let sourcesSeen = {}
55
+ let lastResult = null
56
+ let lastResultAt = 0
57
+ let lastStatus = ''
58
+
59
+ plugin.schema = schemaFor(DEFAULTS)
60
+
61
+ function outPath (key) {
62
+ return config.mode === 'shadow' ? OUTPUTS[key].path + SHADOW_SUFFIX : OUTPUTS[key].path
63
+ }
64
+
65
+ // Loop guard. The server stamps our deltas with $source = plugin.id; tolerate a
66
+ // suffix, as signalk-stw-heel-correction learned to.
67
+ function isOwnSource (src) {
68
+ if (!src) return false
69
+ const s = String(src)
70
+ return s === plugin.id || s.startsWith(plugin.id + '.')
71
+ }
72
+
73
+ // Backstop echo detector for the one path that is both input and output (STW in
74
+ // replace mode): remember published values that differ from their input.
75
+ const recentStw = []
76
+ let loopWarned = false
77
+ function noteStw (ms) {
78
+ recentStw.push(ms)
79
+ if (recentStw.length > 32) recentStw.shift()
80
+ }
81
+ const isStwEcho = ms => recentStw.some(o => Math.abs(o - ms) < 1e-12)
82
+
83
+ function fresh (path, now) {
84
+ const l = latest[path]
85
+ return l && now - l.at < STALE_MS ? l.value : undefined
86
+ }
87
+
88
+ // Refresh on a change of state, and at most once a second within a state so the
89
+ // live numbers in the status keep moving without flooding the admin UI.
90
+ let statusAt = 0
91
+ let statusKind = null
92
+ function status (kind, text) {
93
+ const now = Date.now()
94
+ if (kind === statusKind && now - statusAt < STATUS_INTERVAL_MS) return
95
+ statusKind = kind
96
+ lastStatus = text
97
+ statusAt = now
98
+ app.setPluginStatus(text)
99
+ }
100
+
101
+ function heelDeg (now) {
102
+ const att = fresh('navigation.attitude', now)
103
+ const roll = att && typeof att === 'object' ? att.roll : null
104
+ if (roll == null || !Number.isFinite(roll)) return null
105
+ const h = roll * RAD_TO_DEG
106
+ return Math.abs(h) > MAX_PLAUSIBLE_HEEL_DEG ? null : h
107
+ }
108
+
109
+ function publish (values, timestamp) {
110
+ if (!values.length) return
111
+ // No source object: the server stamps $source with plugin.id, which is what the
112
+ // loop guard and the user's Source Priorities entry match on.
113
+ app.handleMessage(plugin.id, { updates: [{ timestamp, values }] })
114
+ }
115
+
116
+ // Corrected STW goes out as soon as the paddlewheel reports, at its own rate.
117
+ function onStw (rawMs, timestamp, now) {
118
+ const stw = config.corrections.stw
119
+ if (!stw.enabled) return
120
+ const stwKn = rawMs * MS_TO_KN
121
+ // A sensor fault is not a reading; republishing it under this plugin's source
122
+ // would put the source the user ranked first behind a bad number.
123
+ if (stwKn < 0 || stwKn > MAX_PLAUSIBLE_STW_KN) return
124
+ const { c } = correctInputs({ stw: stwKn, heel: heelDeg(now) }, config)
125
+ const ms = c.stw / MS_TO_KN
126
+ if (config.mode === 'replace' && Math.abs(ms - rawMs) > ECHO_MIN_DELTA_MS) noteStw(ms)
127
+ publish([{ path: outPath('stw'), value: ms }], timestamp)
128
+ }
129
+
130
+ // True wind, current and leeway go out on every apparent wind update.
131
+ function onWind (timestamp, now) {
132
+ const awa = fresh('environment.wind.angleApparent', now)
133
+ const aws = fresh('environment.wind.speedApparent', now)
134
+ const stw = fresh('navigation.speedThroughWater', now)
135
+ if (awa == null || aws == null) return status('nowind', 'Waiting for apparent wind')
136
+ if (stw == null) return status('nostw', 'Waiting for speed through water — no true wind without it')
137
+
138
+ const awsKn = aws * MS_TO_KN
139
+ const stwKn = stw * MS_TO_KN
140
+ if (awsKn < 0 || awsKn > MAX_PLAUSIBLE_AWS_KN) return status('badaws', `Not publishing — AWS of ${awsKn.toFixed(1)} kn is not plausible`)
141
+ if (stwKn < 0 || stwKn > MAX_PLAUSIBLE_STW_KN) return status('badstw', `Not publishing — STW of ${stwKn.toFixed(1)} kn is not plausible`)
142
+
143
+ const deg = v => v == null ? undefined : v * RAD_TO_DEG
144
+ const input = {
145
+ awa: deg(Math.atan2(Math.sin(awa), Math.cos(awa))),
146
+ aws: awsKn,
147
+ stw: stwKn,
148
+ heel: heelDeg(now),
149
+ headingMagnetic: deg(fresh('navigation.headingMagnetic', now)),
150
+ headingTrue: deg(fresh('navigation.headingTrue', now)),
151
+ sog: (v => v == null ? undefined : v * MS_TO_KN)(fresh('navigation.speedOverGround', now)),
152
+ cog: deg(fresh('navigation.courseOverGroundTrue', now))
153
+ }
154
+ const result = process(input, config)
155
+ lastResult = { input, ...result }
156
+ lastResultAt = now
157
+
158
+ const o = result.out
159
+ const values = [
160
+ { path: outPath('twa'), value: o.twa / RAD_TO_DEG },
161
+ { path: outPath('tws'), value: o.tws / MS_TO_KN }
162
+ ]
163
+ if (o.twd != null) values.push({ path: outPath('twd'), value: o.twd / RAD_TO_DEG })
164
+ if (config.corrections.leeway.enabled) values.push({ path: outPath('leeway'), value: o.leeway / RAD_TO_DEG })
165
+ if (o.current) values.push({ path: outPath('current'), value: { drift: o.current.drift / MS_TO_KN, setTrue: o.current.set / RAD_TO_DEG } })
166
+ publish(values, timestamp)
167
+
168
+ const missing = []
169
+ if (input.heel == null) missing.push('heel')
170
+ if (o.twd == null) missing.push('heading')
171
+ status('running', `${config.mode === 'shadow' ? 'Shadow' : 'Live'} — TWA ${Math.abs(o.twa).toFixed(1)}°, TWS ${o.tws.toFixed(1)} kn, STW ${o.stw.toFixed(2)} kn` +
172
+ (missing.length ? ` · no ${missing.join(' or ')}` : ''))
173
+ }
174
+
175
+ function onDelta (delta) {
176
+ const now = Date.now()
177
+ let windChanged = false
178
+ let windTimestamp = null
179
+ for (const update of (delta.updates || [])) {
180
+ if (isOwnSource(update.$source)) continue
181
+ for (const v of (update.values || [])) {
182
+ const group = INPUT_OF[v.path]
183
+ if (!group || v.value == null) continue
184
+ if (v.path !== 'navigation.attitude' && !Number.isFinite(v.value)) continue
185
+
186
+ const src = String(update.$source || '')
187
+ ;(sourcesSeen[group] = sourcesSeen[group] || new Set()).add(src)
188
+ const wanted = config.sources[group]
189
+ if (wanted && src !== wanted) continue
190
+
191
+ if (v.path === 'navigation.speedThroughWater' && config.mode === 'replace' && isStwEcho(v.value)) {
192
+ if (!loopWarned) {
193
+ loopWarned = true
194
+ app.error(`Loop guard mismatch: received STW this plugin published, but its $source was "${src}" rather than "${plugin.id}". Skipping it — set the STW source in Settings.`)
195
+ }
196
+ continue
197
+ }
198
+
199
+ latest[v.path] = { value: v.value, source: src, at: now }
200
+ if (group === 'apparentWind') { windChanged = true; windTimestamp = update.timestamp }
201
+ if (group === 'stw') onStw(v.value, update.timestamp, now)
202
+ }
203
+ }
204
+ if (windChanged) onWind(windTimestamp, now)
205
+ }
206
+
207
+ function startWith (cfg) {
208
+ stopEverything()
209
+ config = cfg
210
+ latest = {}
211
+ sourcesSeen = {}
212
+ lastResult = null
213
+ running = true
214
+
215
+ if (config.mode === 'shadow') {
216
+ // The shadow paths are not in the Signal K schema; give consumers their units.
217
+ app.handleMessage(plugin.id, {
218
+ updates: [{
219
+ meta: Object.keys(OUTPUTS).map(k => ({
220
+ path: OUTPUTS[k].path + SHADOW_SUFFIX,
221
+ value: { description: OUTPUTS[k].description, ...(OUTPUTS[k].units ? { units: OUTPUTS[k].units } : {}) }
222
+ }))
223
+ }]
224
+ })
225
+ }
226
+
227
+ // sourcePolicy 'all': every source at full rate, chosen here rather than by
228
+ // priorities, which this plugin's own output may outrank in replace mode.
229
+ const subscription = {
230
+ context: 'vessels.self',
231
+ sourcePolicy: 'all',
232
+ subscribe: Object.keys(INPUT_OF).map(path => ({ path }))
233
+ }
234
+
235
+ function trySubscribe (attempt) {
236
+ retryTimer = null
237
+ if (!running) return
238
+ const sm = app.subscriptionmanager
239
+ if (sm && typeof sm.subscribe === 'function') {
240
+ try {
241
+ sm.subscribe(subscription, unsubscribes, err => app.setPluginError(String(err)), onDelta)
242
+ status('waiting', 'Waiting for apparent wind')
243
+ return
244
+ } catch (e) {
245
+ app.error(`subscribe failed: ${e.message}`)
246
+ }
247
+ }
248
+ if (attempt >= 20) {
249
+ app.setPluginError('Could not subscribe — subscriptionmanager unavailable')
250
+ return
251
+ }
252
+ retryTimer = setTimeout(() => trySubscribe(attempt + 1), 500)
253
+ }
254
+
255
+ const on = CORRECTIONS.filter(c => config.corrections[c.id] && config.corrections[c.id].enabled).map(c => c.id)
256
+ app.debug(`started in ${config.mode} mode; corrections on: ${on.join(', ') || 'none'}`)
257
+ status('starting', `Starting — ${config.mode} mode`)
258
+ trySubscribe(0)
259
+ }
260
+
261
+ function stopEverything () {
262
+ running = false
263
+ if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
264
+ unsubscribes.forEach(f => f())
265
+ unsubscribes = []
266
+ }
267
+
268
+ plugin.start = function (options) {
269
+ const { config: cfg, errors } = normalizeConfig(options || {})
270
+ if (errors.length) {
271
+ app.setPluginError(`Configuration: ${errors.join('; ')}`)
272
+ return
273
+ }
274
+ startWith(cfg)
275
+ }
276
+
277
+ plugin.stop = function () {
278
+ stopEverything()
279
+ app.setPluginStatus('Stopped')
280
+ }
281
+
282
+ // ---- API for the webapp -------------------------------------------------
283
+
284
+ function liveSnapshot () {
285
+ const now = Date.now()
286
+ const inputs = {}
287
+ for (const [path, l] of Object.entries(latest)) inputs[path] = { source: l.source, ageMs: now - l.at }
288
+ return {
289
+ running,
290
+ status: lastStatus,
291
+ mode: config ? config.mode : null,
292
+ ageMs: lastResult ? now - lastResultAt : null,
293
+ result: lastResult,
294
+ inputs,
295
+ sourcesSeen: Object.fromEntries(Object.entries(sourcesSeen).map(([k, s]) => [k, Array.from(s).sort()])),
296
+ outputPaths: config ? Object.fromEntries(Object.keys(OUTPUTS).map(k => [k, outPath(k)])) : {}
297
+ }
298
+ }
299
+
300
+ function readBody (req) {
301
+ if (req.body && typeof req.body === 'object') return Promise.resolve(req.body)
302
+ return new Promise((resolve, reject) => {
303
+ let s = ''
304
+ req.on('data', d => { s += d })
305
+ req.on('end', () => { try { resolve(s ? JSON.parse(s) : {}) } catch (e) { reject(e) } })
306
+ req.on('error', reject)
307
+ })
308
+ }
309
+
310
+ plugin.registerWithRouter = function (router) {
311
+ router.get('/config', (req, res) => {
312
+ res.set('Cache-Control', 'no-store')
313
+ let stored = {}
314
+ try { stored = app.readPluginOptions().configuration || {} } catch (e) {}
315
+ res.json({ config: config || normalizeConfig(stored).config, defaults: DEFAULTS })
316
+ })
317
+
318
+ router.put('/config', async (req, res) => {
319
+ let body
320
+ try { body = await readBody(req) } catch (e) { return res.status(400).json({ errors: ['body is not valid JSON'] }) }
321
+ const { config: cfg, errors } = normalizeConfig(body)
322
+ if (errors.length) return res.status(400).json({ errors })
323
+ app.savePluginOptions(cfg, err => {
324
+ if (err) return res.status(500).json({ errors: [`could not save: ${err.message || err}`] })
325
+ startWith(cfg)
326
+ res.json({ config: cfg })
327
+ })
328
+ })
329
+
330
+ router.get('/live', (req, res) => {
331
+ res.set('Cache-Control', 'no-store')
332
+ res.json(liveSnapshot())
333
+ })
334
+ }
335
+
336
+ return plugin
337
+ }
338
+
339
+ // JSON schema for the admin "Plugin Config" form, built from the defaults so the form
340
+ // is a working fallback editor and a fresh install starts from this boat's values.
341
+ function schemaFor (defaults) {
342
+ const titles = {
343
+ mode: 'Output: shadow publishes …Corrected paths alongside the instruments; replace publishes the standard paths (rank this plugin first in Source Priorities)',
344
+ variation: 'Magnetic variation (degrees, east positive)',
345
+ sources: 'Input sources ($source to read; blank = any source except this plugin)',
346
+ corrections: 'Corrections (easier to edit in the Performance Processor webapp)'
347
+ }
348
+ function node (value, title) {
349
+ if (Array.isArray(value)) return { type: 'array', title, items: { type: 'number' }, default: value }
350
+ if (value && typeof value === 'object') {
351
+ return {
352
+ type: 'object',
353
+ title,
354
+ properties: Object.fromEntries(Object.entries(value).map(([k, v]) => [k, node(v, k)]))
355
+ }
356
+ }
357
+ if (typeof value === 'boolean') return { type: 'boolean', title, default: value }
358
+ if (typeof value === 'number') return { type: 'number', title, default: value }
359
+ return { type: 'string', title, default: value }
360
+ }
361
+ const schema = node(defaults)
362
+ for (const [k, t] of Object.entries(titles)) schema.properties[k].title = t
363
+ schema.properties.mode.enum = ['shadow', 'replace']
364
+ return schema
365
+ }
366
+
367
+ // Exposed for the test suite only; not part of the Signal K plugin API.
368
+ module.exports._internals = { schemaFor, OUTPUTS, INPUTS, SHADOW_SUFFIX }
@@ -0,0 +1,47 @@
1
+ 'use strict'
2
+
3
+ // Defaults for this boat, fitted to the 2025 races and checked against Jan–Mar 2026
4
+ // (see the README). Every value can be changed in the webapp; these are what a fresh
5
+ // install starts from.
6
+
7
+ const TWS_BINS = [5, 10, 15, 20, 25, 30]
8
+
9
+ const DEFAULTS = {
10
+ mode: 'shadow', // 'shadow' publishes …Corrected paths; 'replace' publishes the standard paths
11
+ variation: 12.8, // degrees, east positive; Sydney
12
+ sources: { apparentWind: '', stw: '', heading: '', attitude: '', gps: '' }, // '' = any but this plugin
13
+ corrections: {
14
+ // Knots added to STW: linearity by speed, plus heel correction by |heel|.
15
+ // The 185-cell heel x STW table reduced to two rows (every cell within 0.03 kn).
16
+ stw: {
17
+ enabled: true,
18
+ linearity: { stw: [0.5, 1, 2, 3, 4, 6, 8.5], add: [0.58, 0.61, 0.38, 0.19, 0.09, 0.02, 0.00] },
19
+ heel: { heel: [0, 10, 20, 30, 35], add: [0.00, -0.03, -0.13, -0.29, -0.29] }
20
+ },
21
+ // Leeway (degrees) = K x heel / STW^2, one K for every sail: downwind heel is
22
+ // small, so leeway there is near zero whatever K is.
23
+ leeway: { enabled: true, k: 7.9446 },
24
+ // Degrees added to |TWA| by TWS; negative = TWA was reading too wide.
25
+ // Rows are blended by TWA: upwind in full to angles.upwind, reach at angles.reach,
26
+ // downwind from angles.downwind. The TWS table shares these angles.
27
+ twa: {
28
+ enabled: true,
29
+ tws: TWS_BINS.slice(),
30
+ upwind: [-12.0, -13.4, -5.7, -4.3, -4.3, -4.3],
31
+ reach: [-10.7, -10.7, -10.7, -10.7, -10.7, -10.7],
32
+ downwind: [-4.2, -3.5, -1.6, -1.3, -1.3, -1.3],
33
+ angles: { upwind: 70, reach: 100, downwind: 130 }
34
+ },
35
+ // Percent applied to TWS by TWS, same rows and blending as the TWA table.
36
+ // Off by default: light air fitted to implausible values, so it is zeroed here.
37
+ tws: {
38
+ enabled: false,
39
+ tws: TWS_BINS.slice(),
40
+ upwind: [0, 0, 0, 0, 0, 0],
41
+ reach: [0, 0, 0, 0, 0, 0],
42
+ downwind: [0, 0, -11.1, -14.4, -14.4, -14.4]
43
+ }
44
+ }
45
+ }
46
+
47
+ module.exports = { DEFAULTS }