sailkick-boat 0.17.2 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/index.js +18 -0
- package/lib/ais/targets.js +171 -0
- package/lib/backfill/index.js +14 -2
- package/lib/proxy/index.js +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -155,6 +155,22 @@ A local InfluxDB is not a competitor here anyway. The app never requests finer t
|
|
|
155
155
|
`ringSampleSec: 5` and it matches anything the UI can draw, from live state. What an old
|
|
156
156
|
database *is* good for is its contents ending up **in the cloud** — see below.
|
|
157
157
|
|
|
158
|
+
## AIS on the boat's own chart
|
|
159
|
+
The app draws other vessels from `GET /api/ais`. The cloud serves that by polling a
|
|
160
|
+
SignalK server over the LAN and gates it behind a boat session — neither of which can
|
|
161
|
+
work from a boat on a mobile link, and the mirror forwards no cookies, so proxying it
|
|
162
|
+
returns 401 whatever you do.
|
|
163
|
+
|
|
164
|
+
The plugin therefore serves `/api/ais` **from the boat's own SignalK**, in the same
|
|
165
|
+
envelope the app already consumes: position, SOG, COG, heading, rate of turn, name,
|
|
166
|
+
dimensions and ship type, plus a ~1 h trail per vessel. Anchored ships stay a single dot
|
|
167
|
+
— a trail point is added only once a vessel has moved more than 30 m — and a target
|
|
168
|
+
unheard for 15 min is dropped. A failed poll keeps the last snapshot rather than blanking
|
|
169
|
+
the chart.
|
|
170
|
+
|
|
171
|
+
This works **with no uplink at all**, which is when other vessels on your chart matter
|
|
172
|
+
most. Turn it off by hand-editing `proxy.serveAis: false`.
|
|
173
|
+
|
|
158
174
|
## Uploading AIS targets
|
|
159
175
|
The cloud app already draws other vessels, but its AIS source polls a SignalK server over
|
|
160
176
|
the LAN and keeps everything in memory — which cannot work once a boat is on a mobile
|
|
@@ -220,6 +236,13 @@ a source archive that is still being written — a `signalk-to-influxdb-v2` buck
|
|
|
220
236
|
recording — makes the first windows re-upload today's data. The timestamps are correct,
|
|
221
237
|
but it is data the cloud already has, and a lot of wasted uplink.
|
|
222
238
|
|
|
239
|
+
**Field types come from the source, not from guessing.** Queries ask InfluxDB for the
|
|
240
|
+
`#datatype` annotation explicitly. Without it the response is unannotated and types have
|
|
241
|
+
to be inferred from the text — which fails hard on a string field whose values sometimes
|
|
242
|
+
look numeric (`"8"` emitted bare as a float, `"1.2.3.4"` quoted as a string), producing
|
|
243
|
+
`422 field type conflict` and aborting the run. Integers also keep their type instead of
|
|
244
|
+
silently becoming floats.
|
|
245
|
+
|
|
223
246
|
**Dense archives are subdivided.** A window is read whole and converted in memory, and a
|
|
224
247
|
busy boat can produce millions of points an hour (54M/day was measured on a real boat —
|
|
225
248
|
about 400 MB of CSV per hour). When a window holds more than `maxRowsPerChunk` points it
|
package/index.js
CHANGED
|
@@ -8,6 +8,7 @@ const { createTelemetry } = require('./lib/telemetry')
|
|
|
8
8
|
const { createHistory } = require('./lib/history')
|
|
9
9
|
const { createBackfill } = require('./lib/backfill')
|
|
10
10
|
const { createAis } = require('./lib/ais')
|
|
11
|
+
const { createAisTargets } = require('./lib/ais/targets')
|
|
11
12
|
const { resolveAccountConfig } = require('./lib/account')
|
|
12
13
|
|
|
13
14
|
// sailkick-boat: one Signal K plugin, two independently-toggleable modules —
|
|
@@ -94,6 +95,7 @@ module.exports = function (app) {
|
|
|
94
95
|
let history = null
|
|
95
96
|
let backfill = null
|
|
96
97
|
let ais = null
|
|
98
|
+
let aisTargets = null
|
|
97
99
|
let statusTimer = null
|
|
98
100
|
let accountStatus = null
|
|
99
101
|
let syncWarning = null
|
|
@@ -291,6 +293,19 @@ module.exports = function (app) {
|
|
|
291
293
|
}
|
|
292
294
|
}
|
|
293
295
|
|
|
296
|
+
// AIS targets for the boat's own browser, from local SignalK. Independent of the
|
|
297
|
+
// AIS upload above: this one works with no uplink, which is when it matters most.
|
|
298
|
+
if (p.serveAis !== false) {
|
|
299
|
+
try {
|
|
300
|
+
aisTargets = createAisTargets(app, { localSignalkUrl: pOpts.localSignalkUrl })
|
|
301
|
+
aisTargets.start()
|
|
302
|
+
pOpts.aisTargets = aisTargets
|
|
303
|
+
} catch (e) {
|
|
304
|
+
(app.error || console.error)('[sailkick-boat] AIS targets start failed: ' + e.message)
|
|
305
|
+
aisTargets = null
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
294
309
|
if (p.serveTelemetry !== false) {
|
|
295
310
|
try {
|
|
296
311
|
telemetry = createTelemetry(app, {})
|
|
@@ -402,6 +417,7 @@ module.exports = function (app) {
|
|
|
402
417
|
if (proxy) parts.push(proxy.status())
|
|
403
418
|
if (telemetry) parts.push(telemetry.status())
|
|
404
419
|
if (history) parts.push(history.status())
|
|
420
|
+
if (aisTargets) parts.push(aisTargets.status())
|
|
405
421
|
if (ais) parts.push(ais.status())
|
|
406
422
|
if (backfill) parts.push(backfill.status())
|
|
407
423
|
try { app.setPluginStatus(parts.join(' | ') || 'idle (both features off)') } catch {}
|
|
@@ -413,6 +429,7 @@ module.exports = function (app) {
|
|
|
413
429
|
try { if (sync) sync.stop() } catch {}
|
|
414
430
|
try { if (telemetry) telemetry.stop() } catch {}
|
|
415
431
|
try { if (history) history.stop() } catch {}
|
|
432
|
+
try { if (aisTargets) aisTargets.stop() } catch {}
|
|
416
433
|
try { if (ais) ais.stop() } catch {}
|
|
417
434
|
try { if (backfill) backfill.stop() } catch {}
|
|
418
435
|
try { if (proxy) proxy.stop() } catch {}
|
|
@@ -421,6 +438,7 @@ module.exports = function (app) {
|
|
|
421
438
|
history = null
|
|
422
439
|
backfill = null
|
|
423
440
|
ais = null
|
|
441
|
+
aisTargets = null
|
|
424
442
|
proxy = null
|
|
425
443
|
accountStatus = null
|
|
426
444
|
syncWarning = null
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Serve the app's GET /api/ais from the boat's OWN SignalK, so other vessels appear on
|
|
4
|
+
// the chart with no uplink at all.
|
|
5
|
+
//
|
|
6
|
+
// Ported from the cloud's server/ais/service.js — same polling shape, same trail rules,
|
|
7
|
+
// same JSON envelope — so public/viewer/ais.js cannot tell the difference. Keep the
|
|
8
|
+
// field set and the trail thresholds in step with that copy.
|
|
9
|
+
//
|
|
10
|
+
// This is the counterpart of lib/ais/index.js, not a duplicate of it: that one pushes
|
|
11
|
+
// AIS to the cloud so the boat's surroundings can be seen from shore; this one answers
|
|
12
|
+
// the browser sitting on the boat. Offshore, only this one can work — and it is the more
|
|
13
|
+
// valuable of the two, since AIS targets on your own chart matter most exactly when
|
|
14
|
+
// there is no connectivity.
|
|
15
|
+
//
|
|
16
|
+
// Why not reuse the upload module's delta stream? It subscribes only to the paths it
|
|
17
|
+
// forwards, and a vessel's NAME arrives as a vessel-level delta (empty path) which that
|
|
18
|
+
// path list deliberately ignores. Polling the REST tree gets identity and dimensions
|
|
19
|
+
// without a second subscription, exactly as the cloud does.
|
|
20
|
+
|
|
21
|
+
const MS_TO_KT = 1.94384
|
|
22
|
+
const R2D = 180 / Math.PI
|
|
23
|
+
const num = (x) => (Number.isFinite(Number(x)) ? Number(x) : null)
|
|
24
|
+
const wrap360 = (d) => ((d % 360) + 360) % 360
|
|
25
|
+
|
|
26
|
+
// ws/wss/http, with or without a /signalk path → REST origin.
|
|
27
|
+
function restOrigin (url) {
|
|
28
|
+
return String(url || '').trim()
|
|
29
|
+
.replace(/\/+$/, '')
|
|
30
|
+
.replace(/\/signalk\/.*$/i, '')
|
|
31
|
+
.replace(/^ws(s?):\/\//i, 'http$1://')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function haversineM (lat1, lon1, lat2, lon2) {
|
|
35
|
+
const toR = Math.PI / 180
|
|
36
|
+
const R = 6371000
|
|
37
|
+
const dLat = (lat2 - lat1) * toR
|
|
38
|
+
const dLon = (lon2 - lon1) * toR
|
|
39
|
+
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * toR) * Math.cos(lat2 * toR) * Math.sin(dLon / 2) ** 2
|
|
40
|
+
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createAisTargets (app, options = {}) {
|
|
44
|
+
const log = (m) => (app.debug ? app.debug('[ais-targets] ' + m) : console.log('[sailkick-boat:ais-targets]', m))
|
|
45
|
+
const cfg = {
|
|
46
|
+
url: options.localSignalkUrl || 'http://127.0.0.1:3000',
|
|
47
|
+
pollMs: options.pollMs || 10000,
|
|
48
|
+
trailMs: options.trailMs || 3600000, // ~1 h of trail
|
|
49
|
+
trailMinMoveM: options.trailMinMoveM || 30, // a point only after moving this far
|
|
50
|
+
trailMaxPoints: options.trailMaxPoints || 240,
|
|
51
|
+
staleMs: options.staleMs || 15 * 60000 // drop a vessel unseen for this long
|
|
52
|
+
}
|
|
53
|
+
const base = restOrigin(cfg.url)
|
|
54
|
+
const state = new Map() // mmsi -> record
|
|
55
|
+
let selfMmsi = null
|
|
56
|
+
let lastPollAt = null
|
|
57
|
+
let lastError = null
|
|
58
|
+
let stopped = false
|
|
59
|
+
let timer = null
|
|
60
|
+
|
|
61
|
+
async function fetchSelf () {
|
|
62
|
+
try {
|
|
63
|
+
const r = await fetch(`${base}/signalk/v1/api/self`, { signal: AbortSignal.timeout(5000) })
|
|
64
|
+
if (r.ok) selfMmsi = String(await r.json()).split(':').pop() // "vessels.urn:…:mmsi:<n>"
|
|
65
|
+
} catch { /* retry next poll */ }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ingest (vessels) {
|
|
69
|
+
const now = Date.now()
|
|
70
|
+
for (const [key, v] of Object.entries(vessels || {})) {
|
|
71
|
+
const mmsi = String(key).split(':').pop()
|
|
72
|
+
if (!mmsi || mmsi === selfMmsi) continue
|
|
73
|
+
const p = v && v.navigation && v.navigation.position && v.navigation.position.value
|
|
74
|
+
if (!p || !Number.isFinite(p.latitude) || !Number.isFinite(p.longitude)) continue
|
|
75
|
+
// The AIS message's own time, beside .value — the poll cadence is not the age of
|
|
76
|
+
// the report.
|
|
77
|
+
const posTs = Date.parse(v.navigation.position.timestamp) || now
|
|
78
|
+
|
|
79
|
+
const cog = num(v.navigation.courseOverGroundTrue && v.navigation.courseOverGroundTrue.value)
|
|
80
|
+
const hT = num(v.navigation.headingTrue && v.navigation.headingTrue.value)
|
|
81
|
+
const hM = num(v.navigation.headingMagnetic && v.navigation.headingMagnetic.value)
|
|
82
|
+
const varn = num(v.navigation.magneticVariation && v.navigation.magneticVariation.value)
|
|
83
|
+
const headingDeg = hT != null ? wrap360(hT * R2D) : hM != null ? wrap360((hM + (varn || 0)) * R2D) : null
|
|
84
|
+
|
|
85
|
+
const rec = state.get(mmsi) || { mmsi, trail: [] }
|
|
86
|
+
// SignalK exposes `name` as a bare string, not the usual {value} wrapper.
|
|
87
|
+
rec.name = (typeof v.name === 'string' ? v.name : v.name && v.name.value) || rec.name || null
|
|
88
|
+
rec.lat = p.latitude
|
|
89
|
+
rec.lon = p.longitude
|
|
90
|
+
const sog = num(v.navigation.speedOverGround && v.navigation.speedOverGround.value)
|
|
91
|
+
rec.sogKt = sog != null ? +(sog * MS_TO_KT).toFixed(1) : null
|
|
92
|
+
rec.cogDeg = cog != null ? wrap360(cog * R2D) : null
|
|
93
|
+
rec.headingDeg = headingDeg
|
|
94
|
+
const len = v.design && v.design.length && v.design.length.value
|
|
95
|
+
rec.loaM = num(len && (len.overall != null ? len.overall : len.hull != null ? len.hull : len))
|
|
96
|
+
rec.beamM = num(v.design && v.design.beam && v.design.beam.value)
|
|
97
|
+
const at = v.design && v.design.aisShipType && v.design.aisShipType.value // { id, name } | id
|
|
98
|
+
rec.shipType = (at && typeof at === 'object' ? at.name : null) || rec.shipType || null
|
|
99
|
+
rec.aisType = (typeof at === 'number' ? at : num(at && at.id)) || rec.aisType || null
|
|
100
|
+
rec.rotRadS = num(v.navigation.rateOfTurn && v.navigation.rateOfTurn.value)
|
|
101
|
+
rec.posTs = posTs
|
|
102
|
+
rec.updatedAt = now
|
|
103
|
+
|
|
104
|
+
// Trail: a point only once the vessel has actually moved, so anchored ships stay a
|
|
105
|
+
// single dot instead of a jittering cloud.
|
|
106
|
+
const last = rec.trail[rec.trail.length - 1]
|
|
107
|
+
if (!last || haversineM(last[0], last[1], p.latitude, p.longitude) > cfg.trailMinMoveM) {
|
|
108
|
+
rec.trail.push([p.latitude, p.longitude, now])
|
|
109
|
+
}
|
|
110
|
+
const cutoff = now - cfg.trailMs
|
|
111
|
+
while (rec.trail.length && rec.trail[0][2] < cutoff) rec.trail.shift()
|
|
112
|
+
if (rec.trail.length > cfg.trailMaxPoints) rec.trail.splice(0, rec.trail.length - cfg.trailMaxPoints)
|
|
113
|
+
|
|
114
|
+
state.set(mmsi, rec)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function poll () {
|
|
119
|
+
if (stopped) return
|
|
120
|
+
try {
|
|
121
|
+
if (!selfMmsi) await fetchSelf()
|
|
122
|
+
const r = await fetch(`${base}/signalk/v1/api/vessels`, { signal: AbortSignal.timeout(8000) })
|
|
123
|
+
if (!r.ok) throw new Error(`vessels ${r.status}`)
|
|
124
|
+
ingest(await r.json())
|
|
125
|
+
lastPollAt = Date.now()
|
|
126
|
+
lastError = null
|
|
127
|
+
} catch (e) {
|
|
128
|
+
lastError = e.message // a failed poll keeps the last snapshot; never throws
|
|
129
|
+
} finally {
|
|
130
|
+
if (!stopped) { timer = setTimeout(poll, cfg.pollMs); if (timer.unref) timer.unref() }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getVessels () {
|
|
135
|
+
const cutoff = Date.now() - cfg.staleMs
|
|
136
|
+
const vessels = []
|
|
137
|
+
for (const rec of state.values()) {
|
|
138
|
+
if (rec.updatedAt < cutoff) { state.delete(rec.mmsi); continue }
|
|
139
|
+
vessels.push({
|
|
140
|
+
mmsi: rec.mmsi, name: rec.name, lat: rec.lat, lon: rec.lon,
|
|
141
|
+
sogKt: rec.sogKt, cogDeg: rec.cogDeg, headingDeg: rec.headingDeg, rotRadS: rec.rotRadS,
|
|
142
|
+
loaM: rec.loaM, beamM: rec.beamM, shipType: rec.shipType, aisType: rec.aisType,
|
|
143
|
+
posTs: rec.posTs, updatedAt: rec.updatedAt,
|
|
144
|
+
trail: rec.trail.map((q) => [q[0], q[1]])
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
return { polledAt: lastPollAt, error: lastError, count: vessels.length, vessels }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function start () { log(`polling ${base}/signalk/v1/api/vessels every ${Math.round(cfg.pollMs / 1000)}s`); poll() }
|
|
151
|
+
function stop () { stopped = true; if (timer) clearTimeout(timer) }
|
|
152
|
+
function available () { return true } // SignalK is local; a failed poll just serves the last snapshot
|
|
153
|
+
|
|
154
|
+
// GET /api/ais — byte-for-byte the envelope the cloud returns.
|
|
155
|
+
function handleAis (req, res) {
|
|
156
|
+
const body = JSON.stringify({ available: true, ...getVessels() })
|
|
157
|
+
res.statusCode = 200
|
|
158
|
+
res.setHeader('Content-Type', 'application/json')
|
|
159
|
+
res.end(req.method === 'HEAD' ? undefined : body)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function status () {
|
|
163
|
+
const n = state.size
|
|
164
|
+
const age = lastPollAt ? Math.round((Date.now() - lastPollAt) / 1000) + 's ago' : 'never'
|
|
165
|
+
return `ais targets: ${n} vessel(s), polled ${age}${lastError ? ' (' + lastError + ')' : ''}`
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { start, stop, status, available, handleAis, getVessels, _ingest: ingest, _state: () => state }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { createAisTargets, restOrigin, haversineM }
|
package/lib/backfill/index.js
CHANGED
|
@@ -79,14 +79,26 @@ function createBackfill (app, options) {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
// --- InfluxDB reads -----------------------------------------------------------
|
|
82
|
+
// Always ask for the #datatype annotation. A raw-Flux body (Content-Type
|
|
83
|
+
// application/vnd.flux) cannot carry a dialect, and InfluxDB then returns UNANNOTATED
|
|
84
|
+
// CSV — so field types have to be guessed from the text, and guessing is wrong in a
|
|
85
|
+
// way that breaks the write outright.
|
|
86
|
+
//
|
|
87
|
+
// The case that bit: a STRING field whose values sometimes look numeric. "8" is
|
|
88
|
+
// emitted bare (a float) while "1.2.3.4" is quoted (a string), so one batch declares
|
|
89
|
+
// two types for one field and InfluxDB rejects the lot with
|
|
90
|
+
// 422 field type conflict: input field "value" on measurement "network..."
|
|
91
|
+
// With the annotation the source declares `string` and every value is quoted
|
|
92
|
+
// consistently. Integers keep their `i` suffix too, instead of silently becoming
|
|
93
|
+
// floats.
|
|
82
94
|
async function flux (conn, body) {
|
|
83
95
|
const url = `${conn.url.replace(/\/+$/, '')}/api/v2/query?org=${encodeURIComponent(conn.org)}`
|
|
84
96
|
let resp
|
|
85
97
|
try {
|
|
86
98
|
resp = await fetch(url, {
|
|
87
99
|
method: 'POST',
|
|
88
|
-
headers: { Authorization: `Token ${conn.token}`, 'Content-Type': 'application/
|
|
89
|
-
body,
|
|
100
|
+
headers: { Authorization: `Token ${conn.token}`, 'Content-Type': 'application/json', Accept: 'application/csv' },
|
|
101
|
+
body: JSON.stringify({ type: 'flux', query: body, dialect: { header: true, annotations: ['datatype'] } }),
|
|
90
102
|
signal: AbortSignal.timeout(cfg.queryTimeoutMs)
|
|
91
103
|
})
|
|
92
104
|
} catch (e) { return { ok: false, message: e.message } }
|
package/lib/proxy/index.js
CHANGED
|
@@ -57,6 +57,7 @@ function createProxy (app, options) {
|
|
|
57
57
|
localPaths: (options.localPaths && options.localPaths.length) ? options.localPaths : ['/signalk'],
|
|
58
58
|
telemetryPath: options.telemetryPath || '/ws/telemetry',
|
|
59
59
|
history: options.history || null,
|
|
60
|
+
aisTargets: options.aisTargets || null,
|
|
60
61
|
openAccess: options.openAccess !== false
|
|
61
62
|
}
|
|
62
63
|
log(`mirroring ${cfg.upstream}; local SignalK ${cfg.localSignalk}; store ${cfg.storeDir}`)
|
|
@@ -140,6 +141,15 @@ function createProxy (app, options) {
|
|
|
140
141
|
if (pathname === '/api/history/series') return cfg.history.handleSeries(req, res)
|
|
141
142
|
if (pathname === '/api/history/track') return cfg.history.handleTrack(req, res)
|
|
142
143
|
}
|
|
144
|
+
// AIS targets, from the boat's own SignalK. The cloud's /api/ais is gated behind a
|
|
145
|
+
// boat session the mirror can never hold (it forwards no cookies), and its poller
|
|
146
|
+
// reads a LAN address it cannot reach once the boat is on a mobile link — so
|
|
147
|
+
// proxying it returns 401 whatever we do. Served locally it also works with no
|
|
148
|
+
// uplink at all, which is when other vessels on the chart matter most.
|
|
149
|
+
if (req.method === 'GET' && cfg.aisTargets && cfg.aisTargets.available() &&
|
|
150
|
+
req.url.split('?')[0] === '/api/ais') {
|
|
151
|
+
return cfg.aisTargets.handleAis(req, res)
|
|
152
|
+
}
|
|
143
153
|
// /api/config drives the app's login gate + Trends toggle. On the single-tenant
|
|
144
154
|
// boat we serve it with auth turned off (no cloud login over the offline HTTP
|
|
145
155
|
// mirror) and history forced on when we serve it locally. All other config passes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sailkick-boat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"description": "EARLY ALPHA — cloud telemetry + offline maps for sailkick boats (www.sailkick.io; register on the web, paste the write token). Gapless boat→cloud telemetry sync to InfluxDB, and a local proxy that keeps the sailkick app and its charts/maps working fully offline on board.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|