signalk-polar-performance-plugin 1.3.3 → 2.0.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/plugin/index.js CHANGED
@@ -1,11 +1,8 @@
1
1
  'use strict'
2
2
 
3
- const { PolarTable } = require('./PolarTable')
4
- const PolarFileStore = require('./PolarFileStore')
5
- const { ImportService, ImportError, createTimestampedId } = require('./import/ImportService')
6
- const canonical = require('./import/canonical')
7
- const { parseMatrixPolarText } = require('./import/matrixText')
3
+ const { Polar } = require('polar-math')
8
4
  const {
5
+ MessageHandler,
9
6
  createSmoothedPolar,
10
7
  createSmoothedHandler,
11
8
  SmoothedAngle,
@@ -15,14 +12,12 @@ const {
15
12
  KalmanSmoother
16
13
  } = require('signalkutilities')
17
14
 
18
- const CURRENT_SETTINGS_VERSION = 1
15
+ const CURRENT_SETTINGS_VERSION = 2
19
16
 
20
17
  const STALE_RESUBSCRIBE_PERIOD = 60000 // ms — idle period before live input subscriptions are re-established
21
18
 
22
19
  const DEFAULT_SETTINGS = {
23
20
  settingsVersion: CURRENT_SETTINGS_VERSION,
24
- activePolar: '',
25
- perfAdjust: 1,
26
21
  showAllTwsLines: true,
27
22
  smootherType: 'Exponential',
28
23
  smootherParamExponential: 1,
@@ -47,9 +42,17 @@ module.exports = (app) => {
47
42
  let changedOptions = {} // staged but not yet applied
48
43
  let hasPendingChanges = false
49
44
  let isRunning = false
50
- let polarTable = null
51
- let store = null
52
- let importService = null
45
+
46
+ // Active polar — sourced from the `polars.activePolar` / `polars.performanceFactor`
47
+ // SK paths (published by a 'polars' resource-provider plugin, e.g. signalk-polar-management).
48
+ // This plugin no longer stores or manages polar files itself.
49
+ let polar = null // polar-math Polar instance, or null when unavailable
50
+ let activePolarId = null
51
+ let activePolarDoc = null // raw canonical resource, for descriptive metadata
52
+ let performanceFactor = 1
53
+ let activePolarHandler = null
54
+ let performanceFactorHandler = null
55
+
53
56
  let windSmoother = null
54
57
  let bspSmoother = null
55
58
  let hdgSmoother = null
@@ -158,39 +161,92 @@ module.exports = (app) => {
158
161
  delete s.useTWSsource
159
162
  delete s.useSOGsource
160
163
  delete s.trueWindSpeedPath
161
-
162
- // Migrate embedded CSV polar to a canonical polar file.
163
- // Only advance settingsVersion if the CSV is successfully written —
164
- // if it fails, keep csvTable so the migration retries on next start.
165
- if (s.csvTable && s.csvTable.trim()) {
166
- try {
167
- const { resource } = parseMatrixPolarText(s.csvTable)
168
- store.saveCanonical('migrated-polar', resource)
169
- s.activePolar = 'migrated-polar'
170
- delete s.csvTable
171
- app.debug('Legacy csvTable migrated to migrated-polar.json')
172
- } catch (e) {
173
- app.setPluginError('Migration failed — could not save legacy CSV: ' + e.message)
174
- app.debug('CSV migration error: %s', e.message)
175
- return s // abort; csvTable kept so next start retries
176
- }
177
- } else {
178
- delete s.csvTable
179
- }
180
-
164
+ delete s.csvTable
181
165
  s.settingsVersion = 1
182
166
  app.debug('Settings migrated from v0 to v1')
183
167
  }
184
168
 
185
- // Persist if any migration ran, so migrations don't repeat on next start
169
+ if (s.settingsVersion < 2) {
170
+ // v1 → v2: polar selection and performance adjustment moved to the
171
+ // `polars.activePolar` / `polars.performanceFactor` SK paths, published
172
+ // by a 'polars' resource-provider plugin (e.g. signalk-polar-management).
173
+ delete s.activePolar
174
+ delete s.perfAdjust
175
+ s.settingsVersion = 2
176
+ app.debug('Settings migrated from v1 to v2')
177
+ }
178
+
179
+ // Persist if any migration ran, so migrations don't repeat on next start.
180
+ // Signal K requires a callback; omitting it throws TypeError and aborts start().
186
181
  if ((s.settingsVersion ?? 0) > version) {
187
- app.savePluginOptions(s)
188
- app.debug('Migrated settings saved (v%d → v%d)', version, s.settingsVersion)
182
+ app.savePluginOptions(s, (err) => {
183
+ if (err) {
184
+ app.error('Failed to save migrated settings: ' + err.message)
185
+ } else {
186
+ app.debug('Migrated settings saved (v%d → v%d)', version, s.settingsVersion)
187
+ }
188
+ })
189
189
  }
190
190
 
191
191
  return s
192
192
  }
193
193
 
194
+ // ---------------------------------------------------------------------------
195
+ // Active polar — sourced from SK paths published by a 'polars' resource provider
196
+ // ---------------------------------------------------------------------------
197
+
198
+ // Extracts the resource id from an `href` of the shape `/resources/polars/<id>`.
199
+ function hrefToId(href) {
200
+ if (typeof href !== 'string') return null
201
+ const match = href.match(/\/resources\/polars\/([^/]+)$/)
202
+ return match ? match[1] : null
203
+ }
204
+
205
+ async function checkPolarProvider() {
206
+ if (!app.resourcesApi || typeof app.resourcesApi.listResources !== 'function') return false
207
+ try {
208
+ await app.resourcesApi.listResources('polars', {})
209
+ return true
210
+ } catch (_e) {
211
+ return false
212
+ }
213
+ }
214
+
215
+ function handleActivePolarDelta() {
216
+ const value = activePolarHandler.value
217
+ const href = value?.href
218
+ if (!href) {
219
+ polar = null
220
+ activePolarId = null
221
+ activePolarDoc = null
222
+ nullifyOutputs()
223
+ app.setPluginStatus('No active polar selected — select one in the polar management webapp')
224
+ return
225
+ }
226
+
227
+ const id = hrefToId(href)
228
+ if (!id) {
229
+ app.setPluginError(`Cannot parse active polar href: ${href}`)
230
+ return
231
+ }
232
+
233
+ app.resourcesApi.getResource('polars', id).then((doc) => {
234
+ try {
235
+ polar = Polar.fromTable(doc)
236
+ activePolarId = id
237
+ activePolarDoc = doc
238
+ app.setPluginStatus(`Polar '${doc.name || id}' loaded`)
239
+ } catch (e) {
240
+ // Keep the last valid polar (if any) rather than dropping outputs on a bad update.
241
+ app.setPluginError(`Invalid active polar '${id}': ${e.message}`)
242
+ if (!polar) nullifyOutputs()
243
+ }
244
+ }).catch((e) => {
245
+ app.setPluginError(`Cannot load active polar '${id}': ${e.message}`)
246
+ if (!polar) nullifyOutputs()
247
+ })
248
+ }
249
+
194
250
  // ---------------------------------------------------------------------------
195
251
  // Hot-apply runtime option changes
196
252
  // ---------------------------------------------------------------------------
@@ -205,25 +261,6 @@ module.exports = (app) => {
205
261
  changedOptions = {}
206
262
  hasPendingChanges = false
207
263
 
208
- // Active polar — load new table
209
- if ('activePolar' in settings || 'perfAdjust' in settings) {
210
- if (keys.includes('activePolar') && settings.activePolar) {
211
- try {
212
- polarTable = store.load(settings.activePolar)
213
- polarTable.setPerformanceAdjustment(settings.perfAdjust || 1)
214
- app.setPluginStatus(`Polar '${settings.activePolar}' loaded`)
215
- } catch (e) {
216
- app.setPluginError(`Cannot load polar '${settings.activePolar}': ${e.message}`)
217
- }
218
- } else if (keys.includes('activePolar') && !settings.activePolar) {
219
- polarTable = null
220
- nullifyOutputs()
221
- app.setPluginStatus('No polar configured — set activePolar or upload a CSV file')
222
- } else if (keys.includes('perfAdjust') && polarTable) {
223
- polarTable.setPerformanceAdjustment(settings.perfAdjust)
224
- }
225
- }
226
-
227
264
  // Smoother type or parameter changes — update all running smoothers in-place
228
265
  const SMOOTHER_KEYS = ['smootherType', 'smootherParamExponential', 'smootherParamMovingAverage', 'smootherParamKalman']
229
266
  if (keys.some(k => SMOOTHER_KEYS.includes(k))) {
@@ -297,7 +334,7 @@ module.exports = (app) => {
297
334
 
298
335
  function computeAndSend() {
299
336
  if (hasPendingChanges) applyOptionChanges()
300
- if (!polarTable) return
337
+ if (!polar) return
301
338
 
302
339
  const wind = windSmoother.polarValue
303
340
  const TWS = wind.magnitude
@@ -333,10 +370,11 @@ module.exports = (app) => {
333
370
 
334
371
  // Polar lookups
335
372
  const isUpwind = TWA < Math.PI / 2
336
- const beatAngle = polarTable.getBeatAngle(TWS)
337
- const runAngle = polarTable.getRunAngle(TWS)
338
- const beatVMG = polarTable.getBeatVMG(TWS)
339
- const runVMG = polarTable.getRunVMG(TWS)
373
+ const { value: targets } = polar.targetsAt({ tws: TWS, performanceFactor })
374
+ const beatAngle = targets?.beat?.twa ?? null
375
+ const runAngle = targets?.run?.twa ?? null
376
+ const beatVMG = targets?.beat?.vmg ?? null
377
+ const runVMG = targets?.run?.vmg ?? null
340
378
  const targetAngle = isUpwind ? beatAngle : runAngle
341
379
  const targetVMG = isUpwind ? beatVMG : runVMG
342
380
 
@@ -396,7 +434,7 @@ module.exports = (app) => {
396
434
  }
397
435
 
398
436
  // Polar speed and performance ratios
399
- const polarSpeed = polarTable.getBoatSpeed(TWS, TWA)
437
+ const { value: polarSpeed } = polar.speedAt({ tws: TWS, twa: TWA, performanceFactor })
400
438
  if (Number.isFinite(polarSpeed) && polarSpeed > 0) {
401
439
  if (settings.polarSpeed) {
402
440
  add('performance.polarSpeed', polarSpeed, 'm/s',
@@ -441,8 +479,8 @@ module.exports = (app) => {
441
479
 
442
480
  // Max speed for current TWS
443
481
  if (settings.maxSpeed) {
444
- const maxSpeed = polarTable.getMaxSpeed(TWS)
445
- const maxSpeedAngle = polarTable.getMaxSpeedAngle(TWS)
482
+ const maxSpeed = targets?.maxSpeed?.speed ?? null
483
+ const maxSpeedAngle = targets?.maxSpeed?.twa ?? null
446
484
  if (Number.isFinite(maxSpeed)) {
447
485
  add('performance.maxSpeed', maxSpeed, 'm/s',
448
486
  'Maximum polar boat speed for current TWS.')
@@ -485,7 +523,7 @@ module.exports = (app) => {
485
523
 
486
524
  schema: () => ({
487
525
  type: "object",
488
- description: "The plugin is configured through its webapp. Open it from the Signal K app list (Webapps → Advanced Wind) to set sources, enable corrections and adjust parameters.",
526
+ description: "The plugin is configured through its webapp. Open it from the Signal K app list (Webapps → Polar Performance) to set sources, enable corrections and adjust parameters. Select the active polar and performance factor in the Polar Management webapp.",
489
527
  properties: {}
490
528
  }),
491
529
 
@@ -497,76 +535,24 @@ module.exports = (app) => {
497
535
  registerWithRouter(router) {
498
536
  app.debug('registerWithRouter')
499
537
 
500
- function getStore() {
501
- if (!store) {
502
- store = new PolarFileStore(app.getDataDirPath())
503
- }
504
- return store
505
- }
506
-
507
- function loadStoredPolar(id) {
508
- return getStore().load(id)
509
- }
510
-
511
- // Size-1 cache: avoids reloading from disk when consecutive query requests
512
- // target the same polar ID (e.g. the N parallel curve fetches on startup).
513
- // perfAdjust is applied fresh on every use so it is never part of the key.
514
- let cachedPolar = null // { id, table } | null
515
-
516
- function loadPolarCached(id) {
517
- if (!cachedPolar || cachedPolar.id !== id) {
518
- cachedPolar = { id, table: loadStoredPolar(id) }
519
- }
520
- cachedPolar.table.setPerformanceAdjustment(settings.perfAdjust || 1)
521
- return cachedPolar.table
522
- }
523
-
524
- function invalidatePolarCache(id) {
525
- if (cachedPolar && cachedPolar.id === id) cachedPolar = null
526
- }
527
-
528
- function getImportService() {
529
- if (!importService) {
530
- importService = new ImportService(getStore())
531
- }
532
- return importService
533
- }
534
-
535
- // Lightweight internet connectivity probe.
536
- // Attempts a HEAD request to Cloudflare's public DNS (1.1.1.1) with a
537
- // 3-second timeout. Returns true if any HTTP response is received,
538
- // false on network error or timeout.
539
- async function checkInternet() {
540
- const TIMEOUT_MS = 3000
541
- try {
542
- const controller = new AbortController()
543
- const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
544
- const response = await fetch('https://1.1.1.1', { method: 'HEAD', signal: controller.signal })
545
- .finally(() => clearTimeout(timer))
546
- return response.status < 600
547
- } catch (_) {
548
- return false
549
- }
550
- }
551
-
552
538
  function errorStatus(error) {
553
- return /Polar not found/i.test(error.message) ? 404 : 500
539
+ return /No active polar/i.test(error.message) ? 404 : 500
554
540
  }
555
541
 
556
542
  function toFixedNumber(value, digits) {
557
543
  return Number.isFinite(value) ? parseFloat(value.toFixed(digits)) : null
558
544
  }
559
545
 
560
- function getPolarTwsValues(table) {
561
- return table.table
546
+ function getPolarTwsValues() {
547
+ return polar.entries
562
548
  .filter(entry => entry.tws > 0.001)
563
549
  .map(entry => parseFloat(entry.tws.toFixed(4)))
564
550
  }
565
551
 
566
- function buildCurveResult(table, tws, stepRad) {
552
+ function buildCurveResult(tws, stepRad) {
567
553
  const points = []
568
554
  for (let twa = 0; twa <= Math.PI + 1e-9; twa += stepRad) {
569
- const tbs = table.getBoatSpeed(tws, twa)
555
+ const { value: tbs } = polar.speedAt({ tws, twa, performanceFactor })
570
556
  if (Number.isFinite(tbs) && tbs > 0) {
571
557
  points.push({
572
558
  twa: toFixedNumber(twa, 5),
@@ -575,252 +561,38 @@ module.exports = (app) => {
575
561
  }
576
562
  }
577
563
 
578
- const beatAngle = table.getBeatAngle(tws)
579
- const beatVMG = table.getBeatVMG(tws)
580
- const runAngle = table.getRunAngle(tws)
581
- const runVMG = table.getRunVMG(tws)
582
- const beatTbs = Number.isFinite(beatAngle) ? table.getBoatSpeed(tws, beatAngle) : null
583
- const runTbs = Number.isFinite(runAngle) ? table.getBoatSpeed(tws, runAngle) : null
584
-
564
+ const { value: targets } = polar.targetsAt({ tws, performanceFactor })
585
565
  return {
586
566
  tws,
587
567
  points,
588
- beat: (Number.isFinite(beatAngle) && Number.isFinite(beatTbs)) ? {
589
- twa: toFixedNumber(beatAngle, 5),
590
- tbs: toFixedNumber(beatTbs, 4),
591
- vmg: toFixedNumber(beatVMG, 4)
568
+ beat: targets?.beat ? {
569
+ twa: toFixedNumber(targets.beat.twa, 5),
570
+ tbs: toFixedNumber(targets.beat.speed, 4),
571
+ vmg: toFixedNumber(targets.beat.vmg, 4)
592
572
  } : null,
593
- run: (Number.isFinite(runAngle) && Number.isFinite(runTbs)) ? {
594
- twa: toFixedNumber(runAngle, 5),
595
- tbs: toFixedNumber(runTbs, 4),
596
- vmg: toFixedNumber(runVMG, 4)
573
+ run: targets?.run ? {
574
+ twa: toFixedNumber(targets.run.twa, 5),
575
+ tbs: toFixedNumber(targets.run.speed, 4),
576
+ vmg: toFixedNumber(targets.run.vmg, 4)
597
577
  } : null
598
578
  }
599
579
  }
600
580
 
601
- function buildSpeedResult(table, tws, twa) {
602
- const tbs = table.getBoatSpeed(tws, twa)
603
- return {
604
- tws,
605
- twa,
606
- tbs: toFixedNumber(tbs, 4),
607
- state: table.getInterpolationState(tws, twa)
608
- }
609
- }
610
-
611
- function buildTargetsResult(table, tws) {
612
- const beatAngle = table.getBeatAngle(tws)
613
- const runAngle = table.getRunAngle(tws)
614
- const beatVMG = table.getBeatVMG(tws)
615
- const runVMG = table.getRunVMG(tws)
616
- const beatTbs = Number.isFinite(beatAngle) ? table.getBoatSpeed(tws, beatAngle) : null
617
- const runTbs = Number.isFinite(runAngle) ? table.getBoatSpeed(tws, runAngle) : null
618
-
619
- return {
620
- tws,
621
- beat: (Number.isFinite(beatAngle) && Number.isFinite(beatTbs)) ? {
622
- twa: toFixedNumber(beatAngle, 5),
623
- tbs: toFixedNumber(beatTbs, 4),
624
- vmg: toFixedNumber(beatVMG, 4)
625
- } : null,
626
- run: (Number.isFinite(runAngle) && Number.isFinite(runTbs)) ? {
627
- twa: toFixedNumber(runAngle, 5),
628
- tbs: toFixedNumber(runTbs, 4),
629
- vmg: toFixedNumber(runVMG, 4)
630
- } : null
631
- }
632
- }
633
-
634
- function buildPerformanceMetric(polarValue, actualValue) {
635
- if (!Number.isFinite(polarValue) || polarValue <= 0 || !Number.isFinite(actualValue)) {
636
- return null
637
- }
638
- return {
639
- polar: toFixedNumber(polarValue, 4),
640
- actual: toFixedNumber(actualValue, 4),
641
- ratio: toFixedNumber(actualValue / polarValue, 5)
642
- }
643
- }
644
-
645
- function buildPerformanceResult(table, tws, twa, bsp) {
646
- const polarSpeed = table.getBoatSpeed(tws, twa)
647
- const upwindLimit = Math.PI / 3
648
- const downwindLimit = 2 * Math.PI / 3
649
-
650
- let direction = 'reaching'
651
- let polarVmg = null
652
- let actualVmg = null
653
- if (twa < upwindLimit) {
654
- direction = 'upwind'
655
- polarVmg = table.getBeatVMG(tws)
656
- actualVmg = bsp * Math.cos(twa)
657
- } else if (twa > downwindLimit) {
658
- direction = 'downwind'
659
- polarVmg = table.getRunVMG(tws)
660
- actualVmg = Math.abs(bsp * Math.cos(twa))
661
- }
662
-
663
- return {
664
- tws,
665
- twa,
666
- bsp,
667
- direction,
668
- speed: buildPerformanceMetric(polarSpeed, bsp),
669
- vmg: direction === 'reaching' ? null : buildPerformanceMetric(polarVmg, actualVmg)
670
- }
671
- }
672
-
673
- function validatePolarResourceBody(resource) {
674
- return canonical.validateCanonicalPolarResourceBody(resource)
675
- }
676
-
677
- function generateCanonicalPolarId(resource) {
678
- return createTimestampedId(
679
- resource?.sailnumber || resource?.name || 'polar',
680
- 'polar',
681
- (candidate) => getStore().exists(candidate),
682
- Date.now
683
- )
684
- }
685
-
686
581
  router.use((req, res, next) => {
687
582
  res.set('Origin-Agent-Cluster', '?1')
688
583
  next()
689
584
  })
690
585
 
691
- router.get('/polars/active', (req, res) => {
692
- if (!settings.activePolar) {
693
- return res.status(404).json({ error: 'No polar is currently active' })
694
- }
695
- res.json({ id: settings.activePolar })
696
- })
697
-
698
- router.put('/polars/active', (req, res) => {
699
- const id = req.body?.id
700
- if (typeof id !== 'string') {
701
- return res.status(400).json({ error: "Expected JSON body with string 'id'" })
702
- }
703
-
704
- if (!id) {
705
- polarTable = null
706
- settings.activePolar = ''
707
- app.savePluginOptions(settings, (err) => {
708
- if (err) app.error('Failed to save settings: ' + err.message)
709
- })
710
- nullifyOutputs()
711
- app.setPluginStatus('No polar configured — set activePolar or upload a CSV file')
712
- return res.json({ id: '' })
713
- }
714
-
715
- try {
716
- polarTable = loadStoredPolar(id)
717
- polarTable.setPerformanceAdjustment(settings.perfAdjust || 1)
718
- settings.activePolar = id
719
- app.savePluginOptions(settings, (err) => {
720
- if (err) app.error('Failed to save settings: ' + err.message)
721
- })
722
- app.setPluginStatus(`Polar '${id}' loaded`)
723
- res.json({ id })
724
- } catch (e) {
725
- res.status(errorStatus(e)).json({ error: e.message })
726
- }
727
- })
728
-
729
- router.delete('/polars/active', (_req, res) => {
730
- polarTable = null
731
- settings.activePolar = ''
732
- app.savePluginOptions(settings, (err) => {
733
- if (err) app.error('Failed to save settings: ' + err.message)
734
- })
735
- nullifyOutputs()
736
- app.setPluginStatus('No polar configured — set activePolar or upload a CSV file')
737
- res.json({ id: '' })
738
- })
739
-
740
- router.get('/imports/formats', (_req, res) => {
741
- res.json(getImportService().listFormats())
742
- })
743
-
744
- router.get('/internet', async (_req, res) => {
745
- res.json({ online: await checkInternet() })
746
- })
747
-
748
- router.get('/imports/sources', (_req, res) => {
749
- res.json(getImportService().listSources())
750
- })
751
-
752
- router.post('/polars', (req, res) => {
753
- const validationError = validatePolarResourceBody(req.body)
754
- if (validationError) {
755
- return res.status(400).json({ error: validationError })
756
- }
757
-
758
- try {
759
- const id = generateCanonicalPolarId(req.body)
760
- getStore().saveCanonical(id, {
761
- ...req.body,
762
- name: req.body.name || id
763
- })
764
- res.status(201).json({ id })
765
- } catch (e) {
766
- res.status(500).json({ error: e.message })
767
- }
768
- })
586
+ // ---- Active polar curve queries (read-only — for the webapp's polar canvas) --
769
587
 
770
- router.post('/imports/text/:format', (req, res) => {
771
- try {
772
- const result = getImportService().importText(req.params.format, req.body)
773
- res.status(201).json({ id: result.id })
774
- } catch (error) {
775
- if (error instanceof ImportError) {
776
- return res.status(error.status).json({ error: error.message })
777
- }
778
- res.status(500).json({ error: error.message })
779
- }
588
+ router.get('/polar/axes/tws', (req, res) => {
589
+ if (!polar) return res.status(404).json({ error: 'No active polar selected' })
590
+ res.json(getPolarTwsValues())
780
591
  })
781
592
 
782
- router.get('/imports/sources/:source/search', async (req, res) => {
783
- if (!await checkInternet()) {
784
- return res.status(503).json({ error: 'No internet connection — external source imports are unavailable' })
785
- }
593
+ router.get('/polar/queries/curve', (req, res) => {
594
+ if (!polar) return res.status(404).json({ error: 'No active polar selected' })
786
595
  try {
787
- const results = await getImportService().searchSource(req.params.source, req.query?.q)
788
- res.json(results)
789
- } catch (error) {
790
- if (error instanceof ImportError) {
791
- return res.status(error.status).json({ error: error.message })
792
- }
793
- res.status(500).json({ error: error.message })
794
- }
795
- })
796
-
797
- router.post('/imports/sources/:source/items/:externalId', async (req, res) => {
798
- if (!await checkInternet()) {
799
- return res.status(503).json({ error: 'No internet connection — external source imports are unavailable' })
800
- }
801
- try {
802
- const result = await getImportService().importSource(req.params.source, req.params.externalId, req.body)
803
- res.status(201).json({ id: result.id })
804
- } catch (error) {
805
- if (error instanceof ImportError) {
806
- return res.status(error.status).json({ error: error.message })
807
- }
808
- res.status(500).json({ error: error.message })
809
- }
810
- })
811
-
812
- router.get('/polars/:id/axes/tws', (req, res) => {
813
- try {
814
- const table = loadStoredPolar(req.params.id)
815
- res.json(getPolarTwsValues(table))
816
- } catch (e) {
817
- res.status(errorStatus(e)).json({ error: e.message })
818
- }
819
- })
820
-
821
- router.get('/polars/:id/queries/curve', (req, res) => {
822
- try {
823
- const table = loadPolarCached(req.params.id)
824
596
  const tws = parseFloat(req.query.tws)
825
597
  if (!Number.isFinite(tws) || tws < 0) {
826
598
  return res.status(400).json({ error: "'tws' query parameter required (m/s)" })
@@ -829,71 +601,7 @@ module.exports = (app) => {
829
601
  if (!Number.isFinite(stepRad) || stepRad <= 0 || stepRad > Math.PI / 2) {
830
602
  return res.status(400).json({ error: "'step' must be between 0 and π/2 radians" })
831
603
  }
832
- res.json(buildCurveResult(table, tws, stepRad))
833
- } catch (e) {
834
- res.status(errorStatus(e)).json({ error: e.message })
835
- }
836
- })
837
-
838
- router.get('/polars/:id/queries/speed', (req, res) => {
839
- try {
840
- const table = loadPolarCached(req.params.id)
841
- const tws = parseFloat(req.query.tws)
842
- const twa = parseFloat(req.query.twa)
843
- if (!Number.isFinite(tws) || tws < 0 || !Number.isFinite(twa) || twa < 0) {
844
- return res.status(400).json({ error: "'tws' and 'twa' query parameters are required" })
845
- }
846
- res.json(buildSpeedResult(table, tws, twa))
847
- } catch (e) {
848
- res.status(errorStatus(e)).json({ error: e.message })
849
- }
850
- })
851
-
852
- router.get('/polars/:id/queries/targets', (req, res) => {
853
- try {
854
- const table = loadPolarCached(req.params.id)
855
- const tws = parseFloat(req.query.tws)
856
- if (!Number.isFinite(tws) || tws < 0) {
857
- return res.status(400).json({ error: "'tws' query parameter required (m/s)" })
858
- }
859
- res.json(buildTargetsResult(table, tws))
860
- } catch (e) {
861
- res.status(errorStatus(e)).json({ error: e.message })
862
- }
863
- })
864
-
865
- router.get('/polars/:id/queries/performance', (req, res) => {
866
- try {
867
- const table = loadPolarCached(req.params.id)
868
- const tws = parseFloat(req.query.tws)
869
- const twa = parseFloat(req.query.twa)
870
- const bsp = parseFloat(req.query.bsp)
871
- if (!Number.isFinite(tws) || tws < 0 || !Number.isFinite(twa) || twa < 0 || !Number.isFinite(bsp) || bsp < 0) {
872
- return res.status(400).json({ error: "'tws', 'twa', and 'bsp' query parameters are required" })
873
- }
874
- res.json(buildPerformanceResult(table, tws, twa, bsp))
875
- } catch (e) {
876
- res.status(errorStatus(e)).json({ error: e.message })
877
- }
878
- })
879
-
880
- router.get('/polars/:id/meta', (req, res) => {
881
- try {
882
- const polarStore = getStore()
883
- const table = polarStore.load(req.params.id)
884
- const meta = polarStore.readMeta(req.params.id)
885
- const twsValues = getPolarTwsValues(table)
886
- res.json({
887
- id: req.params.id,
888
- name: meta.boatName || req.params.id,
889
- sailnumber: meta.sailnumber,
890
- boatType: meta.boatType,
891
- year: meta.year,
892
- source: meta.source,
893
- notes: meta.notes,
894
- twsMin: twsValues.length ? twsValues[0] : null,
895
- twsMax: twsValues.length ? twsValues[twsValues.length - 1] : null
896
- })
604
+ res.json(buildCurveResult(tws, stepRad))
897
605
  } catch (e) {
898
606
  res.status(errorStatus(e)).json({ error: e.message })
899
607
  }
@@ -910,9 +618,10 @@ module.exports = (app) => {
910
618
  const TWA = Number.isFinite(TWAsigned) ? Math.abs(TWAsigned) : null
911
619
  const BSP = bspSmoother ? bspSmoother.value : null
912
620
 
913
- const polarSpeed = (polarTable && Number.isFinite(TWS) && Number.isFinite(TWA))
914
- ? polarTable.getBoatSpeed(TWS, TWA)
621
+ const polarResult = (polar && Number.isFinite(TWS) && Number.isFinite(TWA))
622
+ ? polar.speedAt({ tws: TWS, twa: TWA, performanceFactor })
915
623
  : null
624
+ const polarSpeed = polarResult ? polarResult.value : null
916
625
 
917
626
  const performance = (Number.isFinite(BSP) && Number.isFinite(polarSpeed) && polarSpeed > 0)
918
627
  ? BSP / polarSpeed
@@ -920,17 +629,13 @@ module.exports = (app) => {
920
629
 
921
630
  const si = v => Number.isFinite(v) ? parseFloat(v.toFixed(5)) : null
922
631
 
923
- const polarState = (polarTable && Number.isFinite(TWS) && Number.isFinite(TWA))
924
- ? polarTable.getInterpolationState(TWS, TWA)
925
- : null
926
-
927
632
  res.json({
928
633
  tws: si(TWS),
929
634
  twa: si(TWAsigned),
930
635
  bsp: si(BSP),
931
636
  polarSpeed: si(polarSpeed),
932
637
  performance: Number.isFinite(performance) ? parseFloat(performance.toFixed(5)) : null,
933
- polarState
638
+ polarState: polarResult ? polarResult.state : null
934
639
  })
935
640
  })
936
641
 
@@ -958,8 +663,8 @@ module.exports = (app) => {
958
663
 
959
664
  const bspPath = settings.useSOG ? 'navigation.speedOverGround' : 'navigation.speedThroughWater'
960
665
 
961
- const polarState = (polarTable && Number.isFinite(TWS) && Number.isFinite(TWAsigned))
962
- ? polarTable.getInterpolationState(TWS, Math.abs(TWAsigned))
666
+ const polarState = (polar && Number.isFinite(TWS) && Number.isFinite(TWAsigned))
667
+ ? polar.speedAt({ tws: TWS, twa: Math.abs(TWAsigned), performanceFactor }).state
963
668
  : null
964
669
 
965
670
  // Build outputs object: only include paths that are enabled and were
@@ -1001,9 +706,9 @@ module.exports = (app) => {
1001
706
  })
1002
707
 
1003
708
  // Metadata describing the units and display preferences for each field
1004
- // returned by /live and the canonical curve query endpoints. displayUnits are fetched from the
1005
- // SK server's path metadata so user unit preferences (kn vs m/s etc.) are
1006
- // respected. Falls back to safe defaults when SK metadata is unavailable.
709
+ // returned by /live and the canonical curve query endpoints, plus a
710
+ // read-only summary of the active polar and performance factor
711
+ // (both sourced from the `polars.*` SK paths — not editable here).
1007
712
  router.get('/meta', (req, res) => {
1008
713
  const speed = { formula: 'value * 1.943844', symbol: 'kn', displayFormat: '0.0' }
1009
714
  const angle = { formula: 'value * 57.29577951308231', symbol: '\u00b0', displayFormat: '0.0' }
@@ -1017,7 +722,17 @@ module.exports = (app) => {
1017
722
  performance: { units: 'ratio', displayUnits: ratio },
1018
723
  'curve.tbs': { units: 'm/s', displayUnits: speed },
1019
724
  'curve.vmg': { units: 'm/s', displayUnits: speed },
1020
- 'curve.twa': { units: 'rad', displayUnits: angle }
725
+ 'curve.twa': { units: 'rad', displayUnits: angle },
726
+ activePolar: activePolarDoc ? {
727
+ id: activePolarId,
728
+ name: activePolarDoc.name || activePolarId,
729
+ boatType: activePolarDoc.boatType ?? null,
730
+ sailnumber: activePolarDoc.sailnumber ?? null,
731
+ year: activePolarDoc.year ?? null,
732
+ source: activePolarDoc.source ?? null,
733
+ notes: activePolarDoc.notes ?? null
734
+ } : null,
735
+ performanceFactor
1021
736
  })
1022
737
  })
1023
738
 
@@ -1037,115 +752,52 @@ module.exports = (app) => {
1037
752
  // wind update (or immediately below if the plugin is already running).
1038
753
  Object.assign(changedOptions, req.body)
1039
754
  hasPendingChanges = true
1040
- // Drain immediately so source/polar changes take effect even when the
755
+ // Drain immediately so source changes take effect even when the
1041
756
  // wind data stream is idle.
1042
757
  if (isRunning) applyOptionChanges()
1043
758
  res.json({ ...settings, ...changedOptions, _defaults: DEFAULT_SETTINGS })
1044
759
  })
1045
-
1046
- // ---- Polar file operations -------------------------------------------
1047
-
1048
- router.get('/polars', (req, res) => {
1049
- try {
1050
- const polarStore = getStore()
1051
- const polars = polarStore.list().map((id) => {
1052
- const meta = polarStore.readMeta(id)
1053
- return {
1054
- id,
1055
- name: meta.boatName || id,
1056
- sailnumber: meta.sailnumber,
1057
- boatType: meta.boatType,
1058
- year: meta.year,
1059
- source: meta.source
1060
- }
1061
- })
1062
- res.json(polars)
1063
- } catch (e) {
1064
- res.status(500).json({ error: e.message })
1065
- }
1066
- })
1067
-
1068
- router.get('/polars/:id', (req, res) => {
1069
- try {
1070
- const stored = getStore().readObject(req.params.id)
1071
- res.json({ ...stored, id: req.params.id })
1072
- } catch (e) {
1073
- res.status(404).json({ error: e.message })
1074
- }
1075
- })
1076
-
1077
- router.put('/polars/:id', (req, res) => {
1078
- const validationError = validatePolarResourceBody(req.body)
1079
- if (validationError) {
1080
- return res.status(400).json({ error: validationError })
1081
- }
1082
-
1083
- try {
1084
- getStore().saveCanonical(req.params.id, {
1085
- ...req.body,
1086
- name: req.body.name || req.params.id
1087
- })
1088
- invalidatePolarCache(req.params.id)
1089
- if (settings.activePolar === req.params.id) {
1090
- polarTable = loadStoredPolar(req.params.id)
1091
- polarTable.setPerformanceAdjustment(settings.perfAdjust || 1)
1092
- }
1093
- res.json({ id: req.params.id })
1094
- } catch (e) {
1095
- res.status(500).json({ error: e.message })
1096
- }
1097
- })
1098
-
1099
- router.delete('/polars/:id', (req, res) => {
1100
- try {
1101
- getStore().delete(req.params.id)
1102
- invalidatePolarCache(req.params.id)
1103
- if (settings.activePolar === req.params.id) {
1104
- settings.activePolar = ''
1105
- polarTable = null
1106
- nullifyOutputs()
1107
- app.savePluginOptions(settings, (err) => {
1108
- if (err) app.error('Failed to save settings: ' + err.message)
1109
- })
1110
- }
1111
- res.json({ id: req.params.id })
1112
- } catch (e) {
1113
- res.status(errorStatus(e)).json({ error: e.message })
1114
- }
1115
- })
1116
760
  },
1117
761
 
1118
762
  start(options) {
763
+ app.debug('Starting')
1119
764
  metaSentPaths = new Set() // reset so metadata is re-emitted after restart
1120
765
  lifecycleWarningMap = new Map()
1121
766
  lifecycleWarnings = []
1122
-
1123
- store = new PolarFileStore(app.getDataDirPath())
1124
- importService = new ImportService(store)
767
+ polar = null
768
+ activePolarId = null
769
+ activePolarDoc = null
770
+ performanceFactor = 1
1125
771
 
1126
772
  settings = migrateSettings({ ...DEFAULT_SETTINGS, ...options, settingsVersion: options.settingsVersion ?? 0 })
1127
773
 
1128
- const SmootherClass = getSmootherClass(settings.smootherType)
1129
- const smootherOptions = getSmootherOptions(settings.smootherType, settings)
1130
-
1131
- // Load the active polar table
1132
- if (settings.activePolar) {
1133
- try {
1134
- polarTable = store.load(settings.activePolar)
1135
- polarTable.setPerformanceAdjustment(settings.perfAdjust || 1)
1136
- app.setPluginStatus(`Polar '${settings.activePolar}' loaded`)
1137
- } catch (e) {
1138
- // File missing or corrupt — run without a polar rather than crashing.
1139
- // Keep settings.activePolar so the user can see which file is broken
1140
- // and re-upload it without reconfiguring. Nullify any stale SK values.
1141
- polarTable = null
1142
- nullifyOutputs()
1143
- app.setPluginError(`Polar '${settings.activePolar}' could not be loaded: ${e.message}`)
1144
- app.debug('Polar load error: %s', e.message)
774
+ checkPolarProvider().then((available) => {
775
+ app.debug(`checkPolarProvider: ${available ? 'polars resource provider found' : 'no polars resource provider'}`)
776
+ if (!available) {
777
+ app.setPluginStatus("No 'polars' resource provider registered — install signalk-polar-management (or another plugin providing the 'polars' resource type) to select an active polar.")
1145
778
  }
1146
- } else {
1147
- app.setPluginStatus('No polar configured — set activePolar or upload a CSV file')
779
+ })
780
+
781
+ // Active polar / performance factor — read-only, published by a 'polars' resource provider.
782
+ // These are event-driven (change only on user action), so the idle-watchdog
783
+ // unsubscribe/resubscribe used for sensor handlers below does not apply here: absence
784
+ // of deltas is the normal state, not a sign of a broken subscription, and periodically
785
+ // forcing a resubscribe would just be churn (and possible side effects) for no reason.
786
+ activePolarHandler = new MessageHandler(app, plugin.id, 'activePolar')
787
+ activePolarHandler.path = 'polars.activePolar'
788
+ activePolarHandler.onDelta = () => handleActivePolarDelta()
789
+ activePolarHandler.subscribe()
790
+
791
+ performanceFactorHandler = new MessageHandler(app, plugin.id, 'performanceFactor')
792
+ performanceFactorHandler.path = 'polars.performanceFactor'
793
+ performanceFactorHandler.onDelta = () => {
794
+ const value = performanceFactorHandler.value
795
+ performanceFactor = Number.isFinite(value) ? value : 1
1148
796
  }
797
+ performanceFactorHandler.subscribe()
798
+
799
+ const SmootherClass = getSmootherClass(settings.smootherType)
800
+ const smootherOptions = getSmootherOptions(settings.smootherType, settings)
1149
801
 
1150
802
  // Wind vector smoother (TWS + TWA combined as a Cartesian vector —
1151
803
  // avoids ±π wraparound discontinuity during smoothing)
@@ -1211,12 +863,17 @@ module.exports = (app) => {
1211
863
  },
1212
864
 
1213
865
  stop() {
866
+ app.debug('Stopping')
1214
867
  isRunning = false
1215
- importService = null
1216
868
  nullifyOutputs()
1217
869
  if (windSmoother) { windSmoother.terminate(); windSmoother = null }
1218
870
  if (bspSmoother) { bspSmoother.terminate(); bspSmoother = null }
1219
871
  if (hdgSmoother) { hdgSmoother.terminate(); hdgSmoother = null }
872
+ if (activePolarHandler) { activePolarHandler.unsubscribe(); activePolarHandler = null }
873
+ if (performanceFactorHandler) { performanceFactorHandler.unsubscribe(); performanceFactorHandler = null }
874
+ polar = null
875
+ activePolarId = null
876
+ activePolarDoc = null
1220
877
  lifecycleWarningMap = new Map()
1221
878
  lifecycleWarnings = []
1222
879
  app.debug('Plugin stopped')