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 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 }
@@ -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.17.2",
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": {