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.
@@ -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 }