sailkick-boat 0.23.0 → 0.23.7
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 +57 -0
- package/lib/backfill/index.js +4 -3
- package/lib/cloud/index.js +7 -5
- package/lib/history/index.js +1 -0
- package/lib/net.js +149 -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 +118 -2
- 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
|
|
@@ -330,6 +341,52 @@ at all. The raw channels are always recorded regardless, so the cloud can recomp
|
|
|
330
341
|
history if the maths ever changes: the recorded channel is a materialisation, not the only
|
|
331
342
|
truth.
|
|
332
343
|
|
|
344
|
+
## Two paths, one reading
|
|
345
|
+
|
|
346
|
+
Source priorities solve *several devices on one path*. There is a second, separate case:
|
|
347
|
+
**several paths that mean the same thing**. Signal K publishes active-waypoint course data
|
|
348
|
+
under three prefixes — `navigation.courseGreatCircle.nextPoint.*`,
|
|
349
|
+
`navigation.courseRhumbline.nextPoint.*` and `navigation.course.calcValues.*` — and the
|
|
350
|
+
app maps all three onto the same readouts. A boat publishing more than one gets whichever
|
|
351
|
+
delta arrived last, so the waypoint distance alternates: measured on this boat, 2049.48 nm
|
|
352
|
+
from great circle against 2050.86 nm from the course provider, several times a second.
|
|
353
|
+
|
|
354
|
+
`sourcePriorities` cannot fix that — it arbitrates sources on ONE path, and these are
|
|
355
|
+
different paths, each legitimately sourced. The plugin therefore applies the precedence
|
|
356
|
+
the app already documents on its history side (great circle primary, the other two
|
|
357
|
+
`fallback: true`): a lower-priority prefix is ignored while a better one is publishing,
|
|
358
|
+
and takes over if that one goes quiet for 10 s.
|
|
359
|
+
|
|
360
|
+
An audit of the mapper found exactly one other case: **depth**, fed by both
|
|
361
|
+
`environment.depth.belowSurface` and `belowTransducer`. On a boat publishing both they
|
|
362
|
+
differ by the transducer offset (0.3 m here), so the reading would oscillate in shallow
|
|
363
|
+
water where the sounder streams. Same rule, with `belowSurface` preferred — the honest
|
|
364
|
+
"how much water is under me" figure, and what the mapper itself calls preferred.
|
|
365
|
+
|
|
366
|
+
## Heading: the boat's own true heading
|
|
367
|
+
|
|
368
|
+
Two ways to know true heading — the boat publishes `navigation.headingTrue`, or it is
|
|
369
|
+
derived from `headingMagnetic + magneticVariation`. The plugin uses the **published**
|
|
370
|
+
value.
|
|
371
|
+
|
|
372
|
+
The vendored mapper prefers the derivation, citing a heading frozen at 151° while the
|
|
373
|
+
compass read true ~293° — but that came from another vessel's AIS data, not from a boat's
|
|
374
|
+
own instruments, so it is weak grounds for distrusting your own.
|
|
375
|
+
|
|
376
|
+
A cross-check is still worth having against a genuinely stuck publisher, and it runs
|
|
377
|
+
**only when variation is on the bus**, comparing two *true* headings. That condition
|
|
378
|
+
matters: without variation the derivation is raw magnetic, so comparing against it would
|
|
379
|
+
just measure the variation — 16° on this boat, over 20° in places — and reject a perfectly
|
|
380
|
+
good `headingTrue`, reporting magnetic as true. The check would have caused the very error
|
|
381
|
+
it exists to prevent. When variation is absent the boat is simply taken at its word.
|
|
382
|
+
|
|
383
|
+
With both available a healthy boat sits near zero (0.5° here); a gap over 10° falls back
|
|
384
|
+
to the compass and says so once, and recovers automatically.
|
|
385
|
+
|
|
386
|
+
This also lines the boat up with the cloud's history provider, which takes `headingTrue`
|
|
387
|
+
first. (Its fallback converts `headingMagnetic` **without** adding variation, so a boat
|
|
388
|
+
publishing only magnetic gets raw magnetic in Trends — an upstream bug, flagged.)
|
|
389
|
+
|
|
333
390
|
## Several devices publishing the same value
|
|
334
391
|
|
|
335
392
|
A real N2K network usually has more than one device announcing a given path, and they do
|
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/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 }
|
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
|
@@ -30,6 +30,8 @@ const { signalkValuesToPatch, resolveHeadingDeg } = require('./signalk-map')
|
|
|
30
30
|
|
|
31
31
|
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
32
32
|
const SUBPROTOCOL = 'sailkick.telemetry.v1'
|
|
33
|
+
const wrap360d = (d) => ((d % 360) + 360) % 360
|
|
34
|
+
const wrap180 = (d) => { const w = wrap360d(d); return w > 180 ? w - 360 : w }
|
|
33
35
|
const SEED = { sogKt: 0, cogDeg: 0, headingDeg: 0, awsKt: null, awaDeg: null }
|
|
34
36
|
|
|
35
37
|
function encodeTextFrame (str) {
|
|
@@ -41,9 +43,68 @@ function encodeTextFrame (str) {
|
|
|
41
43
|
return Buffer.concat([header, payload])
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
// Signal K publishes active-waypoint course data under THREE prefixes, and
|
|
47
|
+
// signalk-map.js maps all of them onto the same BoatState fields (wptDistNm and friends)
|
|
48
|
+
// — see COURSE_RE there. On a boat that publishes more than one, whichever delta arrives
|
|
49
|
+
// last wins and the readout flip-flops: measured here, courseGreatCircle said 2049.48 nm
|
|
50
|
+
// while course.calcValues said 2050.86 nm, alternating several times a second.
|
|
51
|
+
//
|
|
52
|
+
// Signal K's own sourcePriorities cannot fix this. It arbitrates between SOURCES on ONE
|
|
53
|
+
// path; here the competing values are on DIFFERENT paths, each legitimately sourced.
|
|
54
|
+
//
|
|
55
|
+
// The app already states the intended order on its history side: great circle is primary,
|
|
56
|
+
// the other two are `fallback: true` (server/history/influx-provider.js). Its LIVE mapper
|
|
57
|
+
// simply never implemented that, which does not show up in the cloud because that
|
|
58
|
+
// deployment reads history rather than live Signal K. So this applies the app's own
|
|
59
|
+
// documented precedence to the live stream.
|
|
60
|
+
//
|
|
61
|
+
// Deliberately NOT patched into lib/telemetry/signalk-map.js: that file is vendored
|
|
62
|
+
// verbatim from the app and must stay byte-comparable. Handed upstream so the rule can
|
|
63
|
+
// move into COURSE_RE and this can be deleted.
|
|
64
|
+
// The boat's PUBLISHED navigation.headingTrue is preferred over deriving true heading
|
|
65
|
+
// from magnetic + variation.
|
|
66
|
+
//
|
|
67
|
+
// The vendored mapper's resolveHeadingDeg() does the opposite. The case it cites — a
|
|
68
|
+
// heading frozen at 151° while the compass read true ~293° — came from ANOTHER vessel's
|
|
69
|
+
// AIS data, not from self telemetry, so it is weak evidence for distrusting a boat's own
|
|
70
|
+
// instruments. This boat's headingTrue is healthy and agrees to 0.52°.
|
|
71
|
+
//
|
|
72
|
+
// A cross-check is still worth having against a genuinely broken publisher, but ONLY when
|
|
73
|
+
// there is something valid to check against. resolveHeadingDeg() returns RAW MAGNETIC
|
|
74
|
+
// when no variation is published, and comparing a true heading against raw magnetic just
|
|
75
|
+
// measures the variation — 16° here, more elsewhere. A guard built on that would reject a
|
|
76
|
+
// perfectly good headingTrue on every boat that does not publish variation, and report
|
|
77
|
+
// magnetic as if it were true: a 16° error introduced by the safety check itself. So the
|
|
78
|
+
// comparison runs only when variation is on the bus, and both sides are true headings.
|
|
79
|
+
// Both sides are TRUE headings, so a healthy boat sits near zero (0.52° here). This is
|
|
80
|
+
// sized to catch a stuck publisher, not to police variation error.
|
|
81
|
+
const HEADING_DISAGREE_DEG = 10
|
|
82
|
+
|
|
83
|
+
// Each group is one BoatState field fed by several paths, most-preferred first.
|
|
84
|
+
const PRECEDENCE_GROUPS = [
|
|
85
|
+
// Active waypoint: wptBrgDeg / wptDistNm / wptVmgKt / wptTtgSec (COURSE_RE).
|
|
86
|
+
['navigation.courseGreatCircle.nextPoint.',
|
|
87
|
+
'navigation.courseRhumbline.nextPoint.',
|
|
88
|
+
'navigation.course.calcValues.'],
|
|
89
|
+
// depthM. Both are published by the same transducer here, 0.3 m apart — that gap IS
|
|
90
|
+
// environment.depth.surfaceToTransducer. belowSurface is the honest "how much water is
|
|
91
|
+
// under me" number and is what signalk-map.js calls preferred, so it wins.
|
|
92
|
+
//
|
|
93
|
+
// NOTE for the re-vendor: the app's two implementations disagree here. Its live mapper
|
|
94
|
+
// comments belowSurface "preferred" and belowTransducer "fallback", while its history
|
|
95
|
+
// provider (influx-provider.js MAP) has belowTransducer primary and belowSurface
|
|
96
|
+
// `fallback: true` — the opposite. So live and Trends can differ by the transducer
|
|
97
|
+
// offset for the same instant. Flagged upstream; this follows the live mapper.
|
|
98
|
+
['environment.depth.belowSurface', 'environment.depth.belowTransducer']
|
|
99
|
+
]
|
|
100
|
+
// How long a higher-priority path stays "live" after its last value. Long enough to
|
|
101
|
+
// cover a slow publisher, short enough that a genuinely stopped source hands over.
|
|
102
|
+
const PRECEDENCE_STALE_MS = 10000
|
|
103
|
+
|
|
44
104
|
function createTelemetry (app, options = {}) {
|
|
45
105
|
const log = (m) => (app.debug ? app.debug('[telemetry] ' + m) : console.log('[sailkick-boat:telemetry]', m))
|
|
46
106
|
let state = null
|
|
107
|
+
const pathSeen = new Map() // 'group:index' -> last ms, for the precedence above
|
|
47
108
|
const clients = new Set()
|
|
48
109
|
const unsubscribes = []
|
|
49
110
|
|
|
@@ -55,13 +116,68 @@ function createTelemetry (app, options = {}) {
|
|
|
55
116
|
for (const s of clients) { try { s.write(frame) } catch { clients.delete(s) } }
|
|
56
117
|
}
|
|
57
118
|
|
|
119
|
+
// Drop a value whose path is covered by a higher-priority sibling that is currently
|
|
120
|
+
// publishing. Anything outside PRECEDENCE_GROUPS passes through untouched.
|
|
121
|
+
function applyPrecedence (values) {
|
|
122
|
+
const hit = (path) => {
|
|
123
|
+
for (let g = 0; g < PRECEDENCE_GROUPS.length; g++) {
|
|
124
|
+
const i = PRECEDENCE_GROUPS[g].findIndex((p) => path === p || path.startsWith(p))
|
|
125
|
+
if (i >= 0) return { g, i }
|
|
126
|
+
}
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
let touched = false
|
|
130
|
+
for (const v of values) {
|
|
131
|
+
if (!v || !v.path) continue
|
|
132
|
+
const h = hit(v.path)
|
|
133
|
+
if (h) { pathSeen.set(`${h.g}:${h.i}`, Date.now()); touched = true }
|
|
134
|
+
}
|
|
135
|
+
if (!touched) return values
|
|
136
|
+
const now = Date.now()
|
|
137
|
+
return values.filter((v) => {
|
|
138
|
+
if (!v || !v.path) return true
|
|
139
|
+
const h = hit(v.path)
|
|
140
|
+
if (!h || h.i === 0) return true // not grouped, or already the top choice
|
|
141
|
+
for (let j = 0; j < h.i; j++) {
|
|
142
|
+
const seen = pathSeen.get(`${h.g}:${j}`)
|
|
143
|
+
if (seen && now - seen < PRECEDENCE_STALE_MS) return false
|
|
144
|
+
}
|
|
145
|
+
return true
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// See HEADING_DISAGREE_DEG. Returns true heading in degrees, or undefined.
|
|
150
|
+
let headingWarned = false
|
|
151
|
+
function resolveHeading (st) {
|
|
152
|
+
const derived = resolveHeadingDeg(st) // magnetic + variation, per the vendored mapper
|
|
153
|
+
const published = st.hdgTrueDeg
|
|
154
|
+
if (!Number.isFinite(published)) return derived
|
|
155
|
+
if (!Number.isFinite(derived)) return published
|
|
156
|
+
// Without variation, `derived` is raw MAGNETIC and the comparison would just measure
|
|
157
|
+
// the variation. Nothing to corroborate against — take the boat at its word.
|
|
158
|
+
if (!Number.isFinite(st.magVarDeg)) return published
|
|
159
|
+
const gap = Math.abs(wrap180(published - derived))
|
|
160
|
+
if (gap > HEADING_DISAGREE_DEG) {
|
|
161
|
+
if (!headingWarned) {
|
|
162
|
+
headingWarned = true
|
|
163
|
+
const warn = app.error ? (m) => app.error('[sailkick-boat:telemetry] ' + m) : log
|
|
164
|
+
warn(`navigation.headingTrue (${published.toFixed(1)}°) disagrees with the compass + variation ` +
|
|
165
|
+
`(${derived.toFixed(1)}°) by ${gap.toFixed(1)}° — using the compass. A headingTrue that is ` +
|
|
166
|
+
'stale or static is a known failure mode; check which device publishes it.')
|
|
167
|
+
}
|
|
168
|
+
return derived
|
|
169
|
+
}
|
|
170
|
+
if (headingWarned) { headingWarned = false; log('navigation.headingTrue agrees with the compass again — using it') }
|
|
171
|
+
return published
|
|
172
|
+
}
|
|
173
|
+
|
|
58
174
|
function onDelta (delta) {
|
|
59
175
|
if (!delta || !Array.isArray(delta.updates)) return
|
|
60
176
|
const patch = {}
|
|
61
177
|
let ts = null
|
|
62
178
|
for (const u of delta.updates) {
|
|
63
179
|
if (!u || !Array.isArray(u.values)) continue
|
|
64
|
-
Object.assign(patch, signalkValuesToPatch(u.values))
|
|
180
|
+
Object.assign(patch, signalkValuesToPatch(applyPrecedence(u.values)))
|
|
65
181
|
if (u.timestamp) ts = u.timestamp
|
|
66
182
|
}
|
|
67
183
|
if (Object.keys(patch).length === 0) return
|
|
@@ -70,7 +186,7 @@ function createTelemetry (app, options = {}) {
|
|
|
70
186
|
state = { ...SEED }
|
|
71
187
|
}
|
|
72
188
|
state = { ...state, ...patch, updatedAt: ts || new Date().toISOString() }
|
|
73
|
-
const hd =
|
|
189
|
+
const hd = resolveHeading(state)
|
|
74
190
|
state.headingDeg = Number.isFinite(hd) ? hd : (state.headingDeg || state.cogDeg || 0)
|
|
75
191
|
broadcast({ type: 'telemetry/update', state })
|
|
76
192
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sailkick-boat",
|
|
3
|
-
"version": "0.23.
|
|
3
|
+
"version": "0.23.7",
|
|
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": {
|