bmweb-cli 0.1.4 → 0.1.7
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 +290 -27
- package/dist/bmweb.js +1591 -255
- package/package.json +1 -1
- package/runtime/core/ipofile/encode.js +701 -0
- package/runtime/core/ipofile/exec.js +155 -0
- package/runtime/core/webshim/web-serial-bus.js +59 -10
- package/runtime/screens/ipo-runtime/program.js +8 -2
package/dist/bmweb.js
CHANGED
|
@@ -81,8 +81,866 @@ function helpLines(spec) {
|
|
|
81
81
|
return rows.map(([l, h]) => ` ${l.padEnd(w)} ${h}`.trimEnd());
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// src/serial.ts
|
|
85
|
+
import { readdirSync } from "node:fs";
|
|
86
|
+
var RX_KICK_MS = 4;
|
|
87
|
+
var PORT_PATTERNS = [
|
|
88
|
+
/^cu\.usbserial/i,
|
|
89
|
+
/^cu\.SLAB/i,
|
|
90
|
+
/^cu\.wchusbserial/i,
|
|
91
|
+
/^ttyUSB/i,
|
|
92
|
+
/^ttyACM/i
|
|
93
|
+
];
|
|
94
|
+
var NodeSerialPort = class {
|
|
95
|
+
path;
|
|
96
|
+
opener;
|
|
97
|
+
binding = null;
|
|
98
|
+
/** chunks heard and not yet read */
|
|
99
|
+
chunks = [];
|
|
100
|
+
/** reads waiting for bytes, oldest first, when the queue is empty */
|
|
101
|
+
waiters = [];
|
|
102
|
+
/** the receive kick, running while a read waits (see startKick) */
|
|
103
|
+
kick = null;
|
|
104
|
+
/** a kick ioctl in flight, so they never pile up */
|
|
105
|
+
kicking = false;
|
|
106
|
+
/** the lines as last set, so a partial setSignals keeps the others */
|
|
107
|
+
lines = { dtr: false, rts: false, brk: false };
|
|
108
|
+
/** the wire trace sink, when the CLI wants one */
|
|
109
|
+
info;
|
|
110
|
+
/**
|
|
111
|
+
* @param path - the device path
|
|
112
|
+
* @param opener - how a binding is opened
|
|
113
|
+
* @param info - vendor and product ids, when known (portLabel shows them)
|
|
114
|
+
*/
|
|
115
|
+
constructor(path, opener, info = {}) {
|
|
116
|
+
this.path = path;
|
|
117
|
+
this.opener = opener;
|
|
118
|
+
this.info = info;
|
|
119
|
+
}
|
|
120
|
+
/** Is a binding open (Web Serial's SerialPort.connected). */
|
|
121
|
+
get connected() {
|
|
122
|
+
return !!this.binding;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Open the device with the given settings.
|
|
126
|
+
* @param cfg - baud, bits, parity
|
|
127
|
+
*/
|
|
128
|
+
async open(cfg) {
|
|
129
|
+
if (this.binding) throw new Error(`${this.path} is already open`);
|
|
130
|
+
const b = await this.opener(this.path, cfg);
|
|
131
|
+
this.binding = b;
|
|
132
|
+
this.chunks = [];
|
|
133
|
+
b.onData((chunk) => this.push(chunk));
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Close the device: the binding goes, a waiting read is told `done`.
|
|
137
|
+
*/
|
|
138
|
+
async close() {
|
|
139
|
+
const b = this.binding;
|
|
140
|
+
this.binding = null;
|
|
141
|
+
this.stopKick();
|
|
142
|
+
this.wakeAll();
|
|
143
|
+
this.chunks = [];
|
|
144
|
+
if (b) await b.close();
|
|
145
|
+
}
|
|
146
|
+
/** Tell every waiting read the port is done, and forget them. */
|
|
147
|
+
wakeAll() {
|
|
148
|
+
const ws = this.waiters;
|
|
149
|
+
this.waiters = [];
|
|
150
|
+
for (const w of ws) w({ value: void 0, done: true });
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Bytes the binding heard: to the waiting read, else queued.
|
|
154
|
+
* @param chunk - the bytes
|
|
155
|
+
*/
|
|
156
|
+
push(chunk) {
|
|
157
|
+
if (!chunk.length) return;
|
|
158
|
+
const w = this.waiters.shift();
|
|
159
|
+
if (w) {
|
|
160
|
+
w({ value: chunk, done: false });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.chunks.push(chunk);
|
|
164
|
+
}
|
|
165
|
+
/** The readable side: getReader() hands back the one reader. */
|
|
166
|
+
get readable() {
|
|
167
|
+
return {
|
|
168
|
+
getReader: () => ({
|
|
169
|
+
read: () => this.read(),
|
|
170
|
+
cancel: async () => this.cancel(),
|
|
171
|
+
releaseLock: () => {
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** The writable side: getWriter() hands back the one writer. */
|
|
177
|
+
get writable() {
|
|
178
|
+
return {
|
|
179
|
+
getWriter: () => ({
|
|
180
|
+
write: (bytes) => this.write(bytes),
|
|
181
|
+
releaseLock: () => {
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* One read: the next chunk, or `done` when the port is closed.
|
|
188
|
+
* @returns the read result
|
|
189
|
+
*/
|
|
190
|
+
read() {
|
|
191
|
+
const next = this.chunks.shift();
|
|
192
|
+
if (next) return Promise.resolve({ value: next, done: false });
|
|
193
|
+
if (!this.binding) return Promise.resolve({ value: void 0, done: true });
|
|
194
|
+
return new Promise((resolve2) => {
|
|
195
|
+
this.waiters.push(resolve2);
|
|
196
|
+
this.startKick();
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Make the driver deliver what it has heard.
|
|
201
|
+
*
|
|
202
|
+
* THE BUG THIS FIXES. On macOS the built-in FTDI driver does not wake the
|
|
203
|
+
* reader when bytes arrive: with a read armed and the process simply
|
|
204
|
+
* waiting, an ECU's answer sat in the driver until some OTHER call touched
|
|
205
|
+
* the device (the next write, a modem-line change, close), and only then
|
|
206
|
+
* came out -- measured on a real car as 0 bytes for the first exchange
|
|
207
|
+
* after open and every later exchange delivering the PREVIOUS one's bytes
|
|
208
|
+
* at its start. Polling the modem lines (a TIOCMGET, no wire traffic)
|
|
209
|
+
* every few milliseconds while a read waits makes each answer arrive
|
|
210
|
+
* within the poll interval, first exchange included. The interval never
|
|
211
|
+
* holds the process open and stops itself once no read is waiting.
|
|
212
|
+
*/
|
|
213
|
+
startKick() {
|
|
214
|
+
if (this.kick) return;
|
|
215
|
+
const tick = () => {
|
|
216
|
+
const b = this.binding;
|
|
217
|
+
if (!b || !this.waiters.length) {
|
|
218
|
+
this.stopKick();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (this.kicking) return;
|
|
222
|
+
this.kicking = true;
|
|
223
|
+
b.get().catch(() => null).then(() => {
|
|
224
|
+
this.kicking = false;
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
this.kick = setInterval(tick, RX_KICK_MS);
|
|
228
|
+
if (typeof this.kick === "object" && "unref" in this.kick)
|
|
229
|
+
this.kick.unref();
|
|
230
|
+
}
|
|
231
|
+
/** Stop the receive kick. */
|
|
232
|
+
stopKick() {
|
|
233
|
+
if (!this.kick) return;
|
|
234
|
+
clearInterval(this.kick);
|
|
235
|
+
this.kick = null;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Cancel the reader: a waiting read is told `done`, buffered bytes go.
|
|
239
|
+
*/
|
|
240
|
+
cancel() {
|
|
241
|
+
this.chunks = [];
|
|
242
|
+
this.wakeAll();
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Write bytes. Resolves when the OS has them, not when they have left the
|
|
246
|
+
* wire: the bus then holds DTR for the telegram's own byte time, and
|
|
247
|
+
* waiting for transmission here would double that hold and lose the
|
|
248
|
+
* ECU's answer (the reference interface's DtrTimeCorrCom is 0.3 ms).
|
|
249
|
+
* @param bytes - the framed request
|
|
250
|
+
*/
|
|
251
|
+
async write(bytes) {
|
|
252
|
+
if (!this.binding) throw new Error(`${this.path} is not open`);
|
|
253
|
+
await this.binding.write(bytes);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Drive the modem lines. A partial call keeps the lines it does not name,
|
|
257
|
+
* as Web Serial does; the binding always gets all three, because
|
|
258
|
+
* `serialport` would otherwise assert the ones left unsaid.
|
|
259
|
+
* @param s - the lines to set
|
|
260
|
+
*/
|
|
261
|
+
async setSignals(s) {
|
|
262
|
+
if (!this.binding) throw new Error(`${this.path} is not open`);
|
|
263
|
+
if (s.dataTerminalReady !== void 0)
|
|
264
|
+
this.lines.dtr = !!s.dataTerminalReady;
|
|
265
|
+
if (s.requestToSend !== void 0) this.lines.rts = !!s.requestToSend;
|
|
266
|
+
if (s.break !== void 0) this.lines.brk = !!s.break;
|
|
267
|
+
await this.binding.set({ ...this.lines });
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Read the modem lines (KL15 arrives on DSR or DCD of a K+DCAN cable).
|
|
271
|
+
* @returns the lines, or null when the binding cannot say
|
|
272
|
+
*/
|
|
273
|
+
async getSignals() {
|
|
274
|
+
if (!this.binding) return null;
|
|
275
|
+
const st = await this.binding.get();
|
|
276
|
+
if (!st) return null;
|
|
277
|
+
return {
|
|
278
|
+
dataSetReady: !!st.dsr,
|
|
279
|
+
dataCarrierDetect: !!st.dcd,
|
|
280
|
+
clearToSend: !!st.cts
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* The USB ids, for the bus's port label.
|
|
285
|
+
* @returns what is known
|
|
286
|
+
*/
|
|
287
|
+
getInfo() {
|
|
288
|
+
return this.info;
|
|
289
|
+
}
|
|
290
|
+
/** Web Serial's disconnect event; a pulled USB cable is not watched here. */
|
|
291
|
+
addEventListener() {
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
var serialportModule = null;
|
|
295
|
+
async function loadSerialport() {
|
|
296
|
+
if (serialportModule) return serialportModule;
|
|
297
|
+
try {
|
|
298
|
+
serialportModule = await import("serialport");
|
|
299
|
+
} catch {
|
|
300
|
+
throw new CliError(
|
|
301
|
+
"the serialport package is not installed; run: npm i -g serialport (or reinstall bmweb-cli with its optional dependencies)"
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return serialportModule;
|
|
305
|
+
}
|
|
306
|
+
async function openSerialportBinding(path, cfg) {
|
|
307
|
+
const mod = await loadSerialport();
|
|
308
|
+
const port = new mod.SerialPort({
|
|
309
|
+
path,
|
|
310
|
+
baudRate: cfg.baudRate,
|
|
311
|
+
dataBits: cfg.dataBits,
|
|
312
|
+
stopBits: cfg.stopBits,
|
|
313
|
+
parity: cfg.parity,
|
|
314
|
+
autoOpen: false,
|
|
315
|
+
// keep the lines where we leave them across close/open
|
|
316
|
+
hupcl: false
|
|
317
|
+
});
|
|
318
|
+
await new Promise(
|
|
319
|
+
(res, rej) => port.open(
|
|
320
|
+
(e) => e ? rej(new CliError(`cannot open ${path}: ${e.message}`)) : res()
|
|
321
|
+
)
|
|
322
|
+
);
|
|
323
|
+
const call = (fn) => new Promise((res, rej) => fn((e) => e ? rej(e) : res()));
|
|
324
|
+
const binding = {
|
|
325
|
+
write: (bytes) => call((cb) => port.write(Buffer.from(bytes), cb)),
|
|
326
|
+
onData: (fn) => port.on("data", (b) => fn(new Uint8Array(b))),
|
|
327
|
+
set: (s) => call(
|
|
328
|
+
(cb) => port.set(
|
|
329
|
+
{
|
|
330
|
+
dtr: s.dtr,
|
|
331
|
+
rts: s.rts,
|
|
332
|
+
brk: s.brk,
|
|
333
|
+
cts: false,
|
|
334
|
+
dsr: false,
|
|
335
|
+
...process.platform === "linux" ? { lowLatency: true } : {}
|
|
336
|
+
},
|
|
337
|
+
cb
|
|
338
|
+
)
|
|
339
|
+
),
|
|
340
|
+
get: () => new Promise((res) => port.get((e, st) => res(e || !st ? null : st))),
|
|
341
|
+
close: () => new Promise((res) => port.close(() => res()))
|
|
342
|
+
};
|
|
343
|
+
await binding.set({ dtr: false, rts: false, brk: false });
|
|
344
|
+
return binding;
|
|
345
|
+
}
|
|
346
|
+
async function listPorts(devDir = "/dev") {
|
|
347
|
+
const found = /* @__PURE__ */ new Map();
|
|
348
|
+
try {
|
|
349
|
+
for (const name of readdirSync(devDir))
|
|
350
|
+
if (PORT_PATTERNS.some((re) => re.test(name)))
|
|
351
|
+
found.set(`${devDir}/${name}`, "");
|
|
352
|
+
} catch {
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
const mod = await loadSerialport();
|
|
356
|
+
for (const p of await mod.SerialPort.list()) {
|
|
357
|
+
const base = p.path.split("/").pop() || p.path;
|
|
358
|
+
if (!PORT_PATTERNS.some((re) => re.test(base))) continue;
|
|
359
|
+
const detail = [
|
|
360
|
+
p.manufacturer,
|
|
361
|
+
p.vendorId && p.productId ? `${p.vendorId}:${p.productId}` : "",
|
|
362
|
+
p.serialNumber ? `sn ${p.serialNumber}` : ""
|
|
363
|
+
].filter(Boolean).join(" ");
|
|
364
|
+
found.set(p.path, detail);
|
|
365
|
+
}
|
|
366
|
+
} catch {
|
|
367
|
+
}
|
|
368
|
+
return [...found.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([path, detail]) => ({ path, detail }));
|
|
369
|
+
}
|
|
370
|
+
function choosePort(wanted, candidates) {
|
|
371
|
+
if (wanted) return wanted;
|
|
372
|
+
if (candidates.length === 1) return candidates[0].path;
|
|
373
|
+
if (!candidates.length)
|
|
374
|
+
throw new CliError(
|
|
375
|
+
"no K+DCAN cable found (looked for cu.usbserial*, cu.SLAB*, cu.wchusbserial*, ttyUSB*, ttyACM*); pass --port <device>"
|
|
376
|
+
);
|
|
377
|
+
throw new CliError(
|
|
378
|
+
`several ports found; pass --port: ${candidates.map((c) => c.path).join(", ")}`
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// src/ws.ts
|
|
383
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
384
|
+
import { createServer, connect } from "node:net";
|
|
385
|
+
var WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
386
|
+
var OP_CONT = 0;
|
|
387
|
+
var OP_TEXT = 1;
|
|
388
|
+
var OP_BINARY = 2;
|
|
389
|
+
var OP_CLOSE = 8;
|
|
390
|
+
var OP_PING = 9;
|
|
391
|
+
var OP_PONG = 10;
|
|
392
|
+
var MAX_MESSAGE = 1 << 20;
|
|
393
|
+
var CLOSE_NORMAL = 1e3;
|
|
394
|
+
var CLOSE_POLICY = 1008;
|
|
395
|
+
var WsConnection = class {
|
|
396
|
+
socket;
|
|
397
|
+
masked;
|
|
398
|
+
handlers = {};
|
|
399
|
+
/** bytes received and not yet parsed into a frame */
|
|
400
|
+
buf = Buffer.alloc(0);
|
|
401
|
+
/** the opcode of the message being assembled across continuation frames */
|
|
402
|
+
fragOp = 0;
|
|
403
|
+
/** the fragments of that message */
|
|
404
|
+
frags = [];
|
|
405
|
+
/** how many bytes those fragments hold */
|
|
406
|
+
fragLen = 0;
|
|
407
|
+
/** has a close frame been sent, so the next one is not sent twice */
|
|
408
|
+
closing = false;
|
|
409
|
+
/** has onClose already fired */
|
|
410
|
+
closed = false;
|
|
411
|
+
/**
|
|
412
|
+
* @param socket - the open TCP socket, past the handshake
|
|
413
|
+
* @param masked - true on the client end, which must mask what it sends
|
|
414
|
+
*/
|
|
415
|
+
constructor(socket, masked) {
|
|
416
|
+
this.socket = socket;
|
|
417
|
+
this.masked = masked;
|
|
418
|
+
socket.setNoDelay(true);
|
|
419
|
+
socket.on("data", (d) => this.feed(d));
|
|
420
|
+
socket.on("error", (e) => {
|
|
421
|
+
this.handlers.onError?.(e);
|
|
422
|
+
this.finish(1006, e.message);
|
|
423
|
+
});
|
|
424
|
+
socket.on("close", () => this.finish(1006, "socket closed"));
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Take the handlers. Set once, right after construction, so no frame is
|
|
428
|
+
* parsed before someone is listening.
|
|
429
|
+
* @param h - the handlers
|
|
430
|
+
*/
|
|
431
|
+
on(h) {
|
|
432
|
+
this.handlers = h;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Send a text frame.
|
|
436
|
+
* @param text - the payload
|
|
437
|
+
*/
|
|
438
|
+
sendText(text) {
|
|
439
|
+
this.send(OP_TEXT, Buffer.from(text, "utf8"));
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Send a binary frame.
|
|
443
|
+
* @param bytes - the payload
|
|
444
|
+
*/
|
|
445
|
+
sendBinary(bytes) {
|
|
446
|
+
this.send(OP_BINARY, Buffer.from(bytes));
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Send a close frame and end the socket.
|
|
450
|
+
* @param code - the close code
|
|
451
|
+
* @param reason - the human reason, at most 123 bytes on the wire
|
|
452
|
+
*/
|
|
453
|
+
close(code = CLOSE_NORMAL, reason = "") {
|
|
454
|
+
if (this.closing) return;
|
|
455
|
+
this.closing = true;
|
|
456
|
+
const r = Buffer.from(reason, "utf8").subarray(0, 123);
|
|
457
|
+
const body = Buffer.alloc(2 + r.length);
|
|
458
|
+
body.writeUInt16BE(code, 0);
|
|
459
|
+
r.copy(body, 2);
|
|
460
|
+
try {
|
|
461
|
+
this.send(OP_CLOSE, body);
|
|
462
|
+
} catch {
|
|
463
|
+
}
|
|
464
|
+
this.socket.end();
|
|
465
|
+
}
|
|
466
|
+
/** Drop the socket without a close frame (a handshake refusal). */
|
|
467
|
+
destroy() {
|
|
468
|
+
this.closing = true;
|
|
469
|
+
this.socket.destroy();
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Frame a payload and write it.
|
|
473
|
+
* @param op - the opcode
|
|
474
|
+
* @param payload - the bytes
|
|
475
|
+
*/
|
|
476
|
+
send(op, payload) {
|
|
477
|
+
if (this.socket.destroyed) return;
|
|
478
|
+
this.socket.write(encodeFrame(op, payload, this.masked));
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Take bytes off the socket and parse every whole frame in them.
|
|
482
|
+
* @param chunk - what arrived
|
|
483
|
+
*/
|
|
484
|
+
feed(chunk) {
|
|
485
|
+
this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk;
|
|
486
|
+
for (; ; ) {
|
|
487
|
+
const frame = decodeFrame(this.buf, !this.masked);
|
|
488
|
+
if (frame === null) return;
|
|
489
|
+
if (frame instanceof Error) {
|
|
490
|
+
this.handlers.onError?.(frame);
|
|
491
|
+
this.close(1002, frame.message);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
this.buf = this.buf.subarray(frame.size);
|
|
495
|
+
this.handle(frame.fin, frame.op, frame.payload);
|
|
496
|
+
if (this.closed) return;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* One parsed frame: control frames answered here, data frames assembled.
|
|
501
|
+
* @param fin - is this the message's last frame
|
|
502
|
+
* @param op - the opcode
|
|
503
|
+
* @param payload - the frame's bytes
|
|
504
|
+
*/
|
|
505
|
+
handle(fin, op, payload) {
|
|
506
|
+
if (op === OP_PING) {
|
|
507
|
+
this.send(OP_PONG, payload);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (op === OP_PONG) return;
|
|
511
|
+
if (op === OP_CLOSE) {
|
|
512
|
+
const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1005;
|
|
513
|
+
const reason = payload.length > 2 ? payload.subarray(2).toString() : "";
|
|
514
|
+
if (!this.closing) this.close(code === 1005 ? CLOSE_NORMAL : code, "");
|
|
515
|
+
this.finish(code, reason);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (op === OP_TEXT || op === OP_BINARY) {
|
|
519
|
+
if (this.frags.length) {
|
|
520
|
+
this.fail("a new message started before the last one finished");
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
this.fragOp = op;
|
|
524
|
+
} else if (op !== OP_CONT) {
|
|
525
|
+
this.fail(`unknown opcode 0x${op.toString(16)}`);
|
|
526
|
+
return;
|
|
527
|
+
} else if (!this.fragOp) {
|
|
528
|
+
this.fail("a continuation frame with nothing to continue");
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
this.fragLen += payload.length;
|
|
532
|
+
if (this.fragLen > MAX_MESSAGE) {
|
|
533
|
+
this.fail(`message longer than ${MAX_MESSAGE} bytes`);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
this.frags.push(payload);
|
|
537
|
+
if (!fin) return;
|
|
538
|
+
const body = this.frags.length === 1 ? this.frags[0] : Buffer.concat(this.frags);
|
|
539
|
+
const wasText = this.fragOp === OP_TEXT;
|
|
540
|
+
this.frags = [];
|
|
541
|
+
this.fragLen = 0;
|
|
542
|
+
this.fragOp = 0;
|
|
543
|
+
if (wasText) this.handlers.onText?.(body.toString("utf8"));
|
|
544
|
+
else this.handlers.onBinary?.(new Uint8Array(body));
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* A protocol violation: report it and close with 1002.
|
|
548
|
+
* @param why - what was wrong
|
|
549
|
+
*/
|
|
550
|
+
fail(why) {
|
|
551
|
+
this.handlers.onError?.(new Error(why));
|
|
552
|
+
this.close(1002, why);
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* The connection is over: tell the owner once.
|
|
556
|
+
* @param code - the close code
|
|
557
|
+
* @param reason - the peer's reason
|
|
558
|
+
*/
|
|
559
|
+
finish(code, reason) {
|
|
560
|
+
if (this.closed) return;
|
|
561
|
+
this.closed = true;
|
|
562
|
+
this.handlers.onClose?.(code, reason);
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
function encodeFrame(op, payload, mask) {
|
|
566
|
+
const len = payload.length;
|
|
567
|
+
const head = len < 126 ? 2 : len < 65536 ? 4 : 10;
|
|
568
|
+
const out = Buffer.alloc(head + (mask ? 4 : 0) + len);
|
|
569
|
+
out[0] = 128 | op;
|
|
570
|
+
if (len < 126) out[1] = len;
|
|
571
|
+
else if (len < 65536) {
|
|
572
|
+
out[1] = 126;
|
|
573
|
+
out.writeUInt16BE(len, 2);
|
|
574
|
+
} else {
|
|
575
|
+
out[1] = 127;
|
|
576
|
+
out.writeUInt32BE(0, 2);
|
|
577
|
+
out.writeUInt32BE(len, 6);
|
|
578
|
+
}
|
|
579
|
+
if (!mask) {
|
|
580
|
+
payload.copy(out, head);
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
out[1] = out[1] | 128;
|
|
584
|
+
const key = randomBytes(4);
|
|
585
|
+
key.copy(out, head);
|
|
586
|
+
for (let i = 0; i < len; i++) out[head + 4 + i] = payload[i] ^ key[i & 3];
|
|
587
|
+
return out;
|
|
588
|
+
}
|
|
589
|
+
function decodeFrame(buf, wantMask) {
|
|
590
|
+
if (buf.length < 2) return null;
|
|
591
|
+
const b0 = buf[0];
|
|
592
|
+
const b1 = buf[1];
|
|
593
|
+
if (b0 & 112) return new Error("reserved frame bits are set");
|
|
594
|
+
const fin = !!(b0 & 128);
|
|
595
|
+
const op = b0 & 15;
|
|
596
|
+
const masked = !!(b1 & 128);
|
|
597
|
+
if (masked !== wantMask)
|
|
598
|
+
return new Error(
|
|
599
|
+
wantMask ? "a client frame was not masked" : "a server frame was masked"
|
|
600
|
+
);
|
|
601
|
+
let len = b1 & 127;
|
|
602
|
+
let at = 2;
|
|
603
|
+
if (len === 126) {
|
|
604
|
+
if (buf.length < at + 2) return null;
|
|
605
|
+
len = buf.readUInt16BE(at);
|
|
606
|
+
at += 2;
|
|
607
|
+
} else if (len === 127) {
|
|
608
|
+
if (buf.length < at + 8) return null;
|
|
609
|
+
const hi = buf.readUInt32BE(at);
|
|
610
|
+
len = buf.readUInt32BE(at + 4);
|
|
611
|
+
at += 8;
|
|
612
|
+
if (hi) return new Error("frame longer than this end will read");
|
|
613
|
+
}
|
|
614
|
+
if (len > MAX_MESSAGE)
|
|
615
|
+
return new Error(`frame longer than ${MAX_MESSAGE} bytes`);
|
|
616
|
+
if (op >= 8 && (!fin || len > 125))
|
|
617
|
+
return new Error("a malformed control frame");
|
|
618
|
+
let key = null;
|
|
619
|
+
if (masked) {
|
|
620
|
+
if (buf.length < at + 4) return null;
|
|
621
|
+
key = buf.subarray(at, at + 4);
|
|
622
|
+
at += 4;
|
|
623
|
+
}
|
|
624
|
+
if (buf.length < at + len) return null;
|
|
625
|
+
const payload = Buffer.from(buf.subarray(at, at + len));
|
|
626
|
+
if (key) for (let i = 0; i < len; i++) payload[i] = payload[i] ^ key[i & 3];
|
|
627
|
+
return { fin, op, payload, size: at + len };
|
|
628
|
+
}
|
|
629
|
+
function acceptKey(key) {
|
|
630
|
+
return createHash("sha1").update(key + WS_GUID).digest("base64");
|
|
631
|
+
}
|
|
632
|
+
async function listenWs(host, port, onClient) {
|
|
633
|
+
const conns = /* @__PURE__ */ new Set();
|
|
634
|
+
const server = createServer((socket) => {
|
|
635
|
+
socket.setNoDelay(true);
|
|
636
|
+
let head = Buffer.alloc(0);
|
|
637
|
+
const onData = (chunk) => {
|
|
638
|
+
head = Buffer.concat([head, chunk]);
|
|
639
|
+
const end = head.indexOf("\r\n\r\n");
|
|
640
|
+
if (end < 0) {
|
|
641
|
+
if (head.length > 8192) socket.destroy();
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
socket.off("data", onData);
|
|
645
|
+
const req = head.subarray(0, end).toString("utf8");
|
|
646
|
+
const rest = head.subarray(end + 4);
|
|
647
|
+
const key = /\r\nsec-websocket-key:[ \t]*(\S+)/i.exec(req)?.[1];
|
|
648
|
+
const upgrade = /\r\nupgrade:[ \t]*websocket/i.test(req);
|
|
649
|
+
if (!key || !upgrade || !/^GET /i.test(req)) {
|
|
650
|
+
socket.end(
|
|
651
|
+
"HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 46\r\n\r\nthis port serves a WebSocket gateway, not HTTP\n"
|
|
652
|
+
);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
socket.write(
|
|
656
|
+
`HTTP/1.1 101 Switching Protocols\r
|
|
657
|
+
Upgrade: websocket\r
|
|
658
|
+
Connection: Upgrade\r
|
|
659
|
+
Sec-WebSocket-Accept: ${acceptKey(key)}\r
|
|
660
|
+
\r
|
|
661
|
+
`
|
|
662
|
+
);
|
|
663
|
+
const conn = new WsConnection(socket, false);
|
|
664
|
+
conns.add(conn);
|
|
665
|
+
socket.on("close", () => conns.delete(conn));
|
|
666
|
+
onClient({
|
|
667
|
+
conn,
|
|
668
|
+
from: `${socket.remoteAddress || "?"}:${socket.remotePort || 0}`
|
|
669
|
+
});
|
|
670
|
+
if (rest.length) setImmediate(() => socket.emit("data", rest));
|
|
671
|
+
};
|
|
672
|
+
socket.on("data", onData);
|
|
673
|
+
socket.on("error", () => socket.destroy());
|
|
674
|
+
});
|
|
675
|
+
await new Promise((res, rej) => {
|
|
676
|
+
server.once("error", rej);
|
|
677
|
+
server.listen(port, host, () => {
|
|
678
|
+
server.off("error", rej);
|
|
679
|
+
res();
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
const addr = server.address();
|
|
683
|
+
return {
|
|
684
|
+
port: typeof addr === "object" && addr ? addr.port : port,
|
|
685
|
+
host,
|
|
686
|
+
raw: server,
|
|
687
|
+
close: async () => {
|
|
688
|
+
for (const c of conns) c.close(CLOSE_NORMAL, "the gateway is stopping");
|
|
689
|
+
await new Promise((res) => server.close(() => res()));
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
async function connectWs(url) {
|
|
694
|
+
const u = new URL(url);
|
|
695
|
+
if (u.protocol !== "ws:")
|
|
696
|
+
throw new Error(
|
|
697
|
+
`${url}: only ws:// is supported here (wss:// needs a TLS front end)`
|
|
698
|
+
);
|
|
699
|
+
const port = Number(u.port || 80);
|
|
700
|
+
const key = randomBytes(16).toString("base64");
|
|
701
|
+
const socket = connect({ host: u.hostname, port });
|
|
702
|
+
socket.setNoDelay(true);
|
|
703
|
+
return new Promise((res, rej) => {
|
|
704
|
+
const fail = (e) => {
|
|
705
|
+
socket.destroy();
|
|
706
|
+
rej(e);
|
|
707
|
+
};
|
|
708
|
+
socket.once("error", fail);
|
|
709
|
+
socket.once("connect", () => {
|
|
710
|
+
socket.write(
|
|
711
|
+
`GET ${u.pathname || "/"}${u.search} HTTP/1.1\r
|
|
712
|
+
Host: ${u.host}\r
|
|
713
|
+
Upgrade: websocket\r
|
|
714
|
+
Connection: Upgrade\r
|
|
715
|
+
Sec-WebSocket-Key: ${key}\r
|
|
716
|
+
Sec-WebSocket-Version: 13\r
|
|
717
|
+
\r
|
|
718
|
+
`
|
|
719
|
+
);
|
|
720
|
+
let head = Buffer.alloc(0);
|
|
721
|
+
const onData = (chunk) => {
|
|
722
|
+
head = Buffer.concat([head, chunk]);
|
|
723
|
+
const end = head.indexOf("\r\n\r\n");
|
|
724
|
+
if (end < 0) {
|
|
725
|
+
if (head.length > 8192)
|
|
726
|
+
fail(new Error(`${url}: no handshake answer`));
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
socket.off("data", onData);
|
|
730
|
+
const res101 = head.subarray(0, end).toString("utf8");
|
|
731
|
+
if (!/^HTTP\/1\.1 101/i.test(res101)) {
|
|
732
|
+
fail(
|
|
733
|
+
new Error(
|
|
734
|
+
`${url}: the server refused the WebSocket upgrade (${res101.split("\r\n")[0]})`
|
|
735
|
+
)
|
|
736
|
+
);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const accept = /\r\nsec-websocket-accept:[ \t]*(\S+)/i.exec(
|
|
740
|
+
res101
|
|
741
|
+
)?.[1];
|
|
742
|
+
if (accept !== acceptKey(key)) {
|
|
743
|
+
fail(new Error(`${url}: the handshake answer did not match the key`));
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
socket.off("error", fail);
|
|
747
|
+
const rest = head.subarray(end + 4);
|
|
748
|
+
const conn = new WsConnection(socket, true);
|
|
749
|
+
if (rest.length) setImmediate(() => socket.emit("data", rest));
|
|
750
|
+
res(conn);
|
|
751
|
+
};
|
|
752
|
+
socket.on("data", onData);
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// src/gateway.ts
|
|
758
|
+
var DEFAULT_LISTEN = "127.0.0.1:6801";
|
|
759
|
+
function parseListen(listen) {
|
|
760
|
+
const [dh, dp] = DEFAULT_LISTEN.split(":");
|
|
761
|
+
const text = String(listen || "").trim();
|
|
762
|
+
if (!text) return { host: dh, port: Number(dp) };
|
|
763
|
+
if (/^\d+$/.test(text)) return { host: dh, port: Number(text) };
|
|
764
|
+
const at = text.lastIndexOf(":");
|
|
765
|
+
if (at < 0) return { host: text, port: Number(dp) };
|
|
766
|
+
const host = text.slice(0, at) || dh;
|
|
767
|
+
const port = Number(text.slice(at + 1));
|
|
768
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
769
|
+
throw new CliError(`--listen ${listen}: not a port number`);
|
|
770
|
+
return { host: host.replace(/^\[|\]$/g, ""), port };
|
|
771
|
+
}
|
|
772
|
+
async function startGateway(opts = {}) {
|
|
773
|
+
const log = opts.log || ((l) => process.stdout.write(`${l}
|
|
774
|
+
`));
|
|
775
|
+
const candidates = opts.ports || await listPorts();
|
|
776
|
+
const device = choosePort(opts.port, candidates);
|
|
777
|
+
const opener = opts.opener || openSerialportBinding;
|
|
778
|
+
const { host, port } = parseListen(opts.listen || DEFAULT_LISTEN);
|
|
779
|
+
let client2 = null;
|
|
780
|
+
let serial = null;
|
|
781
|
+
let portOpen = false;
|
|
782
|
+
let opens = 0;
|
|
783
|
+
let pumping = false;
|
|
784
|
+
const dropCable = async (forget = false) => {
|
|
785
|
+
const s = serial;
|
|
786
|
+
const wasOpen = portOpen;
|
|
787
|
+
portOpen = false;
|
|
788
|
+
pumping = false;
|
|
789
|
+
if (forget) serial = null;
|
|
790
|
+
if (!s || !wasOpen) return;
|
|
791
|
+
try {
|
|
792
|
+
await s.close();
|
|
793
|
+
} catch {
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
const pump = async (s, conn, generation) => {
|
|
797
|
+
const reader = s.readable.getReader();
|
|
798
|
+
pumping = true;
|
|
799
|
+
while (pumping && opens === generation) {
|
|
800
|
+
let r;
|
|
801
|
+
try {
|
|
802
|
+
r = await reader.read();
|
|
803
|
+
} catch {
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (r.done) return;
|
|
807
|
+
if (r.value && r.value.length && client2 === conn && opens === generation)
|
|
808
|
+
conn.sendBinary(r.value);
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
const handle = async (conn, req) => {
|
|
812
|
+
const id = req.id;
|
|
813
|
+
const reply = (body) => {
|
|
814
|
+
if (id === void 0) return;
|
|
815
|
+
conn.sendText(JSON.stringify({ id, ...body }));
|
|
816
|
+
};
|
|
817
|
+
try {
|
|
818
|
+
if (req.op === "open") {
|
|
819
|
+
await dropCable();
|
|
820
|
+
const s = serial || new NodeSerialPort(device, opener);
|
|
821
|
+
serial = s;
|
|
822
|
+
await s.open(req.config);
|
|
823
|
+
portOpen = true;
|
|
824
|
+
const generation = ++opens;
|
|
825
|
+
reply({ ok: true });
|
|
826
|
+
void pump(s, conn, generation);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (req.op === "close") {
|
|
830
|
+
await dropCable();
|
|
831
|
+
reply({ ok: true });
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (req.op === "setSignals") {
|
|
835
|
+
if (!serial || !portOpen) throw new Error(`${device} is not open`);
|
|
836
|
+
await serial.setSignals(req.signals || {});
|
|
837
|
+
reply({ ok: true });
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (req.op === "getSignals") {
|
|
841
|
+
if (!serial || !portOpen) {
|
|
842
|
+
reply({ ok: true, signals: null });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
reply({ ok: true, signals: await serial.getSignals() });
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
throw new Error(`unknown op ${String(req.op)}`);
|
|
849
|
+
} catch (e) {
|
|
850
|
+
reply({ ok: false, error: e.message || String(e) });
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
const server = await listenWs(host, port, (c) => {
|
|
854
|
+
if (client2) {
|
|
855
|
+
log(` refused ${c.from}: a client is already driving the cable`);
|
|
856
|
+
c.conn.on({});
|
|
857
|
+
c.conn.close(CLOSE_POLICY, "this gateway already has a client");
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
client2 = c.conn;
|
|
861
|
+
log(` client ${c.from} connected`);
|
|
862
|
+
let queue = Promise.resolve();
|
|
863
|
+
c.conn.on({
|
|
864
|
+
onText: (text) => {
|
|
865
|
+
let req;
|
|
866
|
+
try {
|
|
867
|
+
req = JSON.parse(text);
|
|
868
|
+
} catch {
|
|
869
|
+
c.conn.sendText(JSON.stringify({ ok: false, error: "not JSON" }));
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
queue = queue.then(() => handle(c.conn, req));
|
|
873
|
+
},
|
|
874
|
+
onBinary: (bytes) => {
|
|
875
|
+
const s = serial;
|
|
876
|
+
if (!s || !portOpen) {
|
|
877
|
+
log(` write of ${bytes.length} bytes with no port open, dropped`);
|
|
878
|
+
c.conn.sendText(
|
|
879
|
+
JSON.stringify({
|
|
880
|
+
event: "writeFailed",
|
|
881
|
+
error: `${device} is not open`
|
|
882
|
+
})
|
|
883
|
+
);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
const w = s.writable.getWriter();
|
|
887
|
+
w.write(bytes).catch((e) => {
|
|
888
|
+
const why = e.message || String(e);
|
|
889
|
+
log(` write failed: ${why}`);
|
|
890
|
+
if (client2 === c.conn)
|
|
891
|
+
c.conn.sendText(
|
|
892
|
+
JSON.stringify({ event: "writeFailed", error: why })
|
|
893
|
+
);
|
|
894
|
+
});
|
|
895
|
+
},
|
|
896
|
+
onClose: () => {
|
|
897
|
+
if (client2 !== c.conn) return;
|
|
898
|
+
client2 = null;
|
|
899
|
+
log(` client ${c.from} disconnected, cable closed`);
|
|
900
|
+
void dropCable(true);
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
c.conn.sendText(
|
|
904
|
+
JSON.stringify({ event: "hello", port: device, gateway: "bmweb" })
|
|
905
|
+
);
|
|
906
|
+
});
|
|
907
|
+
const shown = host === "0.0.0.0" || host === "::" ? host : host;
|
|
908
|
+
log(`gateway: ${device} served at ws://${shown}:${server.port}`);
|
|
909
|
+
log(
|
|
910
|
+
" this is a byte pipe with no gate of its own: anyone who can reach this port can drive the car."
|
|
911
|
+
);
|
|
912
|
+
log(
|
|
913
|
+
host === "127.0.0.1" || host === "localhost" || host === "::1" ? " listening on this machine only (--listen 0.0.0.0:PORT serves the LAN)" : " listening beyond this machine; the port is open to whoever can route to it"
|
|
914
|
+
);
|
|
915
|
+
log(" one client at a time; the cable is closed when the client goes.");
|
|
916
|
+
return {
|
|
917
|
+
port: server.port,
|
|
918
|
+
host,
|
|
919
|
+
device,
|
|
920
|
+
stop: async () => {
|
|
921
|
+
await server.close();
|
|
922
|
+
client2 = null;
|
|
923
|
+
await dropCable(true);
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
async function gatewayCommand(opts = {}) {
|
|
928
|
+
const g = await startGateway(opts);
|
|
929
|
+
await new Promise((res) => {
|
|
930
|
+
const bye = () => {
|
|
931
|
+
process.off("SIGINT", bye);
|
|
932
|
+
process.off("SIGTERM", bye);
|
|
933
|
+
process.stdout.write("\ngateway: stopping, the cable is closed\n");
|
|
934
|
+
g.stop().then(res, res);
|
|
935
|
+
};
|
|
936
|
+
process.on("SIGINT", bye);
|
|
937
|
+
process.on("SIGTERM", bye);
|
|
938
|
+
});
|
|
939
|
+
return [];
|
|
940
|
+
}
|
|
941
|
+
|
|
84
942
|
// src/ipo.ts
|
|
85
|
-
import { readdirSync, readFileSync as readFileSync3, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
943
|
+
import { readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
86
944
|
import { basename, dirname as dirname2, join as join4, resolve } from "node:path";
|
|
87
945
|
|
|
88
946
|
// src/table.ts
|
|
@@ -111,6 +969,7 @@ function formatCount(n) {
|
|
|
111
969
|
import { readFileSync as readFileSync2 } from "node:fs";
|
|
112
970
|
import { join as join3 } from "node:path";
|
|
113
971
|
import { fileURLToPath } from "node:url";
|
|
972
|
+
import { format, inspect } from "node:util";
|
|
114
973
|
import { createContext, runInContext } from "node:vm";
|
|
115
974
|
|
|
116
975
|
// src/runtime-files.json
|
|
@@ -155,6 +1014,7 @@ var runtime_files_default = [
|
|
|
155
1014
|
"core/ipofile/decls.js",
|
|
156
1015
|
"core/ipofile/walk.js",
|
|
157
1016
|
"core/ipofile/exec.js",
|
|
1017
|
+
"core/ipofile/encode.js",
|
|
158
1018
|
"core/ipofile/lex.js",
|
|
159
1019
|
"core/ipofile/parse.js",
|
|
160
1020
|
"core/ipofile/emit.js",
|
|
@@ -284,14 +1144,49 @@ function runtimeGlobals() {
|
|
|
284
1144
|
loadRuntime();
|
|
285
1145
|
return sandboxRef;
|
|
286
1146
|
}
|
|
1147
|
+
function runtimeConsole() {
|
|
1148
|
+
const on = !!process.env.BMWEB_VERBOSE;
|
|
1149
|
+
const say = (...a) => {
|
|
1150
|
+
if (on) process.stderr.write(`${format(...a)}
|
|
1151
|
+
`);
|
|
1152
|
+
};
|
|
1153
|
+
const table = (rows) => {
|
|
1154
|
+
if (on)
|
|
1155
|
+
process.stderr.write(
|
|
1156
|
+
`${inspect(rows, { depth: 3, maxArrayLength: 200, breakLength: 160 })}
|
|
1157
|
+
`
|
|
1158
|
+
);
|
|
1159
|
+
};
|
|
1160
|
+
const noop = () => {
|
|
1161
|
+
};
|
|
1162
|
+
return {
|
|
1163
|
+
log: say,
|
|
1164
|
+
info: say,
|
|
1165
|
+
warn: say,
|
|
1166
|
+
error: say,
|
|
1167
|
+
debug: say,
|
|
1168
|
+
trace: say,
|
|
1169
|
+
table,
|
|
1170
|
+
group: say,
|
|
1171
|
+
groupCollapsed: say,
|
|
1172
|
+
groupEnd: noop,
|
|
1173
|
+
time: noop,
|
|
1174
|
+
timeEnd: noop,
|
|
1175
|
+
assert: noop,
|
|
1176
|
+
dir: table
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
287
1179
|
function hostGlobals() {
|
|
288
1180
|
const noop = () => {
|
|
289
1181
|
};
|
|
290
1182
|
const sandbox = {
|
|
291
|
-
// the scripts
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
1183
|
+
// the app's scripts talk to the browser console: the bus dumps its wire
|
|
1184
|
+
// trace after an IFH error (console.table in a collapsed group), the
|
|
1185
|
+
// variant resolver notes every probe's verdict, the bus reports the
|
|
1186
|
+
// cable. In a terminal those are noise between a command's own lines
|
|
1187
|
+
// (a scan's table had the trace printed through it), so they go to
|
|
1188
|
+
// stderr only when BMWEB_VERBOSE is set, and never to stdout
|
|
1189
|
+
console: runtimeConsole(),
|
|
295
1190
|
// timers.js's bmwSleep falls back to setTimeout where there is no
|
|
296
1191
|
// Worker; program.js schedules screen cycles and drains key presses
|
|
297
1192
|
// through setTimeout; activations.js defers a session end a microtask
|
|
@@ -635,7 +1530,7 @@ function gatherIncludes(dirs, skip) {
|
|
|
635
1530
|
for (const dir of dirs) {
|
|
636
1531
|
let names;
|
|
637
1532
|
try {
|
|
638
|
-
names =
|
|
1533
|
+
names = readdirSync2(dir);
|
|
639
1534
|
} catch {
|
|
640
1535
|
throw new CliError(`include directory not found: ${dir}`);
|
|
641
1536
|
}
|
|
@@ -790,89 +1685,202 @@ function ipoKeys(file, includeDirs, menu, json) {
|
|
|
790
1685
|
if (!rows.length) return [`${s.stem}: no menu keys`];
|
|
791
1686
|
return formatTable(rows, ["MENU", "KEY", "LABEL", "OPENS", "JOBS", "WRITES"]);
|
|
792
1687
|
}
|
|
793
|
-
function ipoCompile(file, includeDirs, out) {
|
|
1688
|
+
function ipoCompile(file, includeDirs, out, exec = false) {
|
|
794
1689
|
const R = loadRuntime();
|
|
795
1690
|
const name = basename(file);
|
|
796
1691
|
if (!R.ipofIsSource(name))
|
|
797
1692
|
throw new CliError(`${name}: compile takes a .IPS or .SRC source`);
|
|
798
1693
|
const s = readScript(file, includeDirs);
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
1694
|
+
const ext = exec ? "ipoexec.json" : "IPO";
|
|
1695
|
+
const target = out || join4(dirname2(resolve(file)), `${s.stem}.${ext}`);
|
|
1696
|
+
let wrote;
|
|
1697
|
+
if (exec) {
|
|
1698
|
+
try {
|
|
1699
|
+
writeFileSync2(target, JSON.stringify(s.exec));
|
|
1700
|
+
} catch {
|
|
1701
|
+
throw new CliError(`cannot write ${target}`);
|
|
1702
|
+
}
|
|
1703
|
+
wrote = `wrote ${target} (the app's exec form; not INPA's binary .IPO)`;
|
|
1704
|
+
} else {
|
|
1705
|
+
let bytes;
|
|
1706
|
+
try {
|
|
1707
|
+
bytes = R.ipofEncode(s.exec);
|
|
1708
|
+
} catch (err) {
|
|
1709
|
+
throw new CliError(
|
|
1710
|
+
`${name}: cannot write a .IPO -- ${err.message}`
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1713
|
+
try {
|
|
1714
|
+
writeFileSync2(target, bytes);
|
|
1715
|
+
} catch {
|
|
1716
|
+
throw new CliError(`cannot write ${target}`);
|
|
1717
|
+
}
|
|
1718
|
+
wrote = `wrote ${target} (${bytes.length} bytes, INPA's .IPO container)`;
|
|
804
1719
|
}
|
|
805
1720
|
const inv = R.ipofInventory(s.exec);
|
|
806
1721
|
return [
|
|
807
1722
|
`${name}: compiled ${Object.keys(s.exec.procs).length} procedures (${inv.menus.length} menus, ${inv.screens.length} screens, ${inv.funcs.length} functions, ${inv.machines.length} state machines)` + (s.includes.length ? `, includes ${s.includes.join(", ")}` : ""),
|
|
808
|
-
|
|
1723
|
+
wrote
|
|
809
1724
|
];
|
|
810
1725
|
}
|
|
811
1726
|
|
|
812
1727
|
// src/live.ts
|
|
813
1728
|
import { createInterface } from "node:readline";
|
|
814
1729
|
|
|
815
|
-
// src/
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
/^
|
|
821
|
-
/^
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
/**
|
|
1730
|
+
// src/gateway-client.ts
|
|
1731
|
+
var REPLY_MS = 1e4;
|
|
1732
|
+
function gatewayUrl(value) {
|
|
1733
|
+
const text = String(value || "").trim();
|
|
1734
|
+
if (!text) throw new CliError("--gateway needs a host:port or a ws:// URL");
|
|
1735
|
+
if (/^wss?:\/\//i.test(text)) return text;
|
|
1736
|
+
if (/^https?:\/\//i.test(text)) return text.replace(/^http/i, "ws");
|
|
1737
|
+
return `ws://${text.includes(":") ? text : `${text}:6801`}`;
|
|
1738
|
+
}
|
|
1739
|
+
var GatewayPort = class {
|
|
1740
|
+
url;
|
|
1741
|
+
conn = null;
|
|
1742
|
+
/** the device the host reports serving, for the cable chip */
|
|
1743
|
+
device = "";
|
|
1744
|
+
/** is a port open on the host */
|
|
1745
|
+
opened = false;
|
|
1746
|
+
/** chunks the host sent and no read has taken */
|
|
830
1747
|
chunks = [];
|
|
831
1748
|
/** reads waiting for bytes, oldest first, when the queue is empty */
|
|
832
1749
|
waiters = [];
|
|
833
|
-
/**
|
|
834
|
-
|
|
835
|
-
/**
|
|
836
|
-
|
|
837
|
-
/** the
|
|
838
|
-
|
|
839
|
-
/** the
|
|
840
|
-
|
|
1750
|
+
/** control calls in flight, by the id they will be answered with */
|
|
1751
|
+
pending = /* @__PURE__ */ new Map();
|
|
1752
|
+
/** the next control message's id */
|
|
1753
|
+
nextId = 1;
|
|
1754
|
+
/** why the socket went, once it has */
|
|
1755
|
+
gone = null;
|
|
1756
|
+
/** the last write the host could not put on the wire */
|
|
1757
|
+
writeError = null;
|
|
841
1758
|
/**
|
|
842
|
-
* @param
|
|
843
|
-
* @param opener - how a binding is opened
|
|
844
|
-
* @param info - vendor and product ids, when known (portLabel shows them)
|
|
1759
|
+
* @param url - the gateway's ws:// URL
|
|
845
1760
|
*/
|
|
846
|
-
constructor(
|
|
847
|
-
this.
|
|
848
|
-
this.opener = opener;
|
|
849
|
-
this.info = info;
|
|
1761
|
+
constructor(url) {
|
|
1762
|
+
this.url = url;
|
|
850
1763
|
}
|
|
851
|
-
/**
|
|
1764
|
+
/**
|
|
1765
|
+
* Open the socket to the host. Called once, before the bus takes the
|
|
1766
|
+
* port, so a gateway that is not there is reported before anything else.
|
|
1767
|
+
* @returns nothing
|
|
1768
|
+
*/
|
|
1769
|
+
async dial() {
|
|
1770
|
+
let conn;
|
|
1771
|
+
try {
|
|
1772
|
+
conn = await connectWs(this.url);
|
|
1773
|
+
} catch (e) {
|
|
1774
|
+
throw new CliError(
|
|
1775
|
+
`cannot reach the gateway at ${this.url}: ${e.message}`
|
|
1776
|
+
);
|
|
1777
|
+
}
|
|
1778
|
+
this.conn = conn;
|
|
1779
|
+
conn.on({
|
|
1780
|
+
onText: (text) => this.onControl(text),
|
|
1781
|
+
onBinary: (bytes) => this.push(bytes),
|
|
1782
|
+
onClose: (code, reason) => {
|
|
1783
|
+
this.gone = reason || (code === 1008 ? "the gateway already has a client" : "the gateway closed the connection");
|
|
1784
|
+
this.opened = false;
|
|
1785
|
+
this.wakeAll();
|
|
1786
|
+
for (const p of this.pending.values())
|
|
1787
|
+
p.rej(new Error(`the gateway connection ended: ${this.gone}`));
|
|
1788
|
+
this.pending.clear();
|
|
1789
|
+
}
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
/** Is a port open on the host (Web Serial's SerialPort.connected). */
|
|
852
1793
|
get connected() {
|
|
853
|
-
return
|
|
1794
|
+
return this.opened;
|
|
1795
|
+
}
|
|
1796
|
+
/** What the host is serving, for the cable chip. */
|
|
1797
|
+
get remoteDevice() {
|
|
1798
|
+
return this.device;
|
|
854
1799
|
}
|
|
855
1800
|
/**
|
|
856
|
-
*
|
|
1801
|
+
* One control message from the host: a reply, or an event.
|
|
1802
|
+
* @param text - the JSON frame
|
|
1803
|
+
*/
|
|
1804
|
+
onControl(text) {
|
|
1805
|
+
let msg;
|
|
1806
|
+
try {
|
|
1807
|
+
msg = JSON.parse(text);
|
|
1808
|
+
} catch {
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
if (typeof msg.event === "string") {
|
|
1812
|
+
if (msg.event === "hello" && typeof msg.port === "string")
|
|
1813
|
+
this.device = msg.port;
|
|
1814
|
+
if (msg.event === "writeFailed")
|
|
1815
|
+
this.writeError = String(msg.error || "the write failed");
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
const id = typeof msg.id === "number" ? msg.id : null;
|
|
1819
|
+
if (id === null) return;
|
|
1820
|
+
const p = this.pending.get(id);
|
|
1821
|
+
if (!p) return;
|
|
1822
|
+
this.pending.delete(id);
|
|
1823
|
+
if (msg.ok === false)
|
|
1824
|
+
p.rej(new Error(String(msg.error || "the gateway refused the call")));
|
|
1825
|
+
else p.res(msg);
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Send one control message and wait for its reply.
|
|
1829
|
+
* @param op - the operation
|
|
1830
|
+
* @param extra - the operation's fields
|
|
1831
|
+
* @returns the reply
|
|
1832
|
+
*/
|
|
1833
|
+
call(op, extra = {}) {
|
|
1834
|
+
const conn = this.conn;
|
|
1835
|
+
if (!conn || this.gone)
|
|
1836
|
+
return Promise.reject(
|
|
1837
|
+
new Error(
|
|
1838
|
+
`the gateway connection is gone: ${this.gone || "not dialled"}`
|
|
1839
|
+
)
|
|
1840
|
+
);
|
|
1841
|
+
const id = this.nextId++;
|
|
1842
|
+
return new Promise((res, rej) => {
|
|
1843
|
+
const timer = setTimeout(() => {
|
|
1844
|
+
this.pending.delete(id);
|
|
1845
|
+
rej(new Error(`the gateway did not answer ${op} in ${REPLY_MS} ms`));
|
|
1846
|
+
}, REPLY_MS);
|
|
1847
|
+
if (typeof timer === "object" && "unref" in timer) timer.unref();
|
|
1848
|
+
this.pending.set(id, {
|
|
1849
|
+
res: (v) => {
|
|
1850
|
+
clearTimeout(timer);
|
|
1851
|
+
res(v);
|
|
1852
|
+
},
|
|
1853
|
+
rej: (e) => {
|
|
1854
|
+
clearTimeout(timer);
|
|
1855
|
+
rej(e);
|
|
1856
|
+
}
|
|
1857
|
+
});
|
|
1858
|
+
conn.sendText(JSON.stringify({ id, op, ...extra }));
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* Open the remote device with the given settings.
|
|
857
1863
|
* @param cfg - baud, bits, parity
|
|
858
1864
|
*/
|
|
859
1865
|
async open(cfg) {
|
|
860
|
-
|
|
861
|
-
const b = await this.opener(this.path, cfg);
|
|
862
|
-
this.binding = b;
|
|
1866
|
+
await this.call("open", { config: cfg });
|
|
863
1867
|
this.chunks = [];
|
|
864
|
-
|
|
1868
|
+
this.writeError = null;
|
|
1869
|
+
this.opened = true;
|
|
865
1870
|
}
|
|
866
|
-
/**
|
|
867
|
-
* Close the device: the binding goes, a waiting read is told `done`.
|
|
868
|
-
*/
|
|
1871
|
+
/** Close the remote device: a waiting read is told done. */
|
|
869
1872
|
async close() {
|
|
870
|
-
|
|
871
|
-
this.binding = null;
|
|
872
|
-
this.stopKick();
|
|
1873
|
+
this.opened = false;
|
|
873
1874
|
this.wakeAll();
|
|
874
1875
|
this.chunks = [];
|
|
875
|
-
if (
|
|
1876
|
+
if (!this.gone) await this.call("close");
|
|
1877
|
+
}
|
|
1878
|
+
/** Drop the socket itself, once the bus is finished with the port. */
|
|
1879
|
+
hangUp() {
|
|
1880
|
+
this.opened = false;
|
|
1881
|
+
this.wakeAll();
|
|
1882
|
+
if (this.conn && !this.gone) this.conn.close();
|
|
1883
|
+
this.conn = null;
|
|
876
1884
|
}
|
|
877
1885
|
/** Tell every waiting read the port is done, and forget them. */
|
|
878
1886
|
wakeAll() {
|
|
@@ -881,7 +1889,7 @@ var NodeSerialPort = class {
|
|
|
881
1889
|
for (const w of ws) w({ value: void 0, done: true });
|
|
882
1890
|
}
|
|
883
1891
|
/**
|
|
884
|
-
* Bytes the
|
|
1892
|
+
* Bytes the host streamed: to the waiting read, else queued.
|
|
885
1893
|
* @param chunk - the bytes
|
|
886
1894
|
*/
|
|
887
1895
|
push(chunk) {
|
|
@@ -916,202 +1924,76 @@ var NodeSerialPort = class {
|
|
|
916
1924
|
}
|
|
917
1925
|
/**
|
|
918
1926
|
* One read: the next chunk, or `done` when the port is closed.
|
|
1927
|
+
*
|
|
1928
|
+
* Waiters queue, as they do on a local port: the bus races a read against
|
|
1929
|
+
* a timeout and takes a fresh reader on every reopen, and a single waiter
|
|
1930
|
+
* slot would orphan the abandoned read and hand its bytes to whichever
|
|
1931
|
+
* handle happened to be held.
|
|
919
1932
|
* @returns the read result
|
|
920
1933
|
*/
|
|
921
1934
|
read() {
|
|
922
1935
|
const next = this.chunks.shift();
|
|
923
1936
|
if (next) return Promise.resolve({ value: next, done: false });
|
|
924
|
-
if (!this.
|
|
925
|
-
return new Promise((resolve2) =>
|
|
926
|
-
this.waiters.push(resolve2);
|
|
927
|
-
this.startKick();
|
|
928
|
-
});
|
|
929
|
-
}
|
|
930
|
-
/**
|
|
931
|
-
* Make the driver deliver what it has heard.
|
|
932
|
-
*
|
|
933
|
-
* THE BUG THIS FIXES. On macOS the built-in FTDI driver does not wake the
|
|
934
|
-
* reader when bytes arrive: with a read armed and the process simply
|
|
935
|
-
* waiting, an ECU's answer sat in the driver until some OTHER call touched
|
|
936
|
-
* the device (the next write, a modem-line change, close), and only then
|
|
937
|
-
* came out -- measured on a real car as 0 bytes for the first exchange
|
|
938
|
-
* after open and every later exchange delivering the PREVIOUS one's bytes
|
|
939
|
-
* at its start. Polling the modem lines (a TIOCMGET, no wire traffic)
|
|
940
|
-
* every few milliseconds while a read waits makes each answer arrive
|
|
941
|
-
* within the poll interval, first exchange included. The interval never
|
|
942
|
-
* holds the process open and stops itself once no read is waiting.
|
|
943
|
-
*/
|
|
944
|
-
startKick() {
|
|
945
|
-
if (this.kick) return;
|
|
946
|
-
const tick = () => {
|
|
947
|
-
const b = this.binding;
|
|
948
|
-
if (!b || !this.waiters.length) {
|
|
949
|
-
this.stopKick();
|
|
950
|
-
return;
|
|
951
|
-
}
|
|
952
|
-
if (this.kicking) return;
|
|
953
|
-
this.kicking = true;
|
|
954
|
-
b.get().catch(() => null).then(() => {
|
|
955
|
-
this.kicking = false;
|
|
956
|
-
});
|
|
957
|
-
};
|
|
958
|
-
this.kick = setInterval(tick, RX_KICK_MS);
|
|
959
|
-
if (typeof this.kick === "object" && "unref" in this.kick)
|
|
960
|
-
this.kick.unref();
|
|
961
|
-
}
|
|
962
|
-
/** Stop the receive kick. */
|
|
963
|
-
stopKick() {
|
|
964
|
-
if (!this.kick) return;
|
|
965
|
-
clearInterval(this.kick);
|
|
966
|
-
this.kick = null;
|
|
1937
|
+
if (!this.opened) return Promise.resolve({ value: void 0, done: true });
|
|
1938
|
+
return new Promise((resolve2) => this.waiters.push(resolve2));
|
|
967
1939
|
}
|
|
968
|
-
/**
|
|
969
|
-
* Cancel the reader: a waiting read is told `done`, buffered bytes go.
|
|
970
|
-
*/
|
|
1940
|
+
/** Cancel the reader: a waiting read is told done, buffered bytes go. */
|
|
971
1941
|
cancel() {
|
|
972
1942
|
this.chunks = [];
|
|
973
1943
|
this.wakeAll();
|
|
974
1944
|
}
|
|
975
1945
|
/**
|
|
976
|
-
* Write bytes.
|
|
977
|
-
*
|
|
978
|
-
*
|
|
979
|
-
*
|
|
1946
|
+
* Write bytes. As on a local port this resolves when the bytes are on
|
|
1947
|
+
* their way, not when they have left the car's wire: the bus then holds
|
|
1948
|
+
* DTR for the telegram's own byte time, and waiting for more here would
|
|
1949
|
+
* lengthen that hold and lose the ECU's answer.
|
|
980
1950
|
* @param bytes - the framed request
|
|
981
1951
|
*/
|
|
982
1952
|
async write(bytes) {
|
|
983
|
-
if (!this.
|
|
984
|
-
|
|
1953
|
+
if (!this.opened) throw new Error(`${this.url} is not open`);
|
|
1954
|
+
const conn = this.conn;
|
|
1955
|
+
if (!conn) throw new Error(`the gateway connection is gone`);
|
|
1956
|
+
const failed = this.writeError;
|
|
1957
|
+
if (failed) {
|
|
1958
|
+
this.writeError = null;
|
|
1959
|
+
throw new Error(failed);
|
|
1960
|
+
}
|
|
1961
|
+
conn.sendBinary(bytes);
|
|
985
1962
|
}
|
|
986
1963
|
/**
|
|
987
|
-
* Drive the modem lines.
|
|
988
|
-
*
|
|
989
|
-
* `serialport` would otherwise assert the ones left unsaid.
|
|
1964
|
+
* Drive the remote modem lines. The host keeps the lines the call does
|
|
1965
|
+
* not name, exactly as a local port does.
|
|
990
1966
|
* @param s - the lines to set
|
|
991
1967
|
*/
|
|
992
1968
|
async setSignals(s) {
|
|
993
|
-
if (!this.
|
|
994
|
-
|
|
995
|
-
this.lines.dtr = !!s.dataTerminalReady;
|
|
996
|
-
if (s.requestToSend !== void 0) this.lines.rts = !!s.requestToSend;
|
|
997
|
-
if (s.break !== void 0) this.lines.brk = !!s.break;
|
|
998
|
-
await this.binding.set({ ...this.lines });
|
|
1969
|
+
if (!this.opened) throw new Error(`${this.url} is not open`);
|
|
1970
|
+
await this.call("setSignals", { signals: s });
|
|
999
1971
|
}
|
|
1000
|
-
/**
|
|
1001
|
-
* Read the modem lines (KL15 arrives on DSR
|
|
1002
|
-
* @returns the lines, or null when the
|
|
1003
|
-
*/
|
|
1004
|
-
async getSignals() {
|
|
1005
|
-
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
return {
|
|
1009
|
-
dataSetReady: !!st.dsr,
|
|
1010
|
-
dataCarrierDetect: !!st.dcd,
|
|
1011
|
-
clearToSend: !!st.cts
|
|
1012
|
-
};
|
|
1972
|
+
/**
|
|
1973
|
+
* Read the remote modem lines (KL15 arrives on DSR of a K+DCAN cable).
|
|
1974
|
+
* @returns the lines, or null when the host cannot say
|
|
1975
|
+
*/
|
|
1976
|
+
async getSignals() {
|
|
1977
|
+
const r = await this.call("getSignals");
|
|
1978
|
+
const sig = r.signals;
|
|
1979
|
+
return sig || null;
|
|
1013
1980
|
}
|
|
1014
1981
|
/**
|
|
1015
|
-
* The USB ids
|
|
1016
|
-
*
|
|
1982
|
+
* The USB ids. A gateway does not forward them: the chip says where the
|
|
1983
|
+
* cable is instead, which is the fact worth showing when it is not here.
|
|
1984
|
+
* @returns nothing known
|
|
1017
1985
|
*/
|
|
1018
1986
|
getInfo() {
|
|
1019
|
-
return
|
|
1987
|
+
return {};
|
|
1020
1988
|
}
|
|
1021
|
-
/** Web Serial's disconnect event;
|
|
1989
|
+
/** Web Serial's disconnect event; the socket's close stands in for it. */
|
|
1022
1990
|
addEventListener() {
|
|
1023
1991
|
}
|
|
1024
1992
|
};
|
|
1025
|
-
var serialportModule = null;
|
|
1026
|
-
async function loadSerialport() {
|
|
1027
|
-
if (serialportModule) return serialportModule;
|
|
1028
|
-
try {
|
|
1029
|
-
serialportModule = await import("serialport");
|
|
1030
|
-
} catch {
|
|
1031
|
-
throw new CliError(
|
|
1032
|
-
"the serialport package is not installed; run: npm i -g serialport (or reinstall bmweb-cli with its optional dependencies)"
|
|
1033
|
-
);
|
|
1034
|
-
}
|
|
1035
|
-
return serialportModule;
|
|
1036
|
-
}
|
|
1037
|
-
async function openSerialportBinding(path, cfg) {
|
|
1038
|
-
const mod = await loadSerialport();
|
|
1039
|
-
const port = new mod.SerialPort({
|
|
1040
|
-
path,
|
|
1041
|
-
baudRate: cfg.baudRate,
|
|
1042
|
-
dataBits: cfg.dataBits,
|
|
1043
|
-
stopBits: cfg.stopBits,
|
|
1044
|
-
parity: cfg.parity,
|
|
1045
|
-
autoOpen: false,
|
|
1046
|
-
// keep the lines where we leave them across close/open
|
|
1047
|
-
hupcl: false
|
|
1048
|
-
});
|
|
1049
|
-
await new Promise(
|
|
1050
|
-
(res, rej) => port.open(
|
|
1051
|
-
(e) => e ? rej(new CliError(`cannot open ${path}: ${e.message}`)) : res()
|
|
1052
|
-
)
|
|
1053
|
-
);
|
|
1054
|
-
const call = (fn) => new Promise((res, rej) => fn((e) => e ? rej(e) : res()));
|
|
1055
|
-
const binding = {
|
|
1056
|
-
write: (bytes) => call((cb) => port.write(Buffer.from(bytes), cb)),
|
|
1057
|
-
onData: (fn) => port.on("data", (b) => fn(new Uint8Array(b))),
|
|
1058
|
-
set: (s) => call(
|
|
1059
|
-
(cb) => port.set(
|
|
1060
|
-
{
|
|
1061
|
-
dtr: s.dtr,
|
|
1062
|
-
rts: s.rts,
|
|
1063
|
-
brk: s.brk,
|
|
1064
|
-
cts: false,
|
|
1065
|
-
dsr: false,
|
|
1066
|
-
...process.platform === "linux" ? { lowLatency: true } : {}
|
|
1067
|
-
},
|
|
1068
|
-
cb
|
|
1069
|
-
)
|
|
1070
|
-
),
|
|
1071
|
-
get: () => new Promise((res) => port.get((e, st) => res(e || !st ? null : st))),
|
|
1072
|
-
close: () => new Promise((res) => port.close(() => res()))
|
|
1073
|
-
};
|
|
1074
|
-
await binding.set({ dtr: false, rts: false, brk: false });
|
|
1075
|
-
return binding;
|
|
1076
|
-
}
|
|
1077
|
-
async function listPorts(devDir = "/dev") {
|
|
1078
|
-
const found = /* @__PURE__ */ new Map();
|
|
1079
|
-
try {
|
|
1080
|
-
for (const name of readdirSync2(devDir))
|
|
1081
|
-
if (PORT_PATTERNS.some((re) => re.test(name)))
|
|
1082
|
-
found.set(`${devDir}/${name}`, "");
|
|
1083
|
-
} catch {
|
|
1084
|
-
}
|
|
1085
|
-
try {
|
|
1086
|
-
const mod = await loadSerialport();
|
|
1087
|
-
for (const p of await mod.SerialPort.list()) {
|
|
1088
|
-
const base = p.path.split("/").pop() || p.path;
|
|
1089
|
-
if (!PORT_PATTERNS.some((re) => re.test(base))) continue;
|
|
1090
|
-
const detail = [
|
|
1091
|
-
p.manufacturer,
|
|
1092
|
-
p.vendorId && p.productId ? `${p.vendorId}:${p.productId}` : "",
|
|
1093
|
-
p.serialNumber ? `sn ${p.serialNumber}` : ""
|
|
1094
|
-
].filter(Boolean).join(" ");
|
|
1095
|
-
found.set(p.path, detail);
|
|
1096
|
-
}
|
|
1097
|
-
} catch {
|
|
1098
|
-
}
|
|
1099
|
-
return [...found.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([path, detail]) => ({ path, detail }));
|
|
1100
|
-
}
|
|
1101
|
-
function choosePort(wanted, candidates) {
|
|
1102
|
-
if (wanted) return wanted;
|
|
1103
|
-
if (candidates.length === 1) return candidates[0].path;
|
|
1104
|
-
if (!candidates.length)
|
|
1105
|
-
throw new CliError(
|
|
1106
|
-
"no K+DCAN cable found (looked for cu.usbserial*, cu.SLAB*, cu.wchusbserial*, ttyUSB*, ttyACM*); pass --port <device>"
|
|
1107
|
-
);
|
|
1108
|
-
throw new CliError(
|
|
1109
|
-
`several ports found; pass --port: ${candidates.map((c) => c.path).join(", ")}`
|
|
1110
|
-
);
|
|
1111
|
-
}
|
|
1112
1993
|
|
|
1113
1994
|
// src/live.ts
|
|
1114
1995
|
var FTDI_HINT = "If this is an FTDI cable, set its latency timer to 1 ms (Linux: done for you; macOS: FTDI D2XX/driver setting; Windows: Device Manager, Port Settings, Advanced).";
|
|
1996
|
+
var dialled = null;
|
|
1115
1997
|
async function connectBus(opts = {}) {
|
|
1116
1998
|
configureSite({
|
|
1117
1999
|
...opts.api ? { base: opts.api } : {},
|
|
@@ -1119,10 +2001,20 @@ async function connectBus(opts = {}) {
|
|
|
1119
2001
|
});
|
|
1120
2002
|
const R = loadRuntime();
|
|
1121
2003
|
const g = runtimeGlobals();
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
2004
|
+
let port;
|
|
2005
|
+
let path;
|
|
2006
|
+
if (opts.gateway) {
|
|
2007
|
+
const url = gatewayUrl(opts.gateway);
|
|
2008
|
+
const remote = new GatewayPort(url);
|
|
2009
|
+
await remote.dial();
|
|
2010
|
+
dialled = remote;
|
|
2011
|
+
port = remote;
|
|
2012
|
+
path = url;
|
|
2013
|
+
} else {
|
|
2014
|
+
const candidates = opts.ports || await listPorts();
|
|
2015
|
+
path = choosePort(opts.port, candidates);
|
|
2016
|
+
port = new NodeSerialPort(path, opts.opener || openSerialportBinding);
|
|
2017
|
+
}
|
|
1126
2018
|
g.navigator.serial = {
|
|
1127
2019
|
requestPort: async () => port,
|
|
1128
2020
|
getPorts: async () => []
|
|
@@ -1131,8 +2023,14 @@ async function connectBus(opts = {}) {
|
|
|
1131
2023
|
try {
|
|
1132
2024
|
label = await R.webBus.connect();
|
|
1133
2025
|
} catch (e) {
|
|
2026
|
+
if (dialled === port) {
|
|
2027
|
+
dialled.hangUp();
|
|
2028
|
+
dialled = null;
|
|
2029
|
+
}
|
|
1134
2030
|
throw new CliError(`cannot open ${path}: ${e.message}`);
|
|
1135
2031
|
}
|
|
2032
|
+
if (port instanceof GatewayPort)
|
|
2033
|
+
label = `gateway ${path}${port.remoteDevice ? ` (${port.remoteDevice})` : ""}`;
|
|
1136
2034
|
return { R, label, path };
|
|
1137
2035
|
}
|
|
1138
2036
|
async function disconnectBus(R) {
|
|
@@ -1142,6 +2040,10 @@ async function disconnectBus(R) {
|
|
|
1142
2040
|
} catch {
|
|
1143
2041
|
}
|
|
1144
2042
|
if (R.webBus.connected) await R.webBus.disconnect();
|
|
2043
|
+
if (dialled) {
|
|
2044
|
+
dialled.hangUp();
|
|
2045
|
+
dialled = null;
|
|
2046
|
+
}
|
|
1145
2047
|
}
|
|
1146
2048
|
async function portsCommand(json, ports) {
|
|
1147
2049
|
const list = ports || await listPorts();
|
|
@@ -1163,6 +2065,22 @@ async function askYesNo(question, io = {}) {
|
|
|
1163
2065
|
rl.close();
|
|
1164
2066
|
return /^y(es)?$/i.test(answer.trim());
|
|
1165
2067
|
}
|
|
2068
|
+
function filterResults(sets, want) {
|
|
2069
|
+
const wanted = new Set(want.map((n) => n.toUpperCase()));
|
|
2070
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2071
|
+
const out = sets.map((set) => {
|
|
2072
|
+
const kept = {};
|
|
2073
|
+
for (const [k, v] of Object.entries(set)) {
|
|
2074
|
+
const up = k.toUpperCase();
|
|
2075
|
+
if (!wanted.has(up)) continue;
|
|
2076
|
+
seen.add(up);
|
|
2077
|
+
kept[k] = v;
|
|
2078
|
+
}
|
|
2079
|
+
return kept;
|
|
2080
|
+
});
|
|
2081
|
+
const missing = want.filter((n) => !seen.has(n.toUpperCase()));
|
|
2082
|
+
return { sets: out, missing };
|
|
2083
|
+
}
|
|
1166
2084
|
async function jobCommand(sgbd, job, opts = {}) {
|
|
1167
2085
|
const R = loadRuntime();
|
|
1168
2086
|
const name = job.toUpperCase();
|
|
@@ -1191,6 +2109,17 @@ async function jobCommand(sgbd, job, opts = {}) {
|
|
|
1191
2109
|
${FTDI_HINT}` : ""}`
|
|
1192
2110
|
);
|
|
1193
2111
|
}
|
|
2112
|
+
const want = (opts.results || []).filter((n) => n);
|
|
2113
|
+
if (want.length) {
|
|
2114
|
+
const f = filterResults(d.sets || [], want);
|
|
2115
|
+
d = { ...d, sets: f.sets };
|
|
2116
|
+
if (f.missing.length) {
|
|
2117
|
+
const warn = opts.warn || ((l) => process.stderr.write(l + "\n"));
|
|
2118
|
+
warn(
|
|
2119
|
+
`bmweb: ${name} on ${target} returned no result named ${f.missing.join(", ")}`
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
1194
2123
|
if (opts.json) return [JSON.stringify(d, null, 2)];
|
|
1195
2124
|
return formatAnswer(d);
|
|
1196
2125
|
}
|
|
@@ -1880,6 +2809,255 @@ function runSearch(query, opts) {
|
|
|
1880
2809
|
return out;
|
|
1881
2810
|
}
|
|
1882
2811
|
|
|
2812
|
+
// src/sgbd.ts
|
|
2813
|
+
function client(api) {
|
|
2814
|
+
return api || runtimeGlobals().api;
|
|
2815
|
+
}
|
|
2816
|
+
async function dataFile(path, gzipped, api) {
|
|
2817
|
+
if (api) return api(`/${path}`).catch(() => null);
|
|
2818
|
+
const g = runtimeGlobals();
|
|
2819
|
+
const load = gzipped ? g.webFetchGz : g.webFetchJson;
|
|
2820
|
+
return load(path);
|
|
2821
|
+
}
|
|
2822
|
+
async function isGroupName(sgbd, api) {
|
|
2823
|
+
const key = String(sgbd || "").trim().toLowerCase();
|
|
2824
|
+
if (!key) return false;
|
|
2825
|
+
const idx = await dataFile("data/groups/index.json", false, api).catch(
|
|
2826
|
+
() => null
|
|
2827
|
+
);
|
|
2828
|
+
return !!idx && (idx.groups || []).includes(key);
|
|
2829
|
+
}
|
|
2830
|
+
async function loadGroupFile(name, api) {
|
|
2831
|
+
const key = name.toLowerCase();
|
|
2832
|
+
const doc = await dataFile(
|
|
2833
|
+
`data/groups/${key}.json.gz`,
|
|
2834
|
+
true,
|
|
2835
|
+
api
|
|
2836
|
+
);
|
|
2837
|
+
if (!doc || typeof doc !== "object")
|
|
2838
|
+
throw new CliError(
|
|
2839
|
+
`the site lists group ${name} but serves no bytecode for it (data/groups/${key}.json.gz)`
|
|
2840
|
+
);
|
|
2841
|
+
return doc;
|
|
2842
|
+
}
|
|
2843
|
+
async function sgbdJobNames(sgbd, api) {
|
|
2844
|
+
const target = sgbd.toLowerCase();
|
|
2845
|
+
if (await isGroupName(target, api)) {
|
|
2846
|
+
const doc2 = await loadGroupFile(target, api);
|
|
2847
|
+
return Object.keys(doc2.jobs || {}).sort();
|
|
2848
|
+
}
|
|
2849
|
+
let doc;
|
|
2850
|
+
try {
|
|
2851
|
+
doc = await client(api)(`/api/ecu/${encodeURIComponent(target)}/jobs`);
|
|
2852
|
+
} catch (e) {
|
|
2853
|
+
throw new CliError(
|
|
2854
|
+
`no module ${sgbd} on the site (${e.message})`
|
|
2855
|
+
);
|
|
2856
|
+
}
|
|
2857
|
+
const list = Array.isArray(doc) ? doc : doc?.jobs || [];
|
|
2858
|
+
const names = list.map(
|
|
2859
|
+
(j) => typeof j === "string" ? j : String(j?.name || "")
|
|
2860
|
+
).filter((n) => n);
|
|
2861
|
+
return [...new Set(names)].sort();
|
|
2862
|
+
}
|
|
2863
|
+
function normalizeResult(r) {
|
|
2864
|
+
if (typeof r === "string") {
|
|
2865
|
+
const at = r.indexOf(" : ");
|
|
2866
|
+
const name2 = (at >= 0 ? r.slice(0, at) : r).trim();
|
|
2867
|
+
return name2 ? { name: name2, comment: at >= 0 ? r.slice(at + 3).trim() : "" } : null;
|
|
2868
|
+
}
|
|
2869
|
+
const o = r;
|
|
2870
|
+
const name = String(o?.name || "").trim();
|
|
2871
|
+
if (!name) return null;
|
|
2872
|
+
return { name, comment: String(o.comment || o.unit || "").trim() };
|
|
2873
|
+
}
|
|
2874
|
+
async function jobInfo(sgbd, job, api) {
|
|
2875
|
+
const R = loadRuntime();
|
|
2876
|
+
const target = sgbd.toLowerCase();
|
|
2877
|
+
const name = job.toUpperCase();
|
|
2878
|
+
const names = await sgbdJobNames(target, api);
|
|
2879
|
+
if (!names.some((n) => n.toUpperCase() === name)) {
|
|
2880
|
+
const near = names.filter((n) => n.toUpperCase().includes(name));
|
|
2881
|
+
throw new CliError(
|
|
2882
|
+
`${target} declares no job ${name}` + (near.length ? ` (did you mean ${near.slice(0, 6).join(", ")}?)` : ` (${names.length} jobs; bmweb sgbd jobs ${target} lists them)`)
|
|
2883
|
+
);
|
|
2884
|
+
}
|
|
2885
|
+
const info = {
|
|
2886
|
+
sgbd: target,
|
|
2887
|
+
job: name,
|
|
2888
|
+
write: R.isWriteJob(name),
|
|
2889
|
+
args: [],
|
|
2890
|
+
results: [],
|
|
2891
|
+
comment: ""
|
|
2892
|
+
};
|
|
2893
|
+
if (await isGroupName(target, api)) return info;
|
|
2894
|
+
const c = client(api);
|
|
2895
|
+
const enc = encodeURIComponent(name);
|
|
2896
|
+
const a = await c(`/api/ecu/${target}/arguments/${enc}`).catch(
|
|
2897
|
+
() => null
|
|
2898
|
+
);
|
|
2899
|
+
const argRows = Array.isArray(a) ? a : a?.arguments || [];
|
|
2900
|
+
info.args = argRows.map((x) => ({
|
|
2901
|
+
name: String(x.ARG || x.name || "").trim(),
|
|
2902
|
+
type: String(x.ARGTYPE || x.type || "").trim(),
|
|
2903
|
+
// the exporter numbers a declaration's comment lines ARGCOMMENT0..n;
|
|
2904
|
+
// they read as one sentence, so they are joined back into one
|
|
2905
|
+
comment: Object.keys(x).filter((k) => /^ARGCOMMENT\d+$/.test(k)).sort(
|
|
2906
|
+
(p, q) => Number(p.replace(/\D/g, "")) - Number(q.replace(/\D/g, ""))
|
|
2907
|
+
).map((k) => String(x[k] || "").trim()).filter((s) => s).join(" ")
|
|
2908
|
+
})).filter((x) => x.name);
|
|
2909
|
+
const r = await c(`/api/ecu/${target}/results/${enc}`).catch(() => null);
|
|
2910
|
+
info.results = (Array.isArray(r) ? r : []).map(normalizeResult).filter((x) => !!x);
|
|
2911
|
+
const jc = info.results.find((x) => /^JOB_?COMMENT/i.test(x.name));
|
|
2912
|
+
info.comment = jc ? jc.comment : info.results[0]?.comment ?? "";
|
|
2913
|
+
return info;
|
|
2914
|
+
}
|
|
2915
|
+
async function jobInfoCommand(sgbd, job, opts = {}) {
|
|
2916
|
+
const info = await jobInfo(sgbd, job, opts.apiFn);
|
|
2917
|
+
if (opts.json) return [JSON.stringify(info, null, 2)];
|
|
2918
|
+
const out = [
|
|
2919
|
+
`${info.sgbd} ${info.job}${info.write ? " [WRITE]" : ""}`
|
|
2920
|
+
];
|
|
2921
|
+
if (info.comment && info.comment !== info.results[0]?.comment)
|
|
2922
|
+
out.push(info.comment);
|
|
2923
|
+
out.push("", `arguments (${info.args.length})`);
|
|
2924
|
+
out.push(
|
|
2925
|
+
...info.args.length ? formatTable(
|
|
2926
|
+
info.args.map((a) => [a.name, a.type, a.comment]),
|
|
2927
|
+
void 0,
|
|
2928
|
+
" "
|
|
2929
|
+
) : [" none declared"]
|
|
2930
|
+
);
|
|
2931
|
+
out.push("", `results (${info.results.length})`);
|
|
2932
|
+
out.push(
|
|
2933
|
+
...info.results.length ? formatTable(
|
|
2934
|
+
info.results.map((r) => [r.name, r.comment]),
|
|
2935
|
+
void 0,
|
|
2936
|
+
" "
|
|
2937
|
+
) : [" none declared"]
|
|
2938
|
+
);
|
|
2939
|
+
return out;
|
|
2940
|
+
}
|
|
2941
|
+
var NAME_LIST_MAX = 6;
|
|
2942
|
+
function nameList(names) {
|
|
2943
|
+
if (names.length <= NAME_LIST_MAX) return names.join(", ");
|
|
2944
|
+
return `${names.slice(0, NAME_LIST_MAX).join(", ")}, +${names.length - NAME_LIST_MAX} more`;
|
|
2945
|
+
}
|
|
2946
|
+
async function sgbdJobsCommand(sgbd, opts = {}) {
|
|
2947
|
+
const R = loadRuntime();
|
|
2948
|
+
const target = sgbd.toLowerCase();
|
|
2949
|
+
const names = await sgbdJobNames(target, opts.apiFn);
|
|
2950
|
+
const group = await isGroupName(target, opts.apiFn);
|
|
2951
|
+
const infos = group ? names.map((n) => ({
|
|
2952
|
+
sgbd: target,
|
|
2953
|
+
job: n,
|
|
2954
|
+
write: R.isWriteJob(n),
|
|
2955
|
+
args: [],
|
|
2956
|
+
results: [],
|
|
2957
|
+
comment: ""
|
|
2958
|
+
})) : await Promise.all(names.map((n) => jobInfo(target, n, opts.apiFn)));
|
|
2959
|
+
if (opts.json)
|
|
2960
|
+
return [JSON.stringify({ sgbd: target, group, jobs: infos }, null, 2)];
|
|
2961
|
+
if (!infos.length) return [`${target} declares no jobs`];
|
|
2962
|
+
const rows = infos.map((i) => [
|
|
2963
|
+
i.job,
|
|
2964
|
+
nameList(i.args.map((a) => a.name)),
|
|
2965
|
+
nameList(i.results.map((r) => r.name)),
|
|
2966
|
+
// the first line only: a declaration's comment can run to a paragraph
|
|
2967
|
+
// and a table row is one line
|
|
2968
|
+
i.comment.split(/\r?\n/)[0].trim(),
|
|
2969
|
+
i.write ? "yes" : ""
|
|
2970
|
+
]);
|
|
2971
|
+
const out = formatTable(rows, [
|
|
2972
|
+
"NAME",
|
|
2973
|
+
"ARGS",
|
|
2974
|
+
"RESULTS",
|
|
2975
|
+
"COMMENT",
|
|
2976
|
+
"WRITE"
|
|
2977
|
+
]);
|
|
2978
|
+
const writes = infos.filter((i) => i.write).length;
|
|
2979
|
+
out.push(
|
|
2980
|
+
"",
|
|
2981
|
+
`${formatCount(infos.length)} job${infos.length === 1 ? "" : "s"}, ${formatCount(writes)} the app would ask about before sending` + (group ? " (a group file carries job names, not declarations)" : "")
|
|
2982
|
+
);
|
|
2983
|
+
return out;
|
|
2984
|
+
}
|
|
2985
|
+
async function sgbdTables(sgbd, api) {
|
|
2986
|
+
const target = sgbd.toLowerCase();
|
|
2987
|
+
if (await isGroupName(target, api)) {
|
|
2988
|
+
const doc2 = await loadGroupFile(target, api);
|
|
2989
|
+
return doc2.tables || {};
|
|
2990
|
+
}
|
|
2991
|
+
const doc = await dataFile(
|
|
2992
|
+
`data/sgbd-tables/${target}.json`,
|
|
2993
|
+
false,
|
|
2994
|
+
api
|
|
2995
|
+
);
|
|
2996
|
+
if (!doc || typeof doc !== "object") {
|
|
2997
|
+
await sgbdJobNames(target, api);
|
|
2998
|
+
return {};
|
|
2999
|
+
}
|
|
3000
|
+
return doc;
|
|
3001
|
+
}
|
|
3002
|
+
function tableColumns(rows) {
|
|
3003
|
+
const cols = [];
|
|
3004
|
+
for (const row of rows || [])
|
|
3005
|
+
for (const k of Object.keys(row || {})) if (!cols.includes(k)) cols.push(k);
|
|
3006
|
+
return cols;
|
|
3007
|
+
}
|
|
3008
|
+
async function sgbdTablesCommand(sgbd, opts = {}) {
|
|
3009
|
+
const target = sgbd.toLowerCase();
|
|
3010
|
+
const tabs = await sgbdTables(target, opts.apiFn);
|
|
3011
|
+
const list = Object.keys(tabs).sort().map((name) => ({
|
|
3012
|
+
name,
|
|
3013
|
+
rows: (tabs[name] || []).length,
|
|
3014
|
+
columns: tableColumns(tabs[name] || [])
|
|
3015
|
+
}));
|
|
3016
|
+
if (opts.json)
|
|
3017
|
+
return [JSON.stringify({ sgbd: target, tables: list }, null, 2)];
|
|
3018
|
+
if (!list.length) return [`${target} carries no lookup tables`];
|
|
3019
|
+
const out = formatTable(
|
|
3020
|
+
list.map((t) => [t.name, t.rows, t.columns.length, nameList(t.columns)]),
|
|
3021
|
+
["NAME", "ROWS", "COLS", "COLUMNS"]
|
|
3022
|
+
);
|
|
3023
|
+
out.push(
|
|
3024
|
+
"",
|
|
3025
|
+
`${formatCount(list.length)} table${list.length === 1 ? "" : "s"} (bmweb sgbd table ${target} <NAME> prints one)`
|
|
3026
|
+
);
|
|
3027
|
+
return out;
|
|
3028
|
+
}
|
|
3029
|
+
async function sgbdTableCommand(sgbd, name, opts = {}) {
|
|
3030
|
+
const target = sgbd.toLowerCase();
|
|
3031
|
+
const tabs = await sgbdTables(target, opts.apiFn);
|
|
3032
|
+
const want = name.toUpperCase();
|
|
3033
|
+
const key = Object.keys(tabs).find((k) => k.toUpperCase() === want);
|
|
3034
|
+
if (!key) {
|
|
3035
|
+
const near = Object.keys(tabs).filter((k) => k.toUpperCase().includes(want)).sort();
|
|
3036
|
+
throw new CliError(
|
|
3037
|
+
`${target} carries no table ${name}` + (near.length ? ` (did you mean ${near.slice(0, 6).join(", ")}?)` : ` (bmweb sgbd tables ${target} lists them)`)
|
|
3038
|
+
);
|
|
3039
|
+
}
|
|
3040
|
+
const rows = tabs[key] || [];
|
|
3041
|
+
if (opts.json)
|
|
3042
|
+
return [JSON.stringify({ sgbd: target, table: key, rows }, null, 2)];
|
|
3043
|
+
const cols = tableColumns(rows);
|
|
3044
|
+
if (!cols.length) return [`${target} ${key}: no rows`];
|
|
3045
|
+
const out = formatTable(
|
|
3046
|
+
rows.map((r) => cols.map((c) => valueCell(r[c]))),
|
|
3047
|
+
cols
|
|
3048
|
+
);
|
|
3049
|
+
out.push(
|
|
3050
|
+
"",
|
|
3051
|
+
`${formatCount(rows.length)} row${rows.length === 1 ? "" : "s"} in ${key}`
|
|
3052
|
+
);
|
|
3053
|
+
return out;
|
|
3054
|
+
}
|
|
3055
|
+
function valueCell(v) {
|
|
3056
|
+
if (v == null) return "";
|
|
3057
|
+
if (typeof v === "object") return JSON.stringify(v);
|
|
3058
|
+
return String(v);
|
|
3059
|
+
}
|
|
3060
|
+
|
|
1883
3061
|
// src/tui.ts
|
|
1884
3062
|
import { writeFileSync as writeFileSync4 } from "node:fs";
|
|
1885
3063
|
import { createInterface as createInterface2, emitKeypressEvents } from "node:readline";
|
|
@@ -2008,6 +3186,12 @@ var TuiUi = class {
|
|
|
2008
3186
|
frame = [];
|
|
2009
3187
|
/** a picker owns the keyboard: the program's key handler must stand back */
|
|
2010
3188
|
modal = false;
|
|
3189
|
+
/** the first line of the viewer shown (INPA's viewer scrolls; so does this) */
|
|
3190
|
+
viewTop = 0;
|
|
3191
|
+
/** the view the scroll position belongs to; a new view starts at the top */
|
|
3192
|
+
viewShown = null;
|
|
3193
|
+
/** how many viewer lines the last frame had room for */
|
|
3194
|
+
viewRoom = 0;
|
|
2011
3195
|
/** the terminal size the frame was drawn for; a change draws fresh */
|
|
2012
3196
|
frameSize = "";
|
|
2013
3197
|
leftResolve = null;
|
|
@@ -2422,6 +3606,32 @@ Save as [fault-memory.txt]: `);
|
|
|
2422
3606
|
paint(p) {
|
|
2423
3607
|
this.flush(this.frameLines(p));
|
|
2424
3608
|
}
|
|
3609
|
+
/**
|
|
3610
|
+
* Scroll the viewer by a key: a line, a page, or to an end. Nothing
|
|
3611
|
+
* happens without a viewer on screen.
|
|
3612
|
+
* @param how - 'up' | 'down' | 'pageup' | 'pagedown' | 'home' | 'end'
|
|
3613
|
+
* @returns whether the key was for the viewer
|
|
3614
|
+
*/
|
|
3615
|
+
scrollView(how) {
|
|
3616
|
+
const p = this.program;
|
|
3617
|
+
if (!p || !p.view) return false;
|
|
3618
|
+
const page = Math.max(1, this.viewRoom - 1);
|
|
3619
|
+
const n = (p.view.lines || []).length;
|
|
3620
|
+
const max = Math.max(0, n - this.viewRoom);
|
|
3621
|
+
const jump = {
|
|
3622
|
+
up: -1,
|
|
3623
|
+
down: 1,
|
|
3624
|
+
pageup: -page,
|
|
3625
|
+
pagedown: page,
|
|
3626
|
+
home: -n,
|
|
3627
|
+
end: n
|
|
3628
|
+
};
|
|
3629
|
+
const by = jump[how];
|
|
3630
|
+
if (by === void 0) return false;
|
|
3631
|
+
this.viewTop = Math.max(0, Math.min(max, this.viewTop + by));
|
|
3632
|
+
this.paint(p);
|
|
3633
|
+
return true;
|
|
3634
|
+
}
|
|
2425
3635
|
/**
|
|
2426
3636
|
* The frame as lines: title, rule, the view or the grid, a blank, the
|
|
2427
3637
|
* key bar, then the status and progress lines. Cut to the terminal's
|
|
@@ -2434,16 +3644,34 @@ Save as [fault-memory.txt]: `);
|
|
|
2434
3644
|
const w = this.term.columns;
|
|
2435
3645
|
const title = `${p.ecu.label || p.ecu.sgbd} ${p.ecu.sgbd}.prg ${p.title || ""}`.trim();
|
|
2436
3646
|
const head = [title, "-".repeat(Math.min(w, 78))];
|
|
2437
|
-
let body = p.view ? [...p.view.lines || []] : this.gridLines(p);
|
|
2438
3647
|
const tail = ["", ...this.keyLines(p), this.statusText, this.progressText];
|
|
2439
3648
|
const room = Math.max(1, this.term.rows - 1 - head.length - tail.length);
|
|
2440
|
-
|
|
2441
|
-
if (
|
|
2442
|
-
const
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
3649
|
+
let body;
|
|
3650
|
+
if (p.view) {
|
|
3651
|
+
const lines = [...p.view.lines || []];
|
|
3652
|
+
if (p.view !== this.viewShown) {
|
|
3653
|
+
this.viewShown = p.view;
|
|
3654
|
+
this.viewTop = 0;
|
|
3655
|
+
}
|
|
3656
|
+
const fits = lines.length <= room;
|
|
3657
|
+
const window = fits ? room : room - 1;
|
|
3658
|
+
this.viewRoom = window;
|
|
3659
|
+
this.viewTop = Math.max(0, Math.min(this.viewTop, lines.length - window));
|
|
3660
|
+
body = lines.slice(this.viewTop, this.viewTop + window);
|
|
3661
|
+
if (!fits)
|
|
3662
|
+
body.push(
|
|
3663
|
+
`\x1B[2mrows ${this.viewTop + 1}-${this.viewTop + body.length} of ${lines.length} \u2191\u2193 PgUp PgDn Home End scroll\x1B[0m`
|
|
3664
|
+
);
|
|
3665
|
+
} else {
|
|
3666
|
+
body = this.gridLines(p);
|
|
3667
|
+
if (body.length > room) body = body.filter((l, i) => l || body[i - 1]);
|
|
3668
|
+
if (body.length > room) {
|
|
3669
|
+
const hidden = body.length - (room - 1);
|
|
3670
|
+
body = [
|
|
3671
|
+
...body.slice(0, room - 1),
|
|
3672
|
+
`(${hidden} more rows: enlarge the terminal)`
|
|
3673
|
+
];
|
|
3674
|
+
}
|
|
2447
3675
|
}
|
|
2448
3676
|
return [...head, ...body, ...tail].map(
|
|
2449
3677
|
(l) => String(l).replace(/[\r\n]/g, " ").slice(0, w)
|
|
@@ -2623,6 +3851,7 @@ async function tuiCommand(chassis, sgbd, opts = {}) {
|
|
|
2623
3851
|
let leaving = false;
|
|
2624
3852
|
const unsubscribe = term.onKey((k) => {
|
|
2625
3853
|
if (ui.modal) return;
|
|
3854
|
+
if (ui.scrollView(k.name)) return;
|
|
2626
3855
|
const what = keyToPress(k);
|
|
2627
3856
|
if (what === null) return;
|
|
2628
3857
|
if (what === "quit") {
|
|
@@ -2662,7 +3891,7 @@ async function tuiCommand(chassis, sgbd, opts = {}) {
|
|
|
2662
3891
|
}
|
|
2663
3892
|
|
|
2664
3893
|
// src/bmweb.ts
|
|
2665
|
-
var VERSION = true ? "0.1.
|
|
3894
|
+
var VERSION = true ? "0.1.7" : "0.0.0-dev";
|
|
2666
3895
|
var INCLUDE = {
|
|
2667
3896
|
include: {
|
|
2668
3897
|
kind: "list",
|
|
@@ -2676,12 +3905,7 @@ var JSON_FLAG = {
|
|
|
2676
3905
|
help: "print machine-readable JSON instead of a table"
|
|
2677
3906
|
}
|
|
2678
3907
|
};
|
|
2679
|
-
var
|
|
2680
|
-
port: {
|
|
2681
|
-
kind: "string",
|
|
2682
|
-
alias: "p",
|
|
2683
|
-
help: "the serial device (the single candidate when there is one)"
|
|
2684
|
-
},
|
|
3908
|
+
var SITE2 = {
|
|
2685
3909
|
api: {
|
|
2686
3910
|
kind: "string",
|
|
2687
3911
|
help: "the site the module data comes from (default https://bmweb.danner.ink/)"
|
|
@@ -2691,6 +3915,19 @@ var LIVE = {
|
|
|
2691
3915
|
help: "fetch the module data again even when the cached copy is fresh"
|
|
2692
3916
|
}
|
|
2693
3917
|
};
|
|
3918
|
+
var LIVE = {
|
|
3919
|
+
port: {
|
|
3920
|
+
kind: "string",
|
|
3921
|
+
alias: "p",
|
|
3922
|
+
help: "the serial device (the single candidate when there is one)"
|
|
3923
|
+
},
|
|
3924
|
+
gateway: {
|
|
3925
|
+
kind: "string",
|
|
3926
|
+
alias: "g",
|
|
3927
|
+
help: "drive a cable another machine is serving: host:port or a ws:// URL"
|
|
3928
|
+
},
|
|
3929
|
+
...SITE2
|
|
3930
|
+
};
|
|
2694
3931
|
var COMMANDS = {
|
|
2695
3932
|
"ipo info": {
|
|
2696
3933
|
usage: "bmweb ipo info <file.IPO|file.IPS|file.SRC> [-I dir]... [--json]",
|
|
@@ -2724,14 +3961,18 @@ var COMMANDS = {
|
|
|
2724
3961
|
}
|
|
2725
3962
|
},
|
|
2726
3963
|
"ipo compile": {
|
|
2727
|
-
usage: "bmweb ipo compile <file.IPS|file.SRC> [-I dir]... [-o out.
|
|
2728
|
-
summary: "compile an INPA source into the app's
|
|
3964
|
+
usage: "bmweb ipo compile <file.IPS|file.SRC> [-I dir]... [-o out.IPO] [--exec]",
|
|
3965
|
+
summary: "compile an INPA source into a real .IPO (or --exec for the app's token form); missing includes are named",
|
|
2729
3966
|
flags: {
|
|
2730
3967
|
...INCLUDE,
|
|
2731
3968
|
out: {
|
|
2732
3969
|
kind: "string",
|
|
2733
3970
|
alias: "o",
|
|
2734
|
-
help: "where to write (default: <stem>.
|
|
3971
|
+
help: "where to write (default: <stem>.IPO beside the source)"
|
|
3972
|
+
},
|
|
3973
|
+
exec: {
|
|
3974
|
+
kind: "bool",
|
|
3975
|
+
help: "write the app's exec form as JSON instead of .IPO bytes"
|
|
2735
3976
|
}
|
|
2736
3977
|
},
|
|
2737
3978
|
async run(pos, flags) {
|
|
@@ -2743,7 +3984,8 @@ var COMMANDS = {
|
|
|
2743
3984
|
return ipoCompile(
|
|
2744
3985
|
file,
|
|
2745
3986
|
flags.include || [],
|
|
2746
|
-
flags.out
|
|
3987
|
+
flags.out,
|
|
3988
|
+
!!flags.exec
|
|
2747
3989
|
);
|
|
2748
3990
|
}
|
|
2749
3991
|
},
|
|
@@ -2812,9 +4054,33 @@ var COMMANDS = {
|
|
|
2812
4054
|
return portsCommand(!!flags.json);
|
|
2813
4055
|
}
|
|
2814
4056
|
},
|
|
4057
|
+
gateway: {
|
|
4058
|
+
usage: "bmweb gateway [--port p] [--listen host:port]",
|
|
4059
|
+
summary: `serve this machine's cable over a WebSocket so another machine can drive it with --gateway (default ${DEFAULT_LISTEN})`,
|
|
4060
|
+
flags: {
|
|
4061
|
+
port: {
|
|
4062
|
+
kind: "string",
|
|
4063
|
+
alias: "p",
|
|
4064
|
+
help: "the serial device (the single candidate when there is one)"
|
|
4065
|
+
},
|
|
4066
|
+
listen: {
|
|
4067
|
+
kind: "string",
|
|
4068
|
+
alias: "l",
|
|
4069
|
+
help: `the address to serve on (default ${DEFAULT_LISTEN}; 0.0.0.0:6801 serves the LAN)`
|
|
4070
|
+
}
|
|
4071
|
+
},
|
|
4072
|
+
async run(pos, flags) {
|
|
4073
|
+
if (pos.length)
|
|
4074
|
+
throw new CliError("usage: " + COMMANDS.gateway.usage);
|
|
4075
|
+
return gatewayCommand({
|
|
4076
|
+
port: flags.port,
|
|
4077
|
+
listen: flags.listen
|
|
4078
|
+
});
|
|
4079
|
+
}
|
|
4080
|
+
},
|
|
2815
4081
|
job: {
|
|
2816
|
-
usage: "bmweb job <sgbd> <JOB> [arg] [--port p] [--api url] [--yes] [--json]",
|
|
2817
|
-
summary: "one raw job on one module over the cable, like the app's Tool32; a write needs --yes or a y answer",
|
|
4082
|
+
usage: "bmweb job <sgbd> <JOB> [arg] [--results a,b] [--info] [--port p] [--gateway h:p] [--api url] [--yes] [--json]",
|
|
4083
|
+
summary: "one raw job on one module over the cable, like the app's Tool32; a write needs --yes or a y answer, and --info reads the declaration instead of running it",
|
|
2818
4084
|
flags: {
|
|
2819
4085
|
...LIVE,
|
|
2820
4086
|
yes: {
|
|
@@ -2822,26 +4088,84 @@ var COMMANDS = {
|
|
|
2822
4088
|
alias: "y",
|
|
2823
4089
|
help: "consent to a write job on the command line"
|
|
2824
4090
|
},
|
|
4091
|
+
results: {
|
|
4092
|
+
kind: "string",
|
|
4093
|
+
alias: "r",
|
|
4094
|
+
help: "comma-separated result names; only these print (case-insensitive)"
|
|
4095
|
+
},
|
|
4096
|
+
info: {
|
|
4097
|
+
kind: "bool",
|
|
4098
|
+
help: "what the SGBD declares about the job (arguments, results, comment); opens no port"
|
|
4099
|
+
},
|
|
2825
4100
|
...JSON_FLAG
|
|
2826
4101
|
},
|
|
2827
4102
|
async run(pos, flags) {
|
|
2828
4103
|
const [sgbd, job, arg] = pos;
|
|
2829
4104
|
if (!sgbd || !job || pos.length > 3)
|
|
2830
4105
|
throw new CliError("usage: " + COMMANDS.job.usage);
|
|
4106
|
+
if (flags.info) {
|
|
4107
|
+
if (arg !== void 0)
|
|
4108
|
+
throw new CliError("--info takes no job argument (nothing is sent)");
|
|
4109
|
+
useSite(flags);
|
|
4110
|
+
return jobInfoCommand(sgbd, job, { json: !!flags.json });
|
|
4111
|
+
}
|
|
2831
4112
|
const { R } = await connectBus(liveOptions(flags));
|
|
2832
4113
|
try {
|
|
2833
4114
|
return await jobCommand(sgbd, job, {
|
|
2834
4115
|
arg,
|
|
2835
4116
|
yes: !!flags.yes,
|
|
2836
|
-
json: !!flags.json
|
|
4117
|
+
json: !!flags.json,
|
|
4118
|
+
results: splitList(flags.results)
|
|
2837
4119
|
});
|
|
2838
4120
|
} finally {
|
|
2839
4121
|
await disconnectBus(R);
|
|
2840
4122
|
}
|
|
2841
4123
|
}
|
|
2842
4124
|
},
|
|
4125
|
+
"sgbd jobs": {
|
|
4126
|
+
usage: "bmweb sgbd jobs <sgbd> [--api url] [--refresh] [--json]",
|
|
4127
|
+
summary: "every job an SGBD declares, with its arguments, results and comment, and which of them the app's write gate would ask about; no cable",
|
|
4128
|
+
flags: { ...SITE2, ...JSON_FLAG },
|
|
4129
|
+
async run(pos, flags) {
|
|
4130
|
+
const sgbd = pos[0];
|
|
4131
|
+
if (!sgbd || pos.length > 1)
|
|
4132
|
+
throw new CliError(
|
|
4133
|
+
"usage: " + COMMANDS["sgbd jobs"].usage
|
|
4134
|
+
);
|
|
4135
|
+
useSite(flags);
|
|
4136
|
+
return sgbdJobsCommand(sgbd, { json: !!flags.json });
|
|
4137
|
+
}
|
|
4138
|
+
},
|
|
4139
|
+
"sgbd tables": {
|
|
4140
|
+
usage: "bmweb sgbd tables <sgbd> [--api url] [--refresh] [--json]",
|
|
4141
|
+
summary: "every lookup table an SGBD carries for its bytecode, with row and column counts; no cable",
|
|
4142
|
+
flags: { ...SITE2, ...JSON_FLAG },
|
|
4143
|
+
async run(pos, flags) {
|
|
4144
|
+
const sgbd = pos[0];
|
|
4145
|
+
if (!sgbd || pos.length > 1)
|
|
4146
|
+
throw new CliError(
|
|
4147
|
+
"usage: " + COMMANDS["sgbd tables"].usage
|
|
4148
|
+
);
|
|
4149
|
+
useSite(flags);
|
|
4150
|
+
return sgbdTablesCommand(sgbd, { json: !!flags.json });
|
|
4151
|
+
}
|
|
4152
|
+
},
|
|
4153
|
+
"sgbd table": {
|
|
4154
|
+
usage: "bmweb sgbd table <sgbd> <NAME> [--api url] [--refresh] [--json]",
|
|
4155
|
+
summary: "one lookup table of an SGBD, its rows as a table; no cable",
|
|
4156
|
+
flags: { ...SITE2, ...JSON_FLAG },
|
|
4157
|
+
async run(pos, flags) {
|
|
4158
|
+
const [sgbd, name] = pos;
|
|
4159
|
+
if (!sgbd || !name || pos.length > 2)
|
|
4160
|
+
throw new CliError(
|
|
4161
|
+
"usage: " + COMMANDS["sgbd table"].usage
|
|
4162
|
+
);
|
|
4163
|
+
useSite(flags);
|
|
4164
|
+
return sgbdTableCommand(sgbd, name, { json: !!flags.json });
|
|
4165
|
+
}
|
|
4166
|
+
},
|
|
2843
4167
|
scan: {
|
|
2844
|
-
usage: "bmweb scan <chassis> [--port p] [--api url] [--share] [--json]",
|
|
4168
|
+
usage: "bmweb scan <chassis> [--port p] [--gateway h:p] [--api url] [--share] [--json]",
|
|
2845
4169
|
summary: `INPA's whole-vehicle script over the cable (${SCAN_CHASSIS.join(" ")}): every fault memory as a report, --share adds a Garage link`,
|
|
2846
4170
|
flags: {
|
|
2847
4171
|
...LIVE,
|
|
@@ -2873,7 +4197,7 @@ var COMMANDS = {
|
|
|
2873
4197
|
}
|
|
2874
4198
|
},
|
|
2875
4199
|
tui: {
|
|
2876
|
-
usage: "bmweb tui [<chassis> <sgbd>] [--port p] [--api url] [--menu m_x]",
|
|
4200
|
+
usage: "bmweb tui [<chassis> <sgbd>] [--port p] [--gateway h:p] [--api url] [--menu m_x]",
|
|
2877
4201
|
summary: "INPA screens in the terminal: the app's home (pick a chassis and a module) with no arguments, else that module; F-keys on the number row, every write asked first, released on quit",
|
|
2878
4202
|
flags: {
|
|
2879
4203
|
...LIVE,
|
|
@@ -2904,10 +4228,20 @@ var COMMANDS = {
|
|
|
2904
4228
|
function liveOptions(flags) {
|
|
2905
4229
|
return {
|
|
2906
4230
|
port: flags.port,
|
|
4231
|
+
gateway: flags.gateway,
|
|
2907
4232
|
api: flags.api,
|
|
2908
4233
|
refresh: !!flags.refresh
|
|
2909
4234
|
};
|
|
2910
4235
|
}
|
|
4236
|
+
function useSite(flags) {
|
|
4237
|
+
configureSite({
|
|
4238
|
+
...flags.api ? { base: flags.api } : {},
|
|
4239
|
+
refresh: !!flags.refresh
|
|
4240
|
+
});
|
|
4241
|
+
}
|
|
4242
|
+
function splitList(v) {
|
|
4243
|
+
return String(v || "").split(",").map((s) => s.trim()).filter((s) => s);
|
|
4244
|
+
}
|
|
2911
4245
|
function helpText() {
|
|
2912
4246
|
const out = [
|
|
2913
4247
|
`bmweb ${VERSION}: BMWeb's tools as a command line (https://bmweb.danner.ink/)`,
|
|
@@ -2925,10 +4259,12 @@ function helpText() {
|
|
|
2925
4259
|
" bmweb <command> --help options of one command",
|
|
2926
4260
|
" bmweb --version",
|
|
2927
4261
|
"",
|
|
2928
|
-
"ports, job, scan and tui talk to the car over a K+DCAN cable (the
|
|
2929
|
-
"package, an optional dependency).
|
|
2930
|
-
"
|
|
2931
|
-
"
|
|
4262
|
+
"ports, gateway, job, scan and tui talk to the car over a K+DCAN cable (the",
|
|
4263
|
+
"serialport package, an optional dependency). One machine can own the cable",
|
|
4264
|
+
"and serve it (bmweb gateway) while another drives it (--gateway host:port).",
|
|
4265
|
+
"Module data and the search index are fetched from the site and cached under",
|
|
4266
|
+
"$XDG_CACHE_HOME/bmweb-cli (default ~/.cache/bmweb-cli) for a day; nothing",
|
|
4267
|
+
"BMW-derived ships here."
|
|
2932
4268
|
);
|
|
2933
4269
|
return out;
|
|
2934
4270
|
}
|