sailkick-boat 0.17.2 → 0.18.0
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 +16 -0
- package/index.js +18 -0
- package/lib/ais/targets.js +171 -0
- 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
|
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/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.0",
|
|
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": {
|