sailkick-boat 0.13.4
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/LICENSE +11 -0
- package/README.md +197 -0
- package/index.js +252 -0
- package/lib/account/index.js +65 -0
- package/lib/history/index.js +247 -0
- package/lib/history/ring.js +162 -0
- package/lib/proxy/cache.js +151 -0
- package/lib/proxy/index.js +393 -0
- package/lib/proxy/manifest.js +121 -0
- package/lib/proxy/seed.js +139 -0
- package/lib/proxy/tiles.js +62 -0
- package/lib/sync/index.js +150 -0
- package/lib/sync/influxWrite.js +49 -0
- package/lib/sync/lineprotocol.js +121 -0
- package/lib/sync/spool.js +101 -0
- package/lib/telemetry/index.js +123 -0
- package/lib/telemetry/signalk-map.js +71 -0
- package/package.json +41 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Slippy-map (XYZ / Web-Mercator) tile math. Pure, dependency-free — shared by the
|
|
4
|
+
// global-base seeder and the region-prefetch handler. Matches the app's tile scheme
|
|
5
|
+
// (public/viewer/coastline-overlay.js): top-left origin, latitude clamped to the
|
|
6
|
+
// Web-Mercator limit.
|
|
7
|
+
|
|
8
|
+
const MAX_LAT = 85.05112878
|
|
9
|
+
|
|
10
|
+
// (lat,lon,z) -> [x,y] tile indices, clamped into [0, 2^z-1].
|
|
11
|
+
function deg2tile (lat, lon, z) {
|
|
12
|
+
const n = 2 ** z
|
|
13
|
+
const clat = Math.max(-MAX_LAT, Math.min(MAX_LAT, lat))
|
|
14
|
+
const latR = clat * Math.PI / 180
|
|
15
|
+
let x = Math.floor((lon + 180) / 360 * n)
|
|
16
|
+
let y = Math.floor((1 - Math.asinh(Math.tan(latR)) / Math.PI) / 2 * n)
|
|
17
|
+
const clamp = (v) => Math.max(0, Math.min(n - 1, v))
|
|
18
|
+
return [clamp(x), clamp(y)]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// bbox [w,s,e,n] -> inclusive tile range at zoom z. North maps to the smaller y
|
|
22
|
+
// (top), west to the smaller x. Antimeridian-crossing bboxes (w > e) are not split
|
|
23
|
+
// here — callers pass single-hemisphere passage boxes.
|
|
24
|
+
function bboxTileRange (bbox, z) {
|
|
25
|
+
const [w, s, e, n] = bbox
|
|
26
|
+
const [xw, yn] = deg2tile(n, w, z) // north-west
|
|
27
|
+
const [xe, ys] = deg2tile(s, e, z) // south-east
|
|
28
|
+
return { x0: Math.min(xw, xe), x1: Math.max(xw, xe), y0: Math.min(yn, ys), y1: Math.max(yn, ys) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Total tiles a bbox covers across [minZoom, maxZoom] inclusive (for the estimate/cap).
|
|
32
|
+
function countBboxTiles (bbox, minZoom, maxZoom) {
|
|
33
|
+
let total = 0
|
|
34
|
+
for (let z = minZoom; z <= maxZoom; z++) {
|
|
35
|
+
const { x0, x1, y0, y1 } = bboxTileRange(bbox, z)
|
|
36
|
+
total += (x1 - x0 + 1) * (y1 - y0 + 1)
|
|
37
|
+
}
|
|
38
|
+
return total
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Yield every "z/x/y" for a bbox across [minZoom, maxZoom] (generator — no big array).
|
|
42
|
+
function * bboxTiles (bbox, minZoom, maxZoom) {
|
|
43
|
+
for (let z = minZoom; z <= maxZoom; z++) {
|
|
44
|
+
const { x0, x1, y0, y1 } = bboxTileRange(bbox, z)
|
|
45
|
+
for (let x = x0; x <= x1; x++) {
|
|
46
|
+
for (let y = y0; y <= y1; y++) yield { z, x, y }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Bounding box [w,s,e,n] of `radiusNm` nautical miles around a point. 1 nm ≈ 1/60°
|
|
52
|
+
// of latitude; longitude degrees shrink with cos(lat) (clamped near the poles).
|
|
53
|
+
function boxAround (lat, lon, radiusNm) {
|
|
54
|
+
const dLat = radiusNm / 60
|
|
55
|
+
const dLon = radiusNm / 60 / Math.max(0.05, Math.cos(lat * Math.PI / 180))
|
|
56
|
+
return [
|
|
57
|
+
Math.max(-180, lon - dLon), Math.max(-85, lat - dLat),
|
|
58
|
+
Math.min(180, lon + dLon), Math.min(85, lat + dLat)
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { deg2tile, bboxTileRange, countBboxTiles, bboxTiles, boxAround, MAX_LAT }
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Telemetry sync module: durable, gapless store-and-forward of self-vessel data
|
|
4
|
+
// to InfluxDB v2 (survives offline periods + Signal K restarts). Lifted verbatim
|
|
5
|
+
// from signalk-to-influxdb-gapless, wrapped as createSync(app, options) ->
|
|
6
|
+
// { start, stop, status } so it can live alongside the proxy module in one plugin.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs')
|
|
9
|
+
const path = require('path')
|
|
10
|
+
const { Spool, DEFAULT_MAX_BYTES } = require('./spool')
|
|
11
|
+
const { writeLines } = require('./influxWrite')
|
|
12
|
+
const { deltaToLines } = require('./lineprotocol')
|
|
13
|
+
|
|
14
|
+
function createSync (app, options) {
|
|
15
|
+
const log = (m) => (app.debug ? app.debug('[sync] ' + m) : console.log('[sailkick-boat:sync]', m))
|
|
16
|
+
let state = null
|
|
17
|
+
|
|
18
|
+
function start () {
|
|
19
|
+
const cfg = {
|
|
20
|
+
influxUrl: options.influxUrl,
|
|
21
|
+
org: options.org || 'sailkick',
|
|
22
|
+
bucket: options.bucket,
|
|
23
|
+
token: options.token,
|
|
24
|
+
timeoutMs: options.requestTimeoutMs || 30000
|
|
25
|
+
}
|
|
26
|
+
if (!cfg.influxUrl || !cfg.bucket || !cfg.token) {
|
|
27
|
+
log('not started — missing influxUrl / bucket / token')
|
|
28
|
+
state = { statusLine: 'sync: not configured', stopped: true }
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const dataDir = (app.getDataDirPath && app.getDataDirPath()) || '.'
|
|
33
|
+
const spoolDir = options.spoolDir || path.join(dataDir, 'spool')
|
|
34
|
+
const spool = new Spool({ dir: spoolDir, maxBytes: options.maxBufferBytes || DEFAULT_MAX_BYTES, logger: log })
|
|
35
|
+
const selfContext = app.selfContext || ('vessels.' + (app.selfId || 'self'))
|
|
36
|
+
|
|
37
|
+
state = {
|
|
38
|
+
cfg, spool, log, batch: [], batchSize: options.batchSize || 1000,
|
|
39
|
+
flushTimer: null, pumpTimer: null, stopped: false, unsubscribes: [],
|
|
40
|
+
retryMin: options.retryMinMs || 1000, retryMax: options.retryMaxMs || 60000,
|
|
41
|
+
backoff: options.retryMinMs || 1000, idlePoll: Math.max(500, options.flushIntervalMs || 1000),
|
|
42
|
+
lastOkAt: null, pumping: false, statusLine: 'sync: starting'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const handleDelta = (delta) => {
|
|
46
|
+
try {
|
|
47
|
+
const lines = deltaToLines(delta, { context: selfContext })
|
|
48
|
+
if (lines.length) state.batch.push(...lines)
|
|
49
|
+
if (state.batch.length >= state.batchSize) flush()
|
|
50
|
+
} catch (e) { log('delta error: ' + e.message) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const flush = () => {
|
|
54
|
+
if (state.stopped || !state.batch.length) return
|
|
55
|
+
const lines = state.batch
|
|
56
|
+
state.batch = []
|
|
57
|
+
spool.append(lines).then(() => pump()).catch((e) => {
|
|
58
|
+
log('spool append failed: ' + e.message)
|
|
59
|
+
state.batch.unshift(...lines)
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function pump () {
|
|
64
|
+
if (state.stopped || state.pumping) return
|
|
65
|
+
state.pumping = true
|
|
66
|
+
try {
|
|
67
|
+
const files = await spool.pending()
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
if (state.stopped) break
|
|
70
|
+
let body
|
|
71
|
+
try { body = await fs.promises.readFile(file, 'utf8') } catch { continue }
|
|
72
|
+
if (!body.trim()) { await spool.remove(file); continue }
|
|
73
|
+
const res = await writeLines(cfg, body)
|
|
74
|
+
if (res.ok) {
|
|
75
|
+
await spool.remove(file); state.lastOkAt = new Date(); state.backoff = state.retryMin
|
|
76
|
+
} else if (res.retryable) {
|
|
77
|
+
state.pumping = false; scheduleRetry(res); return
|
|
78
|
+
} else {
|
|
79
|
+
log(`batch rejected (HTTP ${res.status}) — quarantined`); await spool.quarantine(file)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch (e) {
|
|
83
|
+
log('pump error: ' + e.message); state.pumping = false; scheduleRetry(); return
|
|
84
|
+
}
|
|
85
|
+
state.pumping = false; scheduleIdle(); refreshStatus()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function scheduleRetry (res) {
|
|
89
|
+
if (state.stopped) return
|
|
90
|
+
clearTimeout(state.pumpTimer)
|
|
91
|
+
const delay = state.backoff
|
|
92
|
+
state.backoff = Math.min(state.backoff * 2, state.retryMax)
|
|
93
|
+
state.pumpTimer = setTimeout(pump, delay)
|
|
94
|
+
refreshStatus(`${res && res.status ? 'HTTP ' + res.status : 'offline'} — retry ${Math.round(delay / 1000)}s`)
|
|
95
|
+
}
|
|
96
|
+
function scheduleIdle () {
|
|
97
|
+
if (state.stopped) return
|
|
98
|
+
clearTimeout(state.pumpTimer)
|
|
99
|
+
state.pumpTimer = setTimeout(pump, state.idlePoll)
|
|
100
|
+
}
|
|
101
|
+
async function refreshStatus (suffix) {
|
|
102
|
+
try {
|
|
103
|
+
const { count, bytes } = await spool.stats()
|
|
104
|
+
const last = state.lastOkAt ? state.lastOkAt.toISOString() : 'never'
|
|
105
|
+
state.statusLine = `sync: buffered ${count}f/${Math.round(bytes / 1024)}KB; last ok ${last}${suffix ? '; ' + suffix : ''}`
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
spool.init().then(() => {
|
|
110
|
+
state.flushTimer = setInterval(flush, options.flushIntervalMs || 1000)
|
|
111
|
+
subscribe(handleDelta)
|
|
112
|
+
pump()
|
|
113
|
+
log(`started; buffer dir ${spoolDir}; context ${selfContext}`)
|
|
114
|
+
}).catch((e) => log('init failed: ' + e.message))
|
|
115
|
+
|
|
116
|
+
function subscribe (onDelta) {
|
|
117
|
+
const sub = { context: 'vessels.self', subscribe: [{ path: '*', period: options.subscribePeriodMs || 1000 }] }
|
|
118
|
+
if (app.subscriptionmanager && app.subscriptionmanager.subscribe) {
|
|
119
|
+
app.subscriptionmanager.subscribe(sub, state.unsubscribes, (err) => log('subscription error: ' + err), onDelta)
|
|
120
|
+
} else if (app.signalk && app.signalk.on) {
|
|
121
|
+
const h = (d) => { if (!d.context || d.context === selfContext) onDelta(d) }
|
|
122
|
+
app.signalk.on('delta', h)
|
|
123
|
+
state.unsubscribes.push(() => app.signalk.removeListener('delta', h))
|
|
124
|
+
} else {
|
|
125
|
+
log('no subscription mechanism available')
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function stop () {
|
|
131
|
+
if (!state) return
|
|
132
|
+
state.stopped = true
|
|
133
|
+
clearInterval(state.flushTimer)
|
|
134
|
+
clearTimeout(state.pumpTimer)
|
|
135
|
+
for (const u of (state.unsubscribes || [])) { try { u() } catch {} }
|
|
136
|
+
if (state.batch && state.batch.length) {
|
|
137
|
+
try {
|
|
138
|
+
const name = `${Date.now().toString().padStart(15, '0')}-final.lp`
|
|
139
|
+
fs.writeFileSync(path.join(state.spool.dir, name), state.batch.join('\n') + '\n')
|
|
140
|
+
state.batch = []
|
|
141
|
+
} catch (e) { /* best effort */ }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function status () { return state ? state.statusLine : 'sync: off' }
|
|
146
|
+
|
|
147
|
+
return { start, stop, status }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { createSync }
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// POST gzipped line protocol to InfluxDB v2's /api/v2/write.
|
|
4
|
+
//
|
|
5
|
+
// Return shape:
|
|
6
|
+
// { ok: true, status: 204 }
|
|
7
|
+
// { ok: false, retryable: true, networkError: true } connection failed
|
|
8
|
+
// { ok: false, retryable: true, status } 429 / 5xx (transient)
|
|
9
|
+
// { ok: false, retryable: false, status, body } 4xx (bad data/auth)
|
|
10
|
+
//
|
|
11
|
+
// 4xx is non-retryable: retrying a malformed or unauthorized batch forever would
|
|
12
|
+
// wedge the queue, so the caller quarantines it instead.
|
|
13
|
+
|
|
14
|
+
const zlib = require('zlib')
|
|
15
|
+
|
|
16
|
+
async function writeLines (cfg, body) {
|
|
17
|
+
const base = cfg.influxUrl.replace(/\/+$/, '')
|
|
18
|
+
const url = `${base}/api/v2/write` +
|
|
19
|
+
`?org=${encodeURIComponent(cfg.org)}` +
|
|
20
|
+
`&bucket=${encodeURIComponent(cfg.bucket)}` +
|
|
21
|
+
'&precision=ns'
|
|
22
|
+
|
|
23
|
+
const gz = zlib.gzipSync(Buffer.from(body, 'utf8'))
|
|
24
|
+
|
|
25
|
+
let res
|
|
26
|
+
try {
|
|
27
|
+
res = await fetch(url, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: {
|
|
30
|
+
Authorization: `Token ${cfg.token}`,
|
|
31
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
32
|
+
'Content-Encoding': 'gzip'
|
|
33
|
+
},
|
|
34
|
+
body: gz,
|
|
35
|
+
signal: cfg.timeoutMs ? AbortSignal.timeout(cfg.timeoutMs) : undefined
|
|
36
|
+
})
|
|
37
|
+
} catch (e) {
|
|
38
|
+
return { ok: false, retryable: true, networkError: true, error: e.message }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (res.status === 204) return { ok: true, status: 204 }
|
|
42
|
+
|
|
43
|
+
let text = ''
|
|
44
|
+
try { text = await res.text() } catch {}
|
|
45
|
+
const retryable = res.status === 429 || res.status >= 500
|
|
46
|
+
return { ok: false, retryable, status: res.status, body: text }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { writeLines }
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Convert Signal K deltas to InfluxDB line protocol, matching the schema written
|
|
4
|
+
// by the community `signalk-to-influxdb-v2` plugin — so this plugin's output
|
|
5
|
+
// unifies seamlessly with data already collected under that schema.
|
|
6
|
+
//
|
|
7
|
+
// measurement = Signal K path e.g. navigation.speedOverGround
|
|
8
|
+
// field = "value" the scalar reading
|
|
9
|
+
// tags = context, self, source context = "vessels.<urn>", self = "true"
|
|
10
|
+
//
|
|
11
|
+
// Value handling:
|
|
12
|
+
// number / boolean / string -> measurement=path, field value
|
|
13
|
+
// navigation.position (object w/ lat+lon) -> SPECIAL CASE: fields lat, lon (+altitude)
|
|
14
|
+
// any other object -> flattened RECURSIVELY into dotted measurements,
|
|
15
|
+
// e.g. navigation.attitude {roll,pitch} -> navigation.attitude.roll,
|
|
16
|
+
// navigation.attitude.pitch (each field "value")
|
|
17
|
+
//
|
|
18
|
+
// (The upstream schema also tags position with an s2_cell_id; we intentionally
|
|
19
|
+
// omit it — it needs an S2 library and adds cardinality. lat/lon are identical,
|
|
20
|
+
// so position queries still line up; only that one extra tag is absent on our
|
|
21
|
+
// rows. Add later if geo-binning is needed.)
|
|
22
|
+
//
|
|
23
|
+
// Timestamps use nanosecond precision, so writes are idempotent on
|
|
24
|
+
// (measurement, tagset, timestamp): replaying after a reconnect never dupes.
|
|
25
|
+
|
|
26
|
+
function escapeMeasurement (s) {
|
|
27
|
+
return String(s).replace(/([,\s])/g, '\\$1')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function escapeTag (s) {
|
|
31
|
+
return String(s).replace(/([,=\s])/g, '\\$1')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function escapeStringField (s) {
|
|
35
|
+
return '"' + String(s).replace(/(["\\])/g, '\\$1') + '"'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ms (number) or ISO string / Date -> nanosecond string, or null if unparseable.
|
|
39
|
+
function toNs (ts) {
|
|
40
|
+
let ms
|
|
41
|
+
if (typeof ts === 'number') ms = ts
|
|
42
|
+
else if (ts instanceof Date) ms = ts.getTime()
|
|
43
|
+
else {
|
|
44
|
+
const p = Date.parse(ts)
|
|
45
|
+
ms = Number.isNaN(p) ? null : p
|
|
46
|
+
}
|
|
47
|
+
if (ms == null) return null
|
|
48
|
+
return (BigInt(Math.round(ms)) * 1000000n).toString()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function line (measurement, fieldset, tags, ns) {
|
|
52
|
+
const m = escapeMeasurement(measurement)
|
|
53
|
+
return ns != null ? `${m},${tags} ${fieldset} ${ns}` : `${m},${tags} ${fieldset}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Emit line(s) for one path/value, recursing into objects. Appends to `lines`.
|
|
57
|
+
function emit (path, value, tags, ns, lines) {
|
|
58
|
+
if (value == null) return
|
|
59
|
+
if (typeof value === 'number') {
|
|
60
|
+
if (Number.isFinite(value)) lines.push(line(path, `value=${value}`, tags, ns))
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
if (typeof value === 'boolean') {
|
|
64
|
+
lines.push(line(path, `value=${value ? 'true' : 'false'}`, tags, ns))
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (typeof value === 'string') {
|
|
68
|
+
if (value.length) lines.push(line(path, `value=${escapeStringField(value)}`, tags, ns))
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(value)) return // arrays are not stored
|
|
72
|
+
if (typeof value === 'object') {
|
|
73
|
+
// navigation.position and similar: store as lat/lon (+altitude) fields.
|
|
74
|
+
if (Number.isFinite(value.latitude) && Number.isFinite(value.longitude)) {
|
|
75
|
+
let f = `lat=${value.latitude},lon=${value.longitude}`
|
|
76
|
+
if (Number.isFinite(value.altitude)) f += `,altitude=${value.altitude}`
|
|
77
|
+
lines.push(line(path, f, tags, ns))
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
// any other object: flatten recursively into dotted measurements.
|
|
81
|
+
for (const [k, v] of Object.entries(value)) emit(`${path}.${k}`, v, tags, ns, lines)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// delta -> array of line-protocol strings.
|
|
87
|
+
// opts.context = fallback self context ("vessels.<urn>") when delta.context absent.
|
|
88
|
+
function deltaToLines (delta, opts) {
|
|
89
|
+
const lines = []
|
|
90
|
+
if (!delta || !Array.isArray(delta.updates)) return lines
|
|
91
|
+
const ctx = escapeTag(delta.context || (opts && opts.context) || 'vessels.self')
|
|
92
|
+
|
|
93
|
+
for (const update of delta.updates) {
|
|
94
|
+
if (!update || !Array.isArray(update.values)) continue
|
|
95
|
+
const source = escapeTag(update.$source || sourceLabel(update.source) || 'unknown')
|
|
96
|
+
const ns = toNs(update.timestamp != null ? update.timestamp : Date.now())
|
|
97
|
+
const tags = `context=${ctx},self=true,source=${source}`
|
|
98
|
+
for (const pv of update.values) {
|
|
99
|
+
if (!pv || !pv.path) continue // skip vessel-level '' path objects
|
|
100
|
+
emit(pv.path, pv.value, tags, ns, lines)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return lines
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sourceLabel (source) {
|
|
107
|
+
if (!source) return null
|
|
108
|
+
if (typeof source === 'string') return source
|
|
109
|
+
return [source.label, source.type, source.src, source.talker]
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
.join('.') || null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
deltaToLines,
|
|
116
|
+
emit,
|
|
117
|
+
toNs,
|
|
118
|
+
escapeMeasurement,
|
|
119
|
+
escapeTag,
|
|
120
|
+
escapeStringField
|
|
121
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Durable on-disk spool: the heart of "gapless".
|
|
4
|
+
//
|
|
5
|
+
// Each flushed batch becomes one atomically-published `.lp` file (write to a
|
|
6
|
+
// temp name, then rename). A file existing == not yet acknowledged by InfluxDB;
|
|
7
|
+
// the uploader deletes it only after a 204. Because the spool lives on disk, an
|
|
8
|
+
// offline stretch or a full Signal K restart simply leaves files to be uploaded
|
|
9
|
+
// on next start — nothing is lost. The buffer is bounded by total bytes; on
|
|
10
|
+
// overflow the oldest files are dropped (and logged) so a long-offline boat
|
|
11
|
+
// never fills its own disk.
|
|
12
|
+
|
|
13
|
+
const fs = require('fs')
|
|
14
|
+
const fsp = fs.promises
|
|
15
|
+
const path = require('path')
|
|
16
|
+
|
|
17
|
+
const DEFAULT_MAX_BYTES = 500 * 1024 * 1024 // 500 MB
|
|
18
|
+
|
|
19
|
+
class Spool {
|
|
20
|
+
constructor ({ dir, maxBytes, logger } = {}) {
|
|
21
|
+
this.dir = dir
|
|
22
|
+
this.deadDir = path.join(dir, 'dead')
|
|
23
|
+
this.maxBytes = maxBytes || DEFAULT_MAX_BYTES
|
|
24
|
+
this.log = logger || (() => {})
|
|
25
|
+
this.seq = 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async init () {
|
|
29
|
+
await fsp.mkdir(this.dir, { recursive: true })
|
|
30
|
+
await fsp.mkdir(this.deadDir, { recursive: true })
|
|
31
|
+
// Remove temp files orphaned by a crash mid-write.
|
|
32
|
+
for (const f of await fsp.readdir(this.dir)) {
|
|
33
|
+
if (f.startsWith('.tmp-')) {
|
|
34
|
+
await fsp.unlink(path.join(this.dir, f)).catch(() => {})
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Persist a batch of line-protocol strings as one atomic .lp file.
|
|
40
|
+
async append (lines) {
|
|
41
|
+
if (!lines || !lines.length) return null
|
|
42
|
+
const body = lines.join('\n') + '\n'
|
|
43
|
+
const name = `${Date.now().toString().padStart(15, '0')}-${(this.seq++)
|
|
44
|
+
.toString().padStart(9, '0')}.lp`
|
|
45
|
+
const tmp = path.join(this.dir, `.tmp-${name}`)
|
|
46
|
+
const dst = path.join(this.dir, name)
|
|
47
|
+
await fsp.writeFile(tmp, body)
|
|
48
|
+
await fsp.rename(tmp, dst) // atomic publish
|
|
49
|
+
await this.enforceBound()
|
|
50
|
+
return dst
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Pending .lp files, oldest first (zero-padded names sort chronologically).
|
|
54
|
+
async pending () {
|
|
55
|
+
const files = (await fsp.readdir(this.dir))
|
|
56
|
+
.filter(f => f.endsWith('.lp') && !f.startsWith('.tmp-'))
|
|
57
|
+
.sort()
|
|
58
|
+
return files.map(f => path.join(this.dir, f))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async stats () {
|
|
62
|
+
let bytes = 0
|
|
63
|
+
let count = 0
|
|
64
|
+
for (const f of await this.pending()) {
|
|
65
|
+
try { bytes += (await fsp.stat(f)).size; count++ } catch {}
|
|
66
|
+
}
|
|
67
|
+
return { count, bytes }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async enforceBound () {
|
|
71
|
+
let { bytes } = await this.stats()
|
|
72
|
+
if (bytes <= this.maxBytes) return 0
|
|
73
|
+
let dropped = 0
|
|
74
|
+
let droppedBytes = 0
|
|
75
|
+
for (const f of await this.pending()) {
|
|
76
|
+
if (bytes <= this.maxBytes) break
|
|
77
|
+
try {
|
|
78
|
+
const sz = (await fsp.stat(f)).size
|
|
79
|
+
await fsp.unlink(f)
|
|
80
|
+
bytes -= sz; droppedBytes += sz; dropped++
|
|
81
|
+
} catch {}
|
|
82
|
+
}
|
|
83
|
+
if (dropped) {
|
|
84
|
+
this.log(`buffer exceeded ${this.maxBytes} bytes — dropped ${dropped} oldest file(s) (${droppedBytes} bytes)`)
|
|
85
|
+
}
|
|
86
|
+
return dropped
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async remove (file) {
|
|
90
|
+
await fsp.unlink(file).catch(() => {})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Move a permanently-rejected (4xx) batch aside so it can't block the queue.
|
|
94
|
+
async quarantine (file) {
|
|
95
|
+
const dst = path.join(this.deadDir, path.basename(file))
|
|
96
|
+
await fsp.rename(file, dst).catch(async () => { await this.remove(file) })
|
|
97
|
+
return dst
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { Spool, DEFAULT_MAX_BYTES }
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto')
|
|
4
|
+
const { signalkValuesToPatch, resolveHeadingDeg } = require('./signalk-map')
|
|
5
|
+
|
|
6
|
+
// Serves the sailkick app's /ws/telemetry bus FROM the boat's local SignalK, so
|
|
7
|
+
// the app uses the identical telemetry contract whether it talks to the cloud
|
|
8
|
+
// sailkick server or this on-boat plugin. Faithful port of the server's
|
|
9
|
+
// SignalKSource (SEED, gate-on-first-fix, accumulate, resolve headingDeg,
|
|
10
|
+
// updatedAt) + bus wire format ({type:'hello'|'telemetry/update', state}).
|
|
11
|
+
// Dependency-free WebSocket server (handshake + text frames).
|
|
12
|
+
|
|
13
|
+
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
14
|
+
const SUBPROTOCOL = 'sailkick.telemetry.v1'
|
|
15
|
+
const SEED = { sogKt: 0, cogDeg: 0, headingDeg: 0, awsKt: null, awaDeg: null }
|
|
16
|
+
|
|
17
|
+
function encodeTextFrame (str) {
|
|
18
|
+
const payload = Buffer.from(str, 'utf8')
|
|
19
|
+
const len = payload.length
|
|
20
|
+
let header
|
|
21
|
+
if (len < 126) header = Buffer.from([0x81, len])
|
|
22
|
+
else if (len < 65536) { header = Buffer.alloc(4); header[0] = 0x81; header[1] = 126; header.writeUInt16BE(len, 2) } else { header = Buffer.alloc(10); header[0] = 0x81; header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2) }
|
|
23
|
+
return Buffer.concat([header, payload])
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createTelemetry (app, options = {}) {
|
|
27
|
+
const log = (m) => (app.debug ? app.debug('[telemetry] ' + m) : console.log('[sailkick-boat:telemetry]', m))
|
|
28
|
+
let state = null
|
|
29
|
+
let magSource = null // lock the $source for navigation.headingMagnetic (dual-source guard)
|
|
30
|
+
const clients = new Set()
|
|
31
|
+
const unsubscribes = []
|
|
32
|
+
|
|
33
|
+
function send (socket, obj) {
|
|
34
|
+
try { socket.write(encodeTextFrame(JSON.stringify(obj))) } catch { clients.delete(socket) }
|
|
35
|
+
}
|
|
36
|
+
function broadcast (obj) {
|
|
37
|
+
const frame = encodeTextFrame(JSON.stringify(obj))
|
|
38
|
+
for (const s of clients) { try { s.write(frame) } catch { clients.delete(s) } }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function onDelta (delta) {
|
|
42
|
+
if (!delta || !Array.isArray(delta.updates)) return
|
|
43
|
+
const patch = {}
|
|
44
|
+
let ts = null
|
|
45
|
+
for (const u of delta.updates) {
|
|
46
|
+
if (!u || !Array.isArray(u.values)) continue
|
|
47
|
+
const src = u.$source || (u.source && u.source.label) || ''
|
|
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))
|
|
54
|
+
if (u.timestamp) ts = u.timestamp
|
|
55
|
+
}
|
|
56
|
+
if (Object.keys(patch).length === 0) return
|
|
57
|
+
if (!state) {
|
|
58
|
+
if (!Number.isFinite(patch.lat) || !Number.isFinite(patch.lon)) return // wait for the first fix
|
|
59
|
+
state = { ...SEED }
|
|
60
|
+
}
|
|
61
|
+
state = { ...state, ...patch, updatedAt: ts || new Date().toISOString() }
|
|
62
|
+
const hd = resolveHeadingDeg(state)
|
|
63
|
+
state.headingDeg = Number.isFinite(hd) ? hd : (state.headingDeg || state.cogDeg || 0)
|
|
64
|
+
broadcast({ type: 'telemetry/update', state })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function start () {
|
|
68
|
+
const sub = { context: 'vessels.self', subscribe: [{ path: '*', period: options.periodMs || 1000 }] }
|
|
69
|
+
if (app.subscriptionmanager && app.subscriptionmanager.subscribe) {
|
|
70
|
+
app.subscriptionmanager.subscribe(sub, unsubscribes, (err) => log('subscription error: ' + err), onDelta)
|
|
71
|
+
} else if (app.signalk && app.signalk.on) {
|
|
72
|
+
const selfCtx = app.selfContext || ('vessels.' + (app.selfId || 'self'))
|
|
73
|
+
const h = (d) => { if (!d.context || d.context === selfCtx) onDelta(d) }
|
|
74
|
+
app.signalk.on('delta', h)
|
|
75
|
+
unsubscribes.push(() => app.signalk.removeListener('delta', h))
|
|
76
|
+
} else {
|
|
77
|
+
log('no subscription mechanism available — telemetry inactive')
|
|
78
|
+
}
|
|
79
|
+
log('/ws/telemetry provider started (from local SignalK self stream)')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function stop () {
|
|
83
|
+
for (const u of unsubscribes) { try { u() } catch {} }
|
|
84
|
+
unsubscribes.length = 0
|
|
85
|
+
for (const s of clients) { try { s.destroy() } catch {} }
|
|
86
|
+
clients.clear()
|
|
87
|
+
state = null
|
|
88
|
+
magSource = null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function status () {
|
|
92
|
+
return `telemetry: ${clients.size} client(s), ${state ? 'live' : 'waiting for fix'}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Handle a WebSocket upgrade for /ws/telemetry: handshake, add client, hello.
|
|
96
|
+
function handleUpgrade (req, socket, head) {
|
|
97
|
+
const key = req.headers['sec-websocket-key']
|
|
98
|
+
if (!key) { socket.destroy(); return }
|
|
99
|
+
const accept = crypto.createHash('sha1').update(key + WS_GUID).digest('base64')
|
|
100
|
+
const offered = String(req.headers['sec-websocket-protocol'] || '').split(',').map((s) => s.trim())
|
|
101
|
+
let resp = 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
|
|
102
|
+
`Sec-WebSocket-Accept: ${accept}\r\n`
|
|
103
|
+
if (offered.includes(SUBPROTOCOL)) resp += `Sec-WebSocket-Protocol: ${SUBPROTOCOL}\r\n`
|
|
104
|
+
resp += '\r\n'
|
|
105
|
+
socket.write(resp)
|
|
106
|
+
clients.add(socket)
|
|
107
|
+
const drop = () => clients.delete(socket)
|
|
108
|
+
socket.on('close', drop)
|
|
109
|
+
socket.on('error', () => { drop(); try { socket.destroy() } catch {} })
|
|
110
|
+
socket.on('data', (buf) => { if (buf && buf.length && (buf[0] & 0x0f) === 0x8) { drop(); try { socket.destroy() } catch {} } }) // client close frame
|
|
111
|
+
send(socket, { type: 'hello', source: 'signalk-local', state })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// current BoatState (or null before the first fix) — used as the DB-less ring
|
|
115
|
+
// history source, and by tests.
|
|
116
|
+
function getState () { return state }
|
|
117
|
+
function _ingest (delta) { onDelta(delta) } // for tests
|
|
118
|
+
const _state = getState
|
|
119
|
+
|
|
120
|
+
return { start, stop, status, handleUpgrade, getState, _ingest, _state }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { createTelemetry, encodeTextFrame, SUBPROTOCOL }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// SignalK -> BoatState mapping. Ported VERBATIM from the sailkick app
|
|
4
|
+
// (public/engine/signalk-map.js) so the plugin's /ws/telemetry is identical to
|
|
5
|
+
// the server's SignalKSource. SignalK emits SI units (m/s, radians); BoatState
|
|
6
|
+
// uses knots + degrees. Keep in sync with the app copy.
|
|
7
|
+
|
|
8
|
+
const MS_TO_KT = 1.94384
|
|
9
|
+
const RAD2DEG = 180 / Math.PI
|
|
10
|
+
const wrap360 = (d) => ((d % 360) + 360) % 360
|
|
11
|
+
const wrap180 = (d) => { const w = wrap360(d); return w > 180 ? w - 360 : w }
|
|
12
|
+
|
|
13
|
+
function signalkValuesToPatch (values) {
|
|
14
|
+
const patch = {}
|
|
15
|
+
if (!Array.isArray(values)) return patch
|
|
16
|
+
for (const v of values) {
|
|
17
|
+
if (!v || v.value == null) continue
|
|
18
|
+
switch (v.path) {
|
|
19
|
+
case 'navigation.position': {
|
|
20
|
+
const { latitude, longitude } = v.value || {}
|
|
21
|
+
if (Number.isFinite(latitude) && Number.isFinite(longitude)) {
|
|
22
|
+
patch.lat = latitude
|
|
23
|
+
patch.lon = longitude
|
|
24
|
+
}
|
|
25
|
+
break
|
|
26
|
+
}
|
|
27
|
+
case 'navigation.speedOverGround':
|
|
28
|
+
if (Number.isFinite(v.value)) patch.sogKt = Math.max(0, v.value * MS_TO_KT)
|
|
29
|
+
break
|
|
30
|
+
case 'navigation.courseOverGroundTrue':
|
|
31
|
+
if (Number.isFinite(v.value)) patch.cogDeg = wrap360(v.value * RAD2DEG)
|
|
32
|
+
break
|
|
33
|
+
case 'navigation.headingTrue':
|
|
34
|
+
if (Number.isFinite(v.value)) patch.hdgTrueDeg = wrap360(v.value * RAD2DEG)
|
|
35
|
+
break
|
|
36
|
+
case 'navigation.headingMagnetic':
|
|
37
|
+
if (Number.isFinite(v.value)) patch.hdgMagDeg = v.value * RAD2DEG
|
|
38
|
+
break
|
|
39
|
+
case 'navigation.magneticVariation':
|
|
40
|
+
if (Number.isFinite(v.value) && v.value !== 0) patch.magVarDeg = v.value * RAD2DEG
|
|
41
|
+
break
|
|
42
|
+
case 'environment.wind.speedApparent':
|
|
43
|
+
if (Number.isFinite(v.value)) patch.awsKt = Math.max(0, v.value * MS_TO_KT)
|
|
44
|
+
break
|
|
45
|
+
case 'environment.wind.angleApparent':
|
|
46
|
+
if (Number.isFinite(v.value)) patch.awaDeg = wrap180(v.value * RAD2DEG)
|
|
47
|
+
break
|
|
48
|
+
case 'environment.depth.belowSurface':
|
|
49
|
+
case 'environment.depth.belowTransducer':
|
|
50
|
+
if (Number.isFinite(v.value)) patch.depthM = v.value
|
|
51
|
+
break
|
|
52
|
+
case 'navigation.datetime':
|
|
53
|
+
if (typeof v.value === 'string') patch.gpsTime = v.value
|
|
54
|
+
break
|
|
55
|
+
default:
|
|
56
|
+
break
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return patch
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveHeadingDeg (state) {
|
|
63
|
+
if (!state) return undefined
|
|
64
|
+
if (Number.isFinite(state.hdgMagDeg)) {
|
|
65
|
+
return wrap360(state.hdgMagDeg + (Number.isFinite(state.magVarDeg) ? state.magVarDeg : 0))
|
|
66
|
+
}
|
|
67
|
+
if (Number.isFinite(state.hdgTrueDeg)) return state.hdgTrueDeg
|
|
68
|
+
return undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { signalkValuesToPatch, resolveHeadingDeg, MS_TO_KT }
|