signalk-polar-performance-plugin 1.2.0 → 1.3.1

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,23 @@ 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
+ - `/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.
11
+ - `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.
12
+ - 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.
13
+
14
+ ### Changed
15
+ - Idle input recovery is now always enabled; the temporary `detectStaleData` setting has been removed from the runtime settings UI.
16
+
17
+ ## [1.2.1] - 2026-07-25
18
+
19
+ ### Fixed
20
+ - `openApi.json` was missing from the npm tarball (`files` field in `package.json` did not list it), causing the plugin to crash on install with `Cannot find module '../openApi.json'` (#20).
21
+
22
+ ## [1.2.0] - 2026-07-25
23
+
7
24
  ## [1.1.0] - 2026-07-21
8
25
 
9
26
  ### Added
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/openApi.json ADDED
@@ -0,0 +1,912 @@
1
+ {
2
+ "openapi": "3.0.3",
3
+ "info": {
4
+ "title": "Polar Performance Plugin API",
5
+ "version": "1.0.0",
6
+ "description": "REST API for polar library management and polar performance queries.\nAll speed values are in m/s, all angles in radians."
7
+ },
8
+ "servers": [
9
+ { "url": "/plugins/signalk-polar-performance-plugin" }
10
+ ],
11
+ "tags": [
12
+ {
13
+ "name": "polar-manager",
14
+ "description": "Polar library management: list, replace, inspect, delete, and select canonical polars by id."
15
+ },
16
+ {
17
+ "name": "polar-query",
18
+ "description": "Polar performance queries: inspect axes and derive speed, targets, curves, and performance ratios for any stored polar."
19
+ },
20
+ {
21
+ "name": "polar-import",
22
+ "description": "Optional polar ingestion extension: discover supported external sources and text formats, convert them to canonical polar resources, and store them in the library."
23
+ }
24
+ ],
25
+ "paths": {
26
+
27
+ "/polars": {
28
+ "get": {
29
+ "tags": ["polar-manager"],
30
+ "operationId": "listPolars",
31
+ "summary": "List all stored polars",
32
+ "description": "Returns a summary of every polar in the library.",
33
+ "responses": {
34
+ "200": {
35
+ "description": "Array of polar summaries",
36
+ "content": {
37
+ "application/json": {
38
+ "schema": {
39
+ "type": "array",
40
+ "items": { "$ref": "#/components/schemas/PolarSummary" }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
46
+ },
47
+ "post": {
48
+ "tags": ["polar-manager"],
49
+ "operationId": "createPolar",
50
+ "summary": "Create a canonical polar with an auto-generated id",
51
+ "description": "Stores the supplied canonical PolarTable resource under an auto-generated id and returns that id.",
52
+ "requestBody": {
53
+ "required": true,
54
+ "content": {
55
+ "application/json": {
56
+ "schema": { "$ref": "#/components/schemas/PolarResourceBody" }
57
+ }
58
+ }
59
+ },
60
+ "responses": {
61
+ "201": {
62
+ "description": "Polar stored with an auto-generated id",
63
+ "content": {
64
+ "application/json": {
65
+ "schema": { "$ref": "#/components/schemas/IdResponse" }
66
+ }
67
+ }
68
+ },
69
+ "400": {
70
+ "description": "Invalid canonical polar resource",
71
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
72
+ }
73
+ }
74
+ }
75
+ },
76
+
77
+ "/imports/formats": {
78
+ "get": {
79
+ "tags": ["polar-import"],
80
+ "operationId": "listImportFormats",
81
+ "summary": "List supported text import formats",
82
+ "description": "Returns the format identifiers accepted by the text import endpoint. These formats are conversion inputs only; imported data is stored as canonical polar resources.",
83
+ "responses": {
84
+ "200": {
85
+ "description": "Supported text import formats",
86
+ "content": {
87
+ "application/json": {
88
+ "schema": {
89
+ "type": "array",
90
+ "items": { "$ref": "#/components/schemas/ImportFormatDescriptor" }
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+ }
97
+ },
98
+
99
+ "/imports/text/{format}": {
100
+ "post": {
101
+ "tags": ["polar-import"],
102
+ "operationId": "importPolarFromText",
103
+ "summary": "Import a polar from text content",
104
+ "description": "Parses the supplied text payload in the specified format, converts it to the canonical polar resource model, stores it in the local library, and returns the assigned id.",
105
+ "parameters": [
106
+ {
107
+ "name": "format",
108
+ "in": "path",
109
+ "required": true,
110
+ "description": "Text format identifier returned by GET /imports/formats.",
111
+ "schema": { "type": "string" }
112
+ }
113
+ ],
114
+ "requestBody": {
115
+ "required": true,
116
+ "content": {
117
+ "application/json": {
118
+ "schema": { "$ref": "#/components/schemas/TextPolarImportRequest" }
119
+ }
120
+ }
121
+ },
122
+ "responses": {
123
+ "201": {
124
+ "description": "Polar imported and stored",
125
+ "content": {
126
+ "application/json": {
127
+ "schema": { "$ref": "#/components/schemas/IdResponse" }
128
+ }
129
+ }
130
+ },
131
+ "400": {
132
+ "description": "Unsupported format or invalid text payload",
133
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
134
+ },
135
+ "409": {
136
+ "description": "Requested id already exists",
137
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
138
+ }
139
+ }
140
+ }
141
+ },
142
+
143
+ "/imports/sources": {
144
+ "get": {
145
+ "tags": ["polar-import"],
146
+ "operationId": "listImportSources",
147
+ "summary": "List supported external import sources",
148
+ "description": "Returns the external sources from which polars can be searched and imported. The current backend exposes the official ORC active certificate source as `orc`.",
149
+ "responses": {
150
+ "200": {
151
+ "description": "Supported external sources",
152
+ "content": {
153
+ "application/json": {
154
+ "schema": {
155
+ "type": "array",
156
+ "items": { "$ref": "#/components/schemas/ImportSourceDescriptor" }
157
+ }
158
+ }
159
+ }
160
+ }
161
+ }
162
+ }
163
+ },
164
+
165
+ "/imports/sources/{source}/search": {
166
+ "get": {
167
+ "tags": ["polar-import"],
168
+ "operationId": "searchImportSource",
169
+ "summary": "Search an external polar source",
170
+ "description": "Queries the specified external source for polar candidates matching the search term. For the `orc` source this searches the official active certificate index by certificate RefNo, boat name, sail number, and class.",
171
+ "parameters": [
172
+ {
173
+ "name": "source",
174
+ "in": "path",
175
+ "required": true,
176
+ "description": "External source identifier returned by GET /imports/sources. The current backend exposes `orc` for the official ORC active certificate source.",
177
+ "schema": { "type": "string" }
178
+ },
179
+ {
180
+ "name": "q",
181
+ "in": "query",
182
+ "required": false,
183
+ "description": "Free-text search string matched against source-specific polar metadata.",
184
+ "schema": { "type": "string" }
185
+ }
186
+ ],
187
+ "responses": {
188
+ "200": {
189
+ "description": "Matching external polar candidates",
190
+ "content": {
191
+ "application/json": {
192
+ "schema": {
193
+ "type": "array",
194
+ "items": { "$ref": "#/components/schemas/ExternalPolarSummary" }
195
+ }
196
+ }
197
+ }
198
+ },
199
+ "404": {
200
+ "description": "Import source not found",
201
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
202
+ },
203
+ "502": {
204
+ "description": "Failed to reach the external source",
205
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
206
+ }
207
+ }
208
+ }
209
+ },
210
+
211
+ "/imports/sources/{source}/items/{externalId}": {
212
+ "post": {
213
+ "tags": ["polar-import"],
214
+ "operationId": "importPolarFromSource",
215
+ "summary": "Import a polar from an external source",
216
+ "description": "Fetches the specified external polar, converts it to the canonical resource model, stores it in the local library, and returns the assigned id. For the `orc` source, `externalId` is the certificate RefNo from the official active certificate index.",
217
+ "parameters": [
218
+ {
219
+ "name": "source",
220
+ "in": "path",
221
+ "required": true,
222
+ "description": "External source identifier returned by GET /imports/sources. The current backend exposes `orc` for the official ORC active certificate source.",
223
+ "schema": { "type": "string" }
224
+ },
225
+ {
226
+ "name": "externalId",
227
+ "in": "path",
228
+ "required": true,
229
+ "description": "External polar identifier returned by GET /imports/sources/{source}/search. For the `orc` source, this is the certificate RefNo.",
230
+ "schema": { "type": "string" }
231
+ }
232
+ ],
233
+ "responses": {
234
+ "201": {
235
+ "description": "Polar imported and stored",
236
+ "content": {
237
+ "application/json": {
238
+ "schema": { "$ref": "#/components/schemas/IdResponse" }
239
+ }
240
+ }
241
+ },
242
+ "404": {
243
+ "description": "Import source or external polar not found",
244
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
245
+ },
246
+ "409": {
247
+ "description": "Requested id already exists",
248
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
249
+ },
250
+ "502": {
251
+ "description": "Failed to reach the external source",
252
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
253
+ }
254
+ }
255
+ }
256
+ },
257
+
258
+ "/polars/active": {
259
+ "get": {
260
+ "tags": ["polar-manager"],
261
+ "operationId": "getActivePolar",
262
+ "summary": "Get the active polar id",
263
+ "description": "Returns the id of the currently active polar.",
264
+ "responses": {
265
+ "200": {
266
+ "description": "Active polar id",
267
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdResponse" } } }
268
+ },
269
+ "404": {
270
+ "description": "No polar is currently active",
271
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
272
+ }
273
+ }
274
+ },
275
+ "put": {
276
+ "tags": ["polar-manager"],
277
+ "operationId": "setActivePolar",
278
+ "summary": "Set the active polar",
279
+ "description": "Makes the identified polar the active polar for live computation.",
280
+ "requestBody": {
281
+ "required": true,
282
+ "content": {
283
+ "application/json": {
284
+ "schema": { "$ref": "#/components/schemas/IdResponse" }
285
+ }
286
+ }
287
+ },
288
+ "responses": {
289
+ "200": {
290
+ "description": "Active polar updated",
291
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdResponse" } } }
292
+ },
293
+ "404": {
294
+ "description": "Polar not found",
295
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
296
+ }
297
+ }
298
+ },
299
+ "delete": {
300
+ "tags": ["polar-manager"],
301
+ "operationId": "clearActivePolar",
302
+ "summary": "Clear the active polar",
303
+ "description": "Clears the active polar so no stored polar is used for live computation.",
304
+ "responses": {
305
+ "200": {
306
+ "description": "Active polar cleared",
307
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdResponse" } } }
308
+ }
309
+ }
310
+ }
311
+ },
312
+
313
+ "/polars/{id}": {
314
+ "parameters": [
315
+ { "$ref": "#/components/parameters/polarId" }
316
+ ],
317
+ "get": {
318
+ "tags": ["polar-manager"],
319
+ "operationId": "getPolar",
320
+ "summary": "Get a canonical polar resource",
321
+ "description": "Returns the full canonical PolarTable resource, including axes, values, units, symmetry assumptions, and derived metadata.",
322
+ "responses": {
323
+ "200": {
324
+ "description": "Canonical polar resource",
325
+ "content": {
326
+ "application/json": {
327
+ "schema": { "$ref": "#/components/schemas/PolarResource" }
328
+ }
329
+ }
330
+ },
331
+ "404": {
332
+ "description": "Polar not found",
333
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
334
+ }
335
+ }
336
+ },
337
+ "put": {
338
+ "tags": ["polar-manager"],
339
+ "operationId": "replacePolar",
340
+ "summary": "Create or replace a canonical polar resource",
341
+ "description": "Stores the supplied canonical PolarTable resource under the given id. If the polar exists it is replaced; otherwise it is created.",
342
+ "requestBody": {
343
+ "required": true,
344
+ "content": {
345
+ "application/json": {
346
+ "schema": { "$ref": "#/components/schemas/PolarResourceBody" }
347
+ }
348
+ }
349
+ },
350
+ "responses": {
351
+ "200": {
352
+ "description": "Polar stored",
353
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdResponse" } } }
354
+ },
355
+ "400": {
356
+ "description": "Invalid canonical resource body",
357
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
358
+ }
359
+ }
360
+ },
361
+ "delete": {
362
+ "tags": ["polar-manager"],
363
+ "operationId": "deletePolar",
364
+ "summary": "Delete a polar",
365
+ "description": "Permanently removes the identified polar from the library.",
366
+ "responses": {
367
+ "200": {
368
+ "description": "Polar deleted",
369
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdResponse" } } }
370
+ },
371
+ "404": {
372
+ "description": "Polar not found",
373
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
374
+ }
375
+ }
376
+ }
377
+ },
378
+
379
+ "/polars/{id}/meta": {
380
+ "get": {
381
+ "tags": ["polar-manager"],
382
+ "operationId": "getPolarMeta",
383
+ "summary": "Get polar metadata",
384
+ "description": "Returns the stored metadata for the identified polar: name, sailnumber, boatType, year, source, notes, and TWS range.",
385
+ "parameters": [
386
+ { "$ref": "#/components/parameters/polarId" }
387
+ ],
388
+ "responses": {
389
+ "200": {
390
+ "description": "Polar metadata",
391
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PolarMeta" } } }
392
+ },
393
+ "404": {
394
+ "description": "Polar not found",
395
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
396
+ }
397
+ }
398
+ }
399
+ },
400
+
401
+ "/polars/{id}/axes/tws": {
402
+ "get": {
403
+ "tags": ["polar-query"],
404
+ "operationId": "getPolarTws",
405
+ "summary": "TWS values available in a polar",
406
+ "description": "Returns the array of true wind speed values (m/s) for which the polar has tabulated data.",
407
+ "parameters": [
408
+ { "$ref": "#/components/parameters/polarId" }
409
+ ],
410
+ "responses": {
411
+ "200": {
412
+ "description": "TWS values in m/s",
413
+ "content": {
414
+ "application/json": {
415
+ "schema": { "type": "array", "items": { "type": "number" } }
416
+ }
417
+ }
418
+ },
419
+ "404": {
420
+ "description": "Polar not found",
421
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
422
+ }
423
+ }
424
+ }
425
+ },
426
+
427
+ "/polars/{id}/queries/curve": {
428
+ "get": {
429
+ "tags": ["polar-query"],
430
+ "operationId": "getPolarCurve",
431
+ "summary": "Interpolated polar curve at a given TWS",
432
+ "description": "Returns a sampled polar curve at the requested TWS, including beat and run target markers.",
433
+ "parameters": [
434
+ { "$ref": "#/components/parameters/polarId" },
435
+ {
436
+ "name": "tws",
437
+ "in": "query",
438
+ "required": true,
439
+ "description": "True wind speed in m/s",
440
+ "schema": { "type": "number", "minimum": 0 }
441
+ },
442
+ {
443
+ "name": "step",
444
+ "in": "query",
445
+ "required": false,
446
+ "description": "Angular sampling step in radians (default ~0.035 rad = 2 deg)",
447
+ "schema": { "type": "number", "minimum": 0 }
448
+ }
449
+ ],
450
+ "responses": {
451
+ "200": {
452
+ "description": "Polar curve data",
453
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PolarCurveResult" } } }
454
+ },
455
+ "400": {
456
+ "description": "Missing or invalid query parameters",
457
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
458
+ },
459
+ "404": {
460
+ "description": "Polar not found",
461
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
462
+ }
463
+ }
464
+ }
465
+ },
466
+
467
+ "/polars/{id}/queries/speed": {
468
+ "get": {
469
+ "tags": ["polar-query"],
470
+ "operationId": "getPolarSpeed",
471
+ "summary": "Interpolated boat speed at a TWS/TWA point",
472
+ "description": "Returns the polar boat speed at the given true wind speed and angle, plus interpolation quality indicators.",
473
+ "parameters": [
474
+ { "$ref": "#/components/parameters/polarId" },
475
+ {
476
+ "name": "tws",
477
+ "in": "query",
478
+ "required": true,
479
+ "description": "True wind speed in m/s",
480
+ "schema": { "type": "number", "minimum": 0 }
481
+ },
482
+ {
483
+ "name": "twa",
484
+ "in": "query",
485
+ "required": true,
486
+ "description": "True wind angle in radians (positive, 0 to pi)",
487
+ "schema": { "type": "number", "minimum": 0 }
488
+ }
489
+ ],
490
+ "responses": {
491
+ "200": {
492
+ "description": "Interpolated speed result",
493
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PolarSpeedResult" } } }
494
+ },
495
+ "400": {
496
+ "description": "Missing or invalid query parameters",
497
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
498
+ },
499
+ "404": {
500
+ "description": "Polar not found",
501
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
502
+ }
503
+ }
504
+ }
505
+ },
506
+
507
+ "/polars/{id}/queries/targets": {
508
+ "get": {
509
+ "tags": ["polar-query"],
510
+ "operationId": "getPolarTargets",
511
+ "summary": "Optimal beat and run targets at a given TWS",
512
+ "description": "Returns the optimal beat and run angles, boat speeds, and VMG values at the given true wind speed.",
513
+ "parameters": [
514
+ { "$ref": "#/components/parameters/polarId" },
515
+ {
516
+ "name": "tws",
517
+ "in": "query",
518
+ "required": true,
519
+ "description": "True wind speed in m/s",
520
+ "schema": { "type": "number", "minimum": 0 }
521
+ }
522
+ ],
523
+ "responses": {
524
+ "200": {
525
+ "description": "Beat and run targets",
526
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PolarTargetsResult" } } }
527
+ },
528
+ "400": {
529
+ "description": "Missing or invalid query parameters",
530
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
531
+ },
532
+ "404": {
533
+ "description": "Polar not found",
534
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
535
+ }
536
+ }
537
+ }
538
+ },
539
+
540
+ "/polars/{id}/queries/performance": {
541
+ "get": {
542
+ "tags": ["polar-query"],
543
+ "operationId": "getPolarPerformance",
544
+ "summary": "Performance ratio against the polar",
545
+ "description": "Given actual TWS, TWA, and BSP, returns the polar target speed and performance ratio, plus VMG performance. VMG is upwind when TWA < 60 deg (1.047 rad), downwind when TWA > 120 deg (2.094 rad), and null for reaching in between.",
546
+ "parameters": [
547
+ { "$ref": "#/components/parameters/polarId" },
548
+ {
549
+ "name": "tws",
550
+ "in": "query",
551
+ "required": true,
552
+ "description": "True wind speed in m/s",
553
+ "schema": { "type": "number", "minimum": 0 }
554
+ },
555
+ {
556
+ "name": "twa",
557
+ "in": "query",
558
+ "required": true,
559
+ "description": "True wind angle in radians (positive, 0 to pi)",
560
+ "schema": { "type": "number", "minimum": 0 }
561
+ },
562
+ {
563
+ "name": "bsp",
564
+ "in": "query",
565
+ "required": true,
566
+ "description": "Actual boat speed in m/s",
567
+ "schema": { "type": "number", "minimum": 0 }
568
+ }
569
+ ],
570
+ "responses": {
571
+ "200": {
572
+ "description": "Performance result",
573
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PolarPerformanceResult" } } }
574
+ },
575
+ "400": {
576
+ "description": "Missing or invalid query parameters",
577
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
578
+ },
579
+ "404": {
580
+ "description": "Polar not found",
581
+ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
582
+ }
583
+ }
584
+ }
585
+ }
586
+
587
+ },
588
+
589
+ "components": {
590
+ "parameters": {
591
+ "polarId": {
592
+ "name": "id",
593
+ "in": "path",
594
+ "required": true,
595
+ "description": "Polar identifier",
596
+ "schema": { "type": "string" }
597
+ }
598
+ },
599
+ "schemas": {
600
+
601
+ "Error": {
602
+ "type": "object",
603
+ "required": ["error"],
604
+ "properties": {
605
+ "error": { "type": "string" }
606
+ }
607
+ },
608
+
609
+ "IdResponse": {
610
+ "type": "object",
611
+ "required": ["id"],
612
+ "properties": {
613
+ "id": { "type": "string" }
614
+ }
615
+ },
616
+
617
+ "ImportFormatDescriptor": {
618
+ "type": "object",
619
+ "required": ["id", "name"],
620
+ "properties": {
621
+ "id": { "type": "string", "description": "Stable text format identifier used in POST /imports/text/{format}." },
622
+ "name": { "type": "string", "description": "Human-readable format name." },
623
+ "description": { "type": "string", "description": "Short description of the source text format." }
624
+ }
625
+ },
626
+
627
+ "ImportSourceDescriptor": {
628
+ "type": "object",
629
+ "required": ["id", "name"],
630
+ "properties": {
631
+ "id": { "type": "string", "description": "Stable source identifier used in /imports/sources/{source}/... endpoints." },
632
+ "name": { "type": "string", "description": "Human-readable source name." },
633
+ "description": { "type": "string", "description": "Short description of the external source." },
634
+ "url": { "type": "string", "description": "Optional source home page or API URL." },
635
+ "available": { "type": "boolean", "description": "Whether the external source is currently reachable for live imports." },
636
+ "availabilityMessage": { "type": "string", "description": "Human-readable reason when the source is currently unavailable." }
637
+ }
638
+ },
639
+
640
+ "ExternalPolarSummary": {
641
+ "type": "object",
642
+ "required": ["externalId"],
643
+ "properties": {
644
+ "externalId": { "type": "string", "description": "Source-specific polar identifier used when importing a result. For the `orc` source, this is the certificate RefNo." },
645
+ "name": { "type": "string", "description": "Human-readable boat or polar name." },
646
+ "sailnumber": { "type": "string" },
647
+ "boatType": { "type": "string" },
648
+ "year": { "type": "integer", "description": "Certificate or measurement year if known." },
649
+ "source": { "type": "string", "description": "Source identifier that produced this result." },
650
+ "countryId": { "type": "string", "description": "Source-specific country identifier if available." },
651
+ "certificateName": { "type": "string", "description": "Certificate variant label if available." },
652
+ "familyName": { "type": "string", "description": "Source-specific certificate family label if available." }
653
+ }
654
+ },
655
+
656
+ "TextPolarImportRequest": {
657
+ "type": "object",
658
+ "required": ["content"],
659
+ "properties": {
660
+ "content": { "type": "string", "description": "Raw source text to parse and import." },
661
+ "name": { "type": "string", "description": "Optional human-readable label override." },
662
+ "sailnumber": { "type": "string" },
663
+ "boatType": { "type": "string" },
664
+ "year": { "type": "integer", "description": "Certificate or measurement year override." },
665
+ "source": { "type": "string", "description": "Source label override stored in the canonical metadata." },
666
+ "notes": { "type": "string", "description": "Optional notes stored in the canonical metadata." }
667
+ }
668
+ },
669
+
670
+ "PolarSummary": {
671
+ "type": "object",
672
+ "required": ["id"],
673
+ "properties": {
674
+ "id": { "type": "string", "description": "Opaque stable identifier for this polar" },
675
+ "name": { "type": "string", "description": "Human-readable label" },
676
+ "sailnumber": { "type": "string" },
677
+ "boatType": { "type": "string" },
678
+ "year": { "type": "integer", "description": "Certificate or measurement year" },
679
+ "source": { "type": "string", "description": "Data origin, e.g. orc, irc, measured, custom" }
680
+ }
681
+ },
682
+
683
+ "PolarMeta": {
684
+ "allOf": [
685
+ { "$ref": "#/components/schemas/PolarSummary" },
686
+ {
687
+ "type": "object",
688
+ "properties": {
689
+ "notes": { "type": "string" },
690
+ "twsMin": { "type": "number", "description": "Lowest available TWS in the table (m/s)" },
691
+ "twsMax": { "type": "number", "description": "Highest available TWS in the table (m/s)" }
692
+ }
693
+ }
694
+ ]
695
+ },
696
+
697
+ "PolarUnits": {
698
+ "type": "object",
699
+ "required": ["tws", "twa", "boatSpeed"],
700
+ "properties": {
701
+ "tws": { "type": "string", "enum": ["m/s"] },
702
+ "twa": { "type": "string", "enum": ["rad"] },
703
+ "boatSpeed": { "type": "string", "enum": ["m/s"] }
704
+ }
705
+ },
706
+
707
+ "PolarSymmetry": {
708
+ "type": "object",
709
+ "required": ["portStarboardSymmetric"],
710
+ "properties": {
711
+ "portStarboardSymmetric": {
712
+ "type": "boolean",
713
+ "description": "Whether the polar assumes the same performance on port and starboard tack."
714
+ }
715
+ }
716
+ },
717
+
718
+ "PolarAxes": {
719
+ "type": "object",
720
+ "required": ["tws", "twa"],
721
+ "properties": {
722
+ "tws": {
723
+ "type": "array",
724
+ "description": "Sorted true wind speed axis in m/s.",
725
+ "items": { "type": "number" }
726
+ },
727
+ "twa": {
728
+ "type": "array",
729
+ "description": "Sorted true wind angle axis in radians, expressed over 0 to pi.",
730
+ "items": { "type": "number" }
731
+ }
732
+ }
733
+ },
734
+
735
+ "PolarValues": {
736
+ "type": "object",
737
+ "required": ["boatSpeedMatrix"],
738
+ "properties": {
739
+ "boatSpeedMatrix": {
740
+ "type": "array",
741
+ "description": "Boat speed matrix in m/s indexed by TWS row then TWA column.",
742
+ "items": {
743
+ "type": "array",
744
+ "items": { "type": "number" }
745
+ }
746
+ }
747
+ }
748
+ },
749
+
750
+ "PolarDerivedRow": {
751
+ "type": "object",
752
+ "required": ["tws"],
753
+ "properties": {
754
+ "tws": { "type": "number", "description": "True wind speed in m/s for the derived row." },
755
+ "beat": { "nullable": true, "$ref": "#/components/schemas/PolarTargetPoint" },
756
+ "run": { "nullable": true, "$ref": "#/components/schemas/PolarTargetPoint" },
757
+ "maxSpeed": { "type": "number", "description": "Maximum boat speed in m/s at this TWS." },
758
+ "maxSpeedAngle": { "type": "number", "description": "True wind angle in radians at which maxSpeed occurs." }
759
+ }
760
+ },
761
+
762
+ "PolarDerived": {
763
+ "type": "object",
764
+ "properties": {
765
+ "rows": {
766
+ "type": "array",
767
+ "items": { "$ref": "#/components/schemas/PolarDerivedRow" }
768
+ }
769
+ }
770
+ },
771
+
772
+ "PolarResource": {
773
+ "allOf": [
774
+ { "$ref": "#/components/schemas/PolarMeta" },
775
+ {
776
+ "type": "object",
777
+ "required": ["id", "kind", "schemaVersion", "units", "symmetry", "axes", "values"],
778
+ "properties": {
779
+ "kind": {
780
+ "type": "string",
781
+ "enum": ["polarTable"],
782
+ "description": "Canonical resource type identifier."
783
+ },
784
+ "schemaVersion": {
785
+ "type": "string",
786
+ "description": "Version of the canonical PolarTable resource schema."
787
+ },
788
+ "units": { "$ref": "#/components/schemas/PolarUnits" },
789
+ "symmetry": { "$ref": "#/components/schemas/PolarSymmetry" },
790
+ "axes": { "$ref": "#/components/schemas/PolarAxes" },
791
+ "values": { "$ref": "#/components/schemas/PolarValues" },
792
+ "derived": { "$ref": "#/components/schemas/PolarDerived" }
793
+ }
794
+ }
795
+ ]
796
+ },
797
+
798
+ "PolarResourceBody": {
799
+ "type": "object",
800
+ "required": ["kind", "schemaVersion", "units", "symmetry", "axes", "values"],
801
+ "properties": {
802
+ "name": { "type": "string", "description": "Human-readable label" },
803
+ "sailnumber": { "type": "string" },
804
+ "boatType": { "type": "string" },
805
+ "year": { "type": "integer", "description": "Certificate or measurement year" },
806
+ "source": { "type": "string", "description": "Data origin, e.g. orc, irc, measured, custom" },
807
+ "notes": { "type": "string" },
808
+ "kind": {
809
+ "type": "string",
810
+ "enum": ["polarTable"],
811
+ "description": "Canonical resource type identifier."
812
+ },
813
+ "schemaVersion": {
814
+ "type": "string",
815
+ "description": "Version of the canonical PolarTable resource schema."
816
+ },
817
+ "units": { "$ref": "#/components/schemas/PolarUnits" },
818
+ "symmetry": { "$ref": "#/components/schemas/PolarSymmetry" },
819
+ "axes": { "$ref": "#/components/schemas/PolarAxes" },
820
+ "values": { "$ref": "#/components/schemas/PolarValues" },
821
+ "derived": { "$ref": "#/components/schemas/PolarDerived" }
822
+ }
823
+ },
824
+
825
+ "PolarTargetPoint": {
826
+ "type": "object",
827
+ "description": "Optimal sailing target (beat or run).",
828
+ "properties": {
829
+ "twa": { "type": "number", "description": "Optimal true wind angle (rad)" },
830
+ "tbs": { "type": "number", "description": "Boat speed at optimal angle (m/s)" },
831
+ "vmg": { "type": "number", "description": "Velocity made good at optimal angle (m/s)" }
832
+ }
833
+ },
834
+
835
+ "PolarCurveResult": {
836
+ "type": "object",
837
+ "properties": {
838
+ "tws": { "type": "number", "description": "Requested TWS (m/s)" },
839
+ "points": {
840
+ "type": "array",
841
+ "description": "Sampled points from 0 to pi",
842
+ "items": {
843
+ "type": "object",
844
+ "properties": {
845
+ "twa": { "type": "number", "description": "True wind angle (rad)" },
846
+ "tbs": { "type": "number", "description": "Boat speed (m/s)" }
847
+ }
848
+ }
849
+ },
850
+ "beat": { "nullable": true, "$ref": "#/components/schemas/PolarTargetPoint" },
851
+ "run": { "nullable": true, "$ref": "#/components/schemas/PolarTargetPoint" }
852
+ }
853
+ },
854
+
855
+ "PolarSpeedResult": {
856
+ "type": "object",
857
+ "properties": {
858
+ "tws": { "type": "number", "description": "True wind speed (m/s)" },
859
+ "twa": { "type": "number", "description": "True wind angle (rad)" },
860
+ "tbs": { "type": "number", "nullable": true, "description": "Interpolated boat speed (m/s), null if outside polar range" },
861
+ "state": {
862
+ "type": "object",
863
+ "description": "Interpolation quality indicators.",
864
+ "properties": {
865
+ "tws": { "type": "string", "enum": ["below_range", "in_range", "above_range"] },
866
+ "twa": { "type": "string", "enum": ["in_irons", "pinching", "in_range", "extrapolated", "above_range"] }
867
+ }
868
+ }
869
+ }
870
+ },
871
+
872
+ "PolarTargetsResult": {
873
+ "type": "object",
874
+ "properties": {
875
+ "tws": { "type": "number", "description": "True wind speed (m/s)" },
876
+ "beat": { "nullable": true, "$ref": "#/components/schemas/PolarTargetPoint" },
877
+ "run": { "nullable": true, "$ref": "#/components/schemas/PolarTargetPoint" }
878
+ }
879
+ },
880
+
881
+ "PerformanceMetric": {
882
+ "type": "object",
883
+ "properties": {
884
+ "polar": { "type": "number", "description": "Polar reference value (m/s)" },
885
+ "actual": { "type": "number", "description": "Actual measured value (m/s)" },
886
+ "ratio": { "type": "number", "description": "actual / polar" }
887
+ }
888
+ },
889
+
890
+ "PolarPerformanceResult": {
891
+ "type": "object",
892
+ "properties": {
893
+ "tws": { "type": "number", "description": "True wind speed (m/s)" },
894
+ "twa": { "type": "number", "description": "True wind angle (rad)" },
895
+ "bsp": { "type": "number", "description": "Actual boat speed (m/s)" },
896
+ "direction": {
897
+ "type": "string",
898
+ "enum": ["upwind", "reaching", "downwind"],
899
+ "description": "Point of sail. upwind: TWA < 60 deg (1.047 rad); downwind: TWA > 120 deg (2.094 rad); reaching: between."
900
+ },
901
+ "speed": { "$ref": "#/components/schemas/PerformanceMetric" },
902
+ "vmg": {
903
+ "nullable": true,
904
+ "$ref": "#/components/schemas/PerformanceMetric",
905
+ "description": "null when direction is reaching"
906
+ }
907
+ }
908
+ }
909
+
910
+ }
911
+ }
912
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-polar-performance-plugin",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
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": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "icon.png",
11
+ "openApi.json",
11
12
  "plugin/",
12
13
  "public/",
13
14
  "test/",
@@ -25,7 +26,7 @@
25
26
  "prepublishOnly": "npm test"
26
27
  },
27
28
  "dependencies": {
28
- "signalkutilities": "^3.0.1"
29
+ "signalkutilities": "^3.1.0"
29
30
  },
30
31
  "repository": {
31
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