sailkick-boat 0.19.0 → 0.20.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 +21 -4
- package/index.js +18 -1
- package/lib/history/ring.js +30 -7
- package/lib/proxy/index.js +12 -5
- package/package.json +5 -2
- package/public/index.html +97 -0
package/README.md
CHANGED
|
@@ -257,8 +257,12 @@ worth designing properly rather than guessing at, so for now they are simply sep
|
|
|
257
257
|
## Local history (offline Trends + track)
|
|
258
258
|
**One source: a live ring**, sampled from the same BoatState that feeds `/ws/telemetry`
|
|
259
259
|
— no database, works on a Victron GX with nothing else installed. `historyAvailable` is
|
|
260
|
-
forced on so the app shows the Trends panel, and the
|
|
261
|
-
serves,
|
|
260
|
+
forced on so the app shows the Trends panel, and the **nineteen channels match what the
|
|
261
|
+
cloud serves** — wind (true/apparent, speed/angle/direction), SOG/STW/COG/heading, depth,
|
|
262
|
+
VMG, sea and air temperature, port and starboard revs, and the four active-waypoint
|
|
263
|
+
values. So an instrument cell's history flyout shows the same thing whichever provider is
|
|
264
|
+
answering. A channel with no data is simply absent: a boat with no paddlewheel has no
|
|
265
|
+
`stw`, and the waypoint channels appear only while a destination is active.
|
|
262
266
|
|
|
263
267
|
There is deliberately **no way to point this at a local InfluxDB**. Until v0.15.0 a read
|
|
264
268
|
token did exactly that, and it was a trap: the app only ever asks for a *relative* window
|
|
@@ -482,8 +486,21 @@ Point your chart app / browser at:
|
|
|
482
486
|
```bash
|
|
483
487
|
cd ~/.signalk && npm install sailkick-boat # or a packed tarball
|
|
484
488
|
```
|
|
485
|
-
|
|
486
|
-
|
|
489
|
+
The plugin **enables itself on install** and appears in Signal K's **Webapps** menu as
|
|
490
|
+
**Sailkick** — a launcher with two entries, "Open Sailkick" (full app) and "Open on
|
|
491
|
+
phone" (mobile view). Both point at the mirror on this boat, so nothing has to be typed
|
|
492
|
+
by hand. One npm package can only produce one menu item — Signal K dedupes webapps by
|
|
493
|
+
package name — hence one launcher rather than two entries.
|
|
494
|
+
|
|
495
|
+
Nothing is uploaded by enabling it. Telemetry sync needs an account write token and
|
|
496
|
+
refuses to start without one; AIS upload and the backfill are off by default; and the
|
|
497
|
+
worldwide base-map seed is skipped entirely on an unpaired boat, so a fresh install
|
|
498
|
+
downloads only what you actually look at. **The whole app works with no account** —
|
|
499
|
+
charts, instruments, trends, AIS, routes and polars are all served from the boat. An
|
|
500
|
+
account adds cloud sync, off-boat access and long-term history.
|
|
501
|
+
|
|
502
|
+
Set **Data directory** to a path on the SSD (or leave blank for the plugin data dir) —
|
|
503
|
+
that is the one setting worth changing straight away.
|
|
487
504
|
|
|
488
505
|
### Victron GX / Venus OS (Cerbo, Ekrano)
|
|
489
506
|
Works on Venus OS Large (Signal K enabled). Point **Data directory** at USB/SD storage
|
package/index.js
CHANGED
|
@@ -98,6 +98,8 @@ module.exports = function (app) {
|
|
|
98
98
|
let ais = null
|
|
99
99
|
let aisTargets = null
|
|
100
100
|
let profile = null
|
|
101
|
+
let proxyPort = null // what the launcher page needs to build its links
|
|
102
|
+
let pairedSlug = null
|
|
101
103
|
let statusTimer = null
|
|
102
104
|
let accountStatus = null
|
|
103
105
|
let syncWarning = null
|
|
@@ -264,7 +266,7 @@ module.exports = function (app) {
|
|
|
264
266
|
const pOpts = {
|
|
265
267
|
sailkickUrl: upstream.url,
|
|
266
268
|
storeDir: store,
|
|
267
|
-
proxyPort: p.proxyPort == null ? 8080 : p.proxyPort,
|
|
269
|
+
proxyPort: (proxyPort = p.proxyPort == null ? 8080 : p.proxyPort),
|
|
268
270
|
localSignalkUrl: p.localSignalkUrl || 'http://127.0.0.1:3000',
|
|
269
271
|
localPaths: (p.localPaths && p.localPaths.length) ? p.localPaths : PROXY_TUNING.localPaths,
|
|
270
272
|
telemetryPath: p.telemetryPath || PROXY_TUNING.telemetryPath,
|
|
@@ -457,6 +459,8 @@ module.exports = function (app) {
|
|
|
457
459
|
ais = null
|
|
458
460
|
aisTargets = null
|
|
459
461
|
profile = null
|
|
462
|
+
proxyPort = null
|
|
463
|
+
pairedSlug = null
|
|
460
464
|
proxy = null
|
|
461
465
|
accountStatus = null
|
|
462
466
|
syncWarning = null
|
|
@@ -465,6 +469,19 @@ module.exports = function (app) {
|
|
|
465
469
|
// Mounted by Signal K at /plugins/sailkick-boat. Handlers dispatch to the live
|
|
466
470
|
// proxy module (created in start), so enable/disable works at request time.
|
|
467
471
|
plugin.registerWithRouter = function (router) {
|
|
472
|
+
// Read by the launcher page (public/index.html), which Signal K serves at
|
|
473
|
+
// /sailkick-boat/. That page lives outside this process and cannot know which port
|
|
474
|
+
// the mirror was configured on, so it asks. `paired` drives the hint about cloud
|
|
475
|
+
// sync; `running` distinguishes "plugin off" from "mirror port disabled".
|
|
476
|
+
router.get('/info', (req, res) => {
|
|
477
|
+
res.json({
|
|
478
|
+
ok: true,
|
|
479
|
+
running: !!proxy,
|
|
480
|
+
port: proxyPort,
|
|
481
|
+
paired: !!pairedSlug,
|
|
482
|
+
version: (() => { try { return require('./package.json').version } catch { return null } })()
|
|
483
|
+
})
|
|
484
|
+
})
|
|
468
485
|
router.get('/p/*', (req, res) => {
|
|
469
486
|
if (proxy) proxy.handleGet(req, res)
|
|
470
487
|
else res.status(503).send('proxy not enabled')
|
package/lib/history/ring.js
CHANGED
|
@@ -21,6 +21,23 @@ const fsp = fs.promises
|
|
|
21
21
|
const path = require('path')
|
|
22
22
|
|
|
23
23
|
const DEG = Math.PI / 180
|
|
24
|
+
|
|
25
|
+
// The channel set the Trends panel and the instrument history flyouts expect. It must
|
|
26
|
+
// match the cloud's provider (sailkick server/history/influx-provider.js MAP + the
|
|
27
|
+
// three active-waypoint prefixes), so the same cell shows the same history whether the
|
|
28
|
+
// boat or the cloud is answering.
|
|
29
|
+
//
|
|
30
|
+
// This drifted once already: it was frozen at the eight channels that existed before
|
|
31
|
+
// v0.18.6, while the app grew cells for true wind angle, VMG, temperatures, engine revs
|
|
32
|
+
// and the four waypoint values — and `cog` was sampled into the ring but left out of the
|
|
33
|
+
// output entirely. Eleven of nineteen cells had a live number and an empty flyout.
|
|
34
|
+
// test/history-ring.test.js pins this list against the app's own channel names.
|
|
35
|
+
const CHANNELS = [
|
|
36
|
+
'tws', 'twd', 'aws', 'awa', 'twa', 'vmg',
|
|
37
|
+
'sog', 'stw', 'cog', 'heading', 'depth',
|
|
38
|
+
'seaTemp', 'airTemp', 'rpmPort', 'rpmStbd',
|
|
39
|
+
'wptBrg', 'wptDist', 'wptVmg', 'wptTtg'
|
|
40
|
+
]
|
|
24
41
|
const wrap360 = (d) => ((d % 360) + 360) % 360
|
|
25
42
|
const MAX_SAMPLES = 50000
|
|
26
43
|
|
|
@@ -51,7 +68,7 @@ function trueWind (s) {
|
|
|
51
68
|
class RingHistoryProvider {
|
|
52
69
|
constructor ({ source, sampleSec, windowSec, persistFile } = {}) {
|
|
53
70
|
this._source = source
|
|
54
|
-
this._ring = [] // [{ t,
|
|
71
|
+
this._ring = [] // [{ t, ...CHANNELS, lat, lon }]
|
|
55
72
|
const win = windowSec || 3600
|
|
56
73
|
this._windowMs = win * 1000
|
|
57
74
|
this._persistFile = persistFile || null
|
|
@@ -80,6 +97,16 @@ class RingHistoryProvider {
|
|
|
80
97
|
stw: num(s.stwKt),
|
|
81
98
|
aws: num(s.awsKt), awa: num(s.awaDeg), depth: num(s.depthM),
|
|
82
99
|
tws: tw ? tw.tws : null, twd: tw ? tw.twd : null,
|
|
100
|
+
// Everything below arrived in BoatState with v0.18.6 but was never sampled here,
|
|
101
|
+
// so these instrument cells had a live value and an empty history flyout.
|
|
102
|
+
twa: num(s.twaDeg), vmg: num(s.vmgKt),
|
|
103
|
+
seaTemp: num(s.seaTempC), airTemp: num(s.airTempC),
|
|
104
|
+
rpmPort: num(s.rpmPort), rpmStbd: num(s.rpmStbd),
|
|
105
|
+
// Waypoint channels are legitimately null when no destination is active — num()
|
|
106
|
+
// maps that to null and CHANNELS omits empty ones, so the flyout stays blank
|
|
107
|
+
// rather than showing a flat line at zero.
|
|
108
|
+
wptBrg: num(s.wptBrgDeg), wptDist: num(s.wptDistNm),
|
|
109
|
+
wptVmg: num(s.wptVmgKt), wptTtg: num(s.wptTtgSec),
|
|
83
110
|
lat: num(s.lat), lon: num(s.lon)
|
|
84
111
|
}
|
|
85
112
|
this._ring.push(row)
|
|
@@ -97,12 +124,8 @@ class RingHistoryProvider {
|
|
|
97
124
|
|
|
98
125
|
getSeries ({ windowSec } = {}) {
|
|
99
126
|
const rows = this._window(windowSec)
|
|
100
|
-
// Matches the InfluxDB provider's channel set exactly (lib/history/index.js MAP), so
|
|
101
|
-
// the Trends panel looks the same whichever provider is serving. Empty channels are
|
|
102
|
-
// omitted, so a boat with no paddlewheel simply has no `stw` key.
|
|
103
|
-
const chans = ['tws', 'twd', 'aws', 'awa', 'sog', 'stw', 'heading', 'depth']
|
|
104
127
|
const series = {}
|
|
105
|
-
for (const c of
|
|
128
|
+
for (const c of CHANNELS) {
|
|
106
129
|
const pts = rows.filter((r) => r[c] != null).map((r) => [r.t, r[c]])
|
|
107
130
|
if (pts.length) series[c] = pts
|
|
108
131
|
}
|
|
@@ -179,4 +202,4 @@ class RingHistoryProvider {
|
|
|
179
202
|
}
|
|
180
203
|
}
|
|
181
204
|
|
|
182
|
-
module.exports = { RingHistoryProvider, MAX_SAMPLES }
|
|
205
|
+
module.exports = { RingHistoryProvider, MAX_SAMPLES, CHANNELS }
|
package/lib/proxy/index.js
CHANGED
|
@@ -498,12 +498,19 @@ function createProxy (app, options) {
|
|
|
498
498
|
if (clamped.length) log(`area prefetch: ${clamped.map(([l, v]) => `${l} capped at z${v.top}`).join(', ')} (upstream limit — those tiles do not exist)`)
|
|
499
499
|
if (total > CAP) { area = { capped: true, total, radius, maxZoom }; log(`area prefetch: ~${total} tiles too large — reduce radius/detail`); return null }
|
|
500
500
|
const paths = [...enumerateTiles(bbox, minZoom, perLayer)]
|
|
501
|
-
|
|
501
|
+
// Hold our OWN reference and mutate that, never the `area` slot. A prefetch of tens
|
|
502
|
+
// of thousands of tiles outlives a plugin disable by minutes, and stop() sets
|
|
503
|
+
// area = null — so settling handlers that touched `area` threw
|
|
504
|
+
// "Cannot set properties of null (setting 'running')" on every disable/enable. The
|
|
505
|
+
// same reference also protects against a restart having installed a NEWER prefetch:
|
|
506
|
+
// the old run must not report its progress into the new one's counters.
|
|
507
|
+
const run = { done: 0, total: paths.length, running: true, radius, maxZoom, perLayer }
|
|
508
|
+
area = run
|
|
502
509
|
log(`area prefetch: ${radius}nm around ${pos.latitude.toFixed(2)},${pos.longitude.toFixed(2)} to z${maxZoom} — ${paths.length} tiles`)
|
|
503
|
-
|
|
504
|
-
.then((r) => {
|
|
505
|
-
.catch((e) => {
|
|
506
|
-
return
|
|
510
|
+
run.promise = warmMany(paths, { concurrency: pf.concurrency || 4, onProgress: () => { run.done++ } })
|
|
511
|
+
.then((r) => { run.running = false; run.result = r; log(`area prefetch done: ${r.cached} cached, ${r.empty} empty, ${r.failed} failed`); return r })
|
|
512
|
+
.catch((e) => { run.running = false; log('area prefetch error: ' + e.message) })
|
|
513
|
+
return run.promise
|
|
507
514
|
}
|
|
508
515
|
|
|
509
516
|
function areaStatus () {
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sailkick-boat",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Run the sailkick app on board with no internet: charts, weather, climatology, trends and AIS all served from the boat itself. With a sailkick account it also syncs your metrics to the cloud in real time. Alpha, invite-only
|
|
3
|
+
"version": "0.20.1",
|
|
4
|
+
"description": "Run the sailkick app on board with no internet: charts, weather, climatology, trends and AIS all served from the boat itself. With a sailkick account it also syncs your metrics to the cloud in real time. Alpha, invite-only \u2014 info@sailkick.io",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "node --test"
|
|
8
8
|
},
|
|
9
9
|
"keywords": [
|
|
10
10
|
"signalk-node-server-plugin",
|
|
11
|
+
"signalk-webapp",
|
|
11
12
|
"signalk",
|
|
12
13
|
"sailkick",
|
|
13
14
|
"influxdb",
|
|
@@ -15,6 +16,7 @@
|
|
|
15
16
|
"cache",
|
|
16
17
|
"proxy"
|
|
17
18
|
],
|
|
19
|
+
"signalk-plugin-enabled-by-default": true,
|
|
18
20
|
"author": "lauchat",
|
|
19
21
|
"license": "MIT",
|
|
20
22
|
"repository": {
|
|
@@ -35,6 +37,7 @@
|
|
|
35
37
|
"files": [
|
|
36
38
|
"index.js",
|
|
37
39
|
"lib/",
|
|
40
|
+
"public/",
|
|
38
41
|
"README.md",
|
|
39
42
|
"LICENSE"
|
|
40
43
|
]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Sailkick</title>
|
|
7
|
+
<!--
|
|
8
|
+
Launcher for the Signal K admin menu.
|
|
9
|
+
|
|
10
|
+
Signal K mounts <package>/public/ at /<package-name> for any package carrying the
|
|
11
|
+
`signalk-webapp` keyword, and dedupes the menu by PACKAGE name (interfaces/webapps.js,
|
|
12
|
+
uniqBy(webapps,'name')) — so one package can only ever produce one entry. Hence one
|
|
13
|
+
launcher offering both surfaces rather than two menu items.
|
|
14
|
+
|
|
15
|
+
The app itself is not served from here: it lives on the plugin's mirror, on a port the
|
|
16
|
+
owner can change. This page therefore asks the plugin where it is, via the plugin
|
|
17
|
+
router at /plugins/sailkick-boat/info, and falls back to the default port if that
|
|
18
|
+
cannot be reached. The host is always taken from the current URL, so this works over
|
|
19
|
+
whatever address the browser reached Signal K on — LAN IP, .local, or tailnet.
|
|
20
|
+
-->
|
|
21
|
+
<style>
|
|
22
|
+
:root { color-scheme: light dark; --bg:#f6f7f9; --fg:#12161c; --mut:#5c6672; --card:#fff; --line:#e2e6eb; --accent:#0b6ea9 }
|
|
23
|
+
@media (prefers-color-scheme: dark) {
|
|
24
|
+
:root { --bg:#11151a; --fg:#e8edf2; --mut:#96a1ad; --card:#171c23; --line:#252c35; --accent:#4aa8e0 }
|
|
25
|
+
}
|
|
26
|
+
* { box-sizing: border-box }
|
|
27
|
+
body { margin:0; padding:2rem 1rem; background:var(--bg); color:var(--fg);
|
|
28
|
+
font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif }
|
|
29
|
+
main { max-width:44rem; margin:0 auto }
|
|
30
|
+
h1 { margin:0 0 .25rem; font-size:1.5rem; letter-spacing:-.01em }
|
|
31
|
+
.sub { color:var(--mut); margin:0 0 1.5rem }
|
|
32
|
+
.grid { display:grid; gap:1rem; grid-template-columns:repeat(auto-fit,minmax(15rem,1fr)) }
|
|
33
|
+
a.card { display:block; padding:1.1rem 1.2rem; background:var(--card); border:1px solid var(--line);
|
|
34
|
+
border-radius:.7rem; text-decoration:none; color:inherit; transition:border-color .15s, transform .15s }
|
|
35
|
+
a.card:hover { border-color:var(--accent); transform:translateY(-1px) }
|
|
36
|
+
a.card strong { display:block; font-size:1.05rem; margin-bottom:.2rem }
|
|
37
|
+
a.card span { color:var(--mut); font-size:.9rem }
|
|
38
|
+
.note { margin-top:1.5rem; padding:.85rem 1rem; background:var(--card); border:1px solid var(--line);
|
|
39
|
+
border-radius:.6rem; color:var(--mut); font-size:.9rem }
|
|
40
|
+
.note b { color:var(--fg); font-weight:600 }
|
|
41
|
+
code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.87em }
|
|
42
|
+
.warn { border-color:#b4791f }
|
|
43
|
+
</style>
|
|
44
|
+
</head>
|
|
45
|
+
<body>
|
|
46
|
+
<main>
|
|
47
|
+
<h1>Sailkick</h1>
|
|
48
|
+
<p class="sub">Charts, instruments and AIS, served from this boat.</p>
|
|
49
|
+
|
|
50
|
+
<div class="grid">
|
|
51
|
+
<a class="card" id="desktop" href="#"><strong>Open Sailkick</strong><span>Full app — chart, trends, weather</span></a>
|
|
52
|
+
<a class="card" id="mobile" href="#"><strong>Open on phone</strong><span>Mobile view — swipeable instrument decks</span></a>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div class="note" id="note">Locating the mirror…</div>
|
|
56
|
+
</main>
|
|
57
|
+
|
|
58
|
+
<script>
|
|
59
|
+
(function () {
|
|
60
|
+
var DEFAULT_PORT = 8080;
|
|
61
|
+
var note = document.getElementById('note');
|
|
62
|
+
|
|
63
|
+
function apply (port, info) {
|
|
64
|
+
var base = location.protocol + '//' + location.hostname + (port ? ':' + port : '');
|
|
65
|
+
document.getElementById('desktop').href = base + '/';
|
|
66
|
+
document.getElementById('mobile').href = base + '/mobile.html';
|
|
67
|
+
|
|
68
|
+
if (info && info.running === false) {
|
|
69
|
+
note.className = 'note warn';
|
|
70
|
+
note.innerHTML = '<b>The mirror is not running.</b> Enable the plugin in ' +
|
|
71
|
+
'Server → Plugin Config → Sailkick boat companion, then reload this page.';
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (port === 0) {
|
|
75
|
+
note.className = 'note warn';
|
|
76
|
+
note.innerHTML = '<b>The mirror port is disabled</b> (set to 0 in the plugin config), ' +
|
|
77
|
+
'so the app cannot be opened from here. Set a port — 8080 is the default.';
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
var paired = info && info.paired;
|
|
81
|
+
note.innerHTML = 'Serving on <code>' + base + '</code>. Works with no account and no internet — ' +
|
|
82
|
+
'live data comes from this boat’s own Signal K, and maps are cached locally.' +
|
|
83
|
+
(paired ? '' : ' <b>Cloud sync is off</b> until you add a sailkick account in the plugin config; ' +
|
|
84
|
+
'nothing leaves the boat before then.');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fetch('/plugins/sailkick-boat/info', { credentials: 'same-origin' })
|
|
88
|
+
.then(function (r) { return r.ok ? r.json() : null })
|
|
89
|
+
.then(function (j) {
|
|
90
|
+
if (j && typeof j.port === 'number') apply(j.port, j);
|
|
91
|
+
else apply(DEFAULT_PORT, null);
|
|
92
|
+
})
|
|
93
|
+
.catch(function () { apply(DEFAULT_PORT, null); });
|
|
94
|
+
})();
|
|
95
|
+
</script>
|
|
96
|
+
</body>
|
|
97
|
+
</html>
|