sailkick-boat 0.22.2 → 0.23.3
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/README.md +83 -0
- package/index.js +25 -1
- package/lib/backfill/index.js +4 -3
- package/lib/cloud/index.js +7 -5
- package/lib/history/index.js +1 -0
- package/lib/history/ring.js +9 -2
- package/lib/net.js +149 -0
- package/lib/perf/index.js +182 -0
- package/lib/perf/perf-live.js +80 -0
- package/lib/perf/polar.js +133 -0
- package/lib/proxy/cache.js +2 -1
- package/lib/proxy/index.js +3 -2
- package/lib/proxy/manifest.js +2 -1
- package/lib/sync/index.js +23 -3
- package/lib/sync/influxWrite.js +20 -18
- package/lib/telemetry/contract.js +3 -1
- package/lib/telemetry/index.js +19 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,6 +52,17 @@ files to send later — nothing is lost in the gap.
|
|
|
52
52
|
|
|
53
53
|
- Network errors, `429` and `5xx` are retried with backoff (1 s → 60 s); the data stays
|
|
54
54
|
on disk.
|
|
55
|
+
- **All cloud traffic uses core `https`, not `fetch`** — telemetry sync, the offline
|
|
56
|
+
mirror, the cache-manifest poller, the contract check, the backfill and the Sync page
|
|
57
|
+
share one connection pool (`lib/net.js`), so one reset clears every subsystem. Twice in one afternoon this boat's
|
|
58
|
+
Signal K process stopped being able to open *any* outbound HTTPS connection — zero
|
|
59
|
+
sockets to `:443`, while a second process in the same container reached the same host
|
|
60
|
+
in under a second — and it never recovered on its own. Starlink sits behind CGNAT,
|
|
61
|
+
which drops idle NAT mappings without an RST, so a pooled keep-alive socket looks alive
|
|
62
|
+
to the client and is dead on the wire. `fetch` offers no supported way to reset its
|
|
63
|
+
pool from a plugin. Owning an agent means the pool can be rebuilt after repeated
|
|
64
|
+
transport failures (and it is, automatically, after five), and it means the log names
|
|
65
|
+
`ECONNRESET` or `ETIMEDOUT` instead of `fetch`'s uniformly useless "fetch failed".
|
|
55
66
|
- A malformed batch (`4xx` other than the three below) is quarantined to `spool/dead/`
|
|
56
67
|
rather than retried forever, because it would otherwise wedge the queue behind it.
|
|
57
68
|
- **`404`, `401` and `403` are held, not quarantined.** A missing bucket or a rejected
|
|
@@ -292,6 +303,78 @@ Items are matched by **name**, since ids are assigned independently on each side
|
|
|
292
303
|
polar you refined in the web app appears as *cloud only* — or *differs* if the boat has an
|
|
293
304
|
older one of the same name — and one click brings it aboard.
|
|
294
305
|
|
|
306
|
+
## Live polar performance, computed on board
|
|
307
|
+
|
|
308
|
+
The plugin computes **percentage of polar target** (boat speed ÷ what the polar says you
|
|
309
|
+
should be doing) and emits it as two ordinary SignalK deltas:
|
|
310
|
+
|
|
311
|
+
```
|
|
312
|
+
performance.polarSpeed target boat speed, m/s
|
|
313
|
+
performance.polarSpeedRatio achieved / target, 0–1
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Because they are deltas, everything downstream gets them for nothing: telemetry sync
|
|
317
|
+
forwards them to the cloud through the same store-and-forward spool as every raw channel
|
|
318
|
+
— so an offline passage replays them **gapless** rather than leaving a hole — NMEA
|
|
319
|
+
displays and other plugins can read them natively, and the local history ring records the
|
|
320
|
+
rounded percentage as a `perf` channel for offline Trends.
|
|
321
|
+
|
|
322
|
+
The maths is **vendored verbatim** from the app (`shared/engine/perf-live.js` and the pure
|
|
323
|
+
`Polar` evaluator), each file carrying its upstream commit and sha256. There is one
|
|
324
|
+
definition of "the %" — the boat and the screens must not quietly disagree — and
|
|
325
|
+
`test/perf.test.js` replays the upstream test suite against the vendored copy to prove it.
|
|
326
|
+
Fix the maths upstream and re-vendor; never edit the copy.
|
|
327
|
+
|
|
328
|
+
**Nothing is emitted unless the guards pass.** In irons (inside the no-go angle), under
|
|
329
|
+
2 kt of wind, or against a near-zero target, the channel simply stops. A gap is the honest
|
|
330
|
+
representation; a zero would be a lie that drags down every average drawn over it.
|
|
331
|
+
|
|
332
|
+
**No paddlewheel?** The percentage falls back to SOG, which the screens do too — but SOG
|
|
333
|
+
is polluted by current, so the status line says `(from SOG — current-polluted)` rather
|
|
334
|
+
than presenting it as a through-water figure.
|
|
335
|
+
|
|
336
|
+
**Polar staleness.** The percentage is computed against whichever polar the boat has. If
|
|
337
|
+
you refine your polar ashore, the boat keeps using its own copy until you bring it across
|
|
338
|
+
on the **Sync polars & routes** page — this is a manual copy, not background sync. And a
|
|
339
|
+
catalogue polar has to have been fetched at least once while online before it can be used
|
|
340
|
+
at all. The raw channels are always recorded regardless, so the cloud can recompute the
|
|
341
|
+
history if the maths ever changes: the recorded channel is a materialisation, not the only
|
|
342
|
+
truth.
|
|
343
|
+
|
|
344
|
+
## Several devices publishing the same value
|
|
345
|
+
|
|
346
|
+
A real N2K network usually has more than one device announcing a given path, and they do
|
|
347
|
+
not always agree. On the boat this was developed against: three sources for
|
|
348
|
+
`navigation.speedThroughWater`, one of them reporting a constant **0**; and two compasses
|
|
349
|
+
on `navigation.headingMagnetic` **7.5° apart**. Whichever delta arrived last won, so speed
|
|
350
|
+
dropped to zero intermittently and heading — which the app derives from magnetic heading
|
|
351
|
+
plus variation, and which feeds the true-wind calculation — wandered.
|
|
352
|
+
|
|
353
|
+
**The plugin does not arbitrate this, and deliberately so.** Signal K already resolves it
|
|
354
|
+
from `sourcePriorities` in `settings.json`, applied in its delta pipeline *before* any
|
|
355
|
+
consumer sees the value, so one setting fixes the app, KIP, the instruments, the local
|
|
356
|
+
history ring and the telemetry going to the cloud all at once. Set it under
|
|
357
|
+
**Server → Settings → Source Priorities**:
|
|
358
|
+
|
|
359
|
+
```json
|
|
360
|
+
"navigation.speedThroughWater": [{ "sourceRef": "NMEA.27", "timeout": "" }],
|
|
361
|
+
"navigation.headingMagnetic": [{ "sourceRef": "NMEA.23", "timeout": "" }]
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
To find the culprit, compare sources on one path:
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
curl -s http://<boat>:3000/signalk/v1/api/vessels/self/navigation/speedThroughWater
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
`values` lists every source and what each is reporting; `$source` is whichever last won.
|
|
371
|
+
A path where they disagree is worth pinning. Where a cross-check exists it settles which
|
|
372
|
+
is right — magnetic heading plus variation should equal the reported true heading, and on
|
|
373
|
+
that boat one compass matched to 0.25° while the other was 7.5° out.
|
|
374
|
+
|
|
375
|
+
Note that a de-prioritised source still appears **once** when a client subscribes: Signal K
|
|
376
|
+
replays current values on subscription. That is a single stale sample, not a live feed.
|
|
377
|
+
|
|
295
378
|
## Local history (offline Trends + track)
|
|
296
379
|
**One source: a live ring**, sampled from the same BoatState that feeds `/ws/telemetry`
|
|
297
380
|
— no database, works on a Victron GX with nothing else installed. `historyAvailable` is
|
package/index.js
CHANGED
|
@@ -10,6 +10,7 @@ const { createBackfill } = require('./lib/backfill')
|
|
|
10
10
|
const { createAis } = require('./lib/ais')
|
|
11
11
|
const { createAisTargets } = require('./lib/ais/targets')
|
|
12
12
|
const { createProfile } = require('./lib/profile')
|
|
13
|
+
const { createPerf } = require('./lib/perf')
|
|
13
14
|
const { createCloud } = require('./lib/cloud')
|
|
14
15
|
const { resolveAccountConfig } = require('./lib/account')
|
|
15
16
|
|
|
@@ -100,6 +101,7 @@ module.exports = function (app) {
|
|
|
100
101
|
let aisTargets = null
|
|
101
102
|
let profile = null
|
|
102
103
|
let cloud = null
|
|
104
|
+
let perf = null
|
|
103
105
|
let proxyPort = null // what the launcher page needs to build its links
|
|
104
106
|
let pairedSlug = null
|
|
105
107
|
let statusTimer = null
|
|
@@ -356,12 +358,31 @@ module.exports = function (app) {
|
|
|
356
358
|
telemetry = null
|
|
357
359
|
}
|
|
358
360
|
}
|
|
361
|
+
// Live polar performance, computed here so it becomes a RECORDED channel: it rides
|
|
362
|
+
// the same spool as everything else, so an offline passage replays it gapless.
|
|
363
|
+
// Needs telemetry (it samples BoatState) and must exist before history (which
|
|
364
|
+
// samples it), hence the position.
|
|
365
|
+
if (telemetry) {
|
|
366
|
+
try {
|
|
367
|
+
perf = createPerf(app, {
|
|
368
|
+
source: telemetry,
|
|
369
|
+
pluginId: plugin.id,
|
|
370
|
+
storeDir: store,
|
|
371
|
+
profileFile: path.join((app.getDataDirPath && app.getDataDirPath()) || '.', 'profile.json')
|
|
372
|
+
})
|
|
373
|
+
perf.start()
|
|
374
|
+
} catch (e) {
|
|
375
|
+
(app.error || console.error)('[sailkick-boat] performance start failed: ' + e.message)
|
|
376
|
+
perf = null
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
359
380
|
if (pOpts.history.enabled !== false) {
|
|
360
381
|
try {
|
|
361
382
|
// ringSource = the telemetry module: when no local InfluxDB token is set
|
|
362
383
|
// (the common case, e.g. a Victron GX), history serves a DB-less ring from
|
|
363
384
|
// live telemetry. storeDir puts the persistent ring log on the SSD.
|
|
364
|
-
history = createHistory(app, { ...pOpts.history, ringSource: telemetry, storeDir: store })
|
|
385
|
+
history = createHistory(app, { ...pOpts.history, ringSource: telemetry, perfSource: perf, storeDir: store })
|
|
365
386
|
history.start()
|
|
366
387
|
pOpts.history = history // proxy dispatches /api/history to it when available()
|
|
367
388
|
} catch (e) {
|
|
@@ -459,6 +480,7 @@ module.exports = function (app) {
|
|
|
459
480
|
if (history) parts.push(history.status())
|
|
460
481
|
if (aisTargets) parts.push(aisTargets.status())
|
|
461
482
|
if (profile) parts.push(profile.status())
|
|
483
|
+
if (perf) parts.push(perf.status())
|
|
462
484
|
if (ais) parts.push(ais.status())
|
|
463
485
|
if (backfill) parts.push(backfill.status())
|
|
464
486
|
try { app.setPluginStatus(parts.join(' | ') || 'idle (both features off)') } catch {}
|
|
@@ -473,6 +495,7 @@ module.exports = function (app) {
|
|
|
473
495
|
try { if (aisTargets) aisTargets.stop() } catch {}
|
|
474
496
|
try { if (profile) profile.stop() } catch {}
|
|
475
497
|
try { if (cloud) cloud.stop() } catch {}
|
|
498
|
+
try { if (perf) perf.stop() } catch {}
|
|
476
499
|
try { if (ais) ais.stop() } catch {}
|
|
477
500
|
try { if (backfill) backfill.stop() } catch {}
|
|
478
501
|
try { if (proxy) proxy.stop() } catch {}
|
|
@@ -484,6 +507,7 @@ module.exports = function (app) {
|
|
|
484
507
|
aisTargets = null
|
|
485
508
|
profile = null
|
|
486
509
|
cloud = null
|
|
510
|
+
perf = null
|
|
487
511
|
proxyPort = null
|
|
488
512
|
pairedSlug = null
|
|
489
513
|
proxy = null
|
package/lib/backfill/index.js
CHANGED
|
@@ -24,6 +24,7 @@ const fs = require('fs')
|
|
|
24
24
|
const path = require('path')
|
|
25
25
|
const { writeLines } = require('../sync/influxWrite')
|
|
26
26
|
const { csvToLineProtocol } = require('./lineproto')
|
|
27
|
+
const { request } = require('../net')
|
|
27
28
|
|
|
28
29
|
const HOUR_MS = 3600000
|
|
29
30
|
const DEFAULTS = {
|
|
@@ -121,13 +122,13 @@ function createBackfill (app, options) {
|
|
|
121
122
|
const url = `${conn.url.replace(/\/+$/, '')}/api/v2/query?org=${encodeURIComponent(conn.org)}`
|
|
122
123
|
let resp
|
|
123
124
|
try {
|
|
124
|
-
resp = await
|
|
125
|
+
resp = await request(url, {
|
|
125
126
|
method: 'POST',
|
|
126
127
|
headers: { Authorization: `Token ${conn.token}`, 'Content-Type': 'application/json', Accept: 'application/csv' },
|
|
127
128
|
body: JSON.stringify({ type: 'flux', query: body, dialect: { header: true, annotations: ['datatype'] } }),
|
|
128
|
-
|
|
129
|
+
timeoutMs: cfg.queryTimeoutMs
|
|
129
130
|
})
|
|
130
|
-
} catch (e) { return { ok: false, message: e.message } }
|
|
131
|
+
} catch (e) { return { ok: false, message: `${e.message}${e.code ? ` (${e.code})` : ''}` } }
|
|
131
132
|
if (!resp.ok) {
|
|
132
133
|
const t = await resp.text().catch(() => '')
|
|
133
134
|
return { ok: false, status: resp.status, message: `HTTP ${resp.status}: ${t.slice(0, 160)}` }
|
package/lib/cloud/index.js
CHANGED
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
const fs = require('fs')
|
|
28
28
|
const fsp = fs.promises
|
|
29
29
|
const path = require('path')
|
|
30
|
+
// Named to avoid shadowing this module's own request() below.
|
|
31
|
+
const { request: httpRequest } = require('../net')
|
|
30
32
|
|
|
31
33
|
const COOKIE_NAME = 'sk_session'
|
|
32
34
|
const MAX_BODY_BYTES = 8 * 1024 * 1024 // a polar CSV is tiny; this is just a sane ceiling
|
|
@@ -105,11 +107,11 @@ function createCloud (app, options = {}) {
|
|
|
105
107
|
|
|
106
108
|
async function login (slug, password) {
|
|
107
109
|
if (!upstream) return { ok: false, code: 'no-upstream', message: 'no cloud host configured' }
|
|
108
|
-
const got = await attempt(() =>
|
|
110
|
+
const got = await attempt(() => httpRequest(`${upstream}/api/auth/login`, {
|
|
109
111
|
method: 'POST',
|
|
110
112
|
headers: { 'Content-Type': 'application/json' },
|
|
111
113
|
body: JSON.stringify({ slug, password }),
|
|
112
|
-
|
|
114
|
+
timeoutMs
|
|
113
115
|
}))
|
|
114
116
|
if (!got.ok) {
|
|
115
117
|
warn(`login could not reach ${upstream} after 3 attempts — ${why(got.error)}`)
|
|
@@ -151,15 +153,15 @@ function createCloud (app, options = {}) {
|
|
|
151
153
|
// handler and a boat is offline most of the time.
|
|
152
154
|
async function request (apiPath, { method = 'GET', body = null } = {}) {
|
|
153
155
|
if (!session) return { ok: false, status: 401, code: 'logged-out', message: 'not logged in to the cloud' }
|
|
154
|
-
const got = await attempt(() =>
|
|
156
|
+
const got = await attempt(() => httpRequest(upstream + apiPath, {
|
|
155
157
|
method,
|
|
156
158
|
headers: {
|
|
157
159
|
Cookie: session.cookie,
|
|
158
160
|
Accept: 'application/json',
|
|
159
161
|
...(body ? { 'Content-Type': 'application/json' } : {})
|
|
160
162
|
},
|
|
161
|
-
body: body ||
|
|
162
|
-
|
|
163
|
+
body: body || null,
|
|
164
|
+
timeoutMs
|
|
163
165
|
}))
|
|
164
166
|
if (!got.ok) {
|
|
165
167
|
return { ok: false, status: 0, code: 'offline', message: `cannot reach the cloud — ${why(got.error)}. The boat's link may have dropped; try again.` }
|
package/lib/history/index.js
CHANGED
|
@@ -51,6 +51,7 @@ function createHistory (app, options) {
|
|
|
51
51
|
const persistFile = options.ringPersist !== false ? path.join(ringDir, 'history-ring.jsonl') : null
|
|
52
52
|
provider = new RingHistoryProvider({
|
|
53
53
|
source: options.ringSource,
|
|
54
|
+
perfSource: options.perfSource || null, // lib/perf — the computed polar %
|
|
54
55
|
windowSec: options.ringWindowSec,
|
|
55
56
|
sampleSec: options.ringSampleSec,
|
|
56
57
|
persistFile
|
package/lib/history/ring.js
CHANGED
|
@@ -36,7 +36,12 @@ const CHANNELS = [
|
|
|
36
36
|
'tws', 'twd', 'aws', 'awa', 'twa', 'vmg',
|
|
37
37
|
'sog', 'stw', 'cog', 'heading', 'depth',
|
|
38
38
|
'seaTemp', 'airTemp', 'rpmPort', 'rpmStbd',
|
|
39
|
-
'wptBrg', 'wptDist', 'wptVmg', 'wptTtg'
|
|
39
|
+
'wptBrg', 'wptDist', 'wptVmg', 'wptTtg',
|
|
40
|
+
// Computed on the boat (lib/perf) rather than read off the bus: percentage of polar
|
|
41
|
+
// target. Null whenever the guards do not pass — in irons, under 2 kt, no polar — so
|
|
42
|
+
// the channel GAPS instead of flat-lining at zero, which would drag every average
|
|
43
|
+
// drawn over it.
|
|
44
|
+
'perf'
|
|
40
45
|
]
|
|
41
46
|
const wrap360 = (d) => ((d % 360) + 360) % 360
|
|
42
47
|
const MAX_SAMPLES = 50000
|
|
@@ -66,8 +71,9 @@ function trueWind (s) {
|
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
class RingHistoryProvider {
|
|
69
|
-
constructor ({ source, sampleSec, windowSec, persistFile } = {}) {
|
|
74
|
+
constructor ({ source, perfSource, sampleSec, windowSec, persistFile } = {}) {
|
|
70
75
|
this._source = source
|
|
76
|
+
this._perfSource = perfSource || null
|
|
71
77
|
this._ring = [] // [{ t, ...CHANNELS, lat, lon }]
|
|
72
78
|
const win = windowSec || 3600
|
|
73
79
|
this._windowMs = win * 1000
|
|
@@ -107,6 +113,7 @@ class RingHistoryProvider {
|
|
|
107
113
|
// rather than showing a flat line at zero.
|
|
108
114
|
wptBrg: num(s.wptBrgDeg), wptDist: num(s.wptDistNm),
|
|
109
115
|
wptVmg: num(s.wptVmgKt), wptTtg: num(s.wptTtgSec),
|
|
116
|
+
perf: this._perfSource ? num(this._perfSource.getPerf()) : null,
|
|
110
117
|
lat: num(s.lat), lon: num(s.lon)
|
|
111
118
|
}
|
|
112
119
|
this._ring.push(row)
|
package/lib/net.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// The one outbound HTTP client for everything that talks to the cloud.
|
|
4
|
+
//
|
|
5
|
+
// WHY NOT fetch()
|
|
6
|
+
//
|
|
7
|
+
// Twice in one afternoon this boat's Signal K process stopped being able to open ANY
|
|
8
|
+
// outbound HTTPS connection: zero sockets to :443, while live connections to the
|
|
9
|
+
// Starlink dish and the local database stayed up, file descriptors at 50 of 524288, and
|
|
10
|
+
// a second node process in the SAME container reached the same host in 979 ms. It never
|
|
11
|
+
// recovered — 33 minutes the second time. Restarting bought about twenty minutes.
|
|
12
|
+
//
|
|
13
|
+
// Starlink is behind CGNAT, which drops an idle NAT mapping WITHOUT sending an RST. The
|
|
14
|
+
// pooled keep-alive socket then looks alive to the client and is dead on the wire.
|
|
15
|
+
// fetch() (undici) keeps such sockets, and a plugin has no supported way to reset that
|
|
16
|
+
// pool: `undici` is not requirable on the boat (Node bundles it internally),
|
|
17
|
+
// `Connection: close` is a forbidden header that fetch strips, and reaching for the
|
|
18
|
+
// global-dispatcher symbol is version-specific guesswork.
|
|
19
|
+
//
|
|
20
|
+
// Core http/https gives what is actually needed:
|
|
21
|
+
// - an agent we own, so a poisoned pool can be thrown away (resetTransport)
|
|
22
|
+
// - a short keep-alive, so an idle socket is dropped by US before CGNAT drops it
|
|
23
|
+
// - the REAL error code. fetch reports every transport failure as the uniformly
|
|
24
|
+
// useless string "fetch failed" and hides the reason in e.cause, so a wedged process
|
|
25
|
+
// and a boat genuinely at sea produced identical logs. Here a caller sees
|
|
26
|
+
// ECONNRESET / ETIMEDOUT / ENOTFOUND / ECONNREFUSED directly.
|
|
27
|
+
//
|
|
28
|
+
// ONE pool for the whole plugin, so one reset clears every subsystem at once — during
|
|
29
|
+
// the incident sync, the mirror, the manifest poller and the contract check were all
|
|
30
|
+
// wedged together, because they share the process, not because they share code.
|
|
31
|
+
//
|
|
32
|
+
// The response shape mirrors the parts of fetch() the callers actually used, so the call
|
|
33
|
+
// sites read the same: { ok, status, headers.get(), headers.forEach(), headers
|
|
34
|
+
// .getSetCookie(), buffer, text(), json() }. Transport failures THROW, as fetch does —
|
|
35
|
+
// with `.code` set, which fetch never gave us.
|
|
36
|
+
|
|
37
|
+
const http = require('http')
|
|
38
|
+
const https = require('https')
|
|
39
|
+
const { URL } = require('url')
|
|
40
|
+
|
|
41
|
+
// Short keep-alive: long enough that a steady stream reuses a socket, short enough that
|
|
42
|
+
// an idle one is closed by us well before a CGNAT mapping expires.
|
|
43
|
+
const AGENT_OPTS = { keepAlive: true, keepAliveMsecs: 5000, timeout: 15000, maxSockets: 8 }
|
|
44
|
+
const MAX_BODY_BYTES = 64 * 1024 * 1024 // Cesium.js is ~6 MB; this is a sanity ceiling
|
|
45
|
+
|
|
46
|
+
let agents = null
|
|
47
|
+
let generation = 0
|
|
48
|
+
|
|
49
|
+
function pool () {
|
|
50
|
+
if (!agents) agents = { http: new http.Agent(AGENT_OPTS), https: new https.Agent(AGENT_OPTS) }
|
|
51
|
+
return agents
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Throw the pools away. The next request builds fresh sockets — the only thing that
|
|
55
|
+
// recovers a pool whose sockets are dead but look open. destroy() closes idle sockets
|
|
56
|
+
// only; anything in flight finishes normally.
|
|
57
|
+
function resetTransport () {
|
|
58
|
+
if (agents) {
|
|
59
|
+
try { agents.http.destroy() } catch {}
|
|
60
|
+
try { agents.https.destroy() } catch {}
|
|
61
|
+
}
|
|
62
|
+
agents = null
|
|
63
|
+
return ++generation
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function headersView (raw) {
|
|
67
|
+
const lower = {}
|
|
68
|
+
for (const [k, v] of Object.entries(raw || {})) lower[k.toLowerCase()] = v
|
|
69
|
+
return {
|
|
70
|
+
get (name) {
|
|
71
|
+
const v = lower[String(name).toLowerCase()]
|
|
72
|
+
return v == null ? null : (Array.isArray(v) ? v.join(', ') : String(v))
|
|
73
|
+
},
|
|
74
|
+
forEach (fn) {
|
|
75
|
+
for (const [k, v] of Object.entries(lower)) fn(Array.isArray(v) ? v.join(', ') : String(v), k)
|
|
76
|
+
},
|
|
77
|
+
// Node keeps set-cookie as an array already — the one header that must not be joined.
|
|
78
|
+
getSetCookie () {
|
|
79
|
+
const v = lower['set-cookie']
|
|
80
|
+
return v == null ? [] : (Array.isArray(v) ? v : [String(v)])
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// fetch-shaped, minus the parts nothing here uses. Throws on transport failure with
|
|
86
|
+
// `.code` populated; an HTTP error status resolves normally (check `ok`/`status`).
|
|
87
|
+
function request (url, { method = 'GET', headers = {}, body = null, timeoutMs = 20000 } = {}) {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const settled = [] // run when the socket returns to the pool — see the note below
|
|
90
|
+
let u
|
|
91
|
+
try { u = new URL(url) } catch (e) { return reject(Object.assign(new Error(`bad url: ${url}`), { code: 'ERR_INVALID_URL' })) }
|
|
92
|
+
const lib = u.protocol === 'https:' ? https : http
|
|
93
|
+
const agent = u.protocol === 'https:' ? pool().https : pool().http
|
|
94
|
+
|
|
95
|
+
const hdrs = { ...headers }
|
|
96
|
+
if (body != null && hdrs['Content-Length'] == null && hdrs['content-length'] == null) {
|
|
97
|
+
hdrs['Content-Length'] = Buffer.byteLength(body)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const req = lib.request({
|
|
101
|
+
protocol: u.protocol,
|
|
102
|
+
hostname: u.hostname,
|
|
103
|
+
port: u.port || undefined,
|
|
104
|
+
path: u.pathname + u.search,
|
|
105
|
+
method,
|
|
106
|
+
agent,
|
|
107
|
+
headers: hdrs
|
|
108
|
+
}, (res) => {
|
|
109
|
+
const chunks = []
|
|
110
|
+
let size = 0
|
|
111
|
+
res.on('data', (c) => {
|
|
112
|
+
size += c.length
|
|
113
|
+
if (size > MAX_BODY_BYTES) { req.destroy(Object.assign(new Error('response too large'), { code: 'EMSGSIZE' })); return }
|
|
114
|
+
chunks.push(c)
|
|
115
|
+
})
|
|
116
|
+
res.on('error', (e) => { settled.forEach((f) => f()); reject(Object.assign(e, { code: e.code || 'ERR_STREAM' })) })
|
|
117
|
+
res.on('end', () => {
|
|
118
|
+
settled.forEach((f) => f())
|
|
119
|
+
const buffer = Buffer.concat(chunks)
|
|
120
|
+
resolve({
|
|
121
|
+
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
122
|
+
status: res.statusCode,
|
|
123
|
+
headers: headersView(res.headers),
|
|
124
|
+
buffer,
|
|
125
|
+
text: async () => buffer.toString('utf8'),
|
|
126
|
+
json: async () => JSON.parse(buffer.toString('utf8')),
|
|
127
|
+
arrayBuffer: async () => buffer
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
if (timeoutMs) {
|
|
133
|
+
req.setTimeout(timeoutMs, () => req.destroy(Object.assign(new Error(`timed out after ${timeoutMs}ms`), { code: 'ETIMEDOUT' })))
|
|
134
|
+
}
|
|
135
|
+
// An IDLE pooled socket must never be the reason the host process cannot exit — Signal
|
|
136
|
+
// K has to be able to shut down and a test runner has to finish. But an IN-FLIGHT one
|
|
137
|
+
// must hold the loop, or Node exits mid-request and the caller never resolves (which
|
|
138
|
+
// is exactly what a first attempt at this did). So: ref while the request is running,
|
|
139
|
+
// unref once it is back in the pool.
|
|
140
|
+
let sock = null
|
|
141
|
+
const idle = () => { try { if (sock && sock.unref) sock.unref() } catch {} }
|
|
142
|
+
req.on('socket', (s) => { sock = s; try { if (s.ref) s.ref() } catch {} })
|
|
143
|
+
settled.push(idle)
|
|
144
|
+
req.on('error', (e) => { settled.forEach((f) => f()); reject(Object.assign(e, { code: e.code || 'ERR_REQUEST' })) })
|
|
145
|
+
req.end(body == null ? undefined : body)
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { request, resetTransport, _agents: () => agents, _generation: () => generation }
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Live polar performance, computed on the boat.
|
|
4
|
+
//
|
|
5
|
+
// The app already shows a live "% of polar target" on the mobile Polar screen and the
|
|
6
|
+
// desktop ribbon, computed in the browser. Computing it HERE instead makes it a recorded
|
|
7
|
+
// channel: it rides the same store-and-forward spool as every other value, so an offline
|
|
8
|
+
// passage replays it gapless rather than leaving a hole; it sees full-rate SignalK rather
|
|
9
|
+
// than the cloud's 2 s Influx poll; and it keeps working in the boat-served offline mode.
|
|
10
|
+
//
|
|
11
|
+
// The maths is NOT reimplemented — perf-live.js and polar.js are vendored verbatim from
|
|
12
|
+
// the app (see their headers). One definition of "the %", or the boat and the screens
|
|
13
|
+
// would quietly disagree.
|
|
14
|
+
//
|
|
15
|
+
// Output is two ordinary SignalK deltas:
|
|
16
|
+
// performance.polarSpeed target boat speed, m/s (SI, as SignalK expects)
|
|
17
|
+
// performance.polarSpeedRatio achieved / target, 0–1
|
|
18
|
+
// Emitting deltas rather than writing our own measurement means everything downstream
|
|
19
|
+
// gets it for free: lib/sync already subscribes to '*' and forwards to the cloud bucket,
|
|
20
|
+
// NMEA displays and other plugins can read it, and the cloud maps
|
|
21
|
+
// performance.polarSpeedRatio -> the `perf` history channel with one line.
|
|
22
|
+
//
|
|
23
|
+
// ONLY when the guards pass. Inside the no-go wedge, in under 2 kt of wind, or against a
|
|
24
|
+
// near-zero target, nothing is emitted at all: a gap is the honest representation, and a
|
|
25
|
+
// zero would be a lie that pollutes every average drawn over it.
|
|
26
|
+
|
|
27
|
+
const fs = require('fs')
|
|
28
|
+
const path = require('path')
|
|
29
|
+
const { createLivePerf, perfPct } = require('./perf-live')
|
|
30
|
+
const { Polar } = require('./polar')
|
|
31
|
+
|
|
32
|
+
const OWN_PREFIX = 'own:'
|
|
33
|
+
const MS_TO_KT = 1.94384
|
|
34
|
+
const DEFAULTS = {
|
|
35
|
+
intervalMs: 1000, // the 5 s EMA makes this cadence-insensitive; 1 s matches the ring
|
|
36
|
+
polarReloadMs: 60000 // pick up an active-polar change without a restart
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createPerf (app, options = {}) {
|
|
40
|
+
const log = (m) => (app.debug ? app.debug('[perf] ' + m) : console.log('[sailkick-boat:perf]', m))
|
|
41
|
+
const warn = (m) => (app.error ? app.error('[sailkick-boat:perf] ' + m) : console.error('[sailkick-boat:perf]', m))
|
|
42
|
+
|
|
43
|
+
const cfg = { ...DEFAULTS, ...options }
|
|
44
|
+
let live = null
|
|
45
|
+
let polar = null
|
|
46
|
+
let polarId = null
|
|
47
|
+
let polarError = null
|
|
48
|
+
let timer = null
|
|
49
|
+
let reloadTimer = null
|
|
50
|
+
let stopped = false
|
|
51
|
+
let last = null // { pct, target, kind, usingSog }
|
|
52
|
+
let emitted = 0
|
|
53
|
+
let skipped = 0
|
|
54
|
+
let warnedSog = false
|
|
55
|
+
|
|
56
|
+
// --- resolving the active polar -------------------------------------------------
|
|
57
|
+
// The boat has its own profile mirror (lib/profile). `activePolar` is either an
|
|
58
|
+
// own:<id> — a polar the owner authored, whose CSV is in the profile itself — or a
|
|
59
|
+
// catalogue id, whose CSV the mirror has cached under store/polars/<id>.csv exactly as
|
|
60
|
+
// the app fetches it.
|
|
61
|
+
function readProfile () {
|
|
62
|
+
try { return JSON.parse(fs.readFileSync(cfg.profileFile, 'utf8')) } catch { return null }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function csvFor (id, profile) {
|
|
66
|
+
if (id.startsWith(OWN_PREFIX)) {
|
|
67
|
+
const pid = id.slice(OWN_PREFIX.length)
|
|
68
|
+
const item = (profile.polars || []).find((p) => p && p.id === pid)
|
|
69
|
+
return item && typeof item.csv === 'string' ? item.csv : null
|
|
70
|
+
}
|
|
71
|
+
// Catalogue polar: whatever the mirror cached. Not fetched on demand — this must
|
|
72
|
+
// work with no uplink, and an unseen polar simply means no % until the app has
|
|
73
|
+
// opened it once.
|
|
74
|
+
try { return fs.readFileSync(path.join(cfg.storeDir, 'polars', `${id}.csv`), 'utf8') } catch { return null }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function loadPolar () {
|
|
78
|
+
const profile = readProfile()
|
|
79
|
+
const id = profile && typeof profile.activePolar === 'string' ? profile.activePolar : null
|
|
80
|
+
if (!id) {
|
|
81
|
+
if (polarId !== null) log('no active polar selected — performance is not being computed')
|
|
82
|
+
polar = null; polarId = null; polarError = 'no active polar selected'
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if (id === polarId && polar) return // unchanged
|
|
86
|
+
const csv = csvFor(id, profile || {})
|
|
87
|
+
if (!csv) {
|
|
88
|
+
polar = null; polarId = id
|
|
89
|
+
polarError = `the CSV for "${id}" is not on the boat yet`
|
|
90
|
+
warn(`${polarError} — open the polar once in the app while online, or copy it across on the Sync page`)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
polar = Polar.fromCSV(id, csv)
|
|
95
|
+
polarId = id
|
|
96
|
+
polarError = null
|
|
97
|
+
log(`active polar "${polar.name || id}" loaded (no-go ${polar.noGoTwa}°)`)
|
|
98
|
+
} catch (e) {
|
|
99
|
+
polar = null; polarId = id
|
|
100
|
+
polarError = `polar "${id}" will not parse: ${e.message}`
|
|
101
|
+
warn(polarError)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- the computation ------------------------------------------------------------
|
|
106
|
+
function tick () {
|
|
107
|
+
if (stopped || !cfg.source || !cfg.source.getState) return
|
|
108
|
+
const s = cfg.source.getState()
|
|
109
|
+
if (!s) return
|
|
110
|
+
|
|
111
|
+
// The SignalK timestamp, never wall clock: buffered or replayed computation has to
|
|
112
|
+
// be deterministic, and the EMA is time-weighted.
|
|
113
|
+
const now = Date.parse(s.updatedAt) || Date.now()
|
|
114
|
+
const r = live.update(s, now)
|
|
115
|
+
if (!r) { last = null; return } // wind or speed missing — the EMA reset itself
|
|
116
|
+
|
|
117
|
+
const tws = live.avgTws(now)
|
|
118
|
+
const res = perfPct(polar, tws, r.ema)
|
|
119
|
+
|
|
120
|
+
if (live.usingSog && !warnedSog) {
|
|
121
|
+
warnedSog = true
|
|
122
|
+
log('no speed through water — the percentage is computed from SOG, so it is polluted by current')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (res.kind !== 'ok') { last = { kind: res.kind }; skipped++; return }
|
|
126
|
+
last = { kind: 'ok', pct: res.pct, target: res.target, usingSog: live.usingSog }
|
|
127
|
+
emitted++
|
|
128
|
+
emit(res, s.updatedAt)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Standard SignalK paths, in SI. round() lives here so the ratio and the percentage
|
|
132
|
+
// can never disagree: the cloud takes round(ratio * 100).
|
|
133
|
+
function emit (res, timestamp) {
|
|
134
|
+
if (!app.handleMessage) return
|
|
135
|
+
try {
|
|
136
|
+
app.handleMessage(cfg.pluginId || 'sailkick-boat', {
|
|
137
|
+
updates: [{
|
|
138
|
+
timestamp: timestamp || new Date().toISOString(),
|
|
139
|
+
values: [
|
|
140
|
+
{ path: 'performance.polarSpeed', value: res.target / MS_TO_KT },
|
|
141
|
+
{ path: 'performance.polarSpeedRatio', value: res.pct / 100 }
|
|
142
|
+
]
|
|
143
|
+
}]
|
|
144
|
+
})
|
|
145
|
+
} catch (e) { warn('could not emit deltas: ' + e.message) }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- lifecycle ------------------------------------------------------------------
|
|
149
|
+
function start () {
|
|
150
|
+
if (!cfg.source) { log('not started — no telemetry source'); return }
|
|
151
|
+
live = createLivePerf({})
|
|
152
|
+
loadPolar()
|
|
153
|
+
timer = setInterval(tick, cfg.intervalMs)
|
|
154
|
+
reloadTimer = setInterval(loadPolar, cfg.polarReloadMs)
|
|
155
|
+
if (timer.unref) timer.unref()
|
|
156
|
+
if (reloadTimer.unref) reloadTimer.unref()
|
|
157
|
+
log(`computing performance every ${Math.round(cfg.intervalMs / 1000)}s -> performance.polarSpeed{,Ratio}`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function stop () {
|
|
161
|
+
stopped = true
|
|
162
|
+
clearInterval(timer); clearInterval(reloadTimer)
|
|
163
|
+
timer = reloadTimer = null
|
|
164
|
+
live = null; polar = null; polarId = null; last = null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// What the history ring samples. Null whenever the guards did not pass, so the channel
|
|
168
|
+
// gaps rather than flat-lining.
|
|
169
|
+
function getPerf () { return last && last.kind === 'ok' ? last.pct : null }
|
|
170
|
+
|
|
171
|
+
function status () {
|
|
172
|
+
if (!live) return 'perf: off'
|
|
173
|
+
if (polarError) return `perf: ${polarError}`
|
|
174
|
+
if (!last) return 'perf: waiting for wind and speed'
|
|
175
|
+
if (last.kind !== 'ok') return `perf: ${last.kind}`
|
|
176
|
+
return `perf: ${last.pct}% of "${polarId}"${last.usingSog ? ' (from SOG — current-polluted)' : ''}${skipped ? `; ${skipped} guarded` : ''}`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { start, stop, status, getPerf, _tick: tick, _polar: () => polar, _loadPolar: loadPolar, _counts: () => ({ emitted, skipped }) }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { createPerf }
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// VENDORED from sailkick/shared/engine/perf-live.js @ 128cf97 sha256:7ce83a1df13926f0
|
|
2
|
+
// Do not edit here — fix upstream and re-vendor. One definition of "the %".
|
|
3
|
+
//
|
|
4
|
+
// Converted ESM -> CommonJS ONLY (export keywords removed, module.exports appended). No logic changed: the boat and every app
|
|
5
|
+
// surface must produce the same number, and test/perf.test.js replays the upstream
|
|
6
|
+
// suite's cases against this copy to prove it.
|
|
7
|
+
// Live polar performance — ONE definition of "the %", shared by every surface that
|
|
8
|
+
// shows or will compute it: the mobile Polar screen, the desktop ribbon polar, the
|
|
9
|
+
// cloud server (perfPct on the telemetry stream, when it lands) and — vendored, see
|
|
10
|
+
// the handoff when it happens — the boat plugin. Extracted from polar-screen.js /
|
|
11
|
+
// instrument-polar.js, which had drifted to two copies of the same five constants.
|
|
12
|
+
//
|
|
13
|
+
// Pure: no DOM, no fetch, no Date.now() of its own (callers pass `now` so the cloud
|
|
14
|
+
// can replay history deterministically). The polar object comes from polar.js
|
|
15
|
+
// (`speed(tws, signedTwa)` abs-clamps the angle; `noGoTwa` is the first table row).
|
|
16
|
+
|
|
17
|
+
const EMA_TAU_S = 5; // live-point smoothing time constant (s)
|
|
18
|
+
const TWS_AVG_SEC = 60; // "current wind" = 1-min mean TWS
|
|
19
|
+
const MIN_TWS = 2; // below this wind the % is noise…
|
|
20
|
+
const MIN_TARGET = 0.5; // …and below this target it divides by ~zero
|
|
21
|
+
|
|
22
|
+
const wrap180 = (d) => { const x = ((d % 360) + 360) % 360; return x > 180 ? x - 360 : x; };
|
|
23
|
+
|
|
24
|
+
// Stateful smoother over the BoatState stream. update() ingests one sample and
|
|
25
|
+
// returns { raw, ema } (raw feeds trails/recorders; ema feeds the % and the dot),
|
|
26
|
+
// or null while wind or speed is missing — which also RESETS the EMA, so a data
|
|
27
|
+
// gap doesn't get smoothed across.
|
|
28
|
+
function createLivePerf({ emaTauS = EMA_TAU_S, twsAvgSec = TWS_AVG_SEC } = {}) {
|
|
29
|
+
let ema = null, lastT = 0, usingSog = false;
|
|
30
|
+
let twsHist = []; // [{t, v}] for the windowed mean
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
update(s, now) {
|
|
34
|
+
if (Number.isFinite(s?.twsKt)) twsHist.push({ t: now, v: s.twsKt });
|
|
35
|
+
// Prefer the boat's own TWA; derive from TWD − HDG until it's published.
|
|
36
|
+
const twa = Number.isFinite(s?.twaDeg) ? s.twaDeg
|
|
37
|
+
: (Number.isFinite(s?.twdDeg) && Number.isFinite(s?.headingDeg)) ? wrap180(s.twdDeg - s.headingDeg)
|
|
38
|
+
: null;
|
|
39
|
+
usingSog = !Number.isFinite(s?.stwKt);
|
|
40
|
+
const kt = Number.isFinite(s?.stwKt) ? s.stwKt : Number.isFinite(s?.sogKt) ? s.sogKt : null;
|
|
41
|
+
if (twa == null || kt == null) { ema = null; return null; }
|
|
42
|
+
|
|
43
|
+
const dt = ema && lastT ? Math.min(30, (now - lastT) / 1000) : emaTauS;
|
|
44
|
+
const a = dt / (emaTauS + dt);
|
|
45
|
+
// Smooth via the SHORTEST-PATH delta (correct across the stern), then re-wrap
|
|
46
|
+
// the accumulator: without the outer wrap180 a gybe (175°S → 175°P) walks the
|
|
47
|
+
// EMA past 180 and the label reads "190° S" (and any side test flips wrong).
|
|
48
|
+
ema = ema
|
|
49
|
+
? { twa: wrap180(ema.twa + wrap180(twa - ema.twa) * a), kt: ema.kt + (kt - ema.kt) * a }
|
|
50
|
+
: { twa, kt };
|
|
51
|
+
lastT = now;
|
|
52
|
+
return { raw: { twa, kt }, ema };
|
|
53
|
+
},
|
|
54
|
+
avgTws(now) {
|
|
55
|
+
const cut = now - twsAvgSec * 1000;
|
|
56
|
+
twsHist = twsHist.filter((p) => p.t >= cut);
|
|
57
|
+
return twsHist.length ? twsHist.reduce((s, p) => s + p.v, 0) / twsHist.length : null;
|
|
58
|
+
},
|
|
59
|
+
get ema() { return ema; },
|
|
60
|
+
get usingSog() { return usingSog; },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The % itself, with its guards. Discriminated by `kind` so callers can render
|
|
65
|
+
// each state distinctly instead of re-deriving the guards:
|
|
66
|
+
// nodata — no polar / no smoothed point / no wind average yet
|
|
67
|
+
// irons — inside the no-go wedge (a % against a 0-ish target is meaningless)
|
|
68
|
+
// weak — wind or target below the noise floor (target still reported)
|
|
69
|
+
// ok — { pct, target }
|
|
70
|
+
function perfPct(polar, tws, ema) {
|
|
71
|
+
if (!polar || !ema || !Number.isFinite(tws)) return { kind: 'nodata' };
|
|
72
|
+
const target = polar.speed(tws, ema.twa);
|
|
73
|
+
if (Math.abs(ema.twa) < polar.noGoTwa) return { kind: 'irons', target };
|
|
74
|
+
if (tws < MIN_TWS || target < MIN_TARGET) return { kind: 'weak', target };
|
|
75
|
+
return { kind: 'ok', target, pct: Math.round((ema.kt / target) * 100) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
createLivePerf, perfPct, wrap180, EMA_TAU_S, TWS_AVG_SEC, MIN_TWS, MIN_TARGET
|
|
80
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// VENDORED from sailkick/shared/engine/polar.js @ 128cf97 sha256:6a3166fe17ab5806
|
|
2
|
+
// Do not edit here — fix upstream and re-vendor. One definition of "the %".
|
|
3
|
+
//
|
|
4
|
+
// Converted ESM -> CommonJS ONLY (only the pure Polar class is taken — the getActivePolar/loadPolar store layer is app-side (fetch + localStorage) and has no meaning on the boat). No logic changed: the boat and every app
|
|
5
|
+
// surface must produce the same number, and test/perf.test.js replays the upstream
|
|
6
|
+
// suite's cases against this copy to prove it.
|
|
7
|
+
// The boat resolves the ACTIVE polar from its own profile mirror instead — see index.js.
|
|
8
|
+
|
|
9
|
+
class Polar {
|
|
10
|
+
constructor({ id, name, twaRows, twsCols, speeds }) {
|
|
11
|
+
this.id = id;
|
|
12
|
+
this.name = name;
|
|
13
|
+
this.twaRows = twaRows;
|
|
14
|
+
this.twsCols = twsCols;
|
|
15
|
+
this.speeds = speeds;
|
|
16
|
+
this.noGoTwa = twaRows[0];
|
|
17
|
+
this._maxSpeed = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Boat speed in knots at wind speed `twsKn` and signed wind angle
|
|
21
|
+
// `twaDegSigned`. Polar is port/starboard-symmetric — take |twa|.
|
|
22
|
+
// Below noGoTwa → 0. Bilinear interpolation in (TWA, TWS), clamped at the
|
|
23
|
+
// table's TOP edge. Below the FIRST column the table is anchored to a virtual
|
|
24
|
+
// (0 wind → 0 boat speed) column: target = speed(col0, twa) × tws/col0.
|
|
25
|
+
// (The old behaviour linearly EXTRAPOLATED down the low-end gradient — a flat
|
|
26
|
+
// calm still "targeted" 1–2 kn, some tables went NEGATIVE upwind, and the
|
|
27
|
+
// perf% blew up as the target fell through the noise guard.)
|
|
28
|
+
speed(twsKn, twaDegSigned) {
|
|
29
|
+
const twa = Math.min(180, Math.abs(twaDegSigned));
|
|
30
|
+
if (twa < this.noGoTwa) return 0;
|
|
31
|
+
const { twaRows, twsCols, speeds } = this;
|
|
32
|
+
const col0 = twsCols[0];
|
|
33
|
+
if (twsKn < col0) return this.speed(col0, twa) * Math.max(0, twsKn) / col0;
|
|
34
|
+
const tws = Math.min(twsCols[twsCols.length - 1], twsKn);
|
|
35
|
+
|
|
36
|
+
let i = 0;
|
|
37
|
+
while (i < twaRows.length - 2 && twaRows[i + 1] < twa) i++;
|
|
38
|
+
const i1 = i + 1;
|
|
39
|
+
let j = 0;
|
|
40
|
+
while (j < twsCols.length - 2 && twsCols[j + 1] < tws) j++;
|
|
41
|
+
const j1 = j + 1;
|
|
42
|
+
|
|
43
|
+
const ta = (twa - twaRows[i]) / (twaRows[i1] - twaRows[i]);
|
|
44
|
+
const tb = (tws - twsCols[j]) / (twsCols[j1] - twsCols[j]);
|
|
45
|
+
|
|
46
|
+
const s00 = speeds[i][j];
|
|
47
|
+
const s01 = speeds[i][j1];
|
|
48
|
+
const s10 = speeds[i1][j];
|
|
49
|
+
const s11 = speeds[i1][j1];
|
|
50
|
+
|
|
51
|
+
const s0 = s00 * (1 - tb) + s01 * tb;
|
|
52
|
+
const s1 = s10 * (1 - tb) + s11 * tb;
|
|
53
|
+
return s0 * (1 - ta) + s1 * ta;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Peak boat speed across the whole table — used to size the wind grid
|
|
57
|
+
// (we want it big enough to contain 24 h of travel at max polar speed).
|
|
58
|
+
maxSpeed() {
|
|
59
|
+
if (this._maxSpeed != null) return this._maxSpeed;
|
|
60
|
+
let max = 0;
|
|
61
|
+
for (const row of this.speeds) for (const s of row) if (s > max) max = s;
|
|
62
|
+
this._maxSpeed = max;
|
|
63
|
+
return max;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A bound `speed` function, convenient for passing into the isochrone.
|
|
67
|
+
speedFn() {
|
|
68
|
+
return (tws, twa) => this.speed(tws, twa);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- parsing -----------------------------------------------------
|
|
72
|
+
|
|
73
|
+
static fromCSV(id, text) {
|
|
74
|
+
let name = null;
|
|
75
|
+
const twaRows = [];
|
|
76
|
+
const twsCols = [];
|
|
77
|
+
const speeds = [];
|
|
78
|
+
let seenHeader = false;
|
|
79
|
+
|
|
80
|
+
for (let rawLine of text.split(/\r?\n/)) {
|
|
81
|
+
const line = rawLine.trim();
|
|
82
|
+
if (!line) continue;
|
|
83
|
+
if (line.startsWith('#')) {
|
|
84
|
+
const m = line.match(/^#\s*(.+?)\s*$/);
|
|
85
|
+
if (m && !name) {
|
|
86
|
+
// Strip trailing empty CSV cells that spreadsheet exports
|
|
87
|
+
// sometimes leave on the comment line ("Outremer 5X ,,,,,").
|
|
88
|
+
name = m[1].replace(/[\s,]+$/, '');
|
|
89
|
+
}
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const cells = line.split(',').map(s => s.trim());
|
|
93
|
+
// Skip "ghost" all-commas rows from spreadsheet exports.
|
|
94
|
+
if (cells.every(c => c === '')) continue;
|
|
95
|
+
if (!seenHeader) {
|
|
96
|
+
if (cells.length < 2) throw new Error(`Polar "${id}": header has <2 columns`);
|
|
97
|
+
for (let k = 1; k < cells.length; k++) {
|
|
98
|
+
const v = Number(cells[k]);
|
|
99
|
+
if (!Number.isFinite(v)) throw new Error(`Polar "${id}": bad TWS header value "${cells[k]}"`);
|
|
100
|
+
twsCols.push(v);
|
|
101
|
+
}
|
|
102
|
+
if (twsCols.length < 2) throw new Error(`Polar "${id}": need at least 2 TWS columns`);
|
|
103
|
+
seenHeader = true;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (cells.length !== twsCols.length + 1) {
|
|
107
|
+
throw new Error(`Polar "${id}": row "${cells[0]}" has ${cells.length - 1} speed cells, expected ${twsCols.length}`);
|
|
108
|
+
}
|
|
109
|
+
const twa = Number(cells[0]);
|
|
110
|
+
if (!Number.isFinite(twa)) throw new Error(`Polar "${id}": bad TWA value "${cells[0]}"`);
|
|
111
|
+
const row = [];
|
|
112
|
+
for (let k = 1; k < cells.length; k++) {
|
|
113
|
+
const v = Number(cells[k]);
|
|
114
|
+
if (!Number.isFinite(v) || v < 0) throw new Error(`Polar "${id}": bad speed "${cells[k]}" at TWA ${twa}, TWS ${twsCols[k - 1]}`);
|
|
115
|
+
row.push(v);
|
|
116
|
+
}
|
|
117
|
+
twaRows.push(twa);
|
|
118
|
+
speeds.push(row);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (twaRows.length < 2) throw new Error(`Polar "${id}": need at least 2 TWA rows`);
|
|
122
|
+
for (let k = 1; k < twaRows.length; k++) if (twaRows[k] <= twaRows[k - 1]) {
|
|
123
|
+
throw new Error(`Polar "${id}": TWA rows not strictly ascending at ${twaRows[k]}`);
|
|
124
|
+
}
|
|
125
|
+
for (let k = 1; k < twsCols.length; k++) if (twsCols[k] <= twsCols[k - 1]) {
|
|
126
|
+
throw new Error(`Polar "${id}": TWS columns not strictly ascending at ${twsCols[k]}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return new Polar({ id, name: name || id, twaRows, twsCols, speeds });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { Polar }
|
package/lib/proxy/cache.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
const fs = require('fs')
|
|
16
16
|
const fsp = fs.promises
|
|
17
|
+
const { request } = require('../net') // owned connection pool + real error codes; see lib/net.js
|
|
17
18
|
const path = require('path')
|
|
18
19
|
const crypto = require('crypto')
|
|
19
20
|
|
|
@@ -47,7 +48,7 @@ async function readFromDisk (file, meta) {
|
|
|
47
48
|
async function fetchAndStore (url, file, meta, timeoutMs) {
|
|
48
49
|
let resp
|
|
49
50
|
try {
|
|
50
|
-
resp = await
|
|
51
|
+
resp = await request(url, { timeoutMs })
|
|
51
52
|
} catch (e) {
|
|
52
53
|
const err = new Error(`offline / unreachable: ${e.message}`); err.offline = true; throw err
|
|
53
54
|
}
|
package/lib/proxy/index.js
CHANGED
|
@@ -8,6 +8,7 @@ const { createManifest } = require('./manifest')
|
|
|
8
8
|
const { createContractCheck } = require('../telemetry/contract')
|
|
9
9
|
const { createSeeder } = require('./seed')
|
|
10
10
|
const { countBboxTiles, bboxTiles, boxAround } = require('./tiles')
|
|
11
|
+
const { request } = require('../net')
|
|
11
12
|
|
|
12
13
|
// Caching proxy module. The standalone server (origin root, `proxyPort`) is what
|
|
13
14
|
// the browser points at. It routes by path:
|
|
@@ -181,7 +182,7 @@ function createProxy (app, options) {
|
|
|
181
182
|
if (zoomCache) return zoomCache
|
|
182
183
|
const out = { ...FALLBACK_MAX_ZOOM }
|
|
183
184
|
try {
|
|
184
|
-
const r = await
|
|
185
|
+
const r = await request(cfg.upstream + '/api/assets', { timeoutMs: cfg.timeoutMs })
|
|
185
186
|
if (r.ok) {
|
|
186
187
|
const j = await r.json()
|
|
187
188
|
const raster = j && j.tiles && j.tiles.manifest && j.tiles.manifest.maxZoom
|
|
@@ -338,7 +339,7 @@ function createProxy (app, options) {
|
|
|
338
339
|
}
|
|
339
340
|
let r
|
|
340
341
|
try {
|
|
341
|
-
r = await
|
|
342
|
+
r = await request(target, { method: req.method, headers: fwd, body: chunks.length ? Buffer.concat(chunks) : undefined, timeoutMs: cfg.timeoutMs })
|
|
342
343
|
} catch (e) { res.statusCode = 502; res.end('upstream unreachable'); return }
|
|
343
344
|
res.statusCode = r.status
|
|
344
345
|
r.headers.forEach((v, k) => {
|
package/lib/proxy/manifest.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
const fs = require('fs')
|
|
19
19
|
const fsp = fs.promises
|
|
20
20
|
const path = require('path')
|
|
21
|
+
const { request } = require('../net')
|
|
21
22
|
|
|
22
23
|
function createManifest (app, options) {
|
|
23
24
|
const log = (m) => (app.debug ? app.debug('[manifest] ' + m) : console.log('[sailkick-boat:manifest]', m))
|
|
@@ -61,7 +62,7 @@ function createManifest (app, options) {
|
|
|
61
62
|
if (!cfg) return
|
|
62
63
|
let m
|
|
63
64
|
try {
|
|
64
|
-
const resp = await
|
|
65
|
+
const resp = await request(cfg.upstream + cfg.path, { timeoutMs: cfg.timeoutMs })
|
|
65
66
|
if (!resp.ok) return
|
|
66
67
|
m = await resp.json()
|
|
67
68
|
} catch { return } // offline / bad JSON → no-op, nothing invalidated
|
package/lib/sync/index.js
CHANGED
|
@@ -8,9 +8,15 @@
|
|
|
8
8
|
const fs = require('fs')
|
|
9
9
|
const path = require('path')
|
|
10
10
|
const { Spool, DEFAULT_MAX_BYTES } = require('./spool')
|
|
11
|
-
const { writeLines } = require('./influxWrite')
|
|
11
|
+
const { writeLines, resetTransport } = require('./influxWrite')
|
|
12
12
|
const { deltaToLines } = require('./lineprotocol')
|
|
13
13
|
|
|
14
|
+
// Rebuild the connection pool after this many consecutive TRANSPORT failures. Low enough
|
|
15
|
+
// that a wedged process recovers in seconds instead of needing a restart, high enough
|
|
16
|
+
// that an ordinary offline stretch does not churn agents.
|
|
17
|
+
const RESET_AFTER_FAILURES = 5
|
|
18
|
+
const WARN_REPEAT_MS = 300000 // re-state an ongoing outage every 5 min, not every retry
|
|
19
|
+
|
|
14
20
|
function createSync (app, options) {
|
|
15
21
|
const log = (m) => (app.debug ? app.debug('[sync] ' + m) : console.log('[sailkick-boat:sync]', m))
|
|
16
22
|
// Anything that stops telemetry reaching the cloud goes to `warn`, which lands in the
|
|
@@ -18,7 +24,6 @@ function createSync (app, options) {
|
|
|
18
24
|
// an outage must never be visible ONLY in the status line, which is what made a
|
|
19
25
|
// day-long silent failure possible.
|
|
20
26
|
const warn = (m) => (app.error ? app.error('[sailkick-boat:sync] ' + m) : console.error('[sailkick-boat:sync]', m))
|
|
21
|
-
const WARN_REPEAT_MS = 300000 // re-state an ongoing outage every 5 min, not every retry
|
|
22
27
|
let state = null
|
|
23
28
|
|
|
24
29
|
function start () {
|
|
@@ -55,11 +60,26 @@ function createSync (app, options) {
|
|
|
55
60
|
// never reach its endpoint used to produce no log output at all.
|
|
56
61
|
const noteFailure = (res) => {
|
|
57
62
|
state.failCount++
|
|
63
|
+
// A poisoned connection pool cannot heal itself: the sockets look open and are dead
|
|
64
|
+
// on the wire (Starlink is behind CGNAT, which drops idle mappings without an RST),
|
|
65
|
+
// so every retry writes into the same corpse. Twice this left the process unable to
|
|
66
|
+
// open ANY outbound HTTPS for over half an hour while a second process in the same
|
|
67
|
+
// container reached the host in under a second. Throwing the pool away is the one
|
|
68
|
+
// thing that recovers it. Only for TRANSPORT failures — an HTTP status means the
|
|
69
|
+
// connection worked fine.
|
|
70
|
+
if (res && res.networkError && state.failCount % RESET_AFTER_FAILURES === 0) {
|
|
71
|
+
const gen = resetTransport()
|
|
72
|
+
warn(`${state.failCount} consecutive transport failures — rebuilt the connection pool (generation ${gen}); if the link is up this recovers on the next attempt`)
|
|
73
|
+
}
|
|
58
74
|
const now = Date.now()
|
|
59
75
|
if (state.failing && now - state.lastWarnAt < WARN_REPEAT_MS) return
|
|
60
76
|
state.failing = true
|
|
61
77
|
state.lastWarnAt = now
|
|
62
|
-
|
|
78
|
+
// Name the REASON. "fetch failed" told us nothing through two incidents; ECONNRESET
|
|
79
|
+
// and ETIMEDOUT are different problems with different fixes.
|
|
80
|
+
const why = res && res.status
|
|
81
|
+
? `HTTP ${res.status}`
|
|
82
|
+
: `unreachable${res && (res.code || res.error) ? ` (${res.code || res.error})` : ''}`
|
|
63
83
|
warn(`cannot write to ${cfg.influxUrl} — ${why}; ${state.failCount} failed attempt(s), telemetry is buffering on disk`)
|
|
64
84
|
}
|
|
65
85
|
const noteSuccess = () => {
|
package/lib/sync/influxWrite.js
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Return shape:
|
|
6
6
|
// { ok: true, status: 204 }
|
|
7
|
-
// { ok: false, retryable: true, networkError: true }
|
|
8
|
-
// { ok: false, retryable: true, status }
|
|
9
|
-
// { ok: false, configError: true, status, body }
|
|
10
|
-
// { ok: false, retryable: false, status, body }
|
|
7
|
+
// { ok: false, retryable: true, networkError: true, code, error } connection failed
|
|
8
|
+
// { ok: false, retryable: true, status } 429 / 5xx (transient)
|
|
9
|
+
// { ok: false, configError: true, status, body } 404 / 401 / 403
|
|
10
|
+
// { ok: false, retryable: false, status, body } other 4xx (bad data)
|
|
11
11
|
//
|
|
12
12
|
// Most 4xx is non-retryable: retrying a malformed batch forever would wedge the queue,
|
|
13
13
|
// so the caller quarantines it instead.
|
|
@@ -21,7 +21,11 @@
|
|
|
21
21
|
// and keeps retrying slowly, so correcting the config recovers on its own.
|
|
22
22
|
const CONFIG_ERROR_STATUS = new Set([401, 403, 404])
|
|
23
23
|
|
|
24
|
+
// The transport (an owned connection pool, real error codes) lives in lib/net.js —
|
|
25
|
+
// see the long note there on why fetch() is unusable on a CGNAT satellite link.
|
|
26
|
+
|
|
24
27
|
const zlib = require('zlib')
|
|
28
|
+
const { request, resetTransport } = require('../net')
|
|
25
29
|
|
|
26
30
|
async function writeLines (cfg, body) {
|
|
27
31
|
const base = cfg.influxUrl.replace(/\/+$/, '')
|
|
@@ -31,10 +35,9 @@ async function writeLines (cfg, body) {
|
|
|
31
35
|
'&precision=ns'
|
|
32
36
|
|
|
33
37
|
const gz = zlib.gzipSync(Buffer.from(body, 'utf8'))
|
|
34
|
-
|
|
35
|
-
let res
|
|
38
|
+
let r
|
|
36
39
|
try {
|
|
37
|
-
|
|
40
|
+
r = await request(url, {
|
|
38
41
|
method: 'POST',
|
|
39
42
|
headers: {
|
|
40
43
|
Authorization: `Token ${cfg.token}`,
|
|
@@ -42,21 +45,20 @@ async function writeLines (cfg, body) {
|
|
|
42
45
|
'Content-Encoding': 'gzip'
|
|
43
46
|
},
|
|
44
47
|
body: gz,
|
|
45
|
-
|
|
48
|
+
timeoutMs: cfg.timeoutMs
|
|
46
49
|
})
|
|
47
50
|
} catch (e) {
|
|
48
|
-
|
|
51
|
+
// The REAL code, not fetch's "fetch failed" — see lib/net.js.
|
|
52
|
+
return { ok: false, retryable: true, networkError: true, code: e.code || null, error: e.message }
|
|
49
53
|
}
|
|
54
|
+
const status = r.status
|
|
50
55
|
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
try { text = await res.text() } catch {}
|
|
55
|
-
if (CONFIG_ERROR_STATUS.has(res.status)) {
|
|
56
|
-
return { ok: false, configError: true, retryable: false, status: res.status, body: text }
|
|
56
|
+
if (status === 204) return { ok: true, status: 204 }
|
|
57
|
+
if (CONFIG_ERROR_STATUS.has(status)) {
|
|
58
|
+
return { ok: false, configError: true, retryable: false, status, body: await r.text() }
|
|
57
59
|
}
|
|
58
|
-
const retryable =
|
|
59
|
-
return { ok: false, retryable, status
|
|
60
|
+
const retryable = status === 429 || status >= 500
|
|
61
|
+
return { ok: false, retryable, status, body: await r.text() }
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
module.exports = { writeLines }
|
|
64
|
+
module.exports = { writeLines, resetTransport }
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
// sha256sum <sailkick>/public/engine/signalk-map.js | cut -c1-12
|
|
23
23
|
|
|
24
24
|
// sha256(app public/engine/signalk-map.js)[0..12] as ported in v0.18.6
|
|
25
|
+
const { request } = require('../net') // owned connection pool + real error codes
|
|
26
|
+
|
|
25
27
|
const PINNED_APP_HASH = '2015ae986cd8'
|
|
26
28
|
|
|
27
29
|
function createContractCheck (app, options = {}) {
|
|
@@ -37,7 +39,7 @@ function createContractCheck (app, options = {}) {
|
|
|
37
39
|
if (!upstream) return drifted
|
|
38
40
|
let remote
|
|
39
41
|
try {
|
|
40
|
-
const r = await
|
|
42
|
+
const r = await request(String(upstream).replace(/\/+$/, '') + '/health', { timeoutMs })
|
|
41
43
|
if (!r.ok) return drifted
|
|
42
44
|
const body = await r.json()
|
|
43
45
|
remote = body && body.contracts && body.contracts.signalkMap
|
package/lib/telemetry/index.js
CHANGED
|
@@ -3,6 +3,24 @@
|
|
|
3
3
|
const crypto = require('crypto')
|
|
4
4
|
const { signalkValuesToPatch, resolveHeadingDeg } = require('./signalk-map')
|
|
5
5
|
|
|
6
|
+
// Choosing between competing sources is SIGNAL K'S JOB, not this module's. A boat
|
|
7
|
+
// commonly has several devices publishing the same path — this one carries two compasses
|
|
8
|
+
// 7.5 deg apart on navigation.headingMagnetic, and three log sources on
|
|
9
|
+
// speedThroughWater, one of which reports a constant 0. Signal K resolves that from
|
|
10
|
+
// `sourcePriorities` in settings.json, applied in its delta pipeline (deltaPriority.js,
|
|
11
|
+
// called at index.js:268) BEFORE anything downstream sees the delta. So by the time a
|
|
12
|
+
// value reaches here it has already been arbitrated, and every consumer on the boat —
|
|
13
|
+
// this plugin, KIP, the instruments — agrees.
|
|
14
|
+
//
|
|
15
|
+
// This module used to keep its own guard for navigation.headingMagnetic: lock onto the
|
|
16
|
+
// first $source seen and ignore the rest. That was a coin flip (it could equally lock
|
|
17
|
+
// onto the WRONG compass and be quietly 7.5 deg out for the whole session), and once
|
|
18
|
+
// priorities were configured it became actively harmful: Signal K replays current values
|
|
19
|
+
// when a client subscribes, so the first headingMagnetic delta after a restart can be a
|
|
20
|
+
// one-off from a de-prioritised device. Latching onto that would have discarded every
|
|
21
|
+
// real heading delta thereafter — heading frozen at a stale value rather than merely
|
|
22
|
+
// wrong. Removed in v0.22.3; set `sourcePriorities` instead.
|
|
23
|
+
//
|
|
6
24
|
// Serves the sailkick app's /ws/telemetry bus FROM the boat's local SignalK, so
|
|
7
25
|
// the app uses the identical telemetry contract whether it talks to the cloud
|
|
8
26
|
// sailkick server or this on-boat plugin. Faithful port of the server's
|
|
@@ -26,7 +44,6 @@ function encodeTextFrame (str) {
|
|
|
26
44
|
function createTelemetry (app, options = {}) {
|
|
27
45
|
const log = (m) => (app.debug ? app.debug('[telemetry] ' + m) : console.log('[sailkick-boat:telemetry]', m))
|
|
28
46
|
let state = null
|
|
29
|
-
let magSource = null // lock the $source for navigation.headingMagnetic (dual-source guard)
|
|
30
47
|
const clients = new Set()
|
|
31
48
|
const unsubscribes = []
|
|
32
49
|
|
|
@@ -44,13 +61,7 @@ function createTelemetry (app, options = {}) {
|
|
|
44
61
|
let ts = null
|
|
45
62
|
for (const u of delta.updates) {
|
|
46
63
|
if (!u || !Array.isArray(u.values)) continue
|
|
47
|
-
|
|
48
|
-
const vals = u.values.filter((v) => {
|
|
49
|
-
if (!v || v.path !== 'navigation.headingMagnetic') return true
|
|
50
|
-
if (!magSource) magSource = src
|
|
51
|
-
return src === magSource
|
|
52
|
-
})
|
|
53
|
-
Object.assign(patch, signalkValuesToPatch(vals))
|
|
64
|
+
Object.assign(patch, signalkValuesToPatch(u.values))
|
|
54
65
|
if (u.timestamp) ts = u.timestamp
|
|
55
66
|
}
|
|
56
67
|
if (Object.keys(patch).length === 0) return
|
|
@@ -85,7 +96,6 @@ function createTelemetry (app, options = {}) {
|
|
|
85
96
|
for (const s of clients) { try { s.destroy() } catch {} }
|
|
86
97
|
clients.clear()
|
|
87
98
|
state = null
|
|
88
|
-
magSource = null
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
function status () {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sailkick-boat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.3",
|
|
4
4
|
"description": "Run the sailkick app on board with no internet: charts, weather, climatology, trends and AIS all served from the boat itself. With a sailkick account it also syncs your metrics to the cloud in real time. Alpha, invite-only \u2014 info@sailkick.io",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|