signalk-pelora 0.0.0-stage → 0.3.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/LICENSE +17 -0
- package/README.md +120 -2
- package/img/pelora-mark.svg +6 -0
- package/index.js +98 -0
- package/lib/ais.js +64 -0
- package/lib/alarms.js +43 -0
- package/lib/batcher.js +150 -0
- package/lib/collector.js +237 -0
- package/lib/mapping.js +117 -0
- package/lib/queue.js +103 -0
- package/lib/runtime.js +105 -0
- package/lib/signer.js +36 -0
- package/lib/transport.js +79 -0
- package/lib/units.js +59 -0
- package/lib/uploader.js +89 -0
- package/package.json +46 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Copyright 2026 SignalNav
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
you may not use this file except in compliance with the License.
|
|
9
|
+
You may obtain a copy of the License at
|
|
10
|
+
|
|
11
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
See the License for the specific language governing permissions and
|
|
17
|
+
limitations under the License.
|
package/README.md
CHANGED
|
@@ -1,3 +1,121 @@
|
|
|
1
|
-
#
|
|
1
|
+
# signalk-pelora
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Sends a boat's engine hours, engine alarms, position and conditions from a
|
|
4
|
+
[Signal K](https://signalk.org) server to [Pelora](https://signalnav.io), the
|
|
5
|
+
maintenance platform by SignalNav. Works on any Signal K server: a Raspberry Pi
|
|
6
|
+
with OpenPlotter, a Hat Labs HALPI2, a Victron Cerbo GX with Venus OS Large, or
|
|
7
|
+
a laptop.
|
|
8
|
+
|
|
9
|
+
The boat **pushes** to your operator's own Pelora address with a per-boat
|
|
10
|
+
beacon id and secret. Nothing on the boat is opened to the internet, and
|
|
11
|
+
nothing is sent anywhere but that address.
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
1. In Pelora, open the boat → **Engines & data** → create a device of kind
|
|
16
|
+
*Signal K plugin*. Copy the beacon id and the secret (the secret is shown
|
|
17
|
+
once).
|
|
18
|
+
2. On the Signal K server: **App Store** → search `signalk-pelora` → install →
|
|
19
|
+
restart the server.
|
|
20
|
+
3. **Server → Plugin Config → Pelora**: paste the Pelora URL (for example
|
|
21
|
+
`https://yourcompany.signalnav.io`), the beacon id and the secret. Enable
|
|
22
|
+
and submit.
|
|
23
|
+
4. Back in Pelora, the Engines & data page shows the device as seen within a
|
|
24
|
+
minute, and engine hours after the first counters upload (one minute).
|
|
25
|
+
|
|
26
|
+
If your engines are not called `port` / `starboard` / `generator` in Signal K
|
|
27
|
+
(many NMEA 2000 gateways call them `0` and `1`), either fill in *Which engine
|
|
28
|
+
is which* in the plugin, or leave it: the engine arrives as *unknown* and the
|
|
29
|
+
Engines & data page offers to place it.
|
|
30
|
+
|
|
31
|
+
## What is sent
|
|
32
|
+
|
|
33
|
+
| Every | Endpoint | Content |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| 5 min (configurable) | `telemetry/operating/ingest` | one sample per engine per second: rpm, load, fuel rate, coolant and oil temperatures, oil pressure, alternator and battery voltage, gear; the boat's position on top |
|
|
36
|
+
| 5 min | `telemetry/vessel/ingest` | position, SOG, COG, depth, apparent wind, water temperature, autopilot state, one sample every 5 s |
|
|
37
|
+
| 5 min | `telemetry/signalk/ingest` | a snapshot of heading, log, GNSS quality, outside conditions |
|
|
38
|
+
| 5 min (configurable) | `telemetry/counters/ingest` | per engine: `runTime` as hours, fuel used, the interval's coolant / oil pressure / battery min and max |
|
|
39
|
+
| at once | `telemetry/events/ingest` | engine start and stop; every Signal K notification under `notifications.*` when it is raised (`warn`/`alert` → warning, `alarm`/`emergency` → critical) and once more when it clears |
|
|
40
|
+
| 60 s, off by default | `ais/contacts/ingest` | other vessels seen on AIS |
|
|
41
|
+
|
|
42
|
+
Every batch is gzip-compressed and signed as sent; a twin-engine boat under way
|
|
43
|
+
costs about 50 KB an hour, and a boat at rest a few KB.
|
|
44
|
+
|
|
45
|
+
Units are converted from Signal K's SI to what Pelora expects (rpm, °C, kPa,
|
|
46
|
+
L/h, knots, degrees). Engine hours come from `propulsion.<id>.runTime`; if your
|
|
47
|
+
engine does not report it, Pelora derives hours from run time and the hours by
|
|
48
|
+
hand stay available.
|
|
49
|
+
|
|
50
|
+
## When there is no internet
|
|
51
|
+
|
|
52
|
+
Every batch is written to disk first (`<signalk data dir>/signalk-pelora/queue`)
|
|
53
|
+
and uploaded in order. Offline, batches wait up to 50 MB or 7 days, then the
|
|
54
|
+
oldest go. Pelora recognises a batch it already has, so a lost reply never
|
|
55
|
+
duplicates anything. A Pi without a real-time clock boots in 1970: the plugin
|
|
56
|
+
learns the right time from Pelora's first refusal and signs with it.
|
|
57
|
+
|
|
58
|
+
The plugin's status line in the Signal K dashboard shows the engines seen, the
|
|
59
|
+
last upload and how many batches are queued.
|
|
60
|
+
|
|
61
|
+
## Signing
|
|
62
|
+
|
|
63
|
+
Each request carries `X-Beacon-Id`, `X-Timestamp` (unix seconds) and
|
|
64
|
+
`X-Signature: sha256=<HMAC-SHA256(secret, timestamp + "." + body)>`, and
|
|
65
|
+
Pelora refuses anything older than five minutes. `lib/signer.js` holds the
|
|
66
|
+
test vector shared with Pelora's own test suite.
|
|
67
|
+
|
|
68
|
+
## Trying it without a boat
|
|
69
|
+
|
|
70
|
+
Signal K's bundled sample boat (`signalk-server --sample-n2k-data`) has position
|
|
71
|
+
and depth but no engines. This repository carries pretend boats instead; they are
|
|
72
|
+
not part of the npm package, so run them from a clone:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
git clone https://github.com/signalnav/signalk-pelora.git
|
|
76
|
+
node signalk-pelora/dev/feed.js
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Then Server → Data Connections → Add: Input Type *Signal K*, Source *TCP*, host
|
|
80
|
+
`127.0.0.1`, port `10111`, and restart the server. Every twelve minutes the boat
|
|
81
|
+
starts both engines, runs ten minutes with a ninety-second coolant alarm on the
|
|
82
|
+
port engine, and stops — enough to see hours, a trip, an alarm and its clear
|
|
83
|
+
arrive in Pelora.
|
|
84
|
+
|
|
85
|
+
For a whole day on the water, `dev/passage.js` takes the same boat from Palm Cay
|
|
86
|
+
Marina in Nassau to Highbourne Cay, on the same TCP port: an hour under engine, four
|
|
87
|
+
hours under sail with the engines off, half an hour motoring in, then a night at
|
|
88
|
+
anchor (twelve hours, `--night` to change) before it starts over.
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
node dev/passage.js [--from dock|motor|sail|approach|anchor] [--speed 60] [--alarm]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`--speed 60` plays a minute of boat time per second, engine hours included.
|
|
95
|
+
`--alarm` adds the coolant alarm to the first motoring leg. The waypoints are
|
|
96
|
+
approximate; it is test data, not a route.
|
|
97
|
+
|
|
98
|
+
For a whole charter, `dev/week.js` plays a recorded week as the vessel *Aurora*:
|
|
99
|
+
track, speed, depth, wind and temperatures from the recording, engines generated
|
|
100
|
+
from it, because the recording has none. The recording is not in this
|
|
101
|
+
repository; `node dev/make-week.js --src <Beacon trip .jsonl>` builds one from a
|
|
102
|
+
Beacon trip file into `dev/fixtures/exumas-week.json.gz`.
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
node dev/week.js [--day 1] [--time 08:30] [--speed 60] [--name Aurora]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
npm test # node --test, no dependencies
|
|
112
|
+
node dev/make-fixture.js # 12 minutes of a twin-engine trip as Signal K deltas
|
|
113
|
+
node dev/replay.js --url https://demo.signalnav.io --beacon PEL-… --secret … --speed 60
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`dev/replay.js` runs the plugin outside a Signal K server against a real
|
|
117
|
+
Pelora, from any file of one delta per line.
|
|
118
|
+
|
|
119
|
+
## Licence
|
|
120
|
+
|
|
121
|
+
Apache-2.0. © 2026 SignalNav.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="512" height="512" role="img" aria-label="Pelora compass-rose mark">
|
|
2
|
+
<circle cx="50" cy="50" r="37" fill="none" stroke="#3AB7B0" stroke-width="10"/>
|
|
3
|
+
<path d="M50 18 L60 40 L82 50 L60 60 L50 82 L40 60 L18 50 L40 40 Z" fill="#2683FA"/>
|
|
4
|
+
<path d="M50 18 L60 40 L40 40 Z" fill="#FF2E36"/>
|
|
5
|
+
<circle cx="50" cy="50" r="3.8" fill="#FCFCFC"/>
|
|
6
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// signalk-pelora — sends a boat's engine hours, alarms, position and
|
|
3
|
+
// conditions to Pelora (https://signalnav.io). The boat PUSHES to the
|
|
4
|
+
// operator's own Pelora address with a per-boat beacon id and secret; nothing
|
|
5
|
+
// on the boat is opened to the internet, and nothing is sent anywhere else.
|
|
6
|
+
|
|
7
|
+
const { createRuntime, validate } = require('./lib/runtime');
|
|
8
|
+
|
|
9
|
+
module.exports = function (app) {
|
|
10
|
+
const plugin = {
|
|
11
|
+
id: 'signalk-pelora',
|
|
12
|
+
name: 'Pelora',
|
|
13
|
+
description: 'Engine hours, engine alarms, position and conditions to Pelora, the maintenance platform by SignalNav.',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
plugin.schema = {
|
|
17
|
+
type: 'object',
|
|
18
|
+
required: ['peloraUrl', 'beaconId', 'secret'],
|
|
19
|
+
properties: {
|
|
20
|
+
peloraUrl: {
|
|
21
|
+
type: 'string', title: 'Pelora URL',
|
|
22
|
+
description: 'Your Pelora address, for example https://yourcompany.signalnav.io',
|
|
23
|
+
},
|
|
24
|
+
beaconId: {
|
|
25
|
+
type: 'string', title: 'Beacon id',
|
|
26
|
+
description: "From this boat's Engines & data page in Pelora (PEL-…).",
|
|
27
|
+
},
|
|
28
|
+
secret: {
|
|
29
|
+
type: 'string', title: 'Secret',
|
|
30
|
+
description: 'Shown once when the device was created in Pelora. Lost it? Create a new device there.',
|
|
31
|
+
},
|
|
32
|
+
uploadIntervalSec: {
|
|
33
|
+
type: 'integer', title: 'Upload every (seconds)', default: 300, minimum: 5, maximum: 3600,
|
|
34
|
+
description: 'How often engine samples, position and conditions are sent while there is a connection. Alarms and engine start/stop go at once whatever this says.',
|
|
35
|
+
},
|
|
36
|
+
countersIntervalSec: {
|
|
37
|
+
type: 'integer', title: 'Engine hours every (seconds)', default: 300, minimum: 30, maximum: 3600,
|
|
38
|
+
},
|
|
39
|
+
engineMap: {
|
|
40
|
+
type: 'array', title: 'Which engine is which',
|
|
41
|
+
description: 'Only needed when your engines are not called port / starboard / generator in Signal K. Unlisted engines are sent as unknown and can be placed from the Engines & data page.',
|
|
42
|
+
items: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: {
|
|
45
|
+
signalkId: { type: 'string', title: 'Signal K engine id', description: 'The id in propulsion.<id>, e.g. 0, main, engine1' },
|
|
46
|
+
position: { type: 'string', title: 'Is the', enum: ['port', 'starboard', 'genset'], enumNames: ['Port engine', 'Starboard engine', 'Generator'] },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
batteryId: {
|
|
51
|
+
type: 'string', title: 'Battery to report',
|
|
52
|
+
description: 'The id in electrical.batteries.<id>. Blank picks the start/engine battery, then house, then the first seen.',
|
|
53
|
+
},
|
|
54
|
+
includeVessel: { type: 'boolean', title: 'Send position, speed, depth, wind and water temperature', default: true },
|
|
55
|
+
includeSignalK: { type: 'boolean', title: 'Send a Signal K snapshot (heading, log, GNSS quality, outside conditions)', default: true },
|
|
56
|
+
includeAis: { type: 'boolean', title: 'Send AIS contacts', default: false,
|
|
57
|
+
description: 'Other vessels seen by the AIS receiver, once a minute. Off unless your operator asks for it.' },
|
|
58
|
+
queueMaxMb: { type: 'integer', title: 'Keep up to (MB) while offline', default: 50, minimum: 5, maximum: 2000,
|
|
59
|
+
description: 'Batches wait on disk when there is no internet and upload in order when it is back. Oldest are dropped past this size or after 7 days.' },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
plugin.uiSchema = {
|
|
64
|
+
secret: { 'ui:widget': 'password' },
|
|
65
|
+
engineMap: { 'ui:options': { orderable: false } },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
let runtime = null;
|
|
69
|
+
|
|
70
|
+
plugin.start = function (options) {
|
|
71
|
+
const problems = validate(options || {});
|
|
72
|
+
if (problems.length) {
|
|
73
|
+
app.setPluginError(problems.join('; '));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
runtime = createRuntime(app, options);
|
|
78
|
+
runtime.start();
|
|
79
|
+
} catch (e) {
|
|
80
|
+
app.setPluginError('could not start: ' + e.message);
|
|
81
|
+
app.error(e.stack || String(e));
|
|
82
|
+
runtime = null;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
plugin.stop = function () {
|
|
87
|
+
if (runtime) {
|
|
88
|
+
try { runtime.stop(); } catch (e) { app.error('stop: ' + e.message); }
|
|
89
|
+
runtime = null;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
plugin.statusMessage = function () {
|
|
94
|
+
return runtime ? runtime.statusLine() : 'Not running';
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return plugin;
|
|
98
|
+
};
|
package/lib/ais.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Other vessels, from the server's `vessels.*` contexts. Pelora keeps none of
|
|
3
|
+
// this itself (it forwards to the beacon service when that is on), so it is
|
|
4
|
+
// off unless the operator turns it on.
|
|
5
|
+
|
|
6
|
+
const u = require('./units');
|
|
7
|
+
const { iso } = require('./collector');
|
|
8
|
+
|
|
9
|
+
const MMSI_RE = /^vessels\.urn:mrn:imo:mmsi:(\d{7,9})$/;
|
|
10
|
+
|
|
11
|
+
class Ais {
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
this.selfContext = options.selfContext || 'vessels.self';
|
|
14
|
+
this.expireMs = options.expireMs || 3600 * 1000;
|
|
15
|
+
this.contacts = new Map(); // mmsi → {mmsi, name, lat, lon, sog_kn, cog_deg, class, lastSeen}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
onDelta(delta) {
|
|
19
|
+
if (!delta || !Array.isArray(delta.updates) || !delta.context || delta.context === this.selfContext) return;
|
|
20
|
+
const m = MMSI_RE.exec(delta.context);
|
|
21
|
+
if (!m) return;
|
|
22
|
+
const mmsi = m[1];
|
|
23
|
+
let c = this.contacts.get(mmsi);
|
|
24
|
+
if (!c) { c = { mmsi, lastSeen: 0 }; this.contacts.set(mmsi, c); }
|
|
25
|
+
for (const update of delta.updates) {
|
|
26
|
+
const parsed = update && update.timestamp ? Date.parse(update.timestamp) : NaN;
|
|
27
|
+
const at = Number.isFinite(parsed) ? parsed : Date.now();
|
|
28
|
+
for (const item of (update && update.values) || []) {
|
|
29
|
+
if (!item || typeof item.path !== 'string') continue;
|
|
30
|
+
let seen = false;
|
|
31
|
+
if (item.path === 'navigation.position') {
|
|
32
|
+
const pos = u.position(item.value);
|
|
33
|
+
if (pos) { c.lat = pos.lat; c.lon = pos.lon; seen = true; }
|
|
34
|
+
} else if (item.path === 'navigation.speedOverGround') {
|
|
35
|
+
const v = u.msToKn(item.value); if (v !== null) { c.sog_kn = v; seen = true; }
|
|
36
|
+
} else if (item.path === 'navigation.courseOverGroundTrue') {
|
|
37
|
+
const v = u.radToDeg360(item.value); if (v !== null) { c.cog_deg = v; seen = true; }
|
|
38
|
+
} else if (item.path === 'name' || item.path === '') {
|
|
39
|
+
const name = item.path === '' && item.value && typeof item.value === 'object' ? item.value.name : item.value;
|
|
40
|
+
if (typeof name === 'string' && name.trim()) c.name = name.trim().slice(0, 128);
|
|
41
|
+
} else if (item.path === 'sensors.ais.class') {
|
|
42
|
+
c.class = item.value === 'A' || item.value === 'B' ? item.value : 'unknown';
|
|
43
|
+
}
|
|
44
|
+
if (seen) c.lastSeen = Math.max(c.lastSeen, at);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Contacts with a position heard in the last `maxAgeMs`; forgets the rest.
|
|
50
|
+
snapshot(now, maxAgeMs = 15 * 60 * 1000, limit = 500) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const [mmsi, c] of this.contacts) {
|
|
53
|
+
if (now - c.lastSeen > this.expireMs) { this.contacts.delete(mmsi); continue; }
|
|
54
|
+
if (c.lat === undefined || now - c.lastSeen > maxAgeMs) continue;
|
|
55
|
+
const row = { mmsi, lat: c.lat, lon: c.lon, last_seen_utc: iso(c.lastSeen), age_sec: Math.max(0, Math.round((now - c.lastSeen) / 1000)) };
|
|
56
|
+
for (const f of ['name', 'sog_kn', 'cog_deg', 'class']) if (c[f] !== undefined) row[f] = c[f];
|
|
57
|
+
out.push(row);
|
|
58
|
+
}
|
|
59
|
+
out.sort((a, b) => a.age_sec - b.age_sec);
|
|
60
|
+
return out.slice(0, limit);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { Ais };
|
package/lib/alarms.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Signal K notifications → Pelora engine events. A notification is a STATE
|
|
3
|
+
// (normal | nominal | alert | warn | alarm | emergency) the server keeps
|
|
4
|
+
// repeating; Pelora wants the transitions: one event when it is raised, one
|
|
5
|
+
// `fault_clear` when it goes back to normal, nothing in between.
|
|
6
|
+
|
|
7
|
+
const mapping = require('./mapping');
|
|
8
|
+
const { iso } = require('./collector');
|
|
9
|
+
|
|
10
|
+
const SEVERITY = { emergency: 'critical', alarm: 'critical', warn: 'warning', alert: 'warning' };
|
|
11
|
+
|
|
12
|
+
class Alarms {
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.engineMap = options.engineMap || [];
|
|
15
|
+
this.raised = new Map(); // key → {severity, message}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// {key, engineId, value, at} → event or null
|
|
19
|
+
onNotification({ key, engineId, value, at }) {
|
|
20
|
+
const state = value && typeof value === 'object' && typeof value.state === 'string' ? value.state.toLowerCase() : null;
|
|
21
|
+
const severity = state ? SEVERITY[state] || null : null;
|
|
22
|
+
const message = value && typeof value.message === 'string' ? value.message.slice(0, 255) : null;
|
|
23
|
+
const previous = this.raised.get(key);
|
|
24
|
+
const engine = engineId ? mapping.enginePosition(engineId, this.engineMap) : null;
|
|
25
|
+
const base = { time_utc: iso(at), engine: engine || undefined, rule_id: key.slice(0, 128),
|
|
26
|
+
dtc_text: message || key, data: { path: 'notifications.' + key, state, method: value && value.method } };
|
|
27
|
+
|
|
28
|
+
if (severity) {
|
|
29
|
+
if (previous && previous.severity === severity) return null; // repeat
|
|
30
|
+
this.raised.set(key, { severity, message });
|
|
31
|
+
return { ...base, event_type: severity === 'critical' ? 'critical' : 'warning', severity };
|
|
32
|
+
}
|
|
33
|
+
if (previous) {
|
|
34
|
+
this.raised.delete(key);
|
|
35
|
+
return { ...base, event_type: 'fault_clear', severity: 'info' };
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
open() { return [...this.raised.keys()]; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { Alarms, SEVERITY };
|
package/lib/batcher.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The clock of the plugin: samples every second, uploads every
|
|
3
|
+
// uploadIntervalSec (5 min), counters every countersIntervalSec (5 min),
|
|
4
|
+
// events at once. Pelora wants a daily picture and the alarms, not a live
|
|
5
|
+
// link; five minutes keeps a twin-engine boat near 50 KB an hour gzipped.
|
|
6
|
+
// Every batch goes through the queue — even online — so a lost reply never
|
|
7
|
+
// loses a batch and nothing is ever sent out of order.
|
|
8
|
+
|
|
9
|
+
const { Collector } = require('./collector');
|
|
10
|
+
const { Alarms } = require('./alarms');
|
|
11
|
+
const { Ais } = require('./ais');
|
|
12
|
+
|
|
13
|
+
const CAPS = { operating: 600, counters: 120, vessel: 600, events: 200, signalk: 120, ais: 500 };
|
|
14
|
+
const ENDPOINTS = {
|
|
15
|
+
operating: 'telemetry/operating/ingest',
|
|
16
|
+
counters: 'telemetry/counters/ingest',
|
|
17
|
+
vessel: 'telemetry/vessel/ingest',
|
|
18
|
+
events: 'telemetry/events/ingest',
|
|
19
|
+
signalk: 'telemetry/signalk/ingest',
|
|
20
|
+
ais: 'ais/contacts/ingest',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function chunk(items, size) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class Batcher {
|
|
30
|
+
constructor(options) {
|
|
31
|
+
this.beaconId = options.beaconId;
|
|
32
|
+
this.queue = options.queue;
|
|
33
|
+
this.onBatch = options.onBatch || (() => {});
|
|
34
|
+
this.log = options.log || (() => {});
|
|
35
|
+
this.clock = options.clock || (() => Date.now());
|
|
36
|
+
this.setInterval = options.setInterval || setInterval;
|
|
37
|
+
this.clearInterval = options.clearInterval || clearInterval;
|
|
38
|
+
this.uploadIntervalSec = options.uploadIntervalSec || 300;
|
|
39
|
+
this.countersIntervalSec = options.countersIntervalSec || 300;
|
|
40
|
+
this.vesselSampleSec = options.vesselSampleSec || 5;
|
|
41
|
+
this.aisIntervalSec = options.aisIntervalSec || 60;
|
|
42
|
+
this.includeVessel = options.includeVessel !== false;
|
|
43
|
+
this.includeSignalK = options.includeSignalK !== false;
|
|
44
|
+
this.includeAis = !!options.includeAis;
|
|
45
|
+
this.collector = options.collector || new Collector({ engineMap: options.engineMap, batteryId: options.batteryId, clock: this.clock });
|
|
46
|
+
this.alarms = options.alarms || new Alarms({ engineMap: options.engineMap });
|
|
47
|
+
this.ais = options.ais || new Ais({ selfContext: options.selfContext });
|
|
48
|
+
this.operating = [];
|
|
49
|
+
this.vessel = [];
|
|
50
|
+
this.ticks = 0;
|
|
51
|
+
this.timers = [];
|
|
52
|
+
this.batches = 0;
|
|
53
|
+
this.lastEventAt = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
start() {
|
|
57
|
+
this.timers.push(this.setInterval(() => this.tick(), 1000));
|
|
58
|
+
this.timers.push(this.setInterval(() => this.flush(), this.uploadIntervalSec * 1000));
|
|
59
|
+
this.timers.push(this.setInterval(() => this.counters(), this.countersIntervalSec * 1000));
|
|
60
|
+
if (this.includeAis) this.timers.push(this.setInterval(() => this.aisBatch(), this.aisIntervalSec * 1000));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
stop() {
|
|
64
|
+
for (const t of this.timers) this.clearInterval(t);
|
|
65
|
+
this.timers = [];
|
|
66
|
+
this.flush();
|
|
67
|
+
this.counters();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
onDelta(delta) {
|
|
71
|
+
this.collector.onDelta(delta);
|
|
72
|
+
if (this.includeAis) this.ais.onDelta(delta);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
tick() {
|
|
76
|
+
const now = this.clock();
|
|
77
|
+
this.ticks += 1;
|
|
78
|
+
for (const s of this.collector.engineSamples(now)) this.operating.push(s);
|
|
79
|
+
if (this.includeVessel && this.ticks % this.vesselSampleSec === 0) {
|
|
80
|
+
const v = this.collector.vesselSample(now);
|
|
81
|
+
if (v) this.vessel.push(v);
|
|
82
|
+
}
|
|
83
|
+
const events = this.collector.engineEvents(now);
|
|
84
|
+
for (const n of this.collector.takeNotifications()) {
|
|
85
|
+
const ev = this.alarms.onNotification(n);
|
|
86
|
+
if (ev) events.push(ev);
|
|
87
|
+
}
|
|
88
|
+
if (events.length) this.push('events', { beacon_id: this.beaconId, events });
|
|
89
|
+
if (this.operating.length >= CAPS.operating || this.vessel.length >= CAPS.vessel) this.flush();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
flush() {
|
|
93
|
+
const now = this.clock();
|
|
94
|
+
if (this.operating.length) {
|
|
95
|
+
const head = { beacon_id: this.beaconId, source: 'signalk' };
|
|
96
|
+
const pos = this.collector.position(now);
|
|
97
|
+
if (pos) {
|
|
98
|
+
head.position = pos;
|
|
99
|
+
if (this.collector.vessel.sog_kn !== undefined) head.sog_kn = this.collector.vessel.sog_kn;
|
|
100
|
+
if (this.collector.vessel.cog_deg !== undefined) head.cog_deg = this.collector.vessel.cog_deg;
|
|
101
|
+
}
|
|
102
|
+
for (const samples of chunk(this.operating, CAPS.operating)) this.push('operating', { ...head, samples });
|
|
103
|
+
this.operating = [];
|
|
104
|
+
}
|
|
105
|
+
if (this.vessel.length) {
|
|
106
|
+
for (const samples of chunk(this.vessel, CAPS.vessel)) this.push('vessel', { beacon_id: this.beaconId, source: 'signalk', samples });
|
|
107
|
+
this.vessel = [];
|
|
108
|
+
}
|
|
109
|
+
if (this.includeSignalK) {
|
|
110
|
+
const snap = this.collector.signalkSnapshot(now);
|
|
111
|
+
if (snap) this.push('signalk', { beacon_id: this.beaconId, source: 'signalk', time_utc: snap.time_utc, values: snap.values });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
counters() {
|
|
116
|
+
const samples = this.collector.counterSamples(this.clock());
|
|
117
|
+
if (!samples.length) return;
|
|
118
|
+
for (const part of chunk(samples, CAPS.counters)) this.push('counters', { beacon_id: this.beaconId, source: 'signalk', samples: part });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Said once at start: Pelora stamps the device as seen and a wrong secret
|
|
122
|
+
// shows in the status line now, not when the engines are next started.
|
|
123
|
+
hello(info) {
|
|
124
|
+
const now = this.clock();
|
|
125
|
+
const values = {};
|
|
126
|
+
for (const [k, v] of Object.entries(info || {})) values['sk.plugin.' + k] = String(v).slice(0, 64);
|
|
127
|
+
this.push('signalk', { beacon_id: this.beaconId, source: 'signalk',
|
|
128
|
+
time_utc: new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z'), values });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
aisBatch() {
|
|
132
|
+
const now = this.clock();
|
|
133
|
+
const contacts = this.ais.snapshot(now, undefined, CAPS.ais);
|
|
134
|
+
if (!contacts.length) return;
|
|
135
|
+
const body = { beacon_id: this.beaconId, source: 'signalk', observed_at_utc: new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z'), contacts };
|
|
136
|
+
const pos = this.collector.position(now);
|
|
137
|
+
if (pos) body.ownship = pos;
|
|
138
|
+
this.push('ais', body);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
push(kind, body) {
|
|
142
|
+
const text = JSON.stringify(body);
|
|
143
|
+
this.queue.push(ENDPOINTS[kind], text);
|
|
144
|
+
this.batches += 1;
|
|
145
|
+
if (kind === 'events') this.lastEventAt = this.clock();
|
|
146
|
+
this.onBatch(kind, body);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { Batcher, CAPS, ENDPOINTS, chunk };
|