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 +14 -0
- package/README.md +27 -2
- package/docs/developer-reference.md +15 -10
- package/package.json +4 -4
- package/plugin/index.js +143 -180
- package/test/lifecycle.test.js +90 -1
- package/test/outputs.test.js +304 -0
- package/test/routePolicyShim.js +42 -0
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
|
|
226
|
-
| *Pinching* | TWA is between the
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
|
35
|
-
|
|
36
|
-
| `GET` | `/
|
|
37
|
-
| `GET` | `/
|
|
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.
|
|
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.
|
|
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.
|
|
30
|
-
"signalkutilities": "^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}
|
|
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
|
|
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}
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
493
|
-
if (
|
|
494
|
-
|
|
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
|
|
509
|
-
|
|
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
|
|
602
|
-
return res.status(400).json({ error: "'step' must be between
|
|
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
|
|
783
|
-
//
|
|
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
|
}
|
package/test/lifecycle.test.js
CHANGED
|
@@ -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 }
|