signalk-telltale-plugin 0.1.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 +81 -0
- package/index.js +393 -0
- package/lib/actuation.js +92 -0
- package/lib/buffer.js +78 -0
- package/lib/discovery.js +124 -0
- package/lib/hmac.js +20 -0
- package/lib/n2k.js +50 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# signalk-telltale-plugin
|
|
2
|
+
|
|
3
|
+
**Telltale Uplink** — a [Signal K](https://signalk.org/) server plugin that streams a
|
|
4
|
+
decimated, disk-buffered copy of your boat's data to a self-hosted *shore* collector
|
|
5
|
+
over HTTPS, with an HMAC-signed command channel back to the boat. It also supports
|
|
6
|
+
bounded **raw NMEA 2000 capture**, switch **discovery**, and gated **digital-switching
|
|
7
|
+
actuation** — a "universal remote" for your CZone/N2K loads.
|
|
8
|
+
|
|
9
|
+
Part of the [Telltale](https://gitlab.dupuis.xyz/andrewdupuis/telltale) project (boat
|
|
10
|
+
plugin + shore stack: collector, public portal, Grafana, TimescaleDB).
|
|
11
|
+
|
|
12
|
+
## What it does
|
|
13
|
+
|
|
14
|
+
- **Telemetry uplink** — samples a path allowlist on an interval, batches snapshots,
|
|
15
|
+
and POSTs them to the shore collector. A bounded, flash-friendly disk queue survives
|
|
16
|
+
reboots and drains only on ack, so a flaky cellular link never loses or duplicates
|
|
17
|
+
data and never fills the Cerbo's `/data` partition.
|
|
18
|
+
- **Command channel** — every POST may return an HMAC-signed directive: change the
|
|
19
|
+
sample/post intervals (declarative + versioned) or run a one-shot command
|
|
20
|
+
(e.g. `flushBuffer`). Signatures are verified with a shared `commandKey` before
|
|
21
|
+
anything is applied; a bad signature is ignored.
|
|
22
|
+
- **Raw N2K capture** — a shore-initiated, time-boxed, rate-limited window taps the
|
|
23
|
+
canboat-parsed N2K stream (`N2KAnalyzerOut`) and ships `{pgn, src, dst, fields}`
|
|
24
|
+
frames for remote viewing. Auto-expires so cellular usage stays sane.
|
|
25
|
+
- **Discovery (Phase 1, read-only)** — name a load, flip its physical switch a few
|
|
26
|
+
times, and the plugin edge-correlates the switching PGNs to infer the
|
|
27
|
+
`(pgn, instance, switch)` signature, with confidence + co-moving ambiguities.
|
|
28
|
+
- **Digital-switching actuation (gated)** — send 127502 / 126208 to switch a load,
|
|
29
|
+
with read-back confirmation — behind a layered safety model (see below).
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
In the Signal K admin UI: **Appstore → Available → search "telltale" → Install**,
|
|
34
|
+
then restart. No SSH/root required. (Or `npm install signalk-telltale-plugin` in your
|
|
35
|
+
server's data directory.)
|
|
36
|
+
|
|
37
|
+
Then **Server → Plugin Config → Telltale Uplink**, enable it, and set:
|
|
38
|
+
|
|
39
|
+
| Setting | Notes |
|
|
40
|
+
|---|---|
|
|
41
|
+
| **Shore collector URL** | e.g. `https://your-shore/api/ingest` |
|
|
42
|
+
| **Vessel id** | the `source` your shore expects (e.g. `goblin`) |
|
|
43
|
+
| **Ingest token** | bearer that proves the boat may write (matches shore `INGEST_TOKEN`) |
|
|
44
|
+
| **Command key** | shared secret that signs/verifies directives (matches shore `COMMAND_KEY`) |
|
|
45
|
+
| **Sample / post interval (ms)** | default 15000 each |
|
|
46
|
+
| **Paths to send** | empty = a sensible nav/wind/depth/battery default |
|
|
47
|
+
| **Enable actuation** | master arm — **leave OFF** unless actively switching loads |
|
|
48
|
+
| **Switching allowlist** | the boat-side list of confirmed, actuatable targets |
|
|
49
|
+
|
|
50
|
+
## Actuation safety model
|
|
51
|
+
|
|
52
|
+
Actuation is **off by default** and gated by more than the HMAC:
|
|
53
|
+
|
|
54
|
+
1. **The boat-side allowlist is the real control.** The plugin only emits commands
|
|
55
|
+
matching a locally-configured, `confirmed`, non-`excluded` target. A compromised or
|
|
56
|
+
buggy shore cannot address anything you haven't explicitly enabled on the boat.
|
|
57
|
+
2. **Master arm toggle, default OFF.** Telemetry, raw capture, and discovery always
|
|
58
|
+
work; actuation is ignored entirely unless armed.
|
|
59
|
+
3. **TTL on commands.** Stale actuation commands are dropped, never executed late after
|
|
60
|
+
a buffered outage.
|
|
61
|
+
4. **Closed-loop confirm.** A target is only `confirmed` after a Phase-2 test toggles it
|
|
62
|
+
and reads back the resulting 127501 status; only confirmed targets are actuatable.
|
|
63
|
+
5. **Hard exclusions.** Never allowlist loads where a wrong/stale/spoofed state could
|
|
64
|
+
endanger the boat (bilge pumps, nav/steaming lights underway, engine, windlass,
|
|
65
|
+
anything affecting watertight integrity). Cabin lights, fans, fridge: fine.
|
|
66
|
+
|
|
67
|
+
> Actuation requires Signal K's **writable canboatjs** N2K connection (sends to
|
|
68
|
+
> NMEA 2000). Raw capture and discovery work with any N2K input.
|
|
69
|
+
|
|
70
|
+
## Development
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npm test # node:test unit suite (hmac, buffer, discovery, actuation, n2k)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Local edit-in-place loop, the shore stack, and the offline simulators are documented
|
|
77
|
+
in the [project INSTRUCTIONS](https://gitlab.dupuis.xyz/andrewdupuis/telltale).
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const https = require('https');
|
|
6
|
+
const { URL } = require('url');
|
|
7
|
+
const { DiskQueue } = require('./lib/buffer');
|
|
8
|
+
const { verify } = require('./lib/hmac');
|
|
9
|
+
const { tapN2K, createSender } = require('./lib/n2k');
|
|
10
|
+
const { Correlator, instanceOf } = require('./lib/discovery');
|
|
11
|
+
const actuation = require('./lib/actuation');
|
|
12
|
+
|
|
13
|
+
const DEFAULT_PATHS = [
|
|
14
|
+
'navigation.position',
|
|
15
|
+
'navigation.speedOverGround',
|
|
16
|
+
'navigation.courseOverGroundTrue',
|
|
17
|
+
'navigation.headingTrue',
|
|
18
|
+
'navigation.speedThroughWater',
|
|
19
|
+
'environment.wind.speedApparent',
|
|
20
|
+
'environment.wind.angleApparent',
|
|
21
|
+
'environment.wind.speedTrue',
|
|
22
|
+
'environment.wind.angleTrueWater',
|
|
23
|
+
'environment.depth.belowTransducer',
|
|
24
|
+
'environment.water.temperature',
|
|
25
|
+
'electrical.batteries.house.voltage',
|
|
26
|
+
'electrical.batteries.house.capacity.stateOfCharge',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
30
|
+
|
|
31
|
+
module.exports = function (app) {
|
|
32
|
+
const plugin = {};
|
|
33
|
+
plugin.id = 'telltale-uplink';
|
|
34
|
+
plugin.name = 'Telltale Uplink';
|
|
35
|
+
plugin.description = 'Decimated, buffered uplink of SignalK data to the shore collector, with an HMAC-verified command channel, bounded raw-N2K capture, discovery, and gated digital-switching actuation.';
|
|
36
|
+
|
|
37
|
+
let queue = null;
|
|
38
|
+
let sampleTimer = null;
|
|
39
|
+
let postTimer = null;
|
|
40
|
+
let seq = Date.now(); // monotonic-ish across restarts
|
|
41
|
+
let cfgVersion = 0;
|
|
42
|
+
let capVersion = 0;
|
|
43
|
+
let live = { sampleIntervalMs: 15000, postIntervalMs: 15000 };
|
|
44
|
+
const executed = new Set(); // command ids we've run (reported until shore drops them)
|
|
45
|
+
let posting = false;
|
|
46
|
+
|
|
47
|
+
// ---- raw capture / discovery / actuation state ----
|
|
48
|
+
let sender = null;
|
|
49
|
+
let tapOff = null;
|
|
50
|
+
let rawCap = { active: false, pgns: null, maxHz: 5, postIntervalMs: 2000 };
|
|
51
|
+
let rawBuf = [];
|
|
52
|
+
let rawTimer = null;
|
|
53
|
+
let capExpireTimer = null;
|
|
54
|
+
const statusCache = new Map(); // `127501:<instance>` -> { fields, tsMs } (for read-back)
|
|
55
|
+
const rateGate = new Map(); // `<pgn>:<src>` -> last accepted ms (maxHz throttle)
|
|
56
|
+
let disc = null; // active discovery session { sessionId, corr, ... }
|
|
57
|
+
let pendingResults = []; // actuation read-backs to report upstream
|
|
58
|
+
let pendingCandidates = null; // discovery candidates to report upstream
|
|
59
|
+
|
|
60
|
+
plugin.schema = {
|
|
61
|
+
type: 'object',
|
|
62
|
+
required: ['serverUrl', 'ingestToken', 'commandKey'],
|
|
63
|
+
properties: {
|
|
64
|
+
serverUrl: { type: 'string', title: 'Shore collector URL', default: 'https://goblin.dupuis.xyz/api/ingest' },
|
|
65
|
+
source: { type: 'string', title: 'Vessel id', default: 'goblin' },
|
|
66
|
+
ingestToken: { type: 'string', title: 'Ingest token (Bearer, boat → shore)' },
|
|
67
|
+
commandKey: { type: 'string', title: 'Command key (shared secret; verifies shore directives)' },
|
|
68
|
+
sampleIntervalMs: { type: 'number', title: 'Sample interval (ms)', default: 15000 },
|
|
69
|
+
postIntervalMs: { type: 'number', title: 'Post interval (ms)', default: 15000 },
|
|
70
|
+
maxItems: { type: 'number', title: 'Max buffered samples (disk cap)', default: 40000 },
|
|
71
|
+
maxBatch: { type: 'number', title: 'Max samples per POST', default: 500 },
|
|
72
|
+
paths: { type: 'array', title: 'Paths to send (empty = sensible default)', items: { type: 'string' } },
|
|
73
|
+
actuationEnabled: {
|
|
74
|
+
type: 'boolean', default: false,
|
|
75
|
+
title: 'Enable actuation (MASTER ARM — leave OFF unless you are actively switching loads)',
|
|
76
|
+
},
|
|
77
|
+
actuationTargets: {
|
|
78
|
+
type: 'array',
|
|
79
|
+
title: 'Boat-side switching allowlist — the REAL control. Only CONFIRMED, non-excluded entries are remotely actuatable.',
|
|
80
|
+
items: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: {
|
|
83
|
+
name: { type: 'string', title: 'Name' },
|
|
84
|
+
pgn: { type: 'number', title: 'PGN', default: 127501 },
|
|
85
|
+
instance: { type: 'number', title: 'Instance' },
|
|
86
|
+
switch: { type: 'number', title: 'Switch #' },
|
|
87
|
+
confirmed:{ type: 'boolean', title: 'Confirmed (Phase-2 closed-loop test passed)', default: false },
|
|
88
|
+
excluded: { type: 'boolean', title: 'Hard-excluded (never actuate)', default: false },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
let opts = {};
|
|
96
|
+
|
|
97
|
+
function unwrap(v) {
|
|
98
|
+
if (v && typeof v === 'object' && v.value !== undefined && v.timestamp !== undefined) return v.value;
|
|
99
|
+
return v;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function snapshot() {
|
|
103
|
+
const paths = (opts.paths && opts.paths.length) ? opts.paths : DEFAULT_PATHS;
|
|
104
|
+
const values = {};
|
|
105
|
+
for (const p of paths) {
|
|
106
|
+
const v = unwrap(app.getSelfPath(p));
|
|
107
|
+
if (v !== undefined && v !== null) values[p] = v;
|
|
108
|
+
}
|
|
109
|
+
if (Object.keys(values).length === 0) return;
|
|
110
|
+
queue.append({ seq: ++seq, ts: new Date().toISOString(), values });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function postJson(urlStr, headers, bodyObj) {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
const u = new URL(urlStr);
|
|
116
|
+
const lib = u.protocol === 'https:' ? https : http;
|
|
117
|
+
const body = Buffer.from(JSON.stringify(bodyObj));
|
|
118
|
+
const req = lib.request(
|
|
119
|
+
{ method: 'POST', hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80),
|
|
120
|
+
path: u.pathname + u.search,
|
|
121
|
+
headers: { 'content-type': 'application/json', 'content-length': body.length, ...headers },
|
|
122
|
+
timeout: 20000 },
|
|
123
|
+
(res) => {
|
|
124
|
+
let data = '';
|
|
125
|
+
res.on('data', (c) => (data += c));
|
|
126
|
+
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
|
127
|
+
});
|
|
128
|
+
req.on('error', reject);
|
|
129
|
+
req.on('timeout', () => req.destroy(new Error('timeout')));
|
|
130
|
+
req.write(body); req.end();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function reschedule() {
|
|
135
|
+
if (sampleTimer) clearInterval(sampleTimer);
|
|
136
|
+
if (postTimer) clearInterval(postTimer);
|
|
137
|
+
sampleTimer = setInterval(snapshot, live.sampleIntervalMs);
|
|
138
|
+
postTimer = setInterval(flush, live.postIntervalMs);
|
|
139
|
+
app.debug(`timers: sample ${live.sampleIntervalMs}ms, post ${live.postIntervalMs}ms`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ===================== N2K tap (raw / discovery / read-back) =====================
|
|
143
|
+
|
|
144
|
+
// tap only while something needs it (raw capture, discovery, or armed read-back)
|
|
145
|
+
function tapNeeded() { return rawCap.active || !!disc || !!opts.actuationEnabled; }
|
|
146
|
+
function ensureTap() {
|
|
147
|
+
if (tapNeeded() && !tapOff) { tapOff = tapN2K(app, onFrame); app.debug('n2k tap on'); }
|
|
148
|
+
else if (!tapNeeded() && tapOff) { tapOff(); tapOff = null; app.debug('n2k tap off'); }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function onFrame(f) {
|
|
152
|
+
// keep latest 127501 status per instance for actuation read-back
|
|
153
|
+
if (f.pgn === 127501) statusCache.set(`127501:${instanceOf(f.fields)}`, { fields: f.fields, tsMs: f.tsMs });
|
|
154
|
+
// feed an active discovery correlator
|
|
155
|
+
if (disc) disc.corr.observe(f.pgn, f.fields, f.tsMs);
|
|
156
|
+
// raw capture window (filtered + rate-limited so cellular stays sane)
|
|
157
|
+
if (rawCap.active) {
|
|
158
|
+
const pgns = rawCap.pgns && rawCap.pgns.length ? rawCap.pgns : null;
|
|
159
|
+
if (!pgns || pgns.includes(f.pgn)) {
|
|
160
|
+
const rk = `${f.pgn}:${f.src}`;
|
|
161
|
+
const minGap = 1000 / Math.max(1, rawCap.maxHz);
|
|
162
|
+
if (f.tsMs - (rateGate.get(rk) || 0) >= minGap) {
|
|
163
|
+
rateGate.set(rk, f.tsMs);
|
|
164
|
+
rawBuf.push({ ts: new Date(f.tsMs).toISOString(), pgn: f.pgn, src: f.src, dst: f.dst, fields: f.fields });
|
|
165
|
+
if (rawBuf.length > 5000) rawBuf.shift(); // bound memory; raw is best-effort
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const rawUrl = () => opts.serverUrl.replace(/\/api\/ingest\b/, '/api/raw');
|
|
172
|
+
|
|
173
|
+
async function postRaw() {
|
|
174
|
+
if (!rawBuf.length) return;
|
|
175
|
+
const frames = rawBuf.splice(0, 500);
|
|
176
|
+
try {
|
|
177
|
+
await postJson(rawUrl(), { authorization: `Bearer ${opts.ingestToken}` },
|
|
178
|
+
{ source: opts.source || 'goblin', frames });
|
|
179
|
+
} catch (e) { app.debug(`raw post failed: ${e.message}`); } // drop; raw is best-effort
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function startCapture(c) {
|
|
183
|
+
rawCap = {
|
|
184
|
+
active: true, pgns: c.pgns || null, maxHz: c.maxHz || 5,
|
|
185
|
+
postIntervalMs: Math.max(1000, c.postIntervalMs || 2000),
|
|
186
|
+
};
|
|
187
|
+
ensureTap();
|
|
188
|
+
if (rawTimer) clearInterval(rawTimer);
|
|
189
|
+
rawTimer = setInterval(postRaw, rawCap.postIntervalMs);
|
|
190
|
+
if (capExpireTimer) clearTimeout(capExpireTimer);
|
|
191
|
+
capExpireTimer = setTimeout(stopCapture, Math.max(1, c.seconds || 60) * 1000);
|
|
192
|
+
app.setPluginStatus(`raw capture on · ${rawCap.maxHz}Hz · ${c.seconds || 60}s`);
|
|
193
|
+
}
|
|
194
|
+
function stopCapture() {
|
|
195
|
+
if (!rawCap.active) return;
|
|
196
|
+
rawCap.active = false;
|
|
197
|
+
if (rawTimer) { clearInterval(rawTimer); rawTimer = null; }
|
|
198
|
+
if (capExpireTimer) { clearTimeout(capExpireTimer); capExpireTimer = null; }
|
|
199
|
+
postRaw();
|
|
200
|
+
ensureTap();
|
|
201
|
+
app.debug('raw capture off');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ===================== discovery (Phase 1, read-only) =====================
|
|
205
|
+
|
|
206
|
+
function startDiscovery(s) {
|
|
207
|
+
disc = { sessionId: s.sessionId, target: s.target, expected: s.expectedToggles, corr: new Correlator(), timer: null };
|
|
208
|
+
ensureTap();
|
|
209
|
+
disc.timer = setTimeout(finishDiscovery, Math.max(1, s.seconds || 30) * 1000);
|
|
210
|
+
app.setPluginStatus(`discovery: learning "${s.target}" — toggle it now (${s.seconds || 30}s)`);
|
|
211
|
+
app.debug(`discovery session ${s.sessionId} started for "${s.target}"`);
|
|
212
|
+
}
|
|
213
|
+
function finishDiscovery() {
|
|
214
|
+
if (!disc) return;
|
|
215
|
+
const candidates = disc.corr.candidates({ expectedToggles: disc.expected }).map((c) => ({
|
|
216
|
+
pgn: c.pgn, instance: c.instance, switch: c.switch,
|
|
217
|
+
edges: c.edges, confidence: c.confidence, ambiguousWith: c.ambiguousWith,
|
|
218
|
+
}));
|
|
219
|
+
pendingCandidates = { sessionId: disc.sessionId, candidates };
|
|
220
|
+
app.setPluginStatus(`discovery done: ${candidates.length} candidate(s) for "${disc.target}"`);
|
|
221
|
+
disc = null;
|
|
222
|
+
ensureTap();
|
|
223
|
+
flush(); // report promptly
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ===================== actuation (gated; closed-loop) =====================
|
|
227
|
+
|
|
228
|
+
function statusFieldsFor(target) {
|
|
229
|
+
const e = statusCache.get(`127501:${target.instance}`);
|
|
230
|
+
return e ? e.fields : null;
|
|
231
|
+
}
|
|
232
|
+
function currentBit(target) {
|
|
233
|
+
return actuation.readbackMatches(statusFieldsFor(target), target, true) ? true
|
|
234
|
+
: actuation.readbackMatches(statusFieldsFor(target), target, false) ? false : null;
|
|
235
|
+
}
|
|
236
|
+
async function readback(target, want, timeoutMs) {
|
|
237
|
+
const deadline = Date.now() + timeoutMs;
|
|
238
|
+
while (Date.now() < deadline) {
|
|
239
|
+
if (actuation.readbackMatches(statusFieldsFor(target), target, want)) return true;
|
|
240
|
+
await sleep(200);
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function reportResult(cmd, payload, status, result) {
|
|
246
|
+
pendingResults.push({ id: cmd.id, logId: payload.logId, status, result: result || null });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function handleActuate(cmd, kind) {
|
|
250
|
+
const p = cmd.payload || {};
|
|
251
|
+
const target = p.target || {};
|
|
252
|
+
const notAfterMs = p.notAfter ? Date.parse(p.notAfter) : null;
|
|
253
|
+
|
|
254
|
+
const g = actuation.gate({
|
|
255
|
+
armed: !!opts.actuationEnabled, allowlist: opts.actuationTargets || [],
|
|
256
|
+
target, kind, notAfterMs,
|
|
257
|
+
});
|
|
258
|
+
if (!g.allow) { app.error(`actuation rejected (${kind}): ${g.reason}`); return reportResult(cmd, p, 'rejected', { reason: g.reason }); }
|
|
259
|
+
if (!sender || !sender.ready) return reportResult(cmd, p, 'rejected', { reason: 'n2k out not available' });
|
|
260
|
+
|
|
261
|
+
// confirm test toggles to the opposite of the current state and verifies the
|
|
262
|
+
// load followed, then restores it — leaving the boat as found.
|
|
263
|
+
const orig = currentBit(target);
|
|
264
|
+
const want = kind === 'confirm' ? (orig == null ? true : !orig) : !!p.state;
|
|
265
|
+
|
|
266
|
+
sender.send(actuation.buildSwitchControl(target, want));
|
|
267
|
+
let ok = await readback(target, want, 2500);
|
|
268
|
+
let via = '127502';
|
|
269
|
+
if (!ok) { // fallback to the Command Group Function
|
|
270
|
+
sender.send(actuation.buildCommandGroupFunction(target, want));
|
|
271
|
+
ok = await readback(target, want, 2500);
|
|
272
|
+
via = ok ? '126208' : 'none';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (kind === 'confirm' && ok && orig != null) {
|
|
276
|
+
sender.send(actuation.buildSwitchControl(target, orig)); // restore
|
|
277
|
+
await readback(target, orig, 2500);
|
|
278
|
+
}
|
|
279
|
+
reportResult(cmd, p, ok ? 'ok' : 'mismatch', { via, state: want, readback: ok });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ===================== directive application =====================
|
|
283
|
+
|
|
284
|
+
function applyDirectives(d) {
|
|
285
|
+
if (d.config && d.config.version > cfgVersion) {
|
|
286
|
+
cfgVersion = d.config.version;
|
|
287
|
+
live = {
|
|
288
|
+
sampleIntervalMs: Math.max(1000, d.config.sampleIntervalMs || live.sampleIntervalMs),
|
|
289
|
+
postIntervalMs: Math.max(1000, d.config.postIntervalMs || live.postIntervalMs),
|
|
290
|
+
};
|
|
291
|
+
app.setPluginStatus(`config v${cfgVersion}: sample ${live.sampleIntervalMs}ms / post ${live.postIntervalMs}ms`);
|
|
292
|
+
reschedule();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (d.rawCapture && d.rawCapture.version > capVersion) {
|
|
296
|
+
capVersion = d.rawCapture.version;
|
|
297
|
+
if (d.rawCapture.active) startCapture(d.rawCapture); else stopCapture();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (d.discovery && (!disc || disc.sessionId !== d.discovery.sessionId)) {
|
|
301
|
+
// don't re-run a session we already produced candidates for
|
|
302
|
+
if (!pendingCandidates || pendingCandidates.sessionId !== d.discovery.sessionId) startDiscovery(d.discovery);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
for (const c of d.commands || []) {
|
|
306
|
+
if (executed.has(c.id)) continue;
|
|
307
|
+
executed.add(c.id);
|
|
308
|
+
if (c.type === 'flushBuffer') flush();
|
|
309
|
+
else if (c.type === 'actuate') handleActuate(c, 'actuate');
|
|
310
|
+
else if (c.type === 'confirmTarget') handleActuate(c, 'confirm');
|
|
311
|
+
else app.debug(`unknown command type ${c.type} (${c.id})`);
|
|
312
|
+
app.debug(`command ${c.id} ${c.type} handled`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ===================== uplink =====================
|
|
317
|
+
|
|
318
|
+
function haveSomethingToReport() {
|
|
319
|
+
return queue.length > 0 || pendingResults.length > 0 || !!pendingCandidates;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function flush() {
|
|
323
|
+
if (posting || !haveSomethingToReport()) return;
|
|
324
|
+
posting = true;
|
|
325
|
+
const sentResults = pendingResults;
|
|
326
|
+
const sentCandidates = pendingCandidates;
|
|
327
|
+
try {
|
|
328
|
+
const batch = queue.peek(opts.maxBatch || 500);
|
|
329
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
330
|
+
const body = {
|
|
331
|
+
source: opts.source || 'goblin', nonce, cfgVersion, capVersion,
|
|
332
|
+
ackedCommands: [...executed],
|
|
333
|
+
commandResults: sentResults,
|
|
334
|
+
discoveryCandidates: sentCandidates || undefined,
|
|
335
|
+
status: { actuationArmed: !!opts.actuationEnabled, rawCaptureActive: rawCap.active, capVersion },
|
|
336
|
+
batch,
|
|
337
|
+
};
|
|
338
|
+
const { status, body: text } = await postJson(opts.serverUrl, { authorization: `Bearer ${opts.ingestToken}` }, body);
|
|
339
|
+
if (status !== 200) { app.debug(`post HTTP ${status}`); return; }
|
|
340
|
+
const resp = JSON.parse(text);
|
|
341
|
+
queue.dropThrough(resp.ackSeq);
|
|
342
|
+
// these were accepted by shore — clear them (only the ones we just sent)
|
|
343
|
+
if (sentResults.length) pendingResults = pendingResults.slice(sentResults.length);
|
|
344
|
+
if (sentCandidates && pendingCandidates === sentCandidates) pendingCandidates = null;
|
|
345
|
+
|
|
346
|
+
if (resp.directives) {
|
|
347
|
+
if (verify(opts.commandKey, resp.sig, nonce, resp.directives)) applyDirectives(JSON.parse(resp.directives));
|
|
348
|
+
else app.error('directive signature invalid — ignoring shore commands');
|
|
349
|
+
}
|
|
350
|
+
// prune executed ids the shore is no longer sending
|
|
351
|
+
const stillPending = new Set((resp.directives ? (JSON.parse(resp.directives).commands || []) : []).map((c) => c.id));
|
|
352
|
+
for (const id of [...executed]) if (!stillPending.has(id)) executed.delete(id);
|
|
353
|
+
|
|
354
|
+
const armed = opts.actuationEnabled ? ' · ARMED' : '';
|
|
355
|
+
const cap = rawCap.active ? ' · CAPTURING' : '';
|
|
356
|
+
app.setPluginStatus(`buffer ${queue.length} · cfg v${cfgVersion}${cap}${armed}`);
|
|
357
|
+
} catch (e) {
|
|
358
|
+
app.debug(`post failed: ${e.message} (buffered ${queue.length})`);
|
|
359
|
+
} finally {
|
|
360
|
+
posting = false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
plugin.start = function (options) {
|
|
365
|
+
opts = options || {};
|
|
366
|
+
live = {
|
|
367
|
+
sampleIntervalMs: Math.max(1000, opts.sampleIntervalMs || 15000),
|
|
368
|
+
postIntervalMs: Math.max(1000, opts.postIntervalMs || 15000),
|
|
369
|
+
};
|
|
370
|
+
const file = path.join(app.getDataDirPath(), 'telltale-queue.json');
|
|
371
|
+
queue = new DiskQueue({ file, maxItems: opts.maxItems || 40000, log: (m) => app.debug(m) });
|
|
372
|
+
sender = createSender(app, { log: (m) => app.debug(m) });
|
|
373
|
+
ensureTap(); // on if armed (for read-back); otherwise stays off until a directive
|
|
374
|
+
reschedule();
|
|
375
|
+
const armed = opts.actuationEnabled ? ' · ACTUATION ARMED' : '';
|
|
376
|
+
app.setPluginStatus(`uplink started → ${opts.serverUrl}${armed}`);
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
plugin.stop = function () {
|
|
380
|
+
if (sampleTimer) clearInterval(sampleTimer);
|
|
381
|
+
if (postTimer) clearInterval(postTimer);
|
|
382
|
+
if (rawTimer) clearInterval(rawTimer);
|
|
383
|
+
if (capExpireTimer) clearTimeout(capExpireTimer);
|
|
384
|
+
if (disc && disc.timer) clearTimeout(disc.timer);
|
|
385
|
+
sampleTimer = postTimer = rawTimer = capExpireTimer = null;
|
|
386
|
+
disc = null;
|
|
387
|
+
if (tapOff) { tapOff(); tapOff = null; }
|
|
388
|
+
if (sender) sender.stop();
|
|
389
|
+
if (queue) queue.stop();
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
return plugin;
|
|
393
|
+
};
|
package/lib/actuation.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Digital-switching actuation — the layered safety model from docs/FEATURES.md.
|
|
4
|
+
// The boat-side allowlist is the REAL control: a compromised or buggy shore can
|
|
5
|
+
// only address loads you have locally confirmed. Everything here is pure so the
|
|
6
|
+
// gate logic is unit-tested without a bus.
|
|
7
|
+
|
|
8
|
+
// Names that must never be remotely actuatable — a wrong/stale/spoofed state could
|
|
9
|
+
// endanger the boat. The UI/boat default-excludes anything matching; this is a
|
|
10
|
+
// belt-and-braces hint, not the only guard (the allowlist is).
|
|
11
|
+
const DANGER_PATTERNS = [
|
|
12
|
+
/bilge/i, /nav(?:igation)?\s*light/i, /steaming/i, /\bmast\b/i, /anchor light/i,
|
|
13
|
+
/engine/i, /start(?:er)?/i, /windlass/i, /winch/i, /thruster/i, /\bhelm\b/i,
|
|
14
|
+
/seacock/i, /valve/i, /fuel/i, /propane|lpg|gas/i,
|
|
15
|
+
];
|
|
16
|
+
function looksDangerous(name) {
|
|
17
|
+
return DANGER_PATTERNS.some((re) => re.test(String(name || '')));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sameTarget = (a, b) =>
|
|
21
|
+
a && b && Number(a.pgn) === Number(b.pgn) &&
|
|
22
|
+
Number(a.instance) === Number(b.instance) &&
|
|
23
|
+
Number(a.switch) === Number(b.switch);
|
|
24
|
+
|
|
25
|
+
function findTarget(allowlist, target) {
|
|
26
|
+
return (allowlist || []).find((t) => sameTarget(t, target)) || null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The single decision point. Returns {allow, reason}.
|
|
30
|
+
// armed — master actuation-enable toggle (default OFF on the boat)
|
|
31
|
+
// allowlist — locally-configured targets [{pgn,instance,switch,confirmed,excluded}]
|
|
32
|
+
// target — {pgn,instance,switch}
|
|
33
|
+
// kind — 'actuate' (needs confirmed) | 'confirm' (Phase-2 test; confirmed not required)
|
|
34
|
+
// nowMs/notAfterMs — TTL: stale commands are dropped, never executed late
|
|
35
|
+
function gate({ armed, allowlist, target, kind = 'actuate', nowMs = Date.now(), notAfterMs = null }) {
|
|
36
|
+
if (!armed) return { allow: false, reason: 'actuation not armed' };
|
|
37
|
+
if (!target || target.pgn == null || target.instance == null || target.switch == null) {
|
|
38
|
+
return { allow: false, reason: 'malformed target' };
|
|
39
|
+
}
|
|
40
|
+
if (notAfterMs != null && nowMs > notAfterMs) return { allow: false, reason: 'command expired (TTL)' };
|
|
41
|
+
const t = findTarget(allowlist, target);
|
|
42
|
+
if (!t) return { allow: false, reason: 'target not in boat allowlist' };
|
|
43
|
+
if (t.excluded) return { allow: false, reason: 'target hard-excluded' };
|
|
44
|
+
if (kind === 'actuate' && !t.confirmed) return { allow: false, reason: 'target not confirmed' };
|
|
45
|
+
return { allow: true, reason: 'ok' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- canboat JSON builders for app.emit('nmea2000JsonOut', ...) ----
|
|
49
|
+
// Primary path: 127502 Binary Switch Control. We set just the addressed switch.
|
|
50
|
+
function buildSwitchControl(target, state) {
|
|
51
|
+
return {
|
|
52
|
+
pgn: 127502,
|
|
53
|
+
dst: 255,
|
|
54
|
+
Instance: Number(target.instance),
|
|
55
|
+
[`Switch${Number(target.switch)}`]: state ? 'On' : 'Off',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Fallback path: 126208 Command Group Function writing the 127501 field directly.
|
|
60
|
+
// Some banks ignore 127502 and only accept the CGF — we try this if the read-back
|
|
61
|
+
// after 127502 shows no change. Field layout is canboat's; validate on the real bus.
|
|
62
|
+
function buildCommandGroupFunction(target, state) {
|
|
63
|
+
return {
|
|
64
|
+
pgn: 126208,
|
|
65
|
+
dst: 255,
|
|
66
|
+
'Function Code': 'Command',
|
|
67
|
+
PGN: 127501,
|
|
68
|
+
'Number of Parameters': 2,
|
|
69
|
+
list: [
|
|
70
|
+
{ Parameter: 1, Value: Number(target.instance) }, // Instance field
|
|
71
|
+
{ Parameter: 2 + Number(target.switch), Value: state ? 1 : 0 }, // Indicator<switch>
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Read-back check: did the status frame settle to the state we asked for?
|
|
77
|
+
function readbackMatches(statusFields, target, wantState) {
|
|
78
|
+
if (!statusFields) return false;
|
|
79
|
+
const key = Object.keys(statusFields).find((k) => {
|
|
80
|
+
const m = /^(?:indicator|switch)\s*(\d+)$/i.exec(k);
|
|
81
|
+
return m && Number(m[1]) === Number(target.switch);
|
|
82
|
+
});
|
|
83
|
+
if (!key) return false;
|
|
84
|
+
const v = String(statusFields[key]).toLowerCase();
|
|
85
|
+
const bit = v === 'on' || v === '1' || v === 'true';
|
|
86
|
+
return bit === !!wantState;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
gate, findTarget, sameTarget, looksDangerous, DANGER_PATTERNS,
|
|
91
|
+
buildSwitchControl, buildCommandGroupFunction, readbackMatches,
|
|
92
|
+
};
|
package/lib/buffer.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
// A small in-memory queue mirrored to one JSON file. Node is single-threaded so
|
|
6
|
+
// array ops are atomic; disk writes are throttled (flash wear) and bounded (the
|
|
7
|
+
// Venus /data partition must never fill — that can reboot the Cerbo).
|
|
8
|
+
class DiskQueue {
|
|
9
|
+
constructor({ file, maxItems = 40000, maxBytes = 16 * 1024 * 1024, persistMs = 5000, log = () => {} }) {
|
|
10
|
+
this.file = file;
|
|
11
|
+
this.maxItems = maxItems;
|
|
12
|
+
this.maxBytes = maxBytes;
|
|
13
|
+
this.persistMs = persistMs;
|
|
14
|
+
this.log = log;
|
|
15
|
+
this.items = [];
|
|
16
|
+
this.dirty = false;
|
|
17
|
+
this._writing = false;
|
|
18
|
+
this._load();
|
|
19
|
+
this._timer = setInterval(() => this._maybePersist(), persistMs);
|
|
20
|
+
if (this._timer.unref) this._timer.unref();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_load() {
|
|
24
|
+
try {
|
|
25
|
+
const raw = fs.readFileSync(this.file, 'utf8');
|
|
26
|
+
const arr = JSON.parse(raw);
|
|
27
|
+
if (Array.isArray(arr)) this.items = arr;
|
|
28
|
+
this.log(`buffer: loaded ${this.items.length} items`);
|
|
29
|
+
} catch { /* no file yet */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
append(item) {
|
|
33
|
+
this.items.push(item);
|
|
34
|
+
// enforce caps: drop OLDEST first (a stuck link loses the stalest data,
|
|
35
|
+
// never fills the disk)
|
|
36
|
+
let dropped = 0;
|
|
37
|
+
while (this.items.length > this.maxItems) { this.items.shift(); dropped++; }
|
|
38
|
+
// cheap byte guard: only measure when comfortably large
|
|
39
|
+
if (this.items.length > 1000 && this._bytes() > this.maxBytes) {
|
|
40
|
+
while (this.items.length > 500 && this._bytes() > this.maxBytes) { this.items.shift(); dropped++; }
|
|
41
|
+
}
|
|
42
|
+
if (dropped) this.log(`buffer: dropped ${dropped} oldest (cap)`);
|
|
43
|
+
this.dirty = true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
peek(n) { return this.items.slice(0, n); }
|
|
47
|
+
get length() { return this.items.length; }
|
|
48
|
+
|
|
49
|
+
// drop everything with seq <= ackSeq (delivery confirmed)
|
|
50
|
+
dropThrough(ackSeq) {
|
|
51
|
+
if (ackSeq == null) return;
|
|
52
|
+
const before = this.items.length;
|
|
53
|
+
this.items = this.items.filter((it) => it.seq > ackSeq);
|
|
54
|
+
if (this.items.length !== before) this.dirty = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_bytes() { return Buffer.byteLength(JSON.stringify(this.items)); }
|
|
58
|
+
|
|
59
|
+
_maybePersist() {
|
|
60
|
+
if (!this.dirty || this._writing) return;
|
|
61
|
+
this._writing = true;
|
|
62
|
+
this.dirty = false;
|
|
63
|
+
const tmp = this.file + '.tmp';
|
|
64
|
+
const data = JSON.stringify(this.items);
|
|
65
|
+
fs.writeFile(tmp, data, (err) => {
|
|
66
|
+
if (err) { this.dirty = true; this._writing = false; return; }
|
|
67
|
+
fs.rename(tmp, this.file, () => { this._writing = false; });
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
flushSync() {
|
|
72
|
+
try { fs.writeFileSync(this.file, JSON.stringify(this.items)); this.dirty = false; } catch { /* */ }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
stop() { clearInterval(this._timer); this.flushSync(); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { DiskQueue };
|
package/lib/discovery.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Discovery Phase 1 — passive signature learning.
|
|
4
|
+
//
|
|
5
|
+
// You name a target ("spreader lights") and toggle it a few times with the real
|
|
6
|
+
// physical control. We watch the switching-family PGNs and find the
|
|
7
|
+
// (pgn, instance, switch) whose bit transitions in lockstep with your toggles and
|
|
8
|
+
// is otherwise quiet. Pure + deterministic so it can be unit-tested without a bus.
|
|
9
|
+
|
|
10
|
+
// PGNs we treat as "switching family" by default. 127501 Binary Status Report is
|
|
11
|
+
// the universal one; CZone/EmpireBus/Victron also emit proprietary status PGNs,
|
|
12
|
+
// which you can add to the capture filter.
|
|
13
|
+
const DEFAULT_SWITCH_PGNS = [127501];
|
|
14
|
+
|
|
15
|
+
// Truthy mapping for the many ways canboat renders a binary indicator.
|
|
16
|
+
function asBit(v) {
|
|
17
|
+
if (v === 1 || v === true) return 1;
|
|
18
|
+
if (v === 0 || v === false || v == null) return 0;
|
|
19
|
+
const s = String(v).trim().toLowerCase();
|
|
20
|
+
if (s === 'on' || s === '1' || s === 'true' || s === 'yes') return 1;
|
|
21
|
+
if (s === 'off' || s === '0' || s === 'false' || s === 'no') return 0;
|
|
22
|
+
return null; // not a binary field
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Pull every binary switch out of a canboat 127501-style frame. Field names vary
|
|
26
|
+
// across canboat versions: Indicator1..28 / Switch1..28 / "Indicator 1".
|
|
27
|
+
function extractSwitches(fields) {
|
|
28
|
+
const out = [];
|
|
29
|
+
if (!fields || typeof fields !== 'object') return out;
|
|
30
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
31
|
+
const m = /^(?:indicator|switch)\s*(\d+)$/i.exec(k);
|
|
32
|
+
if (!m) continue;
|
|
33
|
+
const bit = asBit(v);
|
|
34
|
+
if (bit == null) continue;
|
|
35
|
+
out.push({ switch: Number(m[1]), state: bit });
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function instanceOf(fields) {
|
|
41
|
+
if (!fields) return 0;
|
|
42
|
+
const i = fields.Instance ?? fields.instance ?? fields['Switch Bank Instance'];
|
|
43
|
+
const n = Number(i);
|
|
44
|
+
return Number.isFinite(n) ? n : 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const keyOf = (pgn, instance, sw) => `${pgn}:${instance}:${sw}`;
|
|
48
|
+
|
|
49
|
+
class Correlator {
|
|
50
|
+
// markWindowMs: how close a bus edge must be to an operator "mark" to count as aligned
|
|
51
|
+
constructor({ markWindowMs = 1500 } = {}) {
|
|
52
|
+
this.markWindowMs = markWindowMs;
|
|
53
|
+
this.channels = new Map(); // key -> {pgn, instance, switch, last, obs, edges:[ms]}
|
|
54
|
+
this.marks = []; // optional operator toggle timestamps (ms)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// mark the moment you physically flipped the switch (optional but sharpens it)
|
|
58
|
+
mark(tsMs) { this.marks.push(tsMs); }
|
|
59
|
+
|
|
60
|
+
observe(pgn, fields, tsMs) {
|
|
61
|
+
const instance = instanceOf(fields);
|
|
62
|
+
for (const { switch: sw, state } of extractSwitches(fields)) {
|
|
63
|
+
const key = keyOf(pgn, instance, sw);
|
|
64
|
+
let ch = this.channels.get(key);
|
|
65
|
+
if (!ch) { ch = { pgn, instance, switch: sw, last: null, obs: 0, edges: [] }; this.channels.set(key, ch); }
|
|
66
|
+
ch.obs++;
|
|
67
|
+
if (ch.last != null && state !== ch.last) ch.edges.push(tsMs);
|
|
68
|
+
ch.last = state;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Rank channels into candidate signatures. expectedToggles (if known) is the
|
|
73
|
+
// number of times you flipped the switch; otherwise we lean on cleanliness.
|
|
74
|
+
candidates({ expectedToggles = null, maxResults = 5 } = {}) {
|
|
75
|
+
const chans = [...this.channels.values()].filter((c) => c.edges.length > 0);
|
|
76
|
+
|
|
77
|
+
const scored = chans.map((c) => {
|
|
78
|
+
const edges = c.edges.length;
|
|
79
|
+
const noise = edges / Math.max(1, c.obs); // 1.0 == flips every frame (bad)
|
|
80
|
+
let score;
|
|
81
|
+
|
|
82
|
+
if (this.marks.length) {
|
|
83
|
+
// alignment: fraction of marks that have a bus edge nearby
|
|
84
|
+
const alignedMarks = this.marks.filter((m) =>
|
|
85
|
+
c.edges.some((e) => Math.abs(e - m) <= this.markWindowMs)).length;
|
|
86
|
+
const alignedEdges = c.edges.filter((e) =>
|
|
87
|
+
this.marks.some((m) => Math.abs(e - m) <= this.markWindowMs)).length;
|
|
88
|
+
const align = alignedMarks / this.marks.length; // 0..1 recall
|
|
89
|
+
const precision = alignedEdges / edges; // 0..1 (penalise stray edges)
|
|
90
|
+
score = align * precision;
|
|
91
|
+
} else if (expectedToggles != null) {
|
|
92
|
+
const dist = Math.abs(edges - expectedToggles);
|
|
93
|
+
score = Math.max(0, 1 - dist / (expectedToggles + 1));
|
|
94
|
+
} else {
|
|
95
|
+
// no priors: prefer few, clean edges over constant chatter
|
|
96
|
+
score = 1 / edges;
|
|
97
|
+
}
|
|
98
|
+
score *= (1 - Math.min(0.9, noise)); // always punish chattery channels
|
|
99
|
+
return { pgn: c.pgn, instance: c.instance, switch: c.switch, edges, obs: c.obs, score, edgeTimes: c.edges };
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// co-moving channels (same edge pattern within the window) are ambiguous: you
|
|
103
|
+
// can't tell them apart from the bus alone — surface them so the human picks.
|
|
104
|
+
for (const a of scored) {
|
|
105
|
+
a.ambiguousWith = scored
|
|
106
|
+
.filter((b) => b !== a && sameEdges(a.edgeTimes, b.edgeTimes, this.markWindowMs))
|
|
107
|
+
.map((b) => ({ pgn: b.pgn, instance: b.instance, switch: b.switch }));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return scored
|
|
111
|
+
.sort((a, b) => b.score - a.score || a.edges - b.edges)
|
|
112
|
+
.slice(0, maxResults)
|
|
113
|
+
.map(({ edgeTimes, ...c }) => ({ ...c, confidence: Number(c.score.toFixed(3)) }));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// two edge-time sets "co-move" if equal length and each pairs within tolerance
|
|
118
|
+
function sameEdges(a, b, tolMs) {
|
|
119
|
+
if (a.length !== b.length || a.length === 0) return false;
|
|
120
|
+
for (let i = 0; i < a.length; i++) if (Math.abs(a[i] - b[i]) > tolMs) return false;
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { Correlator, extractSwitches, instanceOf, asBit, DEFAULT_SWITCH_PGNS };
|
package/lib/hmac.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
|
|
4
|
+
// Wire contract for the shore → boat command channel: HMAC-SHA256 over the
|
|
5
|
+
// concatenation (nonce + directives), both strings, hex-encoded. The collector
|
|
6
|
+
// signs returned directives; the plugin verifies before applying. Keep this the
|
|
7
|
+
// single source of truth — the simulator (shore/tools/simulate-boat.mjs) and the
|
|
8
|
+
// collector reimplement the same formula and must stay in lockstep.
|
|
9
|
+
function sign(key, nonce, directives) {
|
|
10
|
+
return crypto.createHmac('sha256', key || '').update(String(nonce) + String(directives)).digest('hex');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Constant-time compare. Returns false (never throws) on missing/malformed hex.
|
|
14
|
+
function verify(key, sig, nonce, directives) {
|
|
15
|
+
const a = Buffer.from(sig || '', 'hex');
|
|
16
|
+
const b = Buffer.from(sign(key, nonce, directives), 'hex');
|
|
17
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { sign, verify };
|
package/lib/n2k.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Thin adaptor over the SignalK server's NMEA 2000 plumbing. Confirmed against
|
|
4
|
+
// signalk-server source (packages/streams/src/canboatjs.ts emits 'N2KAnalyzerOut';
|
|
5
|
+
// docs/develop/plugins send via 'nmea2000JsonOut' once 'nmea2000OutAvailable'):
|
|
6
|
+
//
|
|
7
|
+
// IN : app.on('N2KAnalyzerOut', pgn => ...) // canboat JSON: {pgn,src,dst,fields,...}
|
|
8
|
+
// OUT : app.emit('nmea2000JsonOut', {pgn,...}) // after 'nmea2000OutAvailable'
|
|
9
|
+
//
|
|
10
|
+
// `app` is just an EventEmitter, so tests pass a fake one.
|
|
11
|
+
|
|
12
|
+
// Subscribe to the raw canboat-parsed N2K stream. Returns an unsubscribe fn.
|
|
13
|
+
function tapN2K(app, onFrame) {
|
|
14
|
+
const handler = (pgn) => {
|
|
15
|
+
if (!pgn || pgn.pgn == null) return;
|
|
16
|
+
onFrame({
|
|
17
|
+
pgn: Number(pgn.pgn),
|
|
18
|
+
src: pgn.src != null ? Number(pgn.src) : null,
|
|
19
|
+
dst: pgn.dst != null ? Number(pgn.dst) : null,
|
|
20
|
+
fields: pgn.fields || pgn.data || {},
|
|
21
|
+
tsMs: pgn.timestamp ? Date.parse(pgn.timestamp) : Date.now(),
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
app.on('N2KAnalyzerOut', handler);
|
|
25
|
+
return () => { try { app.removeListener('N2KAnalyzerOut', handler); } catch { /* */ } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A sender that respects provider readiness. The N2K-out provider may not be up at
|
|
29
|
+
// plugin start; we gate on 'nmea2000OutAvailable' and refuse to send until then so
|
|
30
|
+
// commands are never silently dropped into the void.
|
|
31
|
+
function createSender(app, { log = () => {} } = {}) {
|
|
32
|
+
let ready = false;
|
|
33
|
+
const onReady = () => { ready = true; log('n2k out available'); };
|
|
34
|
+
app.on('nmea2000OutAvailable', onReady);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
get ready() { return ready; },
|
|
38
|
+
// Force-ready (e.g. tests, or servers that don't emit the availability event
|
|
39
|
+
// but do have a writable connection configured).
|
|
40
|
+
markReady() { ready = true; },
|
|
41
|
+
send(pgnObject) {
|
|
42
|
+
if (!ready) return { sent: false, reason: 'n2k out not available' };
|
|
43
|
+
app.emit('nmea2000JsonOut', pgnObject);
|
|
44
|
+
return { sent: true };
|
|
45
|
+
},
|
|
46
|
+
stop() { try { app.removeListener('nmea2000OutAvailable', onReady); } catch { /* */ } },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { tapN2K, createSender };
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "signalk-telltale-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Decimated, buffered uplink of SignalK data to the GOBLIN shore collector, with an HMAC-verified command channel, bounded raw-N2K capture, discovery, and gated digital-switching actuation.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"signalk-node-server-plugin",
|
|
11
|
+
"signalk-category-utility",
|
|
12
|
+
"nmea2000",
|
|
13
|
+
"telltale"
|
|
14
|
+
],
|
|
15
|
+
"signalk-plugin-enabled-by-default": false,
|
|
16
|
+
"author": "Andrew Dupuis",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://gitlab.dupuis.xyz/andrewdupuis/telltale.git",
|
|
21
|
+
"directory": "cerbo/telltale-signalk-plugin"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://gitlab.dupuis.xyz/andrewdupuis/telltale",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://gitlab.dupuis.xyz/andrewdupuis/telltale/-/issues"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"index.js",
|
|
32
|
+
"lib/"
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {}
|
|
35
|
+
}
|