signalk-polar-performance-plugin 2.0.0 → 2.2.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/public/app.js CHANGED
@@ -349,6 +349,13 @@ function polarStateWarnings(d) {
349
349
  const s = d?.polarState
350
350
  if (!s) return []
351
351
  const msgs = []
352
+ // Unavailable results from the management calculation service carry a reason
353
+ // instead of interpolation details.
354
+ if (s.available === false) {
355
+ if (s.reason === 'no_active_polar') msgs.push('No active polar — select one in the polar management webapp')
356
+ else if (s.reason) msgs.push('Polar calculations unavailable (' + s.reason.replace(/_/g, ' ') + ')')
357
+ return msgs
358
+ }
352
359
  if (s.tws === 'below_range') msgs.push('Wind speed is below the polar table range — values are extrapolated')
353
360
  if (s.tws === 'above_range') msgs.push('Wind speed is above the polar table range — values are extrapolated')
354
361
  if (s.twa === 'in_irons') msgs.push('Sailing in irons — too close to the wind for polar data')
@@ -471,6 +478,7 @@ function _tickOverview() {
471
478
  { label: 'Sail number', id: 'ov-polar-sailnumber' },
472
479
  { label: 'Year', id: 'ov-polar-year' },
473
480
  { label: 'Source', id: 'ov-polar-source' },
481
+ { label: 'Sailplan', id: 'ov-polar-sailplan' },
474
482
  { label: 'Performance factor', id: 'ov-polar-perf' },
475
483
  ]))
476
484
  }
@@ -480,6 +488,7 @@ function _tickOverview() {
480
488
  setVal('ov-polar-sailnumber', p.sailnumber || '—')
481
489
  setVal('ov-polar-year', p.year ? String(p.year) : '—')
482
490
  setVal('ov-polar-source', p.source || '—')
491
+ setVal('ov-polar-sailplan', p.sailplanId || '—')
483
492
  setVal('ov-polar-perf', fmtVal(meta?.performanceFactor, 'performance', RATIO_DEFAULT))
484
493
  }
485
494
  }
@@ -512,7 +521,7 @@ function _tickOverview() {
512
521
  if (d?.tws == null) warns.push('True wind speed — no data (environment.wind.speedTrue)')
513
522
  if (d?.twa == null) warns.push('True wind angle — no data (environment.wind.angleTrueWater)')
514
523
  if (d?.bsp == null) warns.push('Boat speed — no data')
515
- if (d?.tws != null && d?.polarState == null) warns.push('No active polar — select one in the polar management webapp')
524
+ if (d?.tws != null && d?.polarState == null) warns.push('Polar calculation service not available — install or enable signalk-polar-management')
516
525
  warns.push(...polarStateWarnings(d))
517
526
  updateWarnings(document.getElementById('ov-warnings'), warns)
518
527
  }
@@ -720,6 +729,7 @@ function switchPage(page) {
720
729
  function startPolling() {
721
730
  setInterval(refreshLive, 1000)
722
731
  setInterval(async () => {
732
+ await loadMeta()
723
733
  await refreshLibrary()
724
734
  if (activePage === 'overview' && polar) polar.setLibraryData(twsList, curves, liveCurve)
725
735
  }, 5000)
@@ -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
@@ -24,6 +25,7 @@ function makeApp(dataDir) {
24
25
  },
25
26
  getDataDirPath: () => dataDir,
26
27
  handleMessage: () => {},
28
+ onPropertyValues: () => () => {},
27
29
  config: { port: 3000 },
28
30
  subscriptionmanager: {
29
31
  subscribe: (_sub, unsubscribes, _onErr, _onDelta) => {
@@ -39,7 +41,7 @@ function makeApp(dataDir) {
39
41
 
40
42
  function freshPlugin(app) {
41
43
  delete require.cache[require.resolve('../plugin/index.js')]
42
- return require('../plugin/index.js')(app)
44
+ return wrapPluginRoutes(require('../plugin/index.js')(app))
43
45
  }
44
46
 
45
47
  // ---------------------------------------------------------------------------
@@ -106,6 +108,25 @@ describe('Plugin lifecycle — start() → stop() → start()', () => {
106
108
  assert.ok(routes.some(r => r.startsWith('GET')), 'no GET routes registered')
107
109
  })
108
110
 
111
+ it('applies readonly access only to safe live and polar metadata routes', () => {
112
+ const router = {
113
+ use: () => {},
114
+ get: () => {},
115
+ put: () => {},
116
+ }
117
+ plugin.registerWithRouter(withRoutePolicies(router))
118
+
119
+ assert.deepEqual(Object.fromEntries(router.routePolicies), {
120
+ 'GET /polar/axes/tws': 'readonly',
121
+ 'GET /polar/queries/curve': 'readonly',
122
+ 'GET /live': 'readonly',
123
+ 'GET /status': 'readonly',
124
+ 'GET /meta': 'readonly',
125
+ 'GET /settings': 'admin',
126
+ 'PUT /settings': 'admin',
127
+ })
128
+ })
129
+
109
130
  it('registerWithRouter + start() + stop() + start() does not throw', () => {
110
131
  const router = {
111
132
  use: () => {},
@@ -116,4 +137,73 @@ describe('Plugin lifecycle — start() → stop() → start()', () => {
116
137
  plugin.stop()
117
138
  assert.doesNotThrow(() => plugin.start({}))
118
139
  })
140
+
141
+ it('keeps subscriptions through silence and receives resumed wind without replay', t => {
142
+ t.mock.timers.enable({ apis: ['setTimeout', 'Date'] })
143
+ const subscribers = new Map()
144
+ let subscriptions = 0
145
+ let unsubscriptions = 0
146
+ let messages = 0
147
+ app.handleMessage = () => { messages++ }
148
+ app.subscriptionmanager.subscribe = (msg, unsubscribes, _err, callback) => {
149
+ subscriptions++
150
+ for (const { path } of msg.subscribe) {
151
+ if (!subscribers.has(path)) subscribers.set(path, new Set())
152
+ subscribers.get(path).add(callback)
153
+ }
154
+ unsubscribes.push(() => {
155
+ unsubscriptions++
156
+ for (const { path } of msg.subscribe) subscribers.get(path).delete(callback)
157
+ })
158
+ }
159
+ const routes = {}
160
+ plugin.registerWithRouter({
161
+ use: () => {}, get: (p, cb) => { routes[p] = cb },
162
+ put: (p, cb) => { routes[`PUT ${p}`] = cb }, post: () => {}, delete: () => {}
163
+ })
164
+ plugin.start({ tackTrue: true })
165
+ const deliver = (path, value) => {
166
+ for (const callback of subscribers.get(path)) {
167
+ callback({ updates: [{ values: [{ path, value }] }] })
168
+ }
169
+ }
170
+ deliver('environment.wind.speedTrue', 5)
171
+ deliver('environment.wind.angleTrueWater', 0.4)
172
+ const initialSubscriptions = subscriptions
173
+ const initialMessages = messages
174
+ t.mock.timers.tick(120000)
175
+ assert.equal(subscriptions, initialSubscriptions)
176
+ assert.equal(unsubscriptions, 0)
177
+ assert.equal(messages, initialMessages, 'silence must not publish cached inputs')
178
+ let status
179
+ routes['/status']({}, { json: value => { status = value } })
180
+ for (const id of ['wind.smoothed', 'bsp.smoothed', 'hdg.smoothed']) {
181
+ const warning = status.lifecycleWarnings.find(w => w.id === id)
182
+ assert.equal(warning?.status, 'idle', id)
183
+ assert.doesNotMatch(warning.message, /resubscrib/i)
184
+ }
185
+ assert.equal(status.inputs.smoothed.tws, null)
186
+ deliver('environment.wind.speedTrue', 6)
187
+ deliver('environment.wind.angleTrueWater', 0.5)
188
+ routes['/status']({}, { json: value => { status = value } })
189
+ assert.equal(status.lifecycleWarnings.some(w => w.id === 'wind.smoothed'), false)
190
+ assert.equal(status.inputs.smoothed.tws, 6)
191
+ assert.equal(subscriptions, initialSubscriptions)
192
+
193
+ routes['PUT /settings']({ body: { useSOG: true } }, { json: () => {} })
194
+ assert.equal(subscriptions, initialSubscriptions + 1, 'a source change must still resubscribe')
195
+ assert.equal(unsubscriptions, 1)
196
+ assert.equal(subscribers.get('navigation.speedThroughWater').size, 0)
197
+ assert.equal(subscribers.get('navigation.speedOverGround').size, 1)
198
+
199
+ routes['PUT /settings']({ body: { tackTrue: false } }, { json: () => {} })
200
+ routes['PUT /settings']({ body: { tackTrue: true } }, { json: () => {} })
201
+ const configuredSubscriptions = subscriptions
202
+ const configuredUnsubscriptions = unsubscriptions
203
+ t.mock.timers.tick(120000)
204
+ routes['/status']({}, { json: value => { status = value } })
205
+ assert.equal(status.lifecycleWarnings.find(w => w.id === 'hdg.smoothed')?.status, 'idle')
206
+ assert.equal(subscriptions, configuredSubscriptions)
207
+ assert.equal(unsubscriptions, configuredUnsubscriptions)
208
+ })
119
209
  })
@@ -0,0 +1,419 @@
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, { withService = true, description = {} } = {}) {
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 calls = [] // every calculation call made on the service handle
60
+ const changeListeners = new Set()
61
+
62
+ // Fake in-process calculation service, as announced by signalk-polar-management
63
+ // through the `polarManagement` PropertyValue. Wraps a real polar-math Polar
64
+ // so numeric expectations stay exact. The handle owns the performance factor;
65
+ // recorded calls let tests assert the plugin never supplies one. `state`
66
+ // drives describe(), mirroring how real active handles follow shared settings.
67
+ const polar = Polar.fromTable(table)
68
+ const state = {
69
+ active: true,
70
+ id: 'ref',
71
+ sailplanId: null,
72
+ performanceFactor: 1,
73
+ name: table.name ?? null,
74
+ twsAxis: table.axes?.tws ?? [],
75
+ ...description
76
+ }
77
+ const handle = {
78
+ speedAt: options => { calls.push({ method: 'speedAt', options }); return polar.speedAt(options) },
79
+ vmgAt: options => { calls.push({ method: 'vmgAt', options }); return polar.vmgAt(options) },
80
+ targetsAt: options => { calls.push({ method: 'targetsAt', options }); return polar.targetsAt(options) },
81
+ rangeAt: options => { calls.push({ method: 'rangeAt', options }); return polar.rangeAt(options) },
82
+ describe: () => state.active
83
+ ? {
84
+ available: true,
85
+ id: state.id,
86
+ sailplanId: state.sailplanId,
87
+ performanceFactor: state.performanceFactor,
88
+ name: state.name,
89
+ boatType: null, sailnumber: null, year: null, source: null, notes: null,
90
+ twsAxis: state.twsAxis
91
+ }
92
+ : { available: false, reason: 'no_active_polar' },
93
+ onChange: callback => { changeListeners.add(callback); return () => changeListeners.delete(callback) }
94
+ }
95
+ const service = {
96
+ apiVersion: 1,
97
+ available: true,
98
+ activePolar: () => handle,
99
+ polar: () => handle
100
+ }
101
+ const notifyChange = (change = { type: 'selection', ids: [] }) => {
102
+ for (const callback of [...changeListeners]) callback(change)
103
+ }
104
+
105
+ const app = {
106
+ debug: () => {}, error: () => {},
107
+ setPluginStatus: () => {}, setPluginError: () => {},
108
+ savePluginOptions: (_o, cb) => cb(null),
109
+ getDataDirPath: () => '.',
110
+ handleMessage: (_id, delta) => {
111
+ for (const update of delta.updates) {
112
+ for (const { path, value } of update.values || []) {
113
+ bus.set(path, value)
114
+ writes.push({ path, value })
115
+ }
116
+ }
117
+ },
118
+ onPropertyValues: (name, callback) => {
119
+ if (name === 'polarManagement' && withService) {
120
+ callback([{ setter: 'signalk-polar-management', value: service }])
121
+ }
122
+ return () => {}
123
+ },
124
+ subscriptionmanager: {
125
+ subscribe: (msg, unsubscribes, _err, callback) => {
126
+ for (const { path } of msg.subscribe) {
127
+ if (!subscribers.has(path)) subscribers.set(path, new Set())
128
+ subscribers.get(path).add(callback)
129
+ }
130
+ unsubscribes.push(() => {
131
+ for (const { path } of msg.subscribe) subscribers.get(path).delete(callback)
132
+ })
133
+ }
134
+ }
135
+ }
136
+ delete require.cache[require.resolve('../plugin/index.js')]
137
+ const plugin = wrapPluginRoutes(require('../plugin/index.js')(app))
138
+ const routes = {}
139
+ plugin.registerWithRouter({
140
+ use: () => {}, get: (p, cb) => { routes[p] = cb },
141
+ put: (p, cb) => { routes[`PUT ${p}`] = cb }, post: () => {}, delete: () => {}
142
+ })
143
+
144
+ const deliver = (path, value) => {
145
+ for (const callback of subscribers.get(path) || []) {
146
+ callback({ updates: [{ $source: 'test', values: [{ path, value }] }] })
147
+ }
148
+ }
149
+ return {
150
+ app, plugin, bus, writes, calls, routes, deliver, state, notifyChange,
151
+ async start(options = ALL_OUTPUTS) {
152
+ plugin.start(options)
153
+ await new Promise(resolve => setImmediate(resolve))
154
+ },
155
+ sail({ tws = TWS, twa, bsp, hdg }) {
156
+ if (bsp !== undefined) deliver('navigation.speedThroughWater', bsp)
157
+ if (hdg !== undefined) deliver('navigation.headingTrue', hdg)
158
+ deliver('environment.wind.speedTrue', tws)
159
+ deliver('environment.wind.angleTrueWater', twa)
160
+ },
161
+ status() {
162
+ let result
163
+ routes['/status']({}, { json: value => { result = value } })
164
+ return result
165
+ }
166
+ }
167
+ }
168
+
169
+ describe('computeAndSend output validity', () => {
170
+ let h
171
+
172
+ beforeEach(() => { h = harness() })
173
+ afterEach(() => {
174
+ try { h.plugin.stop() } catch (_) {}
175
+ delete require.cache[require.resolve('../plugin/index.js')]
176
+ })
177
+
178
+ it('publishes every output while sailing normally upwind on starboard', async () => {
179
+ await h.start()
180
+ const twa = rad(50)
181
+ const bsp = 6 * KNOTS
182
+ h.sail({ twa, bsp, hdg: 1 })
183
+ const polarSpeed = reference.speedAt({ tws: TWS, twa }).value
184
+ const vmg = bsp * Math.cos(twa)
185
+ close(h.bus.get('performance.polarSpeed'), polarSpeed, 'polarSpeed')
186
+ close(h.bus.get('performance.polarSpeedRatio'), bsp / polarSpeed, 'polarSpeedRatio')
187
+ close(h.bus.get('performance.targetSpeed'), targets.beat.vmg / Math.cos(targets.beat.twa), 'targetSpeed')
188
+ close(h.bus.get('performance.targetAngle'), targets.beat.twa, 'targetAngle')
189
+ close(h.bus.get('performance.targetVelocityMadeGood'), targets.beat.vmg, 'targetVMG')
190
+ close(h.bus.get('performance.velocityMadeGood'), vmg, 'VMG')
191
+ close(h.bus.get('performance.polarVelocityMadeGood'), targets.beat.vmg, 'polarVMG')
192
+ close(h.bus.get('performance.polarVelocityMadeGoodRatio'), vmg / targets.beat.vmg, 'VMG ratio')
193
+ close(h.bus.get('performance.beatAngle'), targets.beat.twa, 'beatAngle')
194
+ close(h.bus.get('performance.gybeAngle'), targets.run.twa, 'gybeAngle')
195
+ close(h.bus.get('performance.optimumWindAngle'), twa - targets.beat.twa, 'optimumWindAngle')
196
+ close(h.bus.get('performance.tackTrue'), 1 + targets.beat.twa, 'tackTrue')
197
+ close(h.bus.get('environment.wind.angleTrueWaterDamped'), twa, 'damped TWA')
198
+ close(h.bus.get('performance.boatSpeedDamped'), bsp, 'damped BSP')
199
+ })
200
+
201
+ it('in irons clears polar values, keeps steering advice and VMG live, and restores them on leaving', async () => {
202
+ await h.start()
203
+ const bsp = 4 * KNOTS
204
+ h.sail({ twa: rad(50), bsp, hdg: 1 })
205
+ assert.ok(h.bus.get('performance.polarSpeed') > 0)
206
+
207
+ const ironsTwa = rad(20)
208
+ assert.equal(reference.speedAt({ tws: TWS, twa: ironsTwa }).state.twa, 'in_irons')
209
+ h.sail({ twa: ironsTwa, bsp, hdg: 1 })
210
+ for (const path of ['performance.polarSpeed', 'performance.polarSpeedRatio', 'performance.polarVelocityMadeGood',
211
+ 'performance.polarVelocityMadeGoodRatio', 'performance.beatAngle', 'performance.gybeAngle',
212
+ 'performance.beatAngleVelocityMadeGood', 'performance.gybeAngleVelocityMadeGood',
213
+ 'performance.maxSpeed', 'performance.maxSpeedAngle']) {
214
+ assert.equal(h.bus.get(path), null, path)
215
+ }
216
+ close(h.bus.get('performance.targetSpeed'), targets.beat.vmg / Math.cos(targets.beat.twa), 'targetSpeed in irons')
217
+ close(h.bus.get('performance.targetVelocityMadeGood'), targets.beat.vmg, 'targetVMG in irons')
218
+ close(h.bus.get('performance.targetAngle'), targets.beat.twa, 'targetAngle in irons')
219
+ close(h.bus.get('performance.optimumWindAngle'), ironsTwa - targets.beat.twa, 'optimumWindAngle in irons')
220
+ close(h.bus.get('performance.tackTrue'), 1 + targets.beat.twa, 'tackTrue in irons')
221
+ close(h.bus.get('performance.velocityMadeGood'), bsp * Math.cos(ironsTwa), 'VMG in irons')
222
+
223
+ // VMG must keep updating while in irons, not freeze
224
+ h.sail({ twa: ironsTwa, bsp: 2 * KNOTS })
225
+ close(h.bus.get('performance.velocityMadeGood'), 2 * KNOTS * Math.cos(ironsTwa), 'VMG updates in irons')
226
+
227
+ const status = h.status()
228
+ assert.equal(status.outputs['performance.polarSpeed'], null)
229
+ assert.ok(Math.abs(status.outputs['performance.targetSpeed'] - targets.beat.vmg / Math.cos(targets.beat.twa)) < 0.001)
230
+ assert.ok(Math.abs(status.outputs['performance.targetVelocityMadeGood'] - targets.beat.vmg) < 0.001)
231
+
232
+ // Pinching is not in irons, so nothing is cleared
233
+ const pinchTwa = rad(38)
234
+ assert.equal(reference.speedAt({ tws: TWS, twa: pinchTwa }).state.twa, 'pinching')
235
+ h.sail({ twa: pinchTwa, bsp })
236
+ close(h.bus.get('performance.targetSpeed'), targets.beat.vmg / Math.cos(targets.beat.twa), 'targetSpeed pinching')
237
+ close(h.bus.get('performance.targetVelocityMadeGood'), targets.beat.vmg, 'targetVMG pinching')
238
+ close(h.bus.get('performance.polarVelocityMadeGood'), targets.beat.vmg, 'polarVMG pinching')
239
+ close(h.bus.get('performance.polarVelocityMadeGoodRatio'), bsp * Math.cos(pinchTwa) / targets.beat.vmg, 'ratio pinching')
240
+ close(h.bus.get('performance.beatAngle'), targets.beat.twa, 'beatAngle pinching')
241
+ assert.ok(h.bus.get('performance.maxSpeed') > 0)
242
+
243
+ h.sail({ twa: rad(50), bsp })
244
+ const polarSpeed = reference.speedAt({ tws: TWS, twa: rad(50) }).value
245
+ close(h.bus.get('performance.polarSpeed'), polarSpeed, 'polarSpeed after irons')
246
+ close(h.bus.get('performance.polarSpeedRatio'), bsp / polarSpeed, 'ratio after irons')
247
+ close(h.bus.get('performance.targetSpeed'), targets.beat.vmg / Math.cos(targets.beat.twa), 'targetSpeed after irons')
248
+ })
249
+
250
+ it('clears unavailable outputs only once per transition', async () => {
251
+ await h.start()
252
+ h.sail({ twa: rad(50), bsp: 4 * KNOTS })
253
+ h.sail({ twa: rad(20), bsp: 4 * KNOTS })
254
+ h.sail({ twa: rad(21), bsp: 4 * KNOTS })
255
+ const polarSpeedNulls = h.writes.filter(w => w.path === 'performance.polarSpeed' && w.value === null)
256
+ assert.equal(polarSpeedNulls.length, 1)
257
+ })
258
+
259
+ it('mirrors signed angles and keeps magnitudes on port tack', async () => {
260
+ await h.start()
261
+ const twa = rad(-50)
262
+ const bsp = 6 * KNOTS
263
+ h.sail({ twa, bsp, hdg: 1 })
264
+ close(h.bus.get('performance.targetAngle'), -targets.beat.twa, 'targetAngle port')
265
+ close(h.bus.get('performance.beatAngle'), -targets.beat.twa, 'beatAngle port')
266
+ close(h.bus.get('performance.optimumWindAngle'), -(rad(50) - targets.beat.twa), 'optimumWindAngle port')
267
+ close(h.bus.get('performance.tackTrue'), 1 - targets.beat.twa, 'tackTrue port')
268
+ close(h.bus.get('performance.targetSpeed'), targets.beat.vmg / Math.cos(targets.beat.twa), 'targetSpeed port')
269
+ close(h.bus.get('performance.velocityMadeGood'), bsp * Math.cos(rad(50)), 'VMG port')
270
+
271
+ h.sail({ twa: rad(-20), bsp })
272
+ assert.equal(h.bus.get('performance.polarSpeed'), null)
273
+ close(h.bus.get('performance.targetSpeed'), targets.beat.vmg / Math.cos(targets.beat.twa), 'targetSpeed port irons')
274
+ close(h.bus.get('performance.targetVelocityMadeGood'), targets.beat.vmg, 'targetVMG port irons')
275
+ assert.equal(h.bus.get('performance.beatAngle'), null)
276
+ close(h.bus.get('performance.targetAngle'), -targets.beat.twa, 'targetAngle port irons')
277
+ close(h.bus.get('performance.optimumWindAngle'), -(rad(20) - targets.beat.twa), 'optimumWindAngle port irons')
278
+ close(h.bus.get('performance.tackTrue'), 1 - targets.beat.twa, 'tackTrue port irons')
279
+ })
280
+
281
+ it('selects run targets when sailing downwind', async () => {
282
+ await h.start()
283
+ const twa = rad(150)
284
+ const bsp = 6 * KNOTS
285
+ h.sail({ twa, bsp, hdg: 1 })
286
+ const vmg = bsp * Math.cos(twa)
287
+ close(h.bus.get('performance.targetAngle'), targets.run.twa, 'run targetAngle')
288
+ close(h.bus.get('performance.targetVelocityMadeGood'), targets.run.vmg, 'run targetVMG')
289
+ close(h.bus.get('performance.targetSpeed'), targets.run.vmg / Math.abs(Math.cos(targets.run.twa)), 'run targetSpeed')
290
+ close(h.bus.get('performance.velocityMadeGood'), vmg, 'downwind VMG is negative')
291
+ assert.ok(vmg < 0)
292
+ close(h.bus.get('performance.polarVelocityMadeGoodRatio'), Math.abs(vmg) / targets.run.vmg, 'downwind ratio')
293
+ close(h.bus.get('performance.optimumWindAngle'), twa - targets.run.twa, 'downwind optimumWindAngle')
294
+ close(h.bus.get('performance.tackTrue'), 1 + targets.run.twa, 'downwind tackTrue')
295
+ })
296
+
297
+ it('clears BSP-dependent outputs when boat speed is missing', async () => {
298
+ await h.start()
299
+ h.sail({ twa: rad(50), bsp: 6 * KNOTS })
300
+ assert.ok(h.bus.get('performance.velocityMadeGood') > 0)
301
+ h.sail({ twa: rad(50), bsp: null })
302
+ for (const path of ['performance.velocityMadeGood', 'performance.polarVelocityMadeGoodRatio',
303
+ 'performance.polarSpeedRatio', 'performance.boatSpeedDamped']) {
304
+ assert.equal(h.bus.get(path), null, path)
305
+ }
306
+ assert.ok(h.bus.get('performance.polarSpeed') > 0)
307
+ assert.ok(h.bus.get('performance.targetSpeed') > 0)
308
+ close(h.bus.get('performance.polarVelocityMadeGood'), targets.beat.vmg, 'polarVMG without BSP')
309
+ })
310
+
311
+ it('clears target-dependent outputs when polar targets are unavailable', async () => {
312
+ h = harness(DOWNWIND_ONLY)
313
+ await h.start()
314
+ const noBeat = Polar.fromTable(DOWNWIND_ONLY).targetsAt({ tws: TWS }).value
315
+ assert.equal(noBeat.beat, null)
316
+
317
+ h.sail({ twa: rad(150), bsp: 6 * KNOTS, hdg: 1 })
318
+ assert.ok(h.bus.get('performance.targetSpeed') > 0)
319
+ assert.ok(h.bus.get('performance.polarVelocityMadeGood') > 0)
320
+
321
+ h.sail({ twa: rad(60), bsp: 6 * KNOTS, hdg: 1 })
322
+ for (const path of ['performance.targetSpeed', 'performance.targetAngle',
323
+ 'performance.targetVelocityMadeGood', 'performance.polarVelocityMadeGood',
324
+ 'performance.polarVelocityMadeGoodRatio', 'performance.beatAngle',
325
+ 'performance.beatAngleVelocityMadeGood', 'performance.optimumWindAngle', 'performance.tackTrue']) {
326
+ assert.equal(h.bus.get(path), null, path)
327
+ }
328
+ close(h.bus.get('performance.velocityMadeGood'), 6 * KNOTS * Math.cos(rad(60)), 'actual VMG without targets')
329
+ })
330
+
331
+ it('does not publish disabled outputs and clears outputs toggled off or on stop', async () => {
332
+ await h.start({ settingsVersion: 2, smootherType: 'None', polarSpeed: true })
333
+ h.sail({ twa: rad(50), bsp: 4 * KNOTS })
334
+ const published = new Set(h.writes.map(w => w.path))
335
+ assert.deepEqual([...published].sort(),
336
+ ['performance.polarSpeed', 'performance.polarSpeedRatio', 'performance.targetSpeed'])
337
+ assert.ok(h.bus.get('performance.targetSpeed') > 0)
338
+
339
+ h.routes['PUT /settings']({ body: { polarSpeed: false } }, { json: () => {} })
340
+ assert.equal(h.bus.get('performance.targetSpeed'), null)
341
+ const before = h.writes.length
342
+ h.sail({ twa: rad(50), bsp: 4 * KNOTS })
343
+ assert.equal(h.writes.length, before, 'disabled outputs must not be written')
344
+
345
+ h.routes['PUT /settings']({ body: { polarSpeed: true } }, { json: () => {} })
346
+ h.sail({ twa: rad(50), bsp: 4 * KNOTS })
347
+ assert.ok(h.bus.get('performance.polarSpeed') > 0)
348
+ h.plugin.stop()
349
+ for (const path of ['performance.polarSpeed', 'performance.targetSpeed', 'performance.polarSpeedRatio']) {
350
+ assert.equal(h.bus.get(path), null, path)
351
+ }
352
+ })
353
+
354
+ it('leaves the performance factor and sea state to the management handle', async () => {
355
+ await h.start()
356
+ h.sail({ twa: rad(50), bsp: 6 * KNOTS, hdg: 1 })
357
+ assert.ok(h.calls.length > 0, 'calculations must go through the service handle')
358
+ for (const { method, options } of h.calls) {
359
+ assert.ok(!('performanceFactor' in options), `${method} must not supply performanceFactor`)
360
+ assert.ok(!('seaState' in options), `${method} must not supply seaState`)
361
+ }
362
+ })
363
+
364
+ it('publishes nothing when the calculation service is not available', async () => {
365
+ h = harness(TABLE, { withService: false })
366
+ await h.start()
367
+ h.sail({ twa: rad(50), bsp: 6 * KNOTS, hdg: 1 })
368
+ assert.equal(h.writes.length, 0)
369
+ assert.equal(h.calls.length, 0)
370
+ })
371
+
372
+ it('serves the TWS axis and metadata from the handle description, following selection changes', async () => {
373
+ await h.start()
374
+ let result
375
+ const res = { json: v => { result = v }, status: () => res }
376
+
377
+ h.routes['/polar/axes/tws']({}, res)
378
+ assert.deepEqual(result, TABLE.axes.tws.map(tws => parseFloat(tws.toFixed(4))))
379
+
380
+ h.routes['/meta']({}, res)
381
+ assert.equal(result.activePolar.id, 'ref')
382
+ assert.equal(result.activePolar.name, 'Reference Polar')
383
+ assert.equal(result.activePolar.sailplanId, null)
384
+ assert.equal(result.performanceFactor, 1)
385
+
386
+ // A selection change (e.g. picking a v2 sailplan) is reflected after the
387
+ // onChange notification, with no resource lookups or polars.* deltas.
388
+ h.state.sailplanId = 'jib'
389
+ h.state.twsAxis = [6, 12, 18]
390
+ h.state.performanceFactor = 0.9
391
+ h.notifyChange({ type: 'selection', ids: [] })
392
+ await new Promise(resolve => setImmediate(resolve))
393
+
394
+ h.routes['/polar/axes/tws']({}, res)
395
+ assert.deepEqual(result, [6, 12, 18])
396
+ h.routes['/meta']({}, res)
397
+ assert.equal(result.activePolar.sailplanId, 'jib')
398
+ assert.equal(result.performanceFactor, 0.9)
399
+ })
400
+
401
+ it('clears outputs and polar endpoints when the active polar is deselected', async () => {
402
+ await h.start()
403
+ h.sail({ twa: rad(50), bsp: 6 * KNOTS })
404
+ assert.ok(h.bus.get('performance.polarSpeed') > 0)
405
+
406
+ h.state.active = false
407
+ h.notifyChange({ type: 'selection', ids: [] })
408
+ await new Promise(resolve => setImmediate(resolve))
409
+
410
+ assert.equal(h.bus.get('performance.polarSpeed'), null)
411
+ let result
412
+ const res = { json: v => { result = v }, status: code => ({ json: v => { result = { code, ...v } } }) }
413
+ h.routes['/polar/axes/tws']({}, res)
414
+ assert.equal(result.code, 404)
415
+ h.routes['/meta']({}, res)
416
+ assert.equal(result.activePolar, null)
417
+ assert.equal(result.performanceFactor, null)
418
+ })
419
+ })
@@ -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 }