bmweb-cli 0.1.6 → 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/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
@@ -156,6 +1014,7 @@ var runtime_files_default = [
156
1014
  "core/ipofile/decls.js",
157
1015
  "core/ipofile/walk.js",
158
1016
  "core/ipofile/exec.js",
1017
+ "core/ipofile/encode.js",
159
1018
  "core/ipofile/lex.js",
160
1019
  "core/ipofile/parse.js",
161
1020
  "core/ipofile/emit.js",
@@ -671,7 +1530,7 @@ function gatherIncludes(dirs, skip) {
671
1530
  for (const dir of dirs) {
672
1531
  let names;
673
1532
  try {
674
- names = readdirSync(dir);
1533
+ names = readdirSync2(dir);
675
1534
  } catch {
676
1535
  throw new CliError(`include directory not found: ${dir}`);
677
1536
  }
@@ -826,89 +1685,202 @@ function ipoKeys(file, includeDirs, menu, json) {
826
1685
  if (!rows.length) return [`${s.stem}: no menu keys`];
827
1686
  return formatTable(rows, ["MENU", "KEY", "LABEL", "OPENS", "JOBS", "WRITES"]);
828
1687
  }
829
- function ipoCompile(file, includeDirs, out) {
1688
+ function ipoCompile(file, includeDirs, out, exec = false) {
830
1689
  const R = loadRuntime();
831
1690
  const name = basename(file);
832
1691
  if (!R.ipofIsSource(name))
833
1692
  throw new CliError(`${name}: compile takes a .IPS or .SRC source`);
834
1693
  const s = readScript(file, includeDirs);
835
- const target = out || join4(dirname2(resolve(file)), `${s.stem}.ipoexec.json`);
836
- try {
837
- writeFileSync2(target, JSON.stringify(s.exec));
838
- } catch {
839
- throw new CliError(`cannot write ${target}`);
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)`;
840
1719
  }
841
1720
  const inv = R.ipofInventory(s.exec);
842
1721
  return [
843
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(", ")}` : ""),
844
- `wrote ${target} (the app's exec form; not INPA's binary .IPO)`
1723
+ wrote
845
1724
  ];
846
1725
  }
847
1726
 
848
1727
  // src/live.ts
849
1728
  import { createInterface } from "node:readline";
850
1729
 
851
- // src/serial.ts
852
- import { readdirSync as readdirSync2 } from "node:fs";
853
- var RX_KICK_MS = 4;
854
- var PORT_PATTERNS = [
855
- /^cu\.usbserial/i,
856
- /^cu\.SLAB/i,
857
- /^cu\.wchusbserial/i,
858
- /^ttyUSB/i,
859
- /^ttyACM/i
860
- ];
861
- var NodeSerialPort = class {
862
- path;
863
- opener;
864
- binding = null;
865
- /** chunks heard and not yet read */
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 */
866
1747
  chunks = [];
867
1748
  /** reads waiting for bytes, oldest first, when the queue is empty */
868
1749
  waiters = [];
869
- /** the receive kick, running while a read waits (see startKick) */
870
- kick = null;
871
- /** a kick ioctl in flight, so they never pile up */
872
- kicking = false;
873
- /** the lines as last set, so a partial setSignals keeps the others */
874
- lines = { dtr: false, rts: false, brk: false };
875
- /** the wire trace sink, when the CLI wants one */
876
- info;
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;
877
1758
  /**
878
- * @param path - the device path
879
- * @param opener - how a binding is opened
880
- * @param info - vendor and product ids, when known (portLabel shows them)
1759
+ * @param url - the gateway's ws:// URL
881
1760
  */
882
- constructor(path, opener, info = {}) {
883
- this.path = path;
884
- this.opener = opener;
885
- this.info = info;
1761
+ constructor(url) {
1762
+ this.url = url;
886
1763
  }
887
- /** Is a binding open (Web Serial's SerialPort.connected). */
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). */
888
1793
  get connected() {
889
- return !!this.binding;
1794
+ return this.opened;
1795
+ }
1796
+ /** What the host is serving, for the cable chip. */
1797
+ get remoteDevice() {
1798
+ return this.device;
890
1799
  }
891
1800
  /**
892
- * Open the device with the given settings.
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.
893
1863
  * @param cfg - baud, bits, parity
894
1864
  */
895
1865
  async open(cfg) {
896
- if (this.binding) throw new Error(`${this.path} is already open`);
897
- const b = await this.opener(this.path, cfg);
898
- this.binding = b;
1866
+ await this.call("open", { config: cfg });
899
1867
  this.chunks = [];
900
- b.onData((chunk) => this.push(chunk));
1868
+ this.writeError = null;
1869
+ this.opened = true;
901
1870
  }
902
- /**
903
- * Close the device: the binding goes, a waiting read is told `done`.
904
- */
1871
+ /** Close the remote device: a waiting read is told done. */
905
1872
  async close() {
906
- const b = this.binding;
907
- this.binding = null;
908
- this.stopKick();
1873
+ this.opened = false;
909
1874
  this.wakeAll();
910
1875
  this.chunks = [];
911
- if (b) await b.close();
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;
912
1884
  }
913
1885
  /** Tell every waiting read the port is done, and forget them. */
914
1886
  wakeAll() {
@@ -917,7 +1889,7 @@ var NodeSerialPort = class {
917
1889
  for (const w of ws) w({ value: void 0, done: true });
918
1890
  }
919
1891
  /**
920
- * Bytes the binding heard: to the waiting read, else queued.
1892
+ * Bytes the host streamed: to the waiting read, else queued.
921
1893
  * @param chunk - the bytes
922
1894
  */
923
1895
  push(chunk) {
@@ -952,202 +1924,76 @@ var NodeSerialPort = class {
952
1924
  }
953
1925
  /**
954
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.
955
1932
  * @returns the read result
956
1933
  */
957
1934
  read() {
958
1935
  const next = this.chunks.shift();
959
1936
  if (next) return Promise.resolve({ value: next, done: false });
960
- if (!this.binding) return Promise.resolve({ value: void 0, done: true });
961
- return new Promise((resolve2) => {
962
- this.waiters.push(resolve2);
963
- this.startKick();
964
- });
965
- }
966
- /**
967
- * Make the driver deliver what it has heard.
968
- *
969
- * THE BUG THIS FIXES. On macOS the built-in FTDI driver does not wake the
970
- * reader when bytes arrive: with a read armed and the process simply
971
- * waiting, an ECU's answer sat in the driver until some OTHER call touched
972
- * the device (the next write, a modem-line change, close), and only then
973
- * came out -- measured on a real car as 0 bytes for the first exchange
974
- * after open and every later exchange delivering the PREVIOUS one's bytes
975
- * at its start. Polling the modem lines (a TIOCMGET, no wire traffic)
976
- * every few milliseconds while a read waits makes each answer arrive
977
- * within the poll interval, first exchange included. The interval never
978
- * holds the process open and stops itself once no read is waiting.
979
- */
980
- startKick() {
981
- if (this.kick) return;
982
- const tick = () => {
983
- const b = this.binding;
984
- if (!b || !this.waiters.length) {
985
- this.stopKick();
986
- return;
987
- }
988
- if (this.kicking) return;
989
- this.kicking = true;
990
- b.get().catch(() => null).then(() => {
991
- this.kicking = false;
992
- });
993
- };
994
- this.kick = setInterval(tick, RX_KICK_MS);
995
- if (typeof this.kick === "object" && "unref" in this.kick)
996
- this.kick.unref();
997
- }
998
- /** Stop the receive kick. */
999
- stopKick() {
1000
- if (!this.kick) return;
1001
- clearInterval(this.kick);
1002
- this.kick = null;
1937
+ if (!this.opened) return Promise.resolve({ value: void 0, done: true });
1938
+ return new Promise((resolve2) => this.waiters.push(resolve2));
1003
1939
  }
1004
- /**
1005
- * Cancel the reader: a waiting read is told `done`, buffered bytes go.
1006
- */
1940
+ /** Cancel the reader: a waiting read is told done, buffered bytes go. */
1007
1941
  cancel() {
1008
1942
  this.chunks = [];
1009
1943
  this.wakeAll();
1010
1944
  }
1011
1945
  /**
1012
- * Write bytes. Resolves when the OS has them, not when they have left the
1013
- * wire: the bus then holds DTR for the telegram's own byte time, and
1014
- * waiting for transmission here would double that hold and lose the
1015
- * ECU's answer (the reference interface's DtrTimeCorrCom is 0.3 ms).
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.
1016
1950
  * @param bytes - the framed request
1017
1951
  */
1018
1952
  async write(bytes) {
1019
- if (!this.binding) throw new Error(`${this.path} is not open`);
1020
- await this.binding.write(bytes);
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);
1021
1962
  }
1022
1963
  /**
1023
- * Drive the modem lines. A partial call keeps the lines it does not name,
1024
- * as Web Serial does; the binding always gets all three, because
1025
- * `serialport` would otherwise assert the ones left unsaid.
1026
- * @param s - the lines to set
1027
- */
1028
- async setSignals(s) {
1029
- if (!this.binding) throw new Error(`${this.path} is not open`);
1030
- if (s.dataTerminalReady !== void 0)
1031
- this.lines.dtr = !!s.dataTerminalReady;
1032
- if (s.requestToSend !== void 0) this.lines.rts = !!s.requestToSend;
1033
- if (s.break !== void 0) this.lines.brk = !!s.break;
1034
- await this.binding.set({ ...this.lines });
1964
+ * Drive the remote modem lines. The host keeps the lines the call does
1965
+ * not name, exactly as a local port does.
1966
+ * @param s - the lines to set
1967
+ */
1968
+ async setSignals(s) {
1969
+ if (!this.opened) throw new Error(`${this.url} is not open`);
1970
+ await this.call("setSignals", { signals: s });
1035
1971
  }
1036
1972
  /**
1037
- * Read the modem lines (KL15 arrives on DSR or DCD of a K+DCAN cable).
1038
- * @returns the lines, or null when the binding cannot say
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
1039
1975
  */
1040
1976
  async getSignals() {
1041
- if (!this.binding) return null;
1042
- const st = await this.binding.get();
1043
- if (!st) return null;
1044
- return {
1045
- dataSetReady: !!st.dsr,
1046
- dataCarrierDetect: !!st.dcd,
1047
- clearToSend: !!st.cts
1048
- };
1977
+ const r = await this.call("getSignals");
1978
+ const sig = r.signals;
1979
+ return sig || null;
1049
1980
  }
1050
1981
  /**
1051
- * The USB ids, for the bus's port label.
1052
- * @returns what is known
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
1053
1985
  */
1054
1986
  getInfo() {
1055
- return this.info;
1987
+ return {};
1056
1988
  }
1057
- /** Web Serial's disconnect event; a pulled USB cable is not watched here. */
1989
+ /** Web Serial's disconnect event; the socket's close stands in for it. */
1058
1990
  addEventListener() {
1059
1991
  }
1060
1992
  };
1061
- var serialportModule = null;
1062
- async function loadSerialport() {
1063
- if (serialportModule) return serialportModule;
1064
- try {
1065
- serialportModule = await import("serialport");
1066
- } catch {
1067
- throw new CliError(
1068
- "the serialport package is not installed; run: npm i -g serialport (or reinstall bmweb-cli with its optional dependencies)"
1069
- );
1070
- }
1071
- return serialportModule;
1072
- }
1073
- async function openSerialportBinding(path, cfg) {
1074
- const mod = await loadSerialport();
1075
- const port = new mod.SerialPort({
1076
- path,
1077
- baudRate: cfg.baudRate,
1078
- dataBits: cfg.dataBits,
1079
- stopBits: cfg.stopBits,
1080
- parity: cfg.parity,
1081
- autoOpen: false,
1082
- // keep the lines where we leave them across close/open
1083
- hupcl: false
1084
- });
1085
- await new Promise(
1086
- (res, rej) => port.open(
1087
- (e) => e ? rej(new CliError(`cannot open ${path}: ${e.message}`)) : res()
1088
- )
1089
- );
1090
- const call = (fn) => new Promise((res, rej) => fn((e) => e ? rej(e) : res()));
1091
- const binding = {
1092
- write: (bytes) => call((cb) => port.write(Buffer.from(bytes), cb)),
1093
- onData: (fn) => port.on("data", (b) => fn(new Uint8Array(b))),
1094
- set: (s) => call(
1095
- (cb) => port.set(
1096
- {
1097
- dtr: s.dtr,
1098
- rts: s.rts,
1099
- brk: s.brk,
1100
- cts: false,
1101
- dsr: false,
1102
- ...process.platform === "linux" ? { lowLatency: true } : {}
1103
- },
1104
- cb
1105
- )
1106
- ),
1107
- get: () => new Promise((res) => port.get((e, st) => res(e || !st ? null : st))),
1108
- close: () => new Promise((res) => port.close(() => res()))
1109
- };
1110
- await binding.set({ dtr: false, rts: false, brk: false });
1111
- return binding;
1112
- }
1113
- async function listPorts(devDir = "/dev") {
1114
- const found = /* @__PURE__ */ new Map();
1115
- try {
1116
- for (const name of readdirSync2(devDir))
1117
- if (PORT_PATTERNS.some((re) => re.test(name)))
1118
- found.set(`${devDir}/${name}`, "");
1119
- } catch {
1120
- }
1121
- try {
1122
- const mod = await loadSerialport();
1123
- for (const p of await mod.SerialPort.list()) {
1124
- const base = p.path.split("/").pop() || p.path;
1125
- if (!PORT_PATTERNS.some((re) => re.test(base))) continue;
1126
- const detail = [
1127
- p.manufacturer,
1128
- p.vendorId && p.productId ? `${p.vendorId}:${p.productId}` : "",
1129
- p.serialNumber ? `sn ${p.serialNumber}` : ""
1130
- ].filter(Boolean).join(" ");
1131
- found.set(p.path, detail);
1132
- }
1133
- } catch {
1134
- }
1135
- return [...found.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([path, detail]) => ({ path, detail }));
1136
- }
1137
- function choosePort(wanted, candidates) {
1138
- if (wanted) return wanted;
1139
- if (candidates.length === 1) return candidates[0].path;
1140
- if (!candidates.length)
1141
- throw new CliError(
1142
- "no K+DCAN cable found (looked for cu.usbserial*, cu.SLAB*, cu.wchusbserial*, ttyUSB*, ttyACM*); pass --port <device>"
1143
- );
1144
- throw new CliError(
1145
- `several ports found; pass --port: ${candidates.map((c) => c.path).join(", ")}`
1146
- );
1147
- }
1148
1993
 
1149
1994
  // src/live.ts
1150
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;
1151
1997
  async function connectBus(opts = {}) {
1152
1998
  configureSite({
1153
1999
  ...opts.api ? { base: opts.api } : {},
@@ -1155,10 +2001,20 @@ async function connectBus(opts = {}) {
1155
2001
  });
1156
2002
  const R = loadRuntime();
1157
2003
  const g = runtimeGlobals();
1158
- const candidates = opts.ports || await listPorts();
1159
- const path = choosePort(opts.port, candidates);
1160
- const opener = opts.opener || openSerialportBinding;
1161
- const port = new NodeSerialPort(path, opener);
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
+ }
1162
2018
  g.navigator.serial = {
1163
2019
  requestPort: async () => port,
1164
2020
  getPorts: async () => []
@@ -1167,8 +2023,14 @@ async function connectBus(opts = {}) {
1167
2023
  try {
1168
2024
  label = await R.webBus.connect();
1169
2025
  } catch (e) {
2026
+ if (dialled === port) {
2027
+ dialled.hangUp();
2028
+ dialled = null;
2029
+ }
1170
2030
  throw new CliError(`cannot open ${path}: ${e.message}`);
1171
2031
  }
2032
+ if (port instanceof GatewayPort)
2033
+ label = `gateway ${path}${port.remoteDevice ? ` (${port.remoteDevice})` : ""}`;
1172
2034
  return { R, label, path };
1173
2035
  }
1174
2036
  async function disconnectBus(R) {
@@ -1178,6 +2040,10 @@ async function disconnectBus(R) {
1178
2040
  } catch {
1179
2041
  }
1180
2042
  if (R.webBus.connected) await R.webBus.disconnect();
2043
+ if (dialled) {
2044
+ dialled.hangUp();
2045
+ dialled = null;
2046
+ }
1181
2047
  }
1182
2048
  async function portsCommand(json, ports) {
1183
2049
  const list = ports || await listPorts();
@@ -1199,6 +2065,22 @@ async function askYesNo(question, io = {}) {
1199
2065
  rl.close();
1200
2066
  return /^y(es)?$/i.test(answer.trim());
1201
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
+ }
1202
2084
  async function jobCommand(sgbd, job, opts = {}) {
1203
2085
  const R = loadRuntime();
1204
2086
  const name = job.toUpperCase();
@@ -1227,6 +2109,17 @@ async function jobCommand(sgbd, job, opts = {}) {
1227
2109
  ${FTDI_HINT}` : ""}`
1228
2110
  );
1229
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
+ }
1230
2123
  if (opts.json) return [JSON.stringify(d, null, 2)];
1231
2124
  return formatAnswer(d);
1232
2125
  }
@@ -1916,6 +2809,255 @@ function runSearch(query, opts) {
1916
2809
  return out;
1917
2810
  }
1918
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
+
1919
3061
  // src/tui.ts
1920
3062
  import { writeFileSync as writeFileSync4 } from "node:fs";
1921
3063
  import { createInterface as createInterface2, emitKeypressEvents } from "node:readline";
@@ -2749,7 +3891,7 @@ async function tuiCommand(chassis, sgbd, opts = {}) {
2749
3891
  }
2750
3892
 
2751
3893
  // src/bmweb.ts
2752
- var VERSION = true ? "0.1.6" : "0.0.0-dev";
3894
+ var VERSION = true ? "0.1.7" : "0.0.0-dev";
2753
3895
  var INCLUDE = {
2754
3896
  include: {
2755
3897
  kind: "list",
@@ -2763,12 +3905,7 @@ var JSON_FLAG = {
2763
3905
  help: "print machine-readable JSON instead of a table"
2764
3906
  }
2765
3907
  };
2766
- var LIVE = {
2767
- port: {
2768
- kind: "string",
2769
- alias: "p",
2770
- help: "the serial device (the single candidate when there is one)"
2771
- },
3908
+ var SITE2 = {
2772
3909
  api: {
2773
3910
  kind: "string",
2774
3911
  help: "the site the module data comes from (default https://bmweb.danner.ink/)"
@@ -2778,6 +3915,19 @@ var LIVE = {
2778
3915
  help: "fetch the module data again even when the cached copy is fresh"
2779
3916
  }
2780
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
+ };
2781
3931
  var COMMANDS = {
2782
3932
  "ipo info": {
2783
3933
  usage: "bmweb ipo info <file.IPO|file.IPS|file.SRC> [-I dir]... [--json]",
@@ -2811,14 +3961,18 @@ var COMMANDS = {
2811
3961
  }
2812
3962
  },
2813
3963
  "ipo compile": {
2814
- usage: "bmweb ipo compile <file.IPS|file.SRC> [-I dir]... [-o out.ipoexec.json]",
2815
- summary: "compile an INPA source into the app's exec form (JSON); missing includes are named",
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",
2816
3966
  flags: {
2817
3967
  ...INCLUDE,
2818
3968
  out: {
2819
3969
  kind: "string",
2820
3970
  alias: "o",
2821
- help: "where to write (default: <stem>.ipoexec.json beside the source)"
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"
2822
3976
  }
2823
3977
  },
2824
3978
  async run(pos, flags) {
@@ -2830,7 +3984,8 @@ var COMMANDS = {
2830
3984
  return ipoCompile(
2831
3985
  file,
2832
3986
  flags.include || [],
2833
- flags.out
3987
+ flags.out,
3988
+ !!flags.exec
2834
3989
  );
2835
3990
  }
2836
3991
  },
@@ -2899,9 +4054,33 @@ var COMMANDS = {
2899
4054
  return portsCommand(!!flags.json);
2900
4055
  }
2901
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
+ },
2902
4081
  job: {
2903
- usage: "bmweb job <sgbd> <JOB> [arg] [--port p] [--api url] [--yes] [--json]",
2904
- 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",
2905
4084
  flags: {
2906
4085
  ...LIVE,
2907
4086
  yes: {
@@ -2909,26 +4088,84 @@ var COMMANDS = {
2909
4088
  alias: "y",
2910
4089
  help: "consent to a write job on the command line"
2911
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
+ },
2912
4100
  ...JSON_FLAG
2913
4101
  },
2914
4102
  async run(pos, flags) {
2915
4103
  const [sgbd, job, arg] = pos;
2916
4104
  if (!sgbd || !job || pos.length > 3)
2917
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
+ }
2918
4112
  const { R } = await connectBus(liveOptions(flags));
2919
4113
  try {
2920
4114
  return await jobCommand(sgbd, job, {
2921
4115
  arg,
2922
4116
  yes: !!flags.yes,
2923
- json: !!flags.json
4117
+ json: !!flags.json,
4118
+ results: splitList(flags.results)
2924
4119
  });
2925
4120
  } finally {
2926
4121
  await disconnectBus(R);
2927
4122
  }
2928
4123
  }
2929
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
+ },
2930
4167
  scan: {
2931
- usage: "bmweb scan <chassis> [--port p] [--api url] [--share] [--json]",
4168
+ usage: "bmweb scan <chassis> [--port p] [--gateway h:p] [--api url] [--share] [--json]",
2932
4169
  summary: `INPA's whole-vehicle script over the cable (${SCAN_CHASSIS.join(" ")}): every fault memory as a report, --share adds a Garage link`,
2933
4170
  flags: {
2934
4171
  ...LIVE,
@@ -2960,7 +4197,7 @@ var COMMANDS = {
2960
4197
  }
2961
4198
  },
2962
4199
  tui: {
2963
- 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]",
2964
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",
2965
4202
  flags: {
2966
4203
  ...LIVE,
@@ -2991,10 +4228,20 @@ var COMMANDS = {
2991
4228
  function liveOptions(flags) {
2992
4229
  return {
2993
4230
  port: flags.port,
4231
+ gateway: flags.gateway,
2994
4232
  api: flags.api,
2995
4233
  refresh: !!flags.refresh
2996
4234
  };
2997
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
+ }
2998
4245
  function helpText() {
2999
4246
  const out = [
3000
4247
  `bmweb ${VERSION}: BMWeb's tools as a command line (https://bmweb.danner.ink/)`,
@@ -3012,10 +4259,12 @@ function helpText() {
3012
4259
  " bmweb <command> --help options of one command",
3013
4260
  " bmweb --version",
3014
4261
  "",
3015
- "ports, job, scan and tui talk to the car over a K+DCAN cable (the serialport",
3016
- "package, an optional dependency). Module data and the search index are",
3017
- "fetched from the site and cached under $XDG_CACHE_HOME/bmweb-cli",
3018
- "(default ~/.cache/bmweb-cli) for a day; nothing BMW-derived ships here."
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."
3019
4268
  );
3020
4269
  return out;
3021
4270
  }