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/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 test `typeof window` and read window.<x>; the context's own
292
- // global stands in, as it does in the page (self: fflate's UMD attaches
293
- // its global to `self` when there is no CommonJS `exports`)
294
- console,
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 = readdirSync(dir);
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 target = out || join4(dirname2(resolve(file)), `${s.stem}.ipoexec.json`);
800
- try {
801
- writeFileSync2(target, JSON.stringify(s.exec));
802
- } catch {
803
- 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)`;
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
- `wrote ${target} (the app's exec form; not INPA's binary .IPO)`
1723
+ wrote
809
1724
  ];
810
1725
  }
811
1726
 
812
1727
  // src/live.ts
813
1728
  import { createInterface } from "node:readline";
814
1729
 
815
- // src/serial.ts
816
- import { readdirSync as readdirSync2 } from "node:fs";
817
- var RX_KICK_MS = 4;
818
- var PORT_PATTERNS = [
819
- /^cu\.usbserial/i,
820
- /^cu\.SLAB/i,
821
- /^cu\.wchusbserial/i,
822
- /^ttyUSB/i,
823
- /^ttyACM/i
824
- ];
825
- var NodeSerialPort = class {
826
- path;
827
- opener;
828
- binding = null;
829
- /** 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 */
830
1747
  chunks = [];
831
1748
  /** reads waiting for bytes, oldest first, when the queue is empty */
832
1749
  waiters = [];
833
- /** the receive kick, running while a read waits (see startKick) */
834
- kick = null;
835
- /** a kick ioctl in flight, so they never pile up */
836
- kicking = false;
837
- /** the lines as last set, so a partial setSignals keeps the others */
838
- lines = { dtr: false, rts: false, brk: false };
839
- /** the wire trace sink, when the CLI wants one */
840
- 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;
841
1758
  /**
842
- * @param path - the device path
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(path, opener, info = {}) {
847
- this.path = path;
848
- this.opener = opener;
849
- this.info = info;
1761
+ constructor(url) {
1762
+ this.url = url;
850
1763
  }
851
- /** 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). */
852
1793
  get connected() {
853
- 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;
854
1799
  }
855
1800
  /**
856
- * 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.
857
1863
  * @param cfg - baud, bits, parity
858
1864
  */
859
1865
  async open(cfg) {
860
- if (this.binding) throw new Error(`${this.path} is already open`);
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
- b.onData((chunk) => this.push(chunk));
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
- const b = this.binding;
871
- this.binding = null;
872
- this.stopKick();
1873
+ this.opened = false;
873
1874
  this.wakeAll();
874
1875
  this.chunks = [];
875
- 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;
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 binding heard: to the waiting read, else queued.
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.binding) return Promise.resolve({ value: void 0, done: true });
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. Resolves when the OS has them, not when they have left the
977
- * wire: the bus then holds DTR for the telegram's own byte time, and
978
- * waiting for transmission here would double that hold and lose the
979
- * 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.
980
1950
  * @param bytes - the framed request
981
1951
  */
982
1952
  async write(bytes) {
983
- if (!this.binding) throw new Error(`${this.path} is not open`);
984
- 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);
985
1962
  }
986
1963
  /**
987
- * Drive the modem lines. A partial call keeps the lines it does not name,
988
- * as Web Serial does; the binding always gets all three, because
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.binding) throw new Error(`${this.path} is not open`);
994
- if (s.dataTerminalReady !== void 0)
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 or DCD of a K+DCAN cable).
1002
- * @returns the lines, or null when the binding cannot say
1003
- */
1004
- async getSignals() {
1005
- if (!this.binding) return null;
1006
- const st = await this.binding.get();
1007
- if (!st) return null;
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, for the bus's port label.
1016
- * @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
1017
1985
  */
1018
1986
  getInfo() {
1019
- return this.info;
1987
+ return {};
1020
1988
  }
1021
- /** 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. */
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
- const candidates = opts.ports || await listPorts();
1123
- const path = choosePort(opts.port, candidates);
1124
- const opener = opts.opener || openSerialportBinding;
1125
- 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
+ }
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
- if (body.length > room) body = body.filter((l, i) => l || body[i - 1]);
2441
- if (body.length > room) {
2442
- const hidden = body.length - (room - 1);
2443
- body = [
2444
- ...body.slice(0, room - 1),
2445
- `(${hidden} more rows: enlarge the terminal)`
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.4" : "0.0.0-dev";
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 LIVE = {
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.ipoexec.json]",
2728
- 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",
2729
3966
  flags: {
2730
3967
  ...INCLUDE,
2731
3968
  out: {
2732
3969
  kind: "string",
2733
3970
  alias: "o",
2734
- 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"
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 serialport",
2929
- "package, an optional dependency). Module data and the search index are",
2930
- "fetched from the site and cached under $XDG_CACHE_HOME/bmweb-cli",
2931
- "(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."
2932
4268
  );
2933
4269
  return out;
2934
4270
  }