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 +12 -0
- package/LICENSE +21 -0
- package/README.md +114 -2
- package/index.js +368 -0
- package/lib/defaults.js +47 -0
- package/lib/processor.js +287 -0
- package/package.json +44 -4
- package/public/app.js +328 -0
- package/public/icon.svg +15 -0
- package/public/index.html +86 -0
- package/public/style.css +208 -0
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
|
-
|
|
1
|
+
<img src="public/icon.svg" width="72" align="right" alt="">
|
|
2
2
|
|
|
3
|
-
|
|
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 }
|
package/lib/defaults.js
ADDED
|
@@ -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 }
|