signalk-performance-processor 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.3 — 2026-10-29
4
+
5
+ Still a proof of concept: run it in Shadow mode alongside your instruments.
6
+
7
+ - Fix: the Calibration and Settings pages were empty on a real server. The plugin's `/config` endpoint was shadowed by the server's own route of that name; it is now `/calibration`, and the webapp says so if the calibration cannot be loaded.
8
+ - README and App Store description explain the problem this solves, who it is for, and the results on the boat it was built for.
9
+
10
+ ## 0.0.2 — 2026-10-29
11
+
12
+ Still a proof of concept: run it in Shadow mode alongside your instruments.
13
+
14
+ - Heading for TWD and current comes from `navigation.headingTrue` if present, otherwise magnetic heading plus `navigation.magneticVariation` (normally from the GPS). The variation in Settings is now only a fallback, and the Live page says which is in use.
15
+ - Configuration is edited only in the webapp and saved to `config.json` in the plugin's data directory. The admin Plugin Config form no longer shows the tables, so saving it cannot overwrite them. A configuration saved by 0.0.1 is picked up until the webapp first saves.
16
+ - The configuration file records its format version (`"schema": 1`). Future releases upgrade older files as they load; a file from a newer release is refused and never overwritten.
17
+
3
18
  ## 0.0.1 — 2026-10-29
4
19
 
5
20
  First release, a proof of concept. Run it in Shadow mode alongside your instruments before relying on it.
package/README.md CHANGED
@@ -2,13 +2,51 @@
2
2
 
3
3
  # signalk-performance-processor
4
4
 
5
- > **Proof of concept (0.0.1).** Run it in Shadow mode alongside your instruments and compare before relying on it.
5
+ **True wind direction that holds steady when you tack.**
6
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.
7
+ If your TWD swings 5–10° every time you tack or gybe, the wind hasn't shifted: your true wind calibration is off. It makes real shifts hard to call, puts laylines in the wrong place, and feeds bad numbers into your polars and targets.
8
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.
9
+ This plugin does the calibration job of a B&G H5000-class instrument processor inside Signal K. It corrects boat speed and leeway, then applies H5000-style TWA and TWS correction tables, and publishes corrected true wind and current. You edit everything in a webapp and can watch each correction work live. Run it in **Shadow mode** first: it publishes alongside your existing instruments without changing anything they do.
10
10
 
11
- See the [changelog](CHANGELOG.md) for what changed in each release.
11
+ > **Proof of concept (0.0.3).** Compare it against your instruments in Shadow mode before relying on it. See the [changelog](CHANGELOG.md).
12
+
13
+ ## Why it exists
14
+
15
+ True wind is worked out from apparent wind, boat speed and heading, so it inherits every error in them. The masthead sensor reads a bent airflow upwind, the paddlewheel misreads when heeled, and the boat slides sideways. None of these is the same on every point of sail, and some flip from one tack to the other. That is why an uncalibrated TWD jumps through every tack.
16
+
17
+ Instrument processors fix this with calibration tables, but:
18
+
19
+ - **The tables are locked in the box.** You edit them through display menus, cannot see what each one is doing at any moment, and keep no history of what you changed or why.
20
+ - **Older and simpler systems have less to work with.** An H2000, for example, has a TWS correction that only applies downwind, and many systems have no tables at all.
21
+ - **Formula-based corrections don't fit every boat.** A single upwash formula cannot follow a boat whose correction is twice as big in light air as in a breeze at the same apparent wind angle. That was the case on the boat this was built for; a table can follow it.
22
+
23
+ This plugin moves the calibration into Signal K, where you can see it, change it, log it and fit it from your own race data:
24
+
25
+ - **The same shape as an H5000:** boat speed linearity and heel correction, leeway, and TWA/TWS tables by wind speed with upwind, reach and downwind rows. An existing calibration carries across, and the H5000 tuning rule ("TWD lifts on every tack: take half the error off TWA") works unchanged.
26
+ - **Every correction has a switch and a live readout,** so you can see what each one does to the numbers right now.
27
+ - **The calibration is a versioned JSON file,** easy to back up, compare and fit from logged races offline.
28
+ - **It is safe to try.** Shadow mode publishes `…Corrected` paths beside your instruments. Replace mode takes over the standard paths only when you choose it.
29
+
30
+ ### On the boat it was built for
31
+
32
+ From the offline model the defaults were fitted with: trained on 2025 race logs, tested on Jan–Mar 2026 races it had not seen, and measured by how far true wind direction jumps through each manoeuvre (median). The shipped defaults differ slightly (the owner's leeway K, and the TWA table resampled to H5000 breakpoints).
33
+
34
+ | | Uncorrected | Instrument (H2000) | This plugin |
35
+ |---|---|---|---|
36
+ | Tacks (41) | 6.2° | 2.5° | 2.2° |
37
+ | Gybes (5) | 2.4° | 4.7° | 1.1° |
38
+ | Roundings (38) | 9.3° | 9.7° | 7.3° |
39
+ | Current step through manoeuvres | 0.65 kn | 0.65 kn | 0.37 kn |
40
+
41
+ These figures are one boat's, and the defaults are that boat's. On a different boat, start from the defaults in Shadow mode and refit. After the boat's own rig and log changes in late 2026, the defaults needed refitting too.
42
+
43
+ ## Who it's for
44
+
45
+ - **Racing sailors running Signal K** who want TWD they can call shifts on, and polars built on corrected wind.
46
+ - **Boats with an older or limited instrument processor,** or none, that still have apparent wind, a paddlewheel, heading and heel on the network.
47
+ - **Anyone who wants to see and log** what their wind calibration is doing, rather than trust a box.
48
+
49
+ You need apparent wind, boat speed through the water and heading in Signal K. Heel (`navigation.attitude`) enables the heel and leeway corrections; SOG and COG enable current.
12
50
 
13
51
  ## What it does
14
52
 
@@ -29,7 +67,7 @@ The corrections run in this order, each on the output of the one before:
29
67
  |---|---|
30
68
  | Apparent wind | `environment.wind.angleApparent`, `environment.wind.speedApparent` |
31
69
  | Speed through water | `navigation.speedThroughWater` |
32
- | Heading | `navigation.headingTrue`, or `navigation.headingMagnetic` + the variation in Settings |
70
+ | Heading | `navigation.headingTrue`; otherwise `navigation.headingMagnetic` + `navigation.magneticVariation` (normally from the GPS); otherwise magnetic heading + the fallback variation in Settings |
33
71
  | Heel | `navigation.attitude` (roll) |
34
72
  | SOG and COG | `navigation.speedOverGround`, `navigation.courseOverGroundTrue` |
35
73
 
@@ -63,9 +101,9 @@ Open **Webapps → Performance Processor**.
63
101
 
64
102
  - **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
103
  - **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.
104
+ - **Settings**: Shadow or Replace, the fallback magnetic variation, and which source to read for each input.
67
105
 
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**.
106
+ All configuration lives in the webapp. It is saved to `config.json` in the plugin's data directory (under `~/.signalk/plugin-config-data/`), not in the admin **Plugin Config** form, which only points here; saving that form cannot overwrite the tables. Saving needs write access when Signal K security is on.
69
107
 
70
108
  ## Defaults for this boat
71
109
 
@@ -83,7 +121,23 @@ Fitted to the 2025 race logs and checked on Jan–Mar 2026 races not used in fit
83
121
 
84
122
  The reach row is provisional: it rests on roundings only.
85
123
  - **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.
124
+ - **Fallback variation:** 12.8° E, used only when nothing on the boat sends true heading or magnetic variation. The Live page shows which applies.
125
+
126
+ ## Configuration file
127
+
128
+ `config.json` in the plugin's data directory, written by the webapp:
129
+
130
+ ```json
131
+ {
132
+ "schema": 1,
133
+ "mode": "shadow",
134
+ "variation": 12.8,
135
+ "sources": { "apparentWind": "", "stw": "", "heading": "", "attitude": "", "gps": "", "variation": "" },
136
+ "corrections": { "stw": { … }, "leeway": { … }, "twa": { … }, "tws": { … } }
137
+ }
138
+ ```
139
+
140
+ `schema` is the version of the file's shape. A release that changes the shape bumps `CONFIG_SCHEMA` in `lib/processor.js` and adds a migration from the previous version, so older files are upgraded as they load and nothing is reset. A file without `schema` (saved by 0.0.1) is read as schema 1. A file from a newer release is refused rather than guessed at, and is never overwritten by an older plugin.
87
141
 
88
142
  ## Adding a correction
89
143
 
@@ -93,8 +147,8 @@ Corrections live in `lib/processor.js` as entries in `CORRECTIONS`, each with a
93
147
 
94
148
  Under `/plugins/signalk-performance-processor`:
95
149
 
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.
150
+ - `GET /calibration` returns the configuration in use and the defaults.
151
+ - `PUT /calibration` validates, saves and restarts with a new configuration. It returns 400 with the reasons if the configuration is invalid.
98
152
  - `GET /live` returns the latest result, each step, input ages and the sources seen.
99
153
 
100
154
  ## Development
package/index.js CHANGED
@@ -1,11 +1,17 @@
1
1
  'use strict'
2
2
 
3
+ const fs = require('fs')
4
+ const path = require('path')
3
5
  const { DEFAULTS } = require('./lib/defaults')
4
- const { process, correctInputs, normalizeConfig, CORRECTIONS } = require('./lib/processor')
6
+ const { process, correctInputs, normalizeConfig, CORRECTIONS, CONFIG_SCHEMA } = require('./lib/processor')
5
7
 
6
8
  const MS_TO_KN = 1.94384
7
9
  const RAD_TO_DEG = 180 / Math.PI
8
10
  const STALE_MS = 3000 // an input older than this is not used
11
+ // Variation changes over days, not seconds; a GPS that drops it briefly should not
12
+ // push heading onto the fallback value.
13
+ const VARIATION_MAX_AGE_MS = 10 * 60 * 1000
14
+ const CONFIG_FILE = 'config.json'
9
15
  const STATUS_INTERVAL_MS = 1000
10
16
  // Bounds for sanity-checking sensor input. Anything outside these is a fault, not a reading.
11
17
  const MAX_PLAUSIBLE_STW_KN = 60
@@ -21,7 +27,8 @@ const INPUTS = {
21
27
  stw: ['navigation.speedThroughWater'],
22
28
  heading: ['navigation.headingMagnetic', 'navigation.headingTrue'],
23
29
  attitude: ['navigation.attitude'],
24
- gps: ['navigation.speedOverGround', 'navigation.courseOverGroundTrue']
30
+ gps: ['navigation.speedOverGround', 'navigation.courseOverGroundTrue'],
31
+ variation: ['navigation.magneticVariation']
25
32
  }
26
33
  const INPUT_OF = Object.fromEntries(Object.entries(INPUTS).flatMap(([k, ps]) => ps.map(p => [p, k])))
27
34
 
@@ -41,7 +48,7 @@ module.exports = function (app) {
41
48
  const plugin = {
42
49
  id: 'signalk-performance-processor',
43
50
  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'
51
+ description: 'Steady true wind through tacks and gybes: H5000-style calibration of boat speed, leeway and TWA/TWS, configured in the Performance Processor webapp'
45
52
  }
46
53
 
47
54
  let config = null
@@ -56,7 +63,14 @@ module.exports = function (app) {
56
63
  let lastResultAt = 0
57
64
  let lastStatus = ''
58
65
 
59
- plugin.schema = schemaFor(DEFAULTS)
66
+ // Everything is configured in the webapp and kept in this plugin's data directory,
67
+ // not in the plugin options: the admin form would otherwise show the tables, and
68
+ // saving it could overwrite the webapp's values.
69
+ plugin.schema = {
70
+ type: 'object',
71
+ description: 'Calibration tables and settings are edited in Webapps → Performance Processor.',
72
+ properties: {}
73
+ }
60
74
 
61
75
  function outPath (key) {
62
76
  return config.mode === 'shadow' ? OUTPUTS[key].path + SHADOW_SUFFIX : OUTPUTS[key].path
@@ -80,9 +94,9 @@ module.exports = function (app) {
80
94
  }
81
95
  const isStwEcho = ms => recentStw.some(o => Math.abs(o - ms) < 1e-12)
82
96
 
83
- function fresh (path, now) {
84
- const l = latest[path]
85
- return l && now - l.at < STALE_MS ? l.value : undefined
97
+ function fresh (p, now, maxAge = STALE_MS) {
98
+ const l = latest[p]
99
+ return l && now - l.at < maxAge ? l.value : undefined
86
100
  }
87
101
 
88
102
  // Refresh on a change of state, and at most once a second within a state so the
@@ -148,6 +162,7 @@ module.exports = function (app) {
148
162
  heel: heelDeg(now),
149
163
  headingMagnetic: deg(fresh('navigation.headingMagnetic', now)),
150
164
  headingTrue: deg(fresh('navigation.headingTrue', now)),
165
+ variation: deg(fresh('navigation.magneticVariation', now, VARIATION_MAX_AGE_MS)),
151
166
  sog: (v => v == null ? undefined : v * MS_TO_KN)(fresh('navigation.speedOverGround', now)),
152
167
  cog: deg(fresh('navigation.courseOverGroundTrue', now))
153
168
  }
@@ -265,10 +280,43 @@ module.exports = function (app) {
265
280
  unsubscribes = []
266
281
  }
267
282
 
283
+ // ---- stored configuration ---------------------------------------------------
284
+
285
+ function configFile () {
286
+ return path.join(app.getDataDirPath(), CONFIG_FILE)
287
+ }
288
+
289
+ // The webapp's saved configuration. Before the file exists, take a configuration left
290
+ // in the plugin options by 0.0.1, which stored it there; otherwise start from defaults.
291
+ function loadStored (options) {
292
+ try {
293
+ return JSON.parse(fs.readFileSync(configFile(), 'utf8'))
294
+ } catch (e) {
295
+ if (e.code !== 'ENOENT') app.error(`could not read ${configFile()}: ${e.message}`)
296
+ }
297
+ return options && options.corrections ? options : {}
298
+ }
299
+
300
+ // Write to a temporary file and rename, so a crash mid-write never leaves half a file.
301
+ // Never overwrite a file from a newer release: this version would drop what it
302
+ // does not understand.
303
+ function saveStored (cfg) {
304
+ const file = configFile()
305
+ let onDisk = null
306
+ try { onDisk = JSON.parse(fs.readFileSync(file, 'utf8')).schema } catch (e) {}
307
+ if (Number.isInteger(onDisk) && onDisk > CONFIG_SCHEMA) {
308
+ throw new Error(`the saved configuration is schema ${onDisk}, from a newer version of this plugin; update the plugin rather than overwrite it`)
309
+ }
310
+ fs.mkdirSync(path.dirname(file), { recursive: true })
311
+ fs.writeFileSync(file + '.tmp', JSON.stringify(cfg, null, 2))
312
+ fs.renameSync(file + '.tmp', file)
313
+ }
314
+
268
315
  plugin.start = function (options) {
269
- const { config: cfg, errors } = normalizeConfig(options || {})
316
+ const { config: cfg, errors } = normalizeConfig(loadStored(options))
270
317
  if (errors.length) {
271
- app.setPluginError(`Configuration: ${errors.join('; ')}`)
318
+ app.setPluginError(`Configuration: ${errors.join('; ')} — fix it in the webapp`)
319
+ config = normalizeConfig({}).config // so the webapp can still load and repair it
272
320
  return
273
321
  }
274
322
  startWith(cfg)
@@ -308,23 +356,25 @@ module.exports = function (app) {
308
356
  }
309
357
 
310
358
  plugin.registerWithRouter = function (router) {
311
- router.get('/config', (req, res) => {
359
+ // Not /config: the server registers GET and POST /config on every plugin's router
360
+ // for its own options, ahead of the plugin's routes, so ours would never be reached.
361
+ router.get('/calibration', (req, res) => {
312
362
  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 })
363
+ res.json({ config: config || normalizeConfig(loadStored()).config, defaults: DEFAULTS })
316
364
  })
317
365
 
318
- router.put('/config', async (req, res) => {
366
+ router.put('/calibration', async (req, res) => {
319
367
  let body
320
368
  try { body = await readBody(req) } catch (e) { return res.status(400).json({ errors: ['body is not valid JSON'] }) }
321
369
  const { config: cfg, errors } = normalizeConfig(body)
322
370
  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
- })
371
+ try {
372
+ saveStored(cfg)
373
+ } catch (e) {
374
+ return res.status(500).json({ errors: [`could not save: ${e.message}`] })
375
+ }
376
+ startWith(cfg)
377
+ res.json({ config: cfg })
328
378
  })
329
379
 
330
380
  router.get('/live', (req, res) => {
@@ -336,33 +386,5 @@ module.exports = function (app) {
336
386
  return plugin
337
387
  }
338
388
 
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
389
  // Exposed for the test suite only; not part of the Signal K plugin API.
368
- module.exports._internals = { schemaFor, OUTPUTS, INPUTS, SHADOW_SUFFIX }
390
+ module.exports._internals = { OUTPUTS, INPUTS, SHADOW_SUFFIX, CONFIG_FILE }
package/lib/defaults.js CHANGED
@@ -8,8 +8,10 @@ const TWS_BINS = [5, 10, 15, 20, 25, 30]
8
8
 
9
9
  const DEFAULTS = {
10
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
11
+ // Degrees, east positive. Used only when nothing on the boat publishes
12
+ // navigation.headingTrue or navigation.magneticVariation. Sydney.
13
+ variation: 12.8,
14
+ sources: { apparentWind: '', stw: '', heading: '', attitude: '', gps: '', variation: '' }, // '' = any but this plugin
13
15
  corrections: {
14
16
  // Knots added to STW: linearity by speed, plus heel correction by |heel|.
15
17
  // The 185-cell heel x STW table reduced to two rows (every cell within 0.03 kn).
package/lib/processor.js CHANGED
@@ -145,7 +145,7 @@ function correctInputs (input, config) {
145
145
  return { c, steps }
146
146
  }
147
147
 
148
- // input: { awa, aws, stw, heel?, headingMagnetic?, headingTrue?, sog?, cog? }
148
+ // input: { awa, aws, stw, heel?, headingMagnetic?, headingTrue?, variation?, sog?, cog? }
149
149
  // returns { out: { stw, leeway, twa, tws, heading?, twd?, current? }, steps }
150
150
  function process (input, config) {
151
151
  const { c, steps } = correctInputs(input, config)
@@ -171,9 +171,19 @@ function process (input, config) {
171
171
 
172
172
  const out = { stw: c.stw, leeway: c.leeway, twa: c.twa, tws: c.tws }
173
173
 
174
- const heading = input.headingTrue != null
175
- ? input.headingTrue
176
- : input.headingMagnetic != null ? input.headingMagnetic + config.variation : null
174
+ // True heading: from a true-heading source if there is one, otherwise magnetic
175
+ // heading plus variation, taken from Signal K (the GPS) when available and from the
176
+ // configured fallback when not.
177
+ let heading = null
178
+ if (input.headingTrue != null) {
179
+ heading = input.headingTrue
180
+ out.headingFrom = 'true heading'
181
+ } else if (input.headingMagnetic != null) {
182
+ const fromSignalK = input.variation != null
183
+ out.variation = fromSignalK ? input.variation : config.variation
184
+ out.headingFrom = fromSignalK ? 'magnetic + variation from Signal K' : 'magnetic + fallback variation'
185
+ heading = input.headingMagnetic + out.variation
186
+ }
177
187
  if (heading != null) {
178
188
  out.heading = norm360(heading)
179
189
  out.twd = norm360(heading + c.twa)
@@ -233,6 +243,37 @@ function checkNumber (errors, label, v) {
233
243
  if (typeof v !== 'number' || !Number.isFinite(v)) errors.push(`${label}: must be a number`)
234
244
  }
235
245
 
246
+ // ---- file format versions ----------------------------------------------------
247
+ //
248
+ // Every saved config carries `schema`, the version of its shape. When a release changes
249
+ // that shape, bump CONFIG_SCHEMA and add MIGRATIONS[old], a function that turns a
250
+ // config of schema `old` into schema `old + 1`. Older files are then upgraded step by
251
+ // step as they load and saved back in the current shape; nothing is lost or reset.
252
+ //
253
+ // Schema 1 (0.0.1): mode, variation, sources, corrections { stw, leeway, twa, tws }.
254
+
255
+ const CONFIG_SCHEMA = 1
256
+ const MIGRATIONS = {
257
+ // 1: cfg => ({ ...cfg, schema: 2, ... }),
258
+ }
259
+
260
+ // Bring a raw config up to the current schema. A config without `schema` predates the
261
+ // field and has the schema 1 shape. One from a newer release is refused rather than
262
+ // guessed at, since this version cannot know what its fields mean.
263
+ function migrate (raw, migrations = MIGRATIONS, current = CONFIG_SCHEMA) {
264
+ let cfg = clone(raw)
265
+ let v = cfg.schema == null ? 1 : cfg.schema
266
+ if (!Number.isInteger(v) || v < 1) return { error: `schema: "${cfg.schema}" is not a schema version` }
267
+ if (v > current) return { error: `schema ${v} was saved by a newer version of this plugin (this one reads up to ${current}); update the plugin` }
268
+ while (v < current) {
269
+ if (!migrations[v]) return { error: `schema: no upgrade from ${v} to ${v + 1}` }
270
+ cfg = migrations[v](cfg)
271
+ v++
272
+ }
273
+ cfg.schema = current
274
+ return { config: cfg }
275
+ }
276
+
236
277
  // Merge a stored or submitted config over the defaults and check it. Numbers typed
237
278
  // into a form arrive as strings, so coerce numeric-looking strings first.
238
279
  function normalizeConfig (input) {
@@ -242,10 +283,12 @@ function normalizeConfig (input) {
242
283
  if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v)
243
284
  return v
244
285
  }
245
- const raw = isPlainObject(input) ? clone(input) : {}
286
+ const migrated = migrate(isPlainObject(input) ? input : {})
287
+ if (migrated.error) return { config: { ...clone(DEFAULTS), schema: CONFIG_SCHEMA }, errors: [migrated.error] }
288
+ const raw = migrated.config
246
289
  // Source names are text even when they look numeric.
247
290
  const keepText = { sources: raw.sources, mode: raw.mode }
248
- const config = merge(DEFAULTS, { ...coerce(raw), ...Object.fromEntries(Object.entries(keepText).filter(([, v]) => v !== undefined)) })
291
+ const config = merge({ schema: CONFIG_SCHEMA, ...DEFAULTS }, { ...coerce(raw), ...Object.fromEntries(Object.entries(keepText).filter(([, v]) => v !== undefined)) })
249
292
  const errors = []
250
293
  const k = config.corrections
251
294
 
@@ -283,5 +326,6 @@ module.exports = {
283
326
  correctInputs,
284
327
  normalizeConfig,
285
328
  CORRECTIONS,
286
- _internals: { interp, blendRows, tableLookup, merge, norm180 }
329
+ CONFIG_SCHEMA,
330
+ _internals: { interp, blendRows, tableLookup, merge, norm180, migrate }
287
331
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "signalk-performance-processor",
3
- "version": "0.0.1",
4
- "description": "H5000-style calibration for Signal K: corrects boat speed and leeway, applies TWA and TWS correction tables, and publishes true wind and current. Proof of concept.",
3
+ "version": "0.0.3",
4
+ "description": "True wind direction that holds steady through tacks and gybes. H5000-style calibration in Signal K: boat speed, leeway, and TWA/TWS tables by wind speed, edited and watched live in a webapp. Try it in shadow mode beside your existing instruments.",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "test": "node --test",
package/public/app.js CHANGED
@@ -19,7 +19,8 @@
19
19
  stw: 'Speed through water',
20
20
  heading: 'Heading',
21
21
  attitude: 'Heel (attitude)',
22
- gps: 'SOG and COG'
22
+ gps: 'SOG and COG',
23
+ variation: 'Magnetic variation'
23
24
  }
24
25
 
25
26
  const state = {
@@ -49,14 +50,16 @@
49
50
 
50
51
  async function loadConfig () {
51
52
  try {
52
- const body = await getJson('/config')
53
+ const body = await getJson('/calibration')
54
+ if (!body || !body.config || !body.config.corrections) throw new Error('the plugin returned no calibration')
53
55
  state.saved = body.config
54
56
  state.draft = clone(body.config)
55
57
  state.defaults = body.defaults
56
- $('conn').hidden = true
58
+ state.configError = null
57
59
  } catch (e) {
58
- $('conn').textContent = `Cannot reach the plugin (${e.message}). Is Performance Processor enabled?`
59
- $('conn').hidden = false
60
+ // Kept until a load succeeds: the live poll must not clear it.
61
+ state.configError = `Could not load the calibration (${e.message}). Is Performance Processor enabled and up to date?`
62
+ setTimeout(loadConfig, 5000)
60
63
  }
61
64
  renderAll()
62
65
  }
@@ -79,7 +82,7 @@
79
82
  state.saving = true
80
83
  renderSaveBar()
81
84
  try {
82
- const res = await fetch(API + '/config', {
85
+ const res = await fetch(API + '/calibration', {
83
86
  method: 'PUT',
84
87
  credentials: 'include',
85
88
  headers: { 'Content-Type': 'application/json' },
@@ -137,6 +140,8 @@
137
140
  }
138
141
 
139
142
  function renderHeader () {
143
+ $('cfgError').textContent = state.configError || ''
144
+ $('cfgError').hidden = !state.configError
140
145
  const mode = state.saved ? state.saved.mode : null
141
146
  const badge = $('mode')
142
147
  badge.textContent = mode === 'replace' ? 'Replace' : mode === 'shadow' ? 'Shadow' : '--'
@@ -160,6 +165,13 @@
160
165
  return { twa: Math.atan2(y, x) * 180 / Math.PI, tws: Math.hypot(x, y) }
161
166
  }
162
167
 
168
+ // Where true heading came from, so a fallback variation is visible at a glance.
169
+ function headingNote (o) {
170
+ if (o.headingFrom === 'true heading') return 'from true heading'
171
+ const v = o.variation == null ? '' : ` ${Math.abs(o.variation).toFixed(1)}° ${o.variation < 0 ? 'W' : 'E'}`
172
+ return o.headingFrom === 'magnetic + fallback variation' ? `fallback variation${v}` : `variation${v} from GPS`
173
+ }
174
+
163
175
  function tile (label, value, unit, raw) {
164
176
  return `<div class="tile"><div class="label">${esc(label)}</div>` +
165
177
  `<div class="value">${esc(value)}<span class="unit">${esc(unit)}</span></div>` +
@@ -182,7 +194,7 @@
182
194
  $('tiles').innerHTML = [
183
195
  tile('TWA', fmt(Math.abs(o.twa)), `° ${side(o.twa)}`, `uncorrected ${fmt(Math.abs(u.twa))}°`),
184
196
  tile('TWS', fmt(o.tws), 'kn', `uncorrected ${fmt(u.tws)} kn`),
185
- tile('TWD', o.twd == null ? '--' : fmt(o.twd, 0), '° T', o.twd == null ? 'no heading' : ''),
197
+ tile('TWD', o.twd == null ? '--' : fmt(o.twd, 0), '° T', o.twd == null ? 'no heading' : headingNote(o)),
186
198
  tile('STW', fmt(o.stw, 2), 'kn', `paddlewheel ${fmt(r.input.stw, 2)} kn`),
187
199
  tile('Leeway', fmt(Math.abs(o.leeway)), `° ${side(o.leeway)}`, r.input.heel == null ? 'no heel' : `heel ${fmt(r.input.heel, 0)}°`),
188
200
  tile('Current', o.current ? fmt(o.current.drift, 2) : '--', o.current ? `kn · ${fmt(o.current.set, 0)}°` : 'kn', o.current ? '' : 'no SOG/COG or heading')
package/public/index.html CHANGED
@@ -20,6 +20,7 @@
20
20
  </nav>
21
21
  <div id="status" class="muted status"></div>
22
22
  <div id="conn" class="warn" hidden></div>
23
+ <div id="cfgError" class="warn" hidden></div>
23
24
  </header>
24
25
 
25
26
  <div id="saveBar" class="save-bar" hidden>
@@ -68,8 +69,9 @@
68
69
  </fieldset>
69
70
  </div>
70
71
  <div class="panel">
71
- <h2>Magnetic variation</h2>
72
- <label class="field">Degrees east, for true heading
72
+ <h2>Fallback magnetic variation</h2>
73
+ <p class="muted sub">Used only when nothing on the boat sends true heading or magnetic variation. Normally your GPS supplies variation and this is never used; the Live page shows which applies.</p>
74
+ <label class="field">Degrees east (west negative)
73
75
  <input id="variation" type="number" step="0.1" data-path="variation">
74
76
  </label>
75
77
  </div>