signalk-polar-performance-plugin 2.0.0 → 2.1.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 CHANGED
@@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Changed
10
+ - 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)).
11
+ - Requires signalkutilities >=3.2.0 (subscriptions without cached-value replay, smoothers cleared by `null`) and polar-math >=1.1.1 (in-irons cutoff relative to the 25° curve endpoint).
12
+ - Read-only Signal K users can now access live values, status, active-polar display metadata, and polar curve queries used by the plotter. Curve resolution is bounded to at most 181 samples per request; settings remain administrator-only.
13
+ - Clearer `meta` descriptions for `performance.optimumWindAngle` (where the optimal angle comes from, what zero means) and `performance.polarVelocityMadeGood` (the polar's prediction, not the actual VMG).
14
+
15
+ ### Fixed
16
+ - Removed idle-triggered input resubscriptions to avoid replaying cached values during sensor outages. Idle/stale warnings remain, and existing subscriptions receive data when the source resumes. Source changes and heading toggles still update subscriptions normally.
17
+ - Heading inputs enabled at runtime now use the same lifecycle-warning callbacks as heading inputs enabled at startup.
18
+ - When sailing in irons, all values taken from the polar (polar speed, target speed, target and polar VMG, beat/gybe angles and VMGs, max speed) and the percentages based on them are now cleared consistently. Previously some were cleared, some kept showing and VMG % froze on its last value. This also prevents spikes in the performance percentages while tacking. Steering advice (target angle, optimum wind angle, opposite tack heading) stays available so you can see how far to bear away. Everything returns as soon as you leave irons ([#38](https://github.com/htool/signalk-polar-performance-plugin/issues/38)).
19
+ - When pinching (between the in-irons cutoff and the beat angle), all outputs keep being published.
20
+ - Actual VMG keeps updating in irons instead of freezing on its last value.
21
+ - Performance outputs whose inputs become unavailable (for example missing boat speed, heading or polar targets) are now cleared instead of showing a stale value.
22
+
9
23
  ## [2.0.0] - 2026-09-09
10
24
 
11
25
  ### Added
package/README.md CHANGED
@@ -16,6 +16,8 @@ When a polar lookup cannot be completed or required instrument data is missing/s
16
16
 
17
17
  ## Installation
18
18
 
19
+ Requires signalk-server >=2.35.0 for reliable metadata-first input subscriptions. Idle/stale warnings do not trigger resubscriptions; existing subscriptions receive data when the source resumes.
20
+
19
21
  Install both plugins from the Signal K App Store, or via npm:
20
22
 
21
23
  ```sh
@@ -27,6 +29,12 @@ Then restart Signal K and enable both plugins in **Server → Plugin Config**. W
27
29
 
28
30
  ---
29
31
 
32
+ ## HTTP endpoint access
33
+
34
+ Signal K read-only users can view live values, status, active-polar display metadata, and polar curves used by the plotter. Plugin settings, including settings reads, require administrator access. Anonymous access remains controlled by the server's security configuration and is not enabled by this plugin. See the [developer reference](./docs/developer-reference.md#rest-api) for the endpoint list and access policies.
35
+
36
+ ---
37
+
30
38
  ## Quick start
31
39
 
32
40
  1. Open the **Polar Management** webapp and import or select an active polar (and optionally set a performance factor).
@@ -156,6 +164,23 @@ Requires `navigation.headingTrue` to be available.
156
164
 
157
165
  Useful when you want downstream instruments to use the same smoothed values that drive the performance calculation.
158
166
 
167
+ ### Output handling when pinching and in irons
168
+
169
+ The polar defines two zones above the beat angle:
170
+
171
+ - **Pinching** — TWA is between the in-irons cutoff and the beat angle. The boat is still sailing, and the polar curve is extended smoothly towards zero speed. **All outputs are published.**
172
+ - **In irons** — TWA is below the cutoff, which lies 80 % of the way from 25° to the beat angle (for example ~38.6° for a 42° beat angle). The polar has no sailing state here, so the outputs are handled per kind:
173
+
174
+ | Kind | Outputs | In irons |
175
+ |------|---------|----------|
176
+ | Measured | `velocityMadeGood`, `angleTrueWaterDamped`, `boatSpeedDamped` | Published |
177
+ | Steering advice | `targetAngle`, `optimumWindAngle`, `tackTrue` | Published, so you can see how far to bear away |
178
+ | Polar values and ratios based on them | `polarSpeed`, `polarSpeedRatio`, `targetSpeed`, `targetVelocityMadeGood`, `polarVelocityMadeGood`, `polarVelocityMadeGoodRatio`, `beatAngle`, `gybeAngle`, `beatAngleVelocityMadeGood`, `gybeAngleVelocityMadeGood`, `maxSpeed`, `maxSpeedAngle` | Set to `null` |
179
+
180
+ Clearing the polar values in irons also prevents spikes in the performance ratios while tacking. Through a tack the boat carries its speed head-to-wind while the polar speed falls towards zero, which would briefly drive the ratios to meaningless values. As soon as you bear away out of irons, all values return on the next wind update.
181
+
182
+ A value is set to `null` once when it becomes unavailable and is not repeated until it returns.
183
+
159
184
  ---
160
185
 
161
186
  ## Managing polars
@@ -222,8 +247,8 @@ The webapp shows warnings whenever something prevents accurate calculation:
222
247
  | *True wind angle — no data* | `environment.wind.angleTrueWater` is not arriving. |
223
248
  | *Boat speed — no data* | `navigation.speedThroughWater` (or SOG) is not arriving. |
224
249
  | *No active polar* | No `polars.activePolar` is published. Select an active polar in the polar management webapp. |
225
- | *Sailing in irons* | TWA is below the minimum angle in the polar. No output is produced. |
226
- | *Pinching* | TWA is between the minimum polar angle and the beat angle. Values come from the extrapolated beat zone. |
250
+ | *Sailing in irons* | TWA is below the in-irons cutoff. Polar values and the ratios based on them are set to `null`; measured values and steering advice keep updating. See [Output handling when pinching and in irons](#output-handling-when-pinching-and-in-irons). |
251
+ | *Pinching* | TWA is between the in-irons cutoff and the beat angle. All outputs are published; values come from the extrapolated beat zone. |
227
252
  | *Extrapolated beyond run angle* | TWA is deeper than the run angle. Values come from the cosine-VMG extrapolation model. |
228
253
  | *Wind speed below/above polar range* | TWS is outside the range covered by the polar. Values are extrapolated from the nearest TWS entry. |
229
254
 
@@ -25,16 +25,21 @@ unavailable, the last successfully loaded polar is kept until a valid update arr
25
25
 
26
26
  The plugin exposes a REST API under `/plugins/signalk-polar-performance-plugin/`. Authentication
27
27
  follows Signal K server rules; the same session cookie used by the webapp works for direct API calls.
28
-
29
- | Method | Path | Description |
30
- |--------|------|-------------|
31
- | `GET` | `/live` | Current smoothed TWS, TWA, BSP, polar speed, and polar state. |
32
- | `GET` | `/status` | Full snapshot: raw inputs, smoothed inputs, and all enabled output values. |
33
- | `GET` | `/meta` | Display unit metadata for all fields, plus a read-only summary of the active polar (name, boatType, sailnumber, year, source) and performance factor. |
34
- | `GET` | `/settings` | Current plugin settings (smoother, output toggles, speed source — no polar selection). |
35
- | `PUT` | `/settings` | Update settings. Body: JSON object with changed keys only. |
36
- | `GET` | `/polar/axes/tws` | TWS axis of the currently active polar. 404 if none is active. |
37
- | `GET` | `/polar/queries/curve?tws=&step=` | Interpolated curve for the active polar at the given TWS, with beat/run markers, performance factor already applied. |
28
+ The endpoints marked **Read-only** are available to users with Signal K read-only permission.
29
+ Anonymous access is controlled only by the server's `allow_readonly` setting and is not enabled by
30
+ the plugin. Curve queries are capped at 181 sample angles per request (minimum step 1°).
31
+ `/meta` includes the active polar's boat type, sail number, source, and free-text notes, so those
32
+ fields are also available to read-only users.
33
+
34
+ | Method | Path | Description | Access |
35
+ |--------|------|-------------|--------|
36
+ | `GET` | `/live` | Current smoothed TWS, TWA, BSP, polar speed, and polar state. | Read-only |
37
+ | `GET` | `/status` | Full snapshot: raw inputs, smoothed inputs, and all enabled output values. | Read-only |
38
+ | `GET` | `/meta` | Display unit metadata for all fields, plus a read-only summary of the active polar (name, boatType, sailnumber, year, source) and performance factor. | Read-only |
39
+ | `GET` | `/settings` | Current plugin settings (smoother, output toggles, speed source — no polar selection). | Administrator |
40
+ | `PUT` | `/settings` | Update settings. Body: JSON object with changed keys only. | Administrator |
41
+ | `GET` | `/polar/axes/tws` | TWS axis of the currently active polar. 404 if none is active. | Read-only |
42
+ | `GET` | `/polar/queries/curve?tws=&step=` | Interpolated curve for the active polar at the given TWS, with beat/run markers, performance factor already applied. `step` must be at least 1° and at most π/2 radians. | Read-only |
38
43
 
39
44
  The authoritative machine-readable schema for the two `/polar/*` endpoints is in
40
45
  [../openApi.json](../openApi.json).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-polar-performance-plugin",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Calculates live sailing performance from the active polar selected in a polar management plugin (e.g. signalk-polar-management).",
5
5
  "main": "plugin/index.js",
6
6
  "exports": {
@@ -19,15 +19,15 @@
19
19
  ],
20
20
  "engines": {
21
21
  "node": ">=20",
22
- "signalk-server": ">=2.28.0"
22
+ "signalk-server": ">=2.35.0"
23
23
  },
24
24
  "scripts": {
25
25
  "test": "node --test test/**/*.test.js",
26
26
  "prepublishOnly": "npm test"
27
27
  },
28
28
  "dependencies": {
29
- "polar-math": "^1.0.0",
30
- "signalkutilities": "^3.1.3"
29
+ "polar-math": "^1.1.1",
30
+ "signalkutilities": "^3.2.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "signalk-polar-management": ">=1.0.0"
package/plugin/index.js CHANGED
@@ -14,8 +14,6 @@ const {
14
14
 
15
15
  const CURRENT_SETTINGS_VERSION = 2
16
16
 
17
- const STALE_RESUBSCRIBE_PERIOD = 60000 // ms — idle period before live input subscriptions are re-established
18
-
19
17
  const DEFAULT_SETTINGS = {
20
18
  settingsVersion: CURRENT_SETTINGS_VERSION,
21
19
  showAllTwsLines: true,
@@ -85,7 +83,7 @@ module.exports = (app) => {
85
83
  function _setLifecycleWarning(id, status, path) {
86
84
  const safePath = path || 'unknown path'
87
85
  const message = status === 'idle'
88
- ? `Input ${id} is idle on ${safePath}; resubscribing`
86
+ ? `Input ${id} is idle on ${safePath}`
89
87
  : `Input ${id} is stale on ${safePath}`
90
88
  lifecycleWarningMap.set(id, {
91
89
  id,
@@ -105,9 +103,8 @@ module.exports = (app) => {
105
103
  .sort((a, b) => b.updatedAt - a.updatedAt)
106
104
  }
107
105
 
108
- function _wireHandlerWatchdog({ id, getPath, unsubscribe, subscribe }) {
106
+ function _wireHandlerWatchdog({ id, getPath }) {
109
107
  return {
110
- idlePeriod: STALE_RESUBSCRIBE_PERIOD,
111
108
  onDelta: () => {
112
109
  _clearLifecycleWarning(id)
113
110
  },
@@ -120,14 +117,36 @@ module.exports = (app) => {
120
117
  onIdle: () => {
121
118
  if (!isRunning) return
122
119
  const path = getPath()
123
- app.debug(`[${plugin.id}] idle input ${id} on ${path}; resubscribing`)
120
+ app.debug(`[${plugin.id}] idle input ${id} on ${path}`)
124
121
  _setLifecycleWarning(id, 'idle', path)
125
- unsubscribe()
126
- subscribe()
127
122
  }
128
123
  }
129
124
  }
130
125
 
126
+ // Ratios below this denominator (m/s) are numerically meaningless (near-zero wind).
127
+ const MIN_RATIO_DENOMINATOR = 0.01
128
+
129
+ function finiteOrNull(value) {
130
+ return Number.isFinite(value) ? value : null
131
+ }
132
+
133
+ function signed(angle, port) {
134
+ return angle === null ? null : angle * port
135
+ }
136
+
137
+ function ratio(numerator, denominator) {
138
+ if (!Number.isFinite(numerator) || !Number.isFinite(denominator)) return null
139
+ return denominator > MIN_RATIO_DENOMINATOR ? numerator / denominator : null
140
+ }
141
+
142
+ function publishNulls(paths) {
143
+ if (paths.length === 0) return
144
+ paths.forEach(path => { lastOutputs[path] = null })
145
+ app.handleMessage(plugin.id, {
146
+ updates: [{ values: paths.map(path => ({ path, value: null })) }]
147
+ })
148
+ }
149
+
131
150
  function getSmootherClass(type) {
132
151
  switch (type) {
133
152
  case 'None': return BaseSmoother
@@ -290,9 +309,8 @@ module.exports = (app) => {
290
309
  SmootherClass: SC,
291
310
  smootherOptions: so,
292
311
  ..._wireHandlerWatchdog({
293
- get path() { return hdgSmoother?.handler?.path ?? 'navigation.headingTrue' },
294
- unsubscribe: () => hdgSmoother?.unsubscribe(),
295
- subscribe: () => hdgSmoother?.subscribe(false, true),
312
+ id: 'hdg.smoothed',
313
+ getPath: () => hdgSmoother?.handler?.path ?? 'navigation.headingTrue',
296
314
  })
297
315
  })
298
316
  } else if (!settings.tackTrue && hdgSmoother) {
@@ -309,11 +327,7 @@ module.exports = (app) => {
309
327
  const disabledPaths = keys
310
328
  .filter(k => OUTPUT_PATHS[k] && !settings[k])
311
329
  .flatMap(k => OUTPUT_PATHS[k])
312
- if (disabledPaths.length) {
313
- app.handleMessage(plugin.id, {
314
- updates: [{ values: disabledPaths.map(path => ({ path, value: null })) }]
315
- })
316
- }
330
+ publishNulls(disabledPaths)
317
331
  }
318
332
 
319
333
  // ---------------------------------------------------------------------------
@@ -322,10 +336,7 @@ module.exports = (app) => {
322
336
  // ---------------------------------------------------------------------------
323
337
 
324
338
  function nullifyOutputs() {
325
- const allPaths = Object.values(OUTPUT_PATHS).flat()
326
- app.handleMessage(plugin.id, {
327
- updates: [{ values: allPaths.map(path => ({ path, value: null })) }]
328
- })
339
+ publishNulls(Object.values(OUTPUT_PATHS).flat())
329
340
  }
330
341
 
331
342
  // ---------------------------------------------------------------------------
@@ -339,164 +350,127 @@ module.exports = (app) => {
339
350
  const wind = windSmoother.polarValue
340
351
  const TWS = wind.magnitude
341
352
  const TWAsigned = wind.angle
342
- if (!Number.isFinite(TWS) || !Number.isFinite(TWAsigned)) return
353
+ const windValid = Number.isFinite(TWS) && Number.isFinite(TWAsigned)
343
354
 
344
355
  // TWA is always positive for polar lookups; sign is tracked via `port`
345
- const TWA = Math.abs(TWAsigned)
356
+ const TWA = windValid ? Math.abs(TWAsigned) : null
346
357
  const port = TWAsigned < 0 ? -1 : 1
347
- const BSP = bspSmoother ? bspSmoother.value : null
348
- const HDG = hdgSmoother ? hdgSmoother.value : null
358
+ const BSP = finiteOrNull(bspSmoother ? bspSmoother.value : null)
359
+ const HDG = finiteOrNull(hdgSmoother ? hdgSmoother.value : null)
349
360
 
350
361
  const values = []
351
362
  const metas = []
352
363
 
353
- function add(skPath, value, unit, description) {
354
- if (!Number.isFinite(value)) return
355
- values.push({ path: skPath, value })
356
- if (!metaSentPaths.has(skPath)) {
357
- metas.push({ path: skPath, value: { units: unit, description } })
358
- metaSentPaths.add(skPath)
359
- }
360
- }
361
-
362
- // Always emit smoothed inputs
363
- if (settings.smoothedInputs) {
364
- add('environment.wind.angleTrueWaterDamped', TWAsigned, 'rad',
365
- 'True Wind Angle after smoothing, negative to port.')
366
- if (Number.isFinite(BSP)) {
367
- add('performance.boatSpeedDamped', BSP, 'm/s', 'Boat speed after smoothing.')
364
+ // Each enabled output is either published or explicitly cleared, so
365
+ // consumers never keep seeing a frozen value once its inputs disappear.
366
+ // Nulls are only sent on the transition to unavailable to limit bus traffic.
367
+ function output(settingKey, skPath, value, unit, description) {
368
+ if (!settings[settingKey]) return
369
+ if (Number.isFinite(value)) {
370
+ values.push({ path: skPath, value })
371
+ if (!metaSentPaths.has(skPath)) {
372
+ metas.push({ path: skPath, value: { units: unit, description } })
373
+ metaSentPaths.add(skPath)
374
+ }
375
+ } else if (lastOutputs[skPath] !== null) {
376
+ values.push({ path: skPath, value: null })
368
377
  }
369
378
  }
370
379
 
371
- // Polar lookups
372
- const isUpwind = TWA < Math.PI / 2
373
- const { value: targets } = polar.targetsAt({ tws: TWS, performanceFactor })
374
- const beatAngle = targets?.beat?.twa ?? null
375
- const runAngle = targets?.run?.twa ?? null
376
- const beatVMG = targets?.beat?.vmg ?? null
377
- const runVMG = targets?.run?.vmg ?? null
380
+ // Output classes:
381
+ // - measured values (actual VMG, damped inputs) are published whenever their inputs are valid;
382
+ // - steering advice (target angle, optimum wind angle, tack heading) is always published,
383
+ // including in irons, where it says how far to bear away;
384
+ // - all other polar values and polar ratios are cleared in irons, where the polar says nothing
385
+ // about the boat's current state.
386
+ const isUpwind = windValid && TWA < Math.PI / 2
387
+ const speedResult = windValid ? polar.speedAt({ tws: TWS, twa: TWA, performanceFactor }) : null
388
+ const inIrons = speedResult?.state?.twa === 'in_irons'
389
+ const fromPolar = value => (inIrons ? null : finiteOrNull(value))
390
+
391
+ const targets = windValid ? polar.targetsAt({ tws: TWS, performanceFactor }).value : null
392
+ const adviceBeatAngle = finiteOrNull(targets?.beat?.twa)
393
+ const adviceRunAngle = finiteOrNull(targets?.run?.twa)
394
+ const adviceAngle = isUpwind ? adviceBeatAngle : adviceRunAngle
395
+
396
+ const beatAngle = fromPolar(adviceBeatAngle)
397
+ const runAngle = fromPolar(adviceRunAngle)
398
+ const beatVMG = fromPolar(targets?.beat?.vmg)
399
+ const runVMG = fromPolar(targets?.run?.vmg)
378
400
  const targetAngle = isUpwind ? beatAngle : runAngle
379
401
  const targetVMG = isUpwind ? beatVMG : runVMG
402
+ const maxSpeed = fromPolar(targets?.maxSpeed?.speed)
403
+ const maxSpeedAngle = maxSpeed === null ? null : finiteOrNull(targets?.maxSpeed?.twa)
380
404
 
381
- if (Number.isFinite(beatAngle)) {
382
- if (settings.beatAngle) {
383
- add('performance.beatAngle', beatAngle * port, 'rad',
384
- 'Optimal beat angle for current TWS, negative to port.')
385
- }
386
- if (settings.targetTWA && isUpwind) {
387
- add('performance.targetAngle', beatAngle * port, 'rad',
388
- 'Target TWA — auto-switches between beat and run, negative to port.')
389
- }
390
- }
405
+ // Polar speed at the current TWA is also unavailable outside the polar's angle range
406
+ const speedAtTwa = speedResult?.value
407
+ const polarSpeed = Number.isFinite(speedAtTwa) && speedAtTwa > 0 ? speedAtTwa : null
391
408
 
392
- if (Number.isFinite(runAngle)) {
393
- if (settings.beatAngle) {
394
- add('performance.gybeAngle', runAngle * port, 'rad',
395
- 'Optimal run/gybe angle for current TWS, negative to port.')
396
- }
397
- if (settings.targetTWA && !isUpwind) {
398
- add('performance.targetAngle', runAngle * port, 'rad',
399
- 'Target TWA — auto-switches between beat and run, negative to port.')
400
- }
401
- }
402
-
403
- if (Number.isFinite(beatVMG)) {
404
- if (settings.beatVMG) {
405
- add('performance.beatAngleVelocityMadeGood', beatVMG, 'm/s',
406
- 'Optimal beat VMG for current TWS.')
407
- }
408
- if (settings.targetTWA && isUpwind) {
409
- add('performance.targetVelocityMadeGood', beatVMG, 'm/s',
410
- 'Target VMG — auto-switches between beat and run.')
411
- }
412
- }
413
-
414
- if (Number.isFinite(runVMG)) {
415
- if (settings.beatVMG) {
416
- add('performance.gybeAngleVelocityMadeGood', runVMG, 'm/s',
417
- 'Optimal run VMG for current TWS.')
418
- }
419
- if (settings.targetTWA && !isUpwind) {
420
- add('performance.targetVelocityMadeGood', runVMG, 'm/s',
421
- 'Target VMG — auto-switches between beat and run.')
422
- }
423
- }
424
-
425
- // Optimum wind angle: angular difference between current TWA and optimal angle
426
- if (settings.optimumWindAngle) {
427
- if (isUpwind && Number.isFinite(beatAngle)) {
428
- add('performance.optimumWindAngle', (TWA - beatAngle) * port, 'rad',
429
- 'Difference between TWA and beat angle, negative to port.')
430
- } else if (!isUpwind && Number.isFinite(runAngle)) {
431
- add('performance.optimumWindAngle', (runAngle - TWA) * port * -1, 'rad',
432
- 'Difference between TWA and run angle, negative to port.')
433
- }
434
- }
435
-
436
- // Polar speed and performance ratios
437
- const { value: polarSpeed } = polar.speedAt({ tws: TWS, twa: TWA, performanceFactor })
438
- if (Number.isFinite(polarSpeed) && polarSpeed > 0) {
439
- if (settings.polarSpeed) {
440
- add('performance.polarSpeed', polarSpeed, 'm/s',
441
- 'Polar chart boat speed for current TWS and TWA.')
442
-
443
- // Target speed: only meaningful when sailing within the polar range
444
- if (Number.isFinite(targetAngle) && Number.isFinite(targetVMG)) {
445
- const cosTarget = Math.abs(Math.cos(targetAngle))
446
- if (cosTarget > 0.01) {
447
- add('performance.targetSpeed', targetVMG / cosTarget, 'm/s',
448
- 'Boat speed needed to achieve target VMG at the optimal angle.')
449
- }
450
- }
409
+ const targetCos = targetAngle === null ? 0 : Math.abs(Math.cos(targetAngle))
410
+ const targetSpeed = targetVMG !== null && targetVMG >= 0 && targetCos > 0.01
411
+ ? targetVMG / targetCos
412
+ : null
451
413
 
452
- if (Number.isFinite(BSP)) {
453
- add('performance.polarSpeedRatio', BSP / polarSpeed, 'ratio',
454
- 'Actual boat speed divided by polar speed.')
455
- }
456
- }
414
+ const vmg = windValid && BSP !== null ? BSP * Math.cos(TWA) : null
415
+ const vmgRatio = ratio(vmg === null ? null : Math.abs(vmg), targetVMG)
457
416
 
458
- if (Number.isFinite(BSP)) {
459
- if (settings.VMG && Number.isFinite(targetVMG) && targetVMG > 0) {
460
- const vmg = BSP * Math.cos(TWA)
461
- add('performance.velocityMadeGood', vmg, 'm/s',
462
- 'Actual VMG based on current boat speed and TWA.')
463
- add('performance.polarVelocityMadeGood', targetVMG, 'm/s',
464
- 'Polar VMG for current TWS.')
465
- if (Number.isFinite(vmg)) {
466
- add('performance.polarVelocityMadeGoodRatio', Math.abs(vmg) / targetVMG, 'ratio',
467
- 'Actual VMG divided by polar VMG.')
468
- }
469
- }
470
- }
471
- } else {
472
- // Clear these paths so no stale non-zero value remains on the SK bus
473
- if (settings.polarSpeed) {
474
- values.push({ path: 'performance.polarSpeed', value: null })
475
- values.push({ path: 'performance.polarSpeedRatio', value: null })
476
- values.push({ path: 'performance.targetSpeed', value: null })
477
- }
478
- }
479
-
480
- // Max speed for current TWS
481
- if (settings.maxSpeed) {
482
- const maxSpeed = targets?.maxSpeed?.speed ?? null
483
- const maxSpeedAngle = targets?.maxSpeed?.twa ?? null
484
- if (Number.isFinite(maxSpeed)) {
485
- add('performance.maxSpeed', maxSpeed, 'm/s',
486
- 'Maximum polar boat speed for current TWS.')
487
- add('performance.maxSpeedAngle', maxSpeedAngle * port, 'rad',
488
- 'TWA at which maximum speed is achieved, negative to port.')
489
- }
490
- }
417
+ let optimumWindAngle = null
418
+ if (isUpwind && adviceBeatAngle !== null) optimumWindAngle = (TWA - adviceBeatAngle) * port
419
+ else if (windValid && !isUpwind && adviceRunAngle !== null) optimumWindAngle = (adviceRunAngle - TWA) * port * -1
491
420
 
492
- // Opposite tack heading
493
- if (settings.tackTrue && Number.isFinite(HDG) && Number.isFinite(targetAngle)) {
494
- let tack = port < 0 ? HDG - targetAngle : HDG + targetAngle
421
+ let tack = null
422
+ if (HDG !== null && adviceAngle !== null) {
423
+ tack = port < 0 ? HDG - adviceAngle : HDG + adviceAngle
495
424
  tack = ((tack % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)
496
- add('performance.tackTrue', tack, 'rad',
497
- 'Opposite tack heading relative to true north.')
498
425
  }
499
426
 
427
+ output('smoothedInputs', 'environment.wind.angleTrueWaterDamped', windValid ? TWAsigned : null, 'rad',
428
+ 'True Wind Angle after smoothing, negative to port.')
429
+ output('smoothedInputs', 'performance.boatSpeedDamped', BSP, 'm/s',
430
+ 'Boat speed after smoothing.')
431
+
432
+ output('beatAngle', 'performance.beatAngle', signed(beatAngle, port), 'rad',
433
+ 'Optimal beat angle for current TWS, negative to port.')
434
+ output('beatAngle', 'performance.gybeAngle', signed(runAngle, port), 'rad',
435
+ 'Optimal run/gybe angle for current TWS, negative to port.')
436
+
437
+ output('beatVMG', 'performance.beatAngleVelocityMadeGood', beatVMG, 'm/s',
438
+ 'Optimal beat VMG for current TWS.')
439
+ output('beatVMG', 'performance.gybeAngleVelocityMadeGood', runVMG, 'm/s',
440
+ 'Optimal run VMG for current TWS.')
441
+
442
+ output('targetTWA', 'performance.targetAngle', signed(adviceAngle, port), 'rad',
443
+ 'Target TWA — auto-switches between beat and run, negative to port.')
444
+ output('targetTWA', 'performance.targetVelocityMadeGood', targetVMG, 'm/s',
445
+ 'Target VMG — auto-switches between beat and run.')
446
+
447
+ output('optimumWindAngle', 'performance.optimumWindAngle', optimumWindAngle, 'rad',
448
+ isUpwind
449
+ ? 'Difference between TWA and beat angle, negative to port. The optimal angle comes from the polar for the current true wind speed; zero means sailing at the optimal angle.'
450
+ : 'Difference between TWA and run angle, negative to port. The optimal angle comes from the polar for the current true wind speed; zero means sailing at the optimal angle.')
451
+
452
+ output('polarSpeed', 'performance.polarSpeed', polarSpeed, 'm/s',
453
+ 'Polar chart boat speed for current TWS and TWA.')
454
+ output('polarSpeed', 'performance.targetSpeed', targetSpeed, 'm/s',
455
+ 'Boat speed needed to achieve target VMG at the optimal angle.')
456
+ output('polarSpeed', 'performance.polarSpeedRatio', ratio(BSP, polarSpeed), 'ratio',
457
+ 'Actual boat speed divided by polar speed.')
458
+
459
+ output('VMG', 'performance.velocityMadeGood', vmg, 'm/s',
460
+ 'Actual VMG based on current boat speed and TWA.')
461
+ output('VMG', 'performance.polarVelocityMadeGood', targetVMG, 'm/s',
462
+ "Polar VMG for current TWS. The VMG predicted by the polar, not the vessel's actual VMG.")
463
+ output('VMG', 'performance.polarVelocityMadeGoodRatio', vmgRatio, 'ratio',
464
+ 'Actual VMG divided by polar VMG.')
465
+
466
+ output('maxSpeed', 'performance.maxSpeed', maxSpeed, 'm/s',
467
+ 'Maximum polar boat speed for current TWS.')
468
+ output('maxSpeed', 'performance.maxSpeedAngle', signed(maxSpeedAngle, port), 'rad',
469
+ 'TWA at which maximum speed is achieved, negative to port.')
470
+
471
+ output('tackTrue', 'performance.tackTrue', tack, 'rad',
472
+ 'Opposite tack heading relative to true north.')
473
+
500
474
  if (values.length === 0) return
501
475
 
502
476
  if (metas.length > 0) {
@@ -505,11 +479,8 @@ module.exports = (app) => {
505
479
 
506
480
  app.handleMessage(plugin.id, { updates: [{ values }] })
507
481
 
508
- // Snapshot enabled outputs for /status endpoint
509
- // Each output key maps to an array of { path, value } computed this cycle
510
- const outputSnapshot = {}
511
- values.forEach(({ path, value }) => { outputSnapshot[path] = value })
512
- Object.assign(lastOutputs, outputSnapshot)
482
+ // Snapshot outputs for the /status endpoint and null-transition tracking
483
+ values.forEach(({ path, value }) => { lastOutputs[path] = value })
513
484
  }
514
485
 
515
486
  // ---------------------------------------------------------------------------
@@ -585,12 +556,12 @@ module.exports = (app) => {
585
556
 
586
557
  // ---- Active polar curve queries (read-only — for the webapp's polar canvas) --
587
558
 
588
- router.get('/polar/axes/tws', (req, res) => {
559
+ router.access('readonly').get('/polar/axes/tws', (req, res) => {
589
560
  if (!polar) return res.status(404).json({ error: 'No active polar selected' })
590
561
  res.json(getPolarTwsValues())
591
562
  })
592
563
 
593
- router.get('/polar/queries/curve', (req, res) => {
564
+ router.access('readonly').get('/polar/queries/curve', (req, res) => {
594
565
  if (!polar) return res.status(404).json({ error: 'No active polar selected' })
595
566
  try {
596
567
  const tws = parseFloat(req.query.tws)
@@ -598,8 +569,8 @@ module.exports = (app) => {
598
569
  return res.status(400).json({ error: "'tws' query parameter required (m/s)" })
599
570
  }
600
571
  const stepRad = parseFloat(req.query.step) || (2 * Math.PI / 180)
601
- if (!Number.isFinite(stepRad) || stepRad <= 0 || stepRad > Math.PI / 2) {
602
- return res.status(400).json({ error: "'step' must be between 0 and π/2 radians" })
572
+ if (!Number.isFinite(stepRad) || stepRad < Math.PI / 180 || stepRad > Math.PI / 2) {
573
+ return res.status(400).json({ error: "'step' must be between 1° and π/2 radians" })
603
574
  }
604
575
  res.json(buildCurveResult(tws, stepRad))
605
576
  } catch (e) {
@@ -611,7 +582,7 @@ module.exports = (app) => {
611
582
  // tws/bsp/polarSpeed in m/s; twa in rad (positive = starboard, negative = port).
612
583
  // Returns null for any field not yet available (plugin not running,
613
584
  // no BSP source, polar not loaded, or boat in irons).
614
- router.get('/live', (req, res) => {
585
+ router.access('readonly').get('/live', (req, res) => {
615
586
  const wind = windSmoother?.ready ? windSmoother.polarValue : null
616
587
  const TWS = wind ? wind.magnitude : null
617
588
  const TWAsigned = wind ? wind.angle : null
@@ -646,7 +617,7 @@ module.exports = (app) => {
646
617
  // inputs.smoothed.* — value the plugin actually used for computation
647
618
  // outputs.* — only present for enabled settings; null if polar not ready
648
619
  // polarState — same as /live
649
- router.get('/status', (req, res) => {
620
+ router.access('readonly').get('/status', (req, res) => {
650
621
  const si = v => (Number.isFinite(v) ? parseFloat(v.toFixed(5)) : null)
651
622
 
652
623
  // Raw inputs: read directly from the smoother handlers
@@ -709,7 +680,7 @@ module.exports = (app) => {
709
680
  // returned by /live and the canonical curve query endpoints, plus a
710
681
  // read-only summary of the active polar and performance factor
711
682
  // (both sourced from the `polars.*` SK paths — not editable here).
712
- router.get('/meta', (req, res) => {
683
+ router.access('readonly').get('/meta', (req, res) => {
713
684
  const speed = { formula: 'value * 1.943844', symbol: 'kn', displayFormat: '0.0' }
714
685
  const angle = { formula: 'value * 57.29577951308231', symbol: '\u00b0', displayFormat: '0.0' }
715
686
  const ratio = { formula: 'value * 100', symbol: '%', displayFormat: '0.1' }
@@ -779,10 +750,8 @@ module.exports = (app) => {
779
750
  })
780
751
 
781
752
  // Active polar / performance factor — read-only, published by a 'polars' resource provider.
782
- // These are event-driven (change only on user action), so the idle-watchdog
783
- // unsubscribe/resubscribe used for sensor handlers below does not apply here: absence
784
- // of deltas is the normal state, not a sign of a broken subscription, and periodically
785
- // forcing a resubscribe would just be churn (and possible side effects) for no reason.
753
+ // These are event-driven (change only on user action), so sensor idle warnings
754
+ // do not apply here: absence of deltas is the normal state.
786
755
  activePolarHandler = new MessageHandler(app, plugin.id, 'activePolar')
787
756
  activePolarHandler.path = 'polars.activePolar'
788
757
  activePolarHandler.onDelta = () => handleActivePolarDelta()
@@ -813,8 +782,6 @@ module.exports = (app) => {
813
782
  ..._wireHandlerWatchdog({
814
783
  id: 'wind.smoothed',
815
784
  getPath: () => `${windSmoother?.polar?.pathMagnitude ?? 'environment.wind.speedTrue'}, ${windSmoother?.polar?.pathAngle ?? 'environment.wind.angleTrueWater'}`,
816
- unsubscribe: () => windSmoother?.unsubscribe(),
817
- subscribe: () => windSmoother?.subscribe(true, true),
818
785
  }),
819
786
  onDelta: () => {
820
787
  _clearLifecycleWarning('wind.smoothed')
@@ -836,8 +803,6 @@ module.exports = (app) => {
836
803
  ..._wireHandlerWatchdog({
837
804
  id: 'bsp.smoothed',
838
805
  getPath: () => bspSmoother?.handler?.path ?? (settings.useSOG ? 'navigation.speedOverGround' : 'navigation.speedThroughWater'),
839
- unsubscribe: () => bspSmoother?.unsubscribe(),
840
- subscribe: () => bspSmoother?.subscribe(),
841
806
  })
842
807
  })
843
808
 
@@ -852,8 +817,6 @@ module.exports = (app) => {
852
817
  ..._wireHandlerWatchdog({
853
818
  id: 'hdg.smoothed',
854
819
  getPath: () => hdgSmoother?.handler?.path ?? 'navigation.headingTrue',
855
- unsubscribe: () => hdgSmoother?.unsubscribe(),
856
- subscribe: () => hdgSmoother?.subscribe(false, true),
857
820
  })
858
821
  })
859
822
  }
@@ -5,6 +5,7 @@ const assert = require('node:assert/strict')
5
5
  const fs = require('fs')
6
6
  const path = require('path')
7
7
  const os = require('os')
8
+ const { withRoutePolicies, wrapPluginRoutes } = require('./routePolicyShim')
8
9
 
9
10
  // ---------------------------------------------------------------------------
10
11
  // Minimal app shim
@@ -39,7 +40,7 @@ function makeApp(dataDir) {
39
40
 
40
41
  function freshPlugin(app) {
41
42
  delete require.cache[require.resolve('../plugin/index.js')]
42
- return require('../plugin/index.js')(app)
43
+ return wrapPluginRoutes(require('../plugin/index.js')(app))
43
44
  }
44
45
 
45
46
  // ---------------------------------------------------------------------------
@@ -106,6 +107,25 @@ describe('Plugin lifecycle — start() → stop() → start()', () => {
106
107
  assert.ok(routes.some(r => r.startsWith('GET')), 'no GET routes registered')
107
108
  })
108
109
 
110
+ it('applies readonly access only to safe live and polar metadata routes', () => {
111
+ const router = {
112
+ use: () => {},
113
+ get: () => {},
114
+ put: () => {},
115
+ }
116
+ plugin.registerWithRouter(withRoutePolicies(router))
117
+
118
+ assert.deepEqual(Object.fromEntries(router.routePolicies), {
119
+ 'GET /polar/axes/tws': 'readonly',
120
+ 'GET /polar/queries/curve': 'readonly',
121
+ 'GET /live': 'readonly',
122
+ 'GET /status': 'readonly',
123
+ 'GET /meta': 'readonly',
124
+ 'GET /settings': 'admin',
125
+ 'PUT /settings': 'admin',
126
+ })
127
+ })
128
+
109
129
  it('registerWithRouter + start() + stop() + start() does not throw', () => {
110
130
  const router = {
111
131
  use: () => {},
@@ -116,4 +136,73 @@ describe('Plugin lifecycle — start() → stop() → start()', () => {
116
136
  plugin.stop()
117
137
  assert.doesNotThrow(() => plugin.start({}))
118
138
  })
139
+
140
+ it('keeps subscriptions through silence and receives resumed wind without replay', t => {
141
+ t.mock.timers.enable({ apis: ['setTimeout', 'Date'] })
142
+ const subscribers = new Map()
143
+ let subscriptions = 0
144
+ let unsubscriptions = 0
145
+ let messages = 0
146
+ app.handleMessage = () => { messages++ }
147
+ app.subscriptionmanager.subscribe = (msg, unsubscribes, _err, callback) => {
148
+ subscriptions++
149
+ for (const { path } of msg.subscribe) {
150
+ if (!subscribers.has(path)) subscribers.set(path, new Set())
151
+ subscribers.get(path).add(callback)
152
+ }
153
+ unsubscribes.push(() => {
154
+ unsubscriptions++
155
+ for (const { path } of msg.subscribe) subscribers.get(path).delete(callback)
156
+ })
157
+ }
158
+ const routes = {}
159
+ plugin.registerWithRouter({
160
+ use: () => {}, get: (p, cb) => { routes[p] = cb },
161
+ put: (p, cb) => { routes[`PUT ${p}`] = cb }, post: () => {}, delete: () => {}
162
+ })
163
+ plugin.start({ tackTrue: true })
164
+ const deliver = (path, value) => {
165
+ for (const callback of subscribers.get(path)) {
166
+ callback({ updates: [{ values: [{ path, value }] }] })
167
+ }
168
+ }
169
+ deliver('environment.wind.speedTrue', 5)
170
+ deliver('environment.wind.angleTrueWater', 0.4)
171
+ const initialSubscriptions = subscriptions
172
+ const initialMessages = messages
173
+ t.mock.timers.tick(120000)
174
+ assert.equal(subscriptions, initialSubscriptions)
175
+ assert.equal(unsubscriptions, 0)
176
+ assert.equal(messages, initialMessages, 'silence must not publish cached inputs')
177
+ let status
178
+ routes['/status']({}, { json: value => { status = value } })
179
+ for (const id of ['wind.smoothed', 'bsp.smoothed', 'hdg.smoothed']) {
180
+ const warning = status.lifecycleWarnings.find(w => w.id === id)
181
+ assert.equal(warning?.status, 'idle', id)
182
+ assert.doesNotMatch(warning.message, /resubscrib/i)
183
+ }
184
+ assert.equal(status.inputs.smoothed.tws, null)
185
+ deliver('environment.wind.speedTrue', 6)
186
+ deliver('environment.wind.angleTrueWater', 0.5)
187
+ routes['/status']({}, { json: value => { status = value } })
188
+ assert.equal(status.lifecycleWarnings.some(w => w.id === 'wind.smoothed'), false)
189
+ assert.equal(status.inputs.smoothed.tws, 6)
190
+ assert.equal(subscriptions, initialSubscriptions)
191
+
192
+ routes['PUT /settings']({ body: { useSOG: true } }, { json: () => {} })
193
+ assert.equal(subscriptions, initialSubscriptions + 1, 'a source change must still resubscribe')
194
+ assert.equal(unsubscriptions, 1)
195
+ assert.equal(subscribers.get('navigation.speedThroughWater').size, 0)
196
+ assert.equal(subscribers.get('navigation.speedOverGround').size, 1)
197
+
198
+ routes['PUT /settings']({ body: { tackTrue: false } }, { json: () => {} })
199
+ routes['PUT /settings']({ body: { tackTrue: true } }, { json: () => {} })
200
+ const configuredSubscriptions = subscriptions
201
+ const configuredUnsubscriptions = unsubscriptions
202
+ t.mock.timers.tick(120000)
203
+ routes['/status']({}, { json: value => { status = value } })
204
+ assert.equal(status.lifecycleWarnings.find(w => w.id === 'hdg.smoothed')?.status, 'idle')
205
+ assert.equal(subscriptions, configuredSubscriptions)
206
+ assert.equal(unsubscriptions, configuredUnsubscriptions)
207
+ })
119
208
  })
@@ -0,0 +1,304 @@
1
+ 'use strict'
2
+
3
+ const { describe, it, beforeEach, afterEach } = require('node:test')
4
+ const assert = require('node:assert/strict')
5
+ const { Polar } = require('polar-math')
6
+ const { wrapPluginRoutes } = require('./routePolicyShim')
7
+
8
+ const KNOTS = 1 / 1.94384
9
+ const rad = degrees => degrees * Math.PI / 180
10
+ const target = (degrees, knots) => {
11
+ const twa = rad(degrees)
12
+ const tbs = knots * KNOTS
13
+ return { twa, tbs, vmg: tbs * Math.abs(Math.cos(twa)) }
14
+ }
15
+
16
+ const TABLE = {
17
+ kind: 'polarTable', schemaVersion: '1.0.0', name: 'Reference Polar',
18
+ units: { tws: 'm/s', twa: 'rad', boatSpeed: 'm/s' },
19
+ symmetry: { portStarboardSymmetric: true },
20
+ axes: { tws: [4, 12, 24].map(k => k * KNOTS), twa: [52, 60, 75, 90, 110, 120, 135, 150].map(rad) },
21
+ values: { boatSpeedMatrix: [
22
+ [3.66, 3.88, 4, 3.9, 3.7, 3.54, 3.12, 2.56],
23
+ [6.42, 6.58, 6.72, 6.82, 6.89, 6.83, 6.62, 6.25],
24
+ [6.69, 6.91, 7.27, 7.61, 8.04, 8.44, 8.54, 8.18]
25
+ ].map(row => row.map(k => k * KNOTS)) },
26
+ derived: { rows: [
27
+ { tws: 4 * KNOTS, beat: target(43.2, 3.24), run: target(147.5, 2.63) },
28
+ { tws: 12 * KNOTS, beat: target(39.6, 5.84), run: target(162.4, 5.83) },
29
+ { tws: 24 * KNOTS, beat: target(40.6, 6.11), run: target(178.6, 7.63) }
30
+ ] }
31
+ }
32
+
33
+ // Downwind-only polar: no beat targets exist, so upwind targets are unavailable.
34
+ const DOWNWIND_ONLY = {
35
+ kind: 'polarTable', schemaVersion: '1.0.0', name: 'Downwind Only',
36
+ units: { tws: 'm/s', twa: 'rad', boatSpeed: 'm/s' },
37
+ symmetry: { portStarboardSymmetric: true },
38
+ axes: { tws: [12 * KNOTS], twa: [90, 120, 150].map(rad) },
39
+ values: { boatSpeedMatrix: [[6.8, 6.8, 6.2].map(k => k * KNOTS)] }
40
+ }
41
+
42
+ // settingsVersion skips migration, which would otherwise force Exponential smoothing
43
+ const ALL_OUTPUTS = {
44
+ settingsVersion: 2, smootherType: 'None',
45
+ beatAngle: true, beatVMG: true, targetTWA: true, optimumWindAngle: true,
46
+ VMG: true, maxSpeed: true, polarSpeed: true, tackTrue: true, smoothedInputs: true
47
+ }
48
+
49
+ const TWS = 12 * KNOTS
50
+ const reference = Polar.fromTable(TABLE)
51
+ const targets = reference.targetsAt({ tws: TWS }).value
52
+ const close = (actual, expected, msg) =>
53
+ assert.ok(Number.isFinite(actual) && Math.abs(actual - expected) < 1e-9, `${msg}: ${actual} != ${expected}`)
54
+
55
+ function harness(table = TABLE) {
56
+ const subscribers = new Map()
57
+ const bus = new Map() // latest value per path written by the plugin
58
+ const writes = [] // every { path, value } written, in order
59
+ const app = {
60
+ debug: () => {}, error: () => {},
61
+ setPluginStatus: () => {}, setPluginError: () => {},
62
+ savePluginOptions: (_o, cb) => cb(null),
63
+ getDataDirPath: () => '.',
64
+ handleMessage: (_id, delta) => {
65
+ for (const update of delta.updates) {
66
+ for (const { path, value } of update.values || []) {
67
+ bus.set(path, value)
68
+ writes.push({ path, value })
69
+ }
70
+ }
71
+ },
72
+ resourcesApi: {
73
+ listResources: async () => ({}),
74
+ getResource: async () => table
75
+ },
76
+ subscriptionmanager: {
77
+ subscribe: (msg, unsubscribes, _err, callback) => {
78
+ for (const { path } of msg.subscribe) {
79
+ if (!subscribers.has(path)) subscribers.set(path, new Set())
80
+ subscribers.get(path).add(callback)
81
+ }
82
+ unsubscribes.push(() => {
83
+ for (const { path } of msg.subscribe) subscribers.get(path).delete(callback)
84
+ })
85
+ }
86
+ }
87
+ }
88
+ delete require.cache[require.resolve('../plugin/index.js')]
89
+ const plugin = wrapPluginRoutes(require('../plugin/index.js')(app))
90
+ const routes = {}
91
+ plugin.registerWithRouter({
92
+ use: () => {}, get: (p, cb) => { routes[p] = cb },
93
+ put: (p, cb) => { routes[`PUT ${p}`] = cb }, post: () => {}, delete: () => {}
94
+ })
95
+
96
+ const deliver = (path, value) => {
97
+ for (const callback of subscribers.get(path) || []) {
98
+ callback({ updates: [{ $source: 'test', values: [{ path, value }] }] })
99
+ }
100
+ }
101
+ return {
102
+ app, plugin, bus, writes, routes, deliver,
103
+ async start(options = ALL_OUTPUTS) {
104
+ plugin.start(options)
105
+ deliver('polars.activePolar', { href: '/resources/polars/ref' })
106
+ await new Promise(resolve => setImmediate(resolve))
107
+ },
108
+ sail({ tws = TWS, twa, bsp, hdg }) {
109
+ if (bsp !== undefined) deliver('navigation.speedThroughWater', bsp)
110
+ if (hdg !== undefined) deliver('navigation.headingTrue', hdg)
111
+ deliver('environment.wind.speedTrue', tws)
112
+ deliver('environment.wind.angleTrueWater', twa)
113
+ },
114
+ status() {
115
+ let result
116
+ routes['/status']({}, { json: value => { result = value } })
117
+ return result
118
+ }
119
+ }
120
+ }
121
+
122
+ describe('computeAndSend output validity', () => {
123
+ let h
124
+
125
+ beforeEach(() => { h = harness() })
126
+ afterEach(() => {
127
+ try { h.plugin.stop() } catch (_) {}
128
+ delete require.cache[require.resolve('../plugin/index.js')]
129
+ })
130
+
131
+ it('publishes every output while sailing normally upwind on starboard', async () => {
132
+ await h.start()
133
+ const twa = rad(50)
134
+ const bsp = 6 * KNOTS
135
+ h.sail({ twa, bsp, hdg: 1 })
136
+ const polarSpeed = reference.speedAt({ tws: TWS, twa }).value
137
+ const vmg = bsp * Math.cos(twa)
138
+ close(h.bus.get('performance.polarSpeed'), polarSpeed, 'polarSpeed')
139
+ close(h.bus.get('performance.polarSpeedRatio'), bsp / polarSpeed, 'polarSpeedRatio')
140
+ close(h.bus.get('performance.targetSpeed'), targets.beat.vmg / Math.cos(targets.beat.twa), 'targetSpeed')
141
+ close(h.bus.get('performance.targetAngle'), targets.beat.twa, 'targetAngle')
142
+ close(h.bus.get('performance.targetVelocityMadeGood'), targets.beat.vmg, 'targetVMG')
143
+ close(h.bus.get('performance.velocityMadeGood'), vmg, 'VMG')
144
+ close(h.bus.get('performance.polarVelocityMadeGood'), targets.beat.vmg, 'polarVMG')
145
+ close(h.bus.get('performance.polarVelocityMadeGoodRatio'), vmg / targets.beat.vmg, 'VMG ratio')
146
+ close(h.bus.get('performance.beatAngle'), targets.beat.twa, 'beatAngle')
147
+ close(h.bus.get('performance.gybeAngle'), targets.run.twa, 'gybeAngle')
148
+ close(h.bus.get('performance.optimumWindAngle'), twa - targets.beat.twa, 'optimumWindAngle')
149
+ close(h.bus.get('performance.tackTrue'), 1 + targets.beat.twa, 'tackTrue')
150
+ close(h.bus.get('environment.wind.angleTrueWaterDamped'), twa, 'damped TWA')
151
+ close(h.bus.get('performance.boatSpeedDamped'), bsp, 'damped BSP')
152
+ })
153
+
154
+ it('in irons clears polar values, keeps steering advice and VMG live, and restores them on leaving', async () => {
155
+ await h.start()
156
+ const bsp = 4 * KNOTS
157
+ h.sail({ twa: rad(50), bsp, hdg: 1 })
158
+ assert.ok(h.bus.get('performance.polarSpeed') > 0)
159
+
160
+ const ironsTwa = rad(20)
161
+ assert.equal(reference.speedAt({ tws: TWS, twa: ironsTwa }).state.twa, 'in_irons')
162
+ h.sail({ twa: ironsTwa, bsp, hdg: 1 })
163
+ for (const path of ['performance.polarSpeed', 'performance.polarSpeedRatio', 'performance.targetSpeed',
164
+ 'performance.targetVelocityMadeGood', 'performance.polarVelocityMadeGood',
165
+ 'performance.polarVelocityMadeGoodRatio', 'performance.beatAngle', 'performance.gybeAngle',
166
+ 'performance.beatAngleVelocityMadeGood', 'performance.gybeAngleVelocityMadeGood',
167
+ 'performance.maxSpeed', 'performance.maxSpeedAngle']) {
168
+ assert.equal(h.bus.get(path), null, path)
169
+ }
170
+ close(h.bus.get('performance.targetAngle'), targets.beat.twa, 'targetAngle in irons')
171
+ close(h.bus.get('performance.optimumWindAngle'), ironsTwa - targets.beat.twa, 'optimumWindAngle in irons')
172
+ close(h.bus.get('performance.tackTrue'), 1 + targets.beat.twa, 'tackTrue in irons')
173
+ close(h.bus.get('performance.velocityMadeGood'), bsp * Math.cos(ironsTwa), 'VMG in irons')
174
+
175
+ // VMG must keep updating while in irons, not freeze
176
+ h.sail({ twa: ironsTwa, bsp: 2 * KNOTS })
177
+ close(h.bus.get('performance.velocityMadeGood'), 2 * KNOTS * Math.cos(ironsTwa), 'VMG updates in irons')
178
+
179
+ const status = h.status()
180
+ assert.equal(status.outputs['performance.polarSpeed'], null)
181
+ assert.equal(status.outputs['performance.targetSpeed'], null)
182
+
183
+ // Pinching is not in irons, so nothing is cleared
184
+ const pinchTwa = rad(38)
185
+ assert.equal(reference.speedAt({ tws: TWS, twa: pinchTwa }).state.twa, 'pinching')
186
+ h.sail({ twa: pinchTwa, bsp })
187
+ close(h.bus.get('performance.targetSpeed'), targets.beat.vmg / Math.cos(targets.beat.twa), 'targetSpeed pinching')
188
+ close(h.bus.get('performance.targetVelocityMadeGood'), targets.beat.vmg, 'targetVMG pinching')
189
+ close(h.bus.get('performance.polarVelocityMadeGood'), targets.beat.vmg, 'polarVMG pinching')
190
+ close(h.bus.get('performance.polarVelocityMadeGoodRatio'), bsp * Math.cos(pinchTwa) / targets.beat.vmg, 'ratio pinching')
191
+ close(h.bus.get('performance.beatAngle'), targets.beat.twa, 'beatAngle pinching')
192
+ assert.ok(h.bus.get('performance.maxSpeed') > 0)
193
+
194
+ h.sail({ twa: rad(50), bsp })
195
+ const polarSpeed = reference.speedAt({ tws: TWS, twa: rad(50) }).value
196
+ close(h.bus.get('performance.polarSpeed'), polarSpeed, 'polarSpeed after irons')
197
+ close(h.bus.get('performance.polarSpeedRatio'), bsp / polarSpeed, 'ratio after irons')
198
+ close(h.bus.get('performance.targetSpeed'), targets.beat.vmg / Math.cos(targets.beat.twa), 'targetSpeed after irons')
199
+ })
200
+
201
+ it('clears unavailable outputs only once per transition', async () => {
202
+ await h.start()
203
+ h.sail({ twa: rad(50), bsp: 4 * KNOTS })
204
+ h.sail({ twa: rad(20), bsp: 4 * KNOTS })
205
+ h.sail({ twa: rad(21), bsp: 4 * KNOTS })
206
+ const polarSpeedNulls = h.writes.filter(w => w.path === 'performance.polarSpeed' && w.value === null)
207
+ assert.equal(polarSpeedNulls.length, 1)
208
+ })
209
+
210
+ it('mirrors signed angles and keeps magnitudes on port tack', async () => {
211
+ await h.start()
212
+ const twa = rad(-50)
213
+ const bsp = 6 * KNOTS
214
+ h.sail({ twa, bsp, hdg: 1 })
215
+ close(h.bus.get('performance.targetAngle'), -targets.beat.twa, 'targetAngle port')
216
+ close(h.bus.get('performance.beatAngle'), -targets.beat.twa, 'beatAngle port')
217
+ close(h.bus.get('performance.optimumWindAngle'), -(rad(50) - targets.beat.twa), 'optimumWindAngle port')
218
+ close(h.bus.get('performance.tackTrue'), 1 - targets.beat.twa, 'tackTrue port')
219
+ close(h.bus.get('performance.targetSpeed'), targets.beat.vmg / Math.cos(targets.beat.twa), 'targetSpeed port')
220
+ close(h.bus.get('performance.velocityMadeGood'), bsp * Math.cos(rad(50)), 'VMG port')
221
+
222
+ h.sail({ twa: rad(-20), bsp })
223
+ assert.equal(h.bus.get('performance.polarSpeed'), null)
224
+ assert.equal(h.bus.get('performance.targetSpeed'), null)
225
+ assert.equal(h.bus.get('performance.targetVelocityMadeGood'), null)
226
+ assert.equal(h.bus.get('performance.beatAngle'), null)
227
+ close(h.bus.get('performance.targetAngle'), -targets.beat.twa, 'targetAngle port irons')
228
+ close(h.bus.get('performance.optimumWindAngle'), -(rad(20) - targets.beat.twa), 'optimumWindAngle port irons')
229
+ close(h.bus.get('performance.tackTrue'), 1 - targets.beat.twa, 'tackTrue port irons')
230
+ })
231
+
232
+ it('selects run targets when sailing downwind', async () => {
233
+ await h.start()
234
+ const twa = rad(150)
235
+ const bsp = 6 * KNOTS
236
+ h.sail({ twa, bsp, hdg: 1 })
237
+ const vmg = bsp * Math.cos(twa)
238
+ close(h.bus.get('performance.targetAngle'), targets.run.twa, 'run targetAngle')
239
+ close(h.bus.get('performance.targetVelocityMadeGood'), targets.run.vmg, 'run targetVMG')
240
+ close(h.bus.get('performance.targetSpeed'), targets.run.vmg / Math.abs(Math.cos(targets.run.twa)), 'run targetSpeed')
241
+ close(h.bus.get('performance.velocityMadeGood'), vmg, 'downwind VMG is negative')
242
+ assert.ok(vmg < 0)
243
+ close(h.bus.get('performance.polarVelocityMadeGoodRatio'), Math.abs(vmg) / targets.run.vmg, 'downwind ratio')
244
+ close(h.bus.get('performance.optimumWindAngle'), twa - targets.run.twa, 'downwind optimumWindAngle')
245
+ close(h.bus.get('performance.tackTrue'), 1 + targets.run.twa, 'downwind tackTrue')
246
+ })
247
+
248
+ it('clears BSP-dependent outputs when boat speed is missing', async () => {
249
+ await h.start()
250
+ h.sail({ twa: rad(50), bsp: 6 * KNOTS })
251
+ assert.ok(h.bus.get('performance.velocityMadeGood') > 0)
252
+ h.sail({ twa: rad(50), bsp: null })
253
+ for (const path of ['performance.velocityMadeGood', 'performance.polarVelocityMadeGoodRatio',
254
+ 'performance.polarSpeedRatio', 'performance.boatSpeedDamped']) {
255
+ assert.equal(h.bus.get(path), null, path)
256
+ }
257
+ assert.ok(h.bus.get('performance.polarSpeed') > 0)
258
+ assert.ok(h.bus.get('performance.targetSpeed') > 0)
259
+ close(h.bus.get('performance.polarVelocityMadeGood'), targets.beat.vmg, 'polarVMG without BSP')
260
+ })
261
+
262
+ it('clears target-dependent outputs when polar targets are unavailable', async () => {
263
+ h = harness(DOWNWIND_ONLY)
264
+ await h.start()
265
+ const noBeat = Polar.fromTable(DOWNWIND_ONLY).targetsAt({ tws: TWS }).value
266
+ assert.equal(noBeat.beat, null)
267
+
268
+ h.sail({ twa: rad(150), bsp: 6 * KNOTS, hdg: 1 })
269
+ assert.ok(h.bus.get('performance.targetSpeed') > 0)
270
+ assert.ok(h.bus.get('performance.polarVelocityMadeGood') > 0)
271
+
272
+ h.sail({ twa: rad(60), bsp: 6 * KNOTS, hdg: 1 })
273
+ for (const path of ['performance.targetSpeed', 'performance.targetAngle',
274
+ 'performance.targetVelocityMadeGood', 'performance.polarVelocityMadeGood',
275
+ 'performance.polarVelocityMadeGoodRatio', 'performance.beatAngle',
276
+ 'performance.beatAngleVelocityMadeGood', 'performance.optimumWindAngle', 'performance.tackTrue']) {
277
+ assert.equal(h.bus.get(path), null, path)
278
+ }
279
+ close(h.bus.get('performance.velocityMadeGood'), 6 * KNOTS * Math.cos(rad(60)), 'actual VMG without targets')
280
+ })
281
+
282
+ it('does not publish disabled outputs and clears outputs toggled off or on stop', async () => {
283
+ await h.start({ settingsVersion: 2, smootherType: 'None', polarSpeed: true })
284
+ h.sail({ twa: rad(50), bsp: 4 * KNOTS })
285
+ const published = new Set(h.writes.map(w => w.path))
286
+ assert.deepEqual([...published].sort(),
287
+ ['performance.polarSpeed', 'performance.polarSpeedRatio', 'performance.targetSpeed'])
288
+ assert.ok(h.bus.get('performance.targetSpeed') > 0)
289
+
290
+ h.routes['PUT /settings']({ body: { polarSpeed: false } }, { json: () => {} })
291
+ assert.equal(h.bus.get('performance.targetSpeed'), null)
292
+ const before = h.writes.length
293
+ h.sail({ twa: rad(50), bsp: 4 * KNOTS })
294
+ assert.equal(h.writes.length, before, 'disabled outputs must not be written')
295
+
296
+ h.routes['PUT /settings']({ body: { polarSpeed: true } }, { json: () => {} })
297
+ h.sail({ twa: rad(50), bsp: 4 * KNOTS })
298
+ assert.ok(h.bus.get('performance.polarSpeed') > 0)
299
+ h.plugin.stop()
300
+ for (const path of ['performance.polarSpeed', 'performance.targetSpeed', 'performance.polarSpeedRatio']) {
301
+ assert.equal(h.bus.get(path), null, path)
302
+ }
303
+ })
304
+ })
@@ -0,0 +1,42 @@
1
+ 'use strict'
2
+
3
+ function withRoutePolicies(router) {
4
+ if (router.routePolicies instanceof Map) return router
5
+
6
+ const policies = new Map()
7
+ const methods = ['get', 'post', 'put', 'patch', 'delete']
8
+ const originalMethods = new Map()
9
+ for (const method of methods) {
10
+ if (typeof router[method] !== 'function') continue
11
+ const original = router[method]
12
+ originalMethods.set(method, original)
13
+ router[method] = (route, ...handlers) => {
14
+ policies.set(`${method.toUpperCase()} ${route}`, 'admin')
15
+ return original.call(router, route, ...handlers)
16
+ }
17
+ }
18
+
19
+ router.access = (level) => {
20
+ if (level !== 'readonly' && level !== 'readwrite') {
21
+ throw new Error(`Unsupported route access level: ${level}`)
22
+ }
23
+ const scopedRouter = {}
24
+ for (const [method, original] of originalMethods) {
25
+ scopedRouter[method] = (route, ...handlers) => {
26
+ policies.set(`${method.toUpperCase()} ${route}`, level)
27
+ return original.call(router, route, ...handlers)
28
+ }
29
+ }
30
+ return scopedRouter
31
+ }
32
+ router.routePolicies = policies
33
+ return router
34
+ }
35
+
36
+ function wrapPluginRoutes(plugin) {
37
+ const registerWithRouter = plugin.registerWithRouter
38
+ plugin.registerWithRouter = (router) => registerWithRouter(withRoutePolicies(router))
39
+ return plugin
40
+ }
41
+
42
+ module.exports = { withRoutePolicies, wrapPluginRoutes }