signalk-polar-performance-plugin 1.2.1 → 1.3.2
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 +11 -0
- package/README.md +2 -0
- package/package.json +2 -2
- package/plugin/index.js +96 -18
- package/public/app.js +5 -3
- package/public/index.html +24 -9
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- Signal K admin styling now loads from standalone CSS records in current Vite manifests while retaining support for entry-associated CSS and older server fallbacks.
|
|
11
|
+
- `/live` and `/status` endpoints now return `null` for `tws`/`twa` (and downstream fields) when the wind smoother has no data or is stale, instead of `0`. The guard was checking for the presence of the smoother object rather than its `ready` state.
|
|
12
|
+
- `computeAndSend`: when the polar table lookup fails (boat outside polar range — in irons or above max TWS), the `performance.polarSpeed`, `performance.polarSpeedRatio`, and `performance.targetSpeed` SK paths are now written with `null` instead of `0`. Writing `0` was misleading because it is a valid-looking value rather than an explicit "no data" signal.
|
|
13
|
+
- Live input subscriptions are now re-established after prolonged silence for all subscribed inputs, not just true wind. Boat speed and optional true heading use the same recovery path, and the plugin now reports their lifecycle state through the webapp/status endpoints.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- Idle input recovery is now always enabled; the temporary `detectStaleData` setting has been removed from the runtime settings UI.
|
|
17
|
+
|
|
7
18
|
## [1.2.1] - 2026-07-25
|
|
8
19
|
|
|
9
20
|
### Fixed
|
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Polar Performance reads your boat's true wind speed, true wind angle, and boat speed from Signal K, looks up the corresponding target values from your polar diagram, and publishes performance metrics — beat angle, run angle, VMG, polar speed ratio, and others — back to the Signal K bus in real time. An integrated webapp lets you inspect the live values, manage polars, and configure the plugin while it is running.
|
|
4
4
|
|
|
5
|
+
Current runtime behaviour is also more explicit: when a polar lookup cannot be completed or a required input has no usable value, the plugin writes `null` for the affected output paths and the `/live` and `/status` endpoints expose that state clearly. Idle input recovery is enabled for all live subscriptions, so temporary silence is handled without leaving the plugin in a stale state.
|
|
6
|
+
|
|
5
7
|
---
|
|
6
8
|
|
|
7
9
|
## Installation
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-polar-performance-plugin",
|
|
3
|
-
"version": "1.2
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"description": "Calculates live sailing performance from polar data, with built-in polar management and import tools.",
|
|
5
5
|
"main": "plugin/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"prepublishOnly": "npm test"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"signalkutilities": "^3.0
|
|
29
|
+
"signalkutilities": "^3.1.0"
|
|
30
30
|
},
|
|
31
31
|
"repository": {
|
|
32
32
|
"type": "git",
|
package/plugin/index.js
CHANGED
|
@@ -17,6 +17,8 @@ const {
|
|
|
17
17
|
|
|
18
18
|
const CURRENT_SETTINGS_VERSION = 1
|
|
19
19
|
|
|
20
|
+
const STALE_RESUBSCRIBE_PERIOD = 60000 // ms — idle period before live input subscriptions are re-established
|
|
21
|
+
|
|
20
22
|
const DEFAULT_SETTINGS = {
|
|
21
23
|
settingsVersion: CURRENT_SETTINGS_VERSION,
|
|
22
24
|
activePolar: '',
|
|
@@ -52,6 +54,8 @@ module.exports = (app) => {
|
|
|
52
54
|
let bspSmoother = null
|
|
53
55
|
let hdgSmoother = null
|
|
54
56
|
let metaSentPaths = new Set() // tracks paths that have had metadata emitted
|
|
57
|
+
let lifecycleWarningMap = new Map()
|
|
58
|
+
let lifecycleWarnings = []
|
|
55
59
|
|
|
56
60
|
// Last-computed output values, updated by computeAndSend on every cycle.
|
|
57
61
|
// Keys match the settings keys; values are SI numbers or null.
|
|
@@ -75,6 +79,52 @@ module.exports = (app) => {
|
|
|
75
79
|
// Helpers
|
|
76
80
|
// ---------------------------------------------------------------------------
|
|
77
81
|
|
|
82
|
+
function _setLifecycleWarning(id, status, path) {
|
|
83
|
+
const safePath = path || 'unknown path'
|
|
84
|
+
const message = status === 'idle'
|
|
85
|
+
? `Input ${id} is idle on ${safePath}; resubscribing`
|
|
86
|
+
: `Input ${id} is stale on ${safePath}`
|
|
87
|
+
lifecycleWarningMap.set(id, {
|
|
88
|
+
id,
|
|
89
|
+
status,
|
|
90
|
+
path: safePath,
|
|
91
|
+
message,
|
|
92
|
+
updatedAt: Date.now()
|
|
93
|
+
})
|
|
94
|
+
lifecycleWarnings = Array.from(lifecycleWarningMap.values())
|
|
95
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function _clearLifecycleWarning(id) {
|
|
99
|
+
if (!lifecycleWarningMap.has(id)) return
|
|
100
|
+
lifecycleWarningMap.delete(id)
|
|
101
|
+
lifecycleWarnings = Array.from(lifecycleWarningMap.values())
|
|
102
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function _wireHandlerWatchdog({ id, getPath, unsubscribe, subscribe }) {
|
|
106
|
+
return {
|
|
107
|
+
idlePeriod: STALE_RESUBSCRIBE_PERIOD,
|
|
108
|
+
onDelta: () => {
|
|
109
|
+
_clearLifecycleWarning(id)
|
|
110
|
+
},
|
|
111
|
+
onStale: () => {
|
|
112
|
+
if (!isRunning) return
|
|
113
|
+
const path = getPath()
|
|
114
|
+
app.debug(`[${plugin.id}] stale input ${id} on ${path}`)
|
|
115
|
+
_setLifecycleWarning(id, 'stale', path)
|
|
116
|
+
},
|
|
117
|
+
onIdle: () => {
|
|
118
|
+
if (!isRunning) return
|
|
119
|
+
const path = getPath()
|
|
120
|
+
app.debug(`[${plugin.id}] idle input ${id} on ${path}; resubscribing`)
|
|
121
|
+
_setLifecycleWarning(id, 'idle', path)
|
|
122
|
+
unsubscribe()
|
|
123
|
+
subscribe()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
78
128
|
function getSmootherClass(type) {
|
|
79
129
|
switch (type) {
|
|
80
130
|
case 'None': return BaseSmoother
|
|
@@ -184,11 +234,13 @@ module.exports = (app) => {
|
|
|
184
234
|
if (hdgSmoother) { hdgSmoother.setSmootherClass(SC); hdgSmoother.setSmootherOptions(so) }
|
|
185
235
|
}
|
|
186
236
|
|
|
187
|
-
// Speed source change — re-point the BSP handler
|
|
237
|
+
// Speed source change — re-point the BSP handler with an explicit unsubscribe/subscribe cycle.
|
|
188
238
|
if (keys.includes('useSOG') && bspSmoother) {
|
|
239
|
+
bspSmoother.unsubscribe()
|
|
189
240
|
bspSmoother.handler.path = settings.useSOG
|
|
190
241
|
? 'navigation.speedOverGround'
|
|
191
242
|
: 'navigation.speedThroughWater'
|
|
243
|
+
bspSmoother.subscribe()
|
|
192
244
|
}
|
|
193
245
|
|
|
194
246
|
// Tack heading toggle
|
|
@@ -199,7 +251,12 @@ module.exports = (app) => {
|
|
|
199
251
|
hdgSmoother = new SmoothedAngle(app, plugin.id, 'hdg', 'navigation.headingTrue', {
|
|
200
252
|
angleRange: '0to2pi',
|
|
201
253
|
SmootherClass: SC,
|
|
202
|
-
smootherOptions: so
|
|
254
|
+
smootherOptions: so,
|
|
255
|
+
..._wireHandlerWatchdog({
|
|
256
|
+
get path() { return hdgSmoother?.handler?.path ?? 'navigation.headingTrue' },
|
|
257
|
+
unsubscribe: () => hdgSmoother?.unsubscribe(),
|
|
258
|
+
subscribe: () => hdgSmoother?.subscribe(false, true),
|
|
259
|
+
})
|
|
203
260
|
})
|
|
204
261
|
} else if (!settings.tackTrue && hdgSmoother) {
|
|
205
262
|
hdgSmoother.terminate()
|
|
@@ -374,14 +431,11 @@ module.exports = (app) => {
|
|
|
374
431
|
}
|
|
375
432
|
}
|
|
376
433
|
} else {
|
|
377
|
-
//
|
|
434
|
+
// Clear these paths so no stale non-zero value remains on the SK bus
|
|
378
435
|
if (settings.polarSpeed) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
'Actual boat speed divided by polar speed.')
|
|
383
|
-
add('performance.targetSpeed', 0, 'm/s',
|
|
384
|
-
'Boat speed needed to achieve target VMG at the optimal angle.')
|
|
436
|
+
values.push({ path: 'performance.polarSpeed', value: null })
|
|
437
|
+
values.push({ path: 'performance.polarSpeedRatio', value: null })
|
|
438
|
+
values.push({ path: 'performance.targetSpeed', value: null })
|
|
385
439
|
}
|
|
386
440
|
}
|
|
387
441
|
|
|
@@ -850,7 +904,7 @@ module.exports = (app) => {
|
|
|
850
904
|
// Returns null for any field not yet available (plugin not running,
|
|
851
905
|
// no BSP source, polar not loaded, or boat in irons).
|
|
852
906
|
router.get('/live', (req, res) => {
|
|
853
|
-
const wind = windSmoother ? windSmoother.polarValue : null
|
|
907
|
+
const wind = windSmoother?.ready ? windSmoother.polarValue : null
|
|
854
908
|
const TWS = wind ? wind.magnitude : null
|
|
855
909
|
const TWAsigned = wind ? wind.angle : null
|
|
856
910
|
const TWA = Number.isFinite(TWAsigned) ? Math.abs(TWAsigned) : null
|
|
@@ -896,7 +950,7 @@ module.exports = (app) => {
|
|
|
896
950
|
const rawBsp = si(bspSmoother?.handler?.value ?? null)
|
|
897
951
|
const rawHdg = si(hdgSmoother?.handler?.value ?? null)
|
|
898
952
|
|
|
899
|
-
const wind = windSmoother ? windSmoother.polarValue : null
|
|
953
|
+
const wind = windSmoother?.ready ? windSmoother.polarValue : null
|
|
900
954
|
const TWS = wind ? wind.magnitude : null
|
|
901
955
|
const TWAsigned = wind ? wind.angle : null
|
|
902
956
|
const BSP = bspSmoother ? bspSmoother.value : null
|
|
@@ -941,7 +995,8 @@ module.exports = (app) => {
|
|
|
941
995
|
}
|
|
942
996
|
},
|
|
943
997
|
outputs,
|
|
944
|
-
polarState
|
|
998
|
+
polarState,
|
|
999
|
+
lifecycleWarnings
|
|
945
1000
|
})
|
|
946
1001
|
})
|
|
947
1002
|
|
|
@@ -1062,6 +1117,8 @@ module.exports = (app) => {
|
|
|
1062
1117
|
|
|
1063
1118
|
start(options) {
|
|
1064
1119
|
metaSentPaths = new Set() // reset so metadata is re-emitted after restart
|
|
1120
|
+
lifecycleWarningMap = new Map()
|
|
1121
|
+
lifecycleWarnings = []
|
|
1065
1122
|
|
|
1066
1123
|
store = new PolarFileStore(app.getDataDirPath())
|
|
1067
1124
|
importService = new ImportService(store)
|
|
@@ -1100,7 +1157,17 @@ module.exports = (app) => {
|
|
|
1100
1157
|
app,
|
|
1101
1158
|
pluginId: plugin.id,
|
|
1102
1159
|
SmootherClass,
|
|
1103
|
-
smootherOptions
|
|
1160
|
+
smootherOptions,
|
|
1161
|
+
..._wireHandlerWatchdog({
|
|
1162
|
+
id: 'wind.smoothed',
|
|
1163
|
+
getPath: () => `${windSmoother?.polar?.pathMagnitude ?? 'environment.wind.speedTrue'}, ${windSmoother?.polar?.pathAngle ?? 'environment.wind.angleTrueWater'}`,
|
|
1164
|
+
unsubscribe: () => windSmoother?.unsubscribe(),
|
|
1165
|
+
subscribe: () => windSmoother?.subscribe(true, true),
|
|
1166
|
+
}),
|
|
1167
|
+
onDelta: () => {
|
|
1168
|
+
_clearLifecycleWarning('wind.smoothed')
|
|
1169
|
+
computeAndSend()
|
|
1170
|
+
}
|
|
1104
1171
|
})
|
|
1105
1172
|
|
|
1106
1173
|
// Boat speed (STW or SOG depending on settings)
|
|
@@ -1113,7 +1180,13 @@ module.exports = (app) => {
|
|
|
1113
1180
|
app,
|
|
1114
1181
|
pluginId: plugin.id,
|
|
1115
1182
|
SmootherClass,
|
|
1116
|
-
smootherOptions
|
|
1183
|
+
smootherOptions,
|
|
1184
|
+
..._wireHandlerWatchdog({
|
|
1185
|
+
id: 'bsp.smoothed',
|
|
1186
|
+
getPath: () => bspSmoother?.handler?.path ?? (settings.useSOG ? 'navigation.speedOverGround' : 'navigation.speedThroughWater'),
|
|
1187
|
+
unsubscribe: () => bspSmoother?.unsubscribe(),
|
|
1188
|
+
subscribe: () => bspSmoother?.subscribe(),
|
|
1189
|
+
})
|
|
1117
1190
|
})
|
|
1118
1191
|
|
|
1119
1192
|
// Optional heading handler for opposite-tack computation.
|
|
@@ -1123,13 +1196,16 @@ module.exports = (app) => {
|
|
|
1123
1196
|
hdgSmoother = new SmoothedAngle(app, plugin.id, 'hdg', 'navigation.headingTrue', {
|
|
1124
1197
|
angleRange: '0to2pi',
|
|
1125
1198
|
SmootherClass,
|
|
1126
|
-
smootherOptions
|
|
1199
|
+
smootherOptions,
|
|
1200
|
+
..._wireHandlerWatchdog({
|
|
1201
|
+
id: 'hdg.smoothed',
|
|
1202
|
+
getPath: () => hdgSmoother?.handler?.path ?? 'navigation.headingTrue',
|
|
1203
|
+
unsubscribe: () => hdgSmoother?.unsubscribe(),
|
|
1204
|
+
subscribe: () => hdgSmoother?.subscribe(false, true),
|
|
1205
|
+
})
|
|
1127
1206
|
})
|
|
1128
1207
|
}
|
|
1129
1208
|
|
|
1130
|
-
// Trigger performance computation whenever a new smoothed wind value is ready
|
|
1131
|
-
windSmoother.onChange = computeAndSend
|
|
1132
|
-
|
|
1133
1209
|
isRunning = true
|
|
1134
1210
|
app.debug('Plugin started')
|
|
1135
1211
|
},
|
|
@@ -1141,6 +1217,8 @@ module.exports = (app) => {
|
|
|
1141
1217
|
if (windSmoother) { windSmoother.terminate(); windSmoother = null }
|
|
1142
1218
|
if (bspSmoother) { bspSmoother.terminate(); bspSmoother = null }
|
|
1143
1219
|
if (hdgSmoother) { hdgSmoother.terminate(); hdgSmoother = null }
|
|
1220
|
+
lifecycleWarningMap = new Map()
|
|
1221
|
+
lifecycleWarnings = []
|
|
1144
1222
|
app.debug('Plugin stopped')
|
|
1145
1223
|
}
|
|
1146
1224
|
}
|
package/public/app.js
CHANGED
|
@@ -309,6 +309,7 @@ let settings = null
|
|
|
309
309
|
let polarsList = []
|
|
310
310
|
let importFormats = []
|
|
311
311
|
let internetOnline = false
|
|
312
|
+
let lifecycleWarnings = []
|
|
312
313
|
|
|
313
314
|
// Canvas state
|
|
314
315
|
let polar = null
|
|
@@ -443,6 +444,7 @@ async function refreshLive() {
|
|
|
443
444
|
const st = await apiGet('/status')
|
|
444
445
|
if (st) {
|
|
445
446
|
statusData = st
|
|
447
|
+
lifecycleWarnings = Array.isArray(st.lifecycleWarnings) ? st.lifecycleWarnings : []
|
|
446
448
|
// Populate rawValues and outputValues from /status for the Inputs/Outputs pages
|
|
447
449
|
if (st.inputs) {
|
|
448
450
|
rawValues.tws = st.inputs.raw.tws
|
|
@@ -690,9 +692,9 @@ function _tickInputs() {
|
|
|
690
692
|
setStale('in-bsp-smo', d?.bsp == null)
|
|
691
693
|
|
|
692
694
|
const warns = []
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
695
|
+
lifecycleWarnings.forEach(w => {
|
|
696
|
+
if (w && typeof w.message === 'string') warns.push(w.message)
|
|
697
|
+
})
|
|
696
698
|
updateWarnings(document.getElementById('in-warnings'), warns)
|
|
697
699
|
}
|
|
698
700
|
|
package/public/index.html
CHANGED
|
@@ -16,29 +16,44 @@
|
|
|
16
16
|
el.href = href
|
|
17
17
|
document.head.appendChild(el)
|
|
18
18
|
}
|
|
19
|
+
let manifestFailure = 'manifest contained no CSS assets'
|
|
19
20
|
try {
|
|
20
21
|
const res = await fetch('/admin/.vite/manifest.json')
|
|
21
22
|
if (res.ok) {
|
|
22
23
|
const manifest = await res.json()
|
|
23
|
-
|
|
24
|
+
const stylesheets = new Set()
|
|
24
25
|
for (const entry of Object.values(manifest)) {
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
appendStylesheet('/admin/' + css)
|
|
28
|
-
found = true
|
|
26
|
+
if (entry.file?.endsWith('.css')) {
|
|
27
|
+
stylesheets.add(entry.file)
|
|
29
28
|
}
|
|
29
|
+
if (entry.isEntry) {
|
|
30
|
+
for (const css of entry.css ?? []) {
|
|
31
|
+
stylesheets.add(css)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (stylesheets.size > 0) {
|
|
36
|
+
for (const css of stylesheets) appendStylesheet('/admin/' + css)
|
|
37
|
+
return
|
|
30
38
|
}
|
|
31
|
-
|
|
39
|
+
} else {
|
|
40
|
+
manifestFailure = `manifest request returned ${res.status}`
|
|
32
41
|
}
|
|
33
|
-
} catch (e) {
|
|
42
|
+
} catch (e) {
|
|
43
|
+
manifestFailure = `manifest request failed: ${e.message}`
|
|
44
|
+
}
|
|
34
45
|
try {
|
|
35
46
|
const res = await fetch('/')
|
|
36
47
|
const html = await res.text()
|
|
37
48
|
const doc = new DOMParser().parseFromString(html, 'text/html')
|
|
38
49
|
const link = doc.querySelector('link[rel="stylesheet"]')
|
|
39
|
-
if (link)
|
|
50
|
+
if (link) {
|
|
51
|
+
appendStylesheet(new URL(link.getAttribute('href'), res.url).href)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
console.warn(`[polar] Could not load SignalK admin stylesheet: ${manifestFailure}; legacy admin page contained no stylesheet link.`)
|
|
40
55
|
} catch (e) {
|
|
41
|
-
console.warn(
|
|
56
|
+
console.warn(`[polar] Could not load SignalK admin stylesheet: ${manifestFailure}; legacy fallback failed: ${e.message}`)
|
|
42
57
|
}
|
|
43
58
|
})()
|
|
44
59
|
</script>
|