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 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
- # Temporary Holding Version
1
+ # signalk-pelora
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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 };