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 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.1",
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.1"
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; it auto-resubscribes
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
- // Zero out to prevent stale values accumulating in the data model
434
+ // Clear these paths so no stale non-zero value remains on the SK bus
378
435
  if (settings.polarSpeed) {
379
- add('performance.polarSpeed', 0, 'm/s',
380
- 'Polar chart boat speed for current TWS and TWA.')
381
- add('performance.polarSpeedRatio', 0, 'ratio',
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
- if (rawValues.tws == null) warns.push('environment.wind.speedTrue no data from instruments')
694
- if (rawValues.twa == null) warns.push('environment.wind.angleTrueWater — no data from instruments')
695
- if (rawValues.bsp == null) warns.push((settings?.useSOG ? 'navigation.speedOverGround' : 'navigation.speedThroughWater') + ' — no data from instruments')
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
- let found = false
24
+ const stylesheets = new Set()
24
25
  for (const entry of Object.values(manifest)) {
25
- if (!entry.isEntry) continue
26
- for (const css of entry.css ?? []) {
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
- if (found) return
39
+ } else {
40
+ manifestFailure = `manifest request returned ${res.status}`
32
41
  }
33
- } catch (e) { /* fall through */ }
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) appendStylesheet(new URL(link.getAttribute('href'), res.url).href)
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('[polar] Could not load SignalK admin stylesheet:', e)
56
+ console.warn(`[polar] Could not load SignalK admin stylesheet: ${manifestFailure}; legacy fallback failed: ${e.message}`)
42
57
  }
43
58
  })()
44
59
  </script>