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/README.md +281 -26
- package/dist/bmweb.js +1492 -243
- package/package.json +1 -1
- package/runtime/core/ipofile/encode.js +701 -0
- package/runtime/core/ipofile/exec.js +155 -0
- package/runtime/core/webshim/web-serial-bus.js +59 -10
- package/runtime/screens/ipo-runtime/program.js +8 -2
package/dist/bmweb.js
CHANGED
|
@@ -81,8 +81,866 @@ function helpLines(spec) {
|
|
|
81
81
|
return rows.map(([l, h]) => ` ${l.padEnd(w)} ${h}`.trimEnd());
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// src/serial.ts
|
|
85
|
+
import { readdirSync } from "node:fs";
|
|
86
|
+
var RX_KICK_MS = 4;
|
|
87
|
+
var PORT_PATTERNS = [
|
|
88
|
+
/^cu\.usbserial/i,
|
|
89
|
+
/^cu\.SLAB/i,
|
|
90
|
+
/^cu\.wchusbserial/i,
|
|
91
|
+
/^ttyUSB/i,
|
|
92
|
+
/^ttyACM/i
|
|
93
|
+
];
|
|
94
|
+
var NodeSerialPort = class {
|
|
95
|
+
path;
|
|
96
|
+
opener;
|
|
97
|
+
binding = null;
|
|
98
|
+
/** chunks heard and not yet read */
|
|
99
|
+
chunks = [];
|
|
100
|
+
/** reads waiting for bytes, oldest first, when the queue is empty */
|
|
101
|
+
waiters = [];
|
|
102
|
+
/** the receive kick, running while a read waits (see startKick) */
|
|
103
|
+
kick = null;
|
|
104
|
+
/** a kick ioctl in flight, so they never pile up */
|
|
105
|
+
kicking = false;
|
|
106
|
+
/** the lines as last set, so a partial setSignals keeps the others */
|
|
107
|
+
lines = { dtr: false, rts: false, brk: false };
|
|
108
|
+
/** the wire trace sink, when the CLI wants one */
|
|
109
|
+
info;
|
|
110
|
+
/**
|
|
111
|
+
* @param path - the device path
|
|
112
|
+
* @param opener - how a binding is opened
|
|
113
|
+
* @param info - vendor and product ids, when known (portLabel shows them)
|
|
114
|
+
*/
|
|
115
|
+
constructor(path, opener, info = {}) {
|
|
116
|
+
this.path = path;
|
|
117
|
+
this.opener = opener;
|
|
118
|
+
this.info = info;
|
|
119
|
+
}
|
|
120
|
+
/** Is a binding open (Web Serial's SerialPort.connected). */
|
|
121
|
+
get connected() {
|
|
122
|
+
return !!this.binding;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Open the device with the given settings.
|
|
126
|
+
* @param cfg - baud, bits, parity
|
|
127
|
+
*/
|
|
128
|
+
async open(cfg) {
|
|
129
|
+
if (this.binding) throw new Error(`${this.path} is already open`);
|
|
130
|
+
const b = await this.opener(this.path, cfg);
|
|
131
|
+
this.binding = b;
|
|
132
|
+
this.chunks = [];
|
|
133
|
+
b.onData((chunk) => this.push(chunk));
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Close the device: the binding goes, a waiting read is told `done`.
|
|
137
|
+
*/
|
|
138
|
+
async close() {
|
|
139
|
+
const b = this.binding;
|
|
140
|
+
this.binding = null;
|
|
141
|
+
this.stopKick();
|
|
142
|
+
this.wakeAll();
|
|
143
|
+
this.chunks = [];
|
|
144
|
+
if (b) await b.close();
|
|
145
|
+
}
|
|
146
|
+
/** Tell every waiting read the port is done, and forget them. */
|
|
147
|
+
wakeAll() {
|
|
148
|
+
const ws = this.waiters;
|
|
149
|
+
this.waiters = [];
|
|
150
|
+
for (const w of ws) w({ value: void 0, done: true });
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Bytes the binding heard: to the waiting read, else queued.
|
|
154
|
+
* @param chunk - the bytes
|
|
155
|
+
*/
|
|
156
|
+
push(chunk) {
|
|
157
|
+
if (!chunk.length) return;
|
|
158
|
+
const w = this.waiters.shift();
|
|
159
|
+
if (w) {
|
|
160
|
+
w({ value: chunk, done: false });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.chunks.push(chunk);
|
|
164
|
+
}
|
|
165
|
+
/** The readable side: getReader() hands back the one reader. */
|
|
166
|
+
get readable() {
|
|
167
|
+
return {
|
|
168
|
+
getReader: () => ({
|
|
169
|
+
read: () => this.read(),
|
|
170
|
+
cancel: async () => this.cancel(),
|
|
171
|
+
releaseLock: () => {
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** The writable side: getWriter() hands back the one writer. */
|
|
177
|
+
get writable() {
|
|
178
|
+
return {
|
|
179
|
+
getWriter: () => ({
|
|
180
|
+
write: (bytes) => this.write(bytes),
|
|
181
|
+
releaseLock: () => {
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* One read: the next chunk, or `done` when the port is closed.
|
|
188
|
+
* @returns the read result
|
|
189
|
+
*/
|
|
190
|
+
read() {
|
|
191
|
+
const next = this.chunks.shift();
|
|
192
|
+
if (next) return Promise.resolve({ value: next, done: false });
|
|
193
|
+
if (!this.binding) return Promise.resolve({ value: void 0, done: true });
|
|
194
|
+
return new Promise((resolve2) => {
|
|
195
|
+
this.waiters.push(resolve2);
|
|
196
|
+
this.startKick();
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Make the driver deliver what it has heard.
|
|
201
|
+
*
|
|
202
|
+
* THE BUG THIS FIXES. On macOS the built-in FTDI driver does not wake the
|
|
203
|
+
* reader when bytes arrive: with a read armed and the process simply
|
|
204
|
+
* waiting, an ECU's answer sat in the driver until some OTHER call touched
|
|
205
|
+
* the device (the next write, a modem-line change, close), and only then
|
|
206
|
+
* came out -- measured on a real car as 0 bytes for the first exchange
|
|
207
|
+
* after open and every later exchange delivering the PREVIOUS one's bytes
|
|
208
|
+
* at its start. Polling the modem lines (a TIOCMGET, no wire traffic)
|
|
209
|
+
* every few milliseconds while a read waits makes each answer arrive
|
|
210
|
+
* within the poll interval, first exchange included. The interval never
|
|
211
|
+
* holds the process open and stops itself once no read is waiting.
|
|
212
|
+
*/
|
|
213
|
+
startKick() {
|
|
214
|
+
if (this.kick) return;
|
|
215
|
+
const tick = () => {
|
|
216
|
+
const b = this.binding;
|
|
217
|
+
if (!b || !this.waiters.length) {
|
|
218
|
+
this.stopKick();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (this.kicking) return;
|
|
222
|
+
this.kicking = true;
|
|
223
|
+
b.get().catch(() => null).then(() => {
|
|
224
|
+
this.kicking = false;
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
this.kick = setInterval(tick, RX_KICK_MS);
|
|
228
|
+
if (typeof this.kick === "object" && "unref" in this.kick)
|
|
229
|
+
this.kick.unref();
|
|
230
|
+
}
|
|
231
|
+
/** Stop the receive kick. */
|
|
232
|
+
stopKick() {
|
|
233
|
+
if (!this.kick) return;
|
|
234
|
+
clearInterval(this.kick);
|
|
235
|
+
this.kick = null;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Cancel the reader: a waiting read is told `done`, buffered bytes go.
|
|
239
|
+
*/
|
|
240
|
+
cancel() {
|
|
241
|
+
this.chunks = [];
|
|
242
|
+
this.wakeAll();
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Write bytes. Resolves when the OS has them, not when they have left the
|
|
246
|
+
* wire: the bus then holds DTR for the telegram's own byte time, and
|
|
247
|
+
* waiting for transmission here would double that hold and lose the
|
|
248
|
+
* ECU's answer (the reference interface's DtrTimeCorrCom is 0.3 ms).
|
|
249
|
+
* @param bytes - the framed request
|
|
250
|
+
*/
|
|
251
|
+
async write(bytes) {
|
|
252
|
+
if (!this.binding) throw new Error(`${this.path} is not open`);
|
|
253
|
+
await this.binding.write(bytes);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Drive the modem lines. A partial call keeps the lines it does not name,
|
|
257
|
+
* as Web Serial does; the binding always gets all three, because
|
|
258
|
+
* `serialport` would otherwise assert the ones left unsaid.
|
|
259
|
+
* @param s - the lines to set
|
|
260
|
+
*/
|
|
261
|
+
async setSignals(s) {
|
|
262
|
+
if (!this.binding) throw new Error(`${this.path} is not open`);
|
|
263
|
+
if (s.dataTerminalReady !== void 0)
|
|
264
|
+
this.lines.dtr = !!s.dataTerminalReady;
|
|
265
|
+
if (s.requestToSend !== void 0) this.lines.rts = !!s.requestToSend;
|
|
266
|
+
if (s.break !== void 0) this.lines.brk = !!s.break;
|
|
267
|
+
await this.binding.set({ ...this.lines });
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Read the modem lines (KL15 arrives on DSR or DCD of a K+DCAN cable).
|
|
271
|
+
* @returns the lines, or null when the binding cannot say
|
|
272
|
+
*/
|
|
273
|
+
async getSignals() {
|
|
274
|
+
if (!this.binding) return null;
|
|
275
|
+
const st = await this.binding.get();
|
|
276
|
+
if (!st) return null;
|
|
277
|
+
return {
|
|
278
|
+
dataSetReady: !!st.dsr,
|
|
279
|
+
dataCarrierDetect: !!st.dcd,
|
|
280
|
+
clearToSend: !!st.cts
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* The USB ids, for the bus's port label.
|
|
285
|
+
* @returns what is known
|
|
286
|
+
*/
|
|
287
|
+
getInfo() {
|
|
288
|
+
return this.info;
|
|
289
|
+
}
|
|
290
|
+
/** Web Serial's disconnect event; a pulled USB cable is not watched here. */
|
|
291
|
+
addEventListener() {
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
var serialportModule = null;
|
|
295
|
+
async function loadSerialport() {
|
|
296
|
+
if (serialportModule) return serialportModule;
|
|
297
|
+
try {
|
|
298
|
+
serialportModule = await import("serialport");
|
|
299
|
+
} catch {
|
|
300
|
+
throw new CliError(
|
|
301
|
+
"the serialport package is not installed; run: npm i -g serialport (or reinstall bmweb-cli with its optional dependencies)"
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return serialportModule;
|
|
305
|
+
}
|
|
306
|
+
async function openSerialportBinding(path, cfg) {
|
|
307
|
+
const mod = await loadSerialport();
|
|
308
|
+
const port = new mod.SerialPort({
|
|
309
|
+
path,
|
|
310
|
+
baudRate: cfg.baudRate,
|
|
311
|
+
dataBits: cfg.dataBits,
|
|
312
|
+
stopBits: cfg.stopBits,
|
|
313
|
+
parity: cfg.parity,
|
|
314
|
+
autoOpen: false,
|
|
315
|
+
// keep the lines where we leave them across close/open
|
|
316
|
+
hupcl: false
|
|
317
|
+
});
|
|
318
|
+
await new Promise(
|
|
319
|
+
(res, rej) => port.open(
|
|
320
|
+
(e) => e ? rej(new CliError(`cannot open ${path}: ${e.message}`)) : res()
|
|
321
|
+
)
|
|
322
|
+
);
|
|
323
|
+
const call = (fn) => new Promise((res, rej) => fn((e) => e ? rej(e) : res()));
|
|
324
|
+
const binding = {
|
|
325
|
+
write: (bytes) => call((cb) => port.write(Buffer.from(bytes), cb)),
|
|
326
|
+
onData: (fn) => port.on("data", (b) => fn(new Uint8Array(b))),
|
|
327
|
+
set: (s) => call(
|
|
328
|
+
(cb) => port.set(
|
|
329
|
+
{
|
|
330
|
+
dtr: s.dtr,
|
|
331
|
+
rts: s.rts,
|
|
332
|
+
brk: s.brk,
|
|
333
|
+
cts: false,
|
|
334
|
+
dsr: false,
|
|
335
|
+
...process.platform === "linux" ? { lowLatency: true } : {}
|
|
336
|
+
},
|
|
337
|
+
cb
|
|
338
|
+
)
|
|
339
|
+
),
|
|
340
|
+
get: () => new Promise((res) => port.get((e, st) => res(e || !st ? null : st))),
|
|
341
|
+
close: () => new Promise((res) => port.close(() => res()))
|
|
342
|
+
};
|
|
343
|
+
await binding.set({ dtr: false, rts: false, brk: false });
|
|
344
|
+
return binding;
|
|
345
|
+
}
|
|
346
|
+
async function listPorts(devDir = "/dev") {
|
|
347
|
+
const found = /* @__PURE__ */ new Map();
|
|
348
|
+
try {
|
|
349
|
+
for (const name of readdirSync(devDir))
|
|
350
|
+
if (PORT_PATTERNS.some((re) => re.test(name)))
|
|
351
|
+
found.set(`${devDir}/${name}`, "");
|
|
352
|
+
} catch {
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
const mod = await loadSerialport();
|
|
356
|
+
for (const p of await mod.SerialPort.list()) {
|
|
357
|
+
const base = p.path.split("/").pop() || p.path;
|
|
358
|
+
if (!PORT_PATTERNS.some((re) => re.test(base))) continue;
|
|
359
|
+
const detail = [
|
|
360
|
+
p.manufacturer,
|
|
361
|
+
p.vendorId && p.productId ? `${p.vendorId}:${p.productId}` : "",
|
|
362
|
+
p.serialNumber ? `sn ${p.serialNumber}` : ""
|
|
363
|
+
].filter(Boolean).join(" ");
|
|
364
|
+
found.set(p.path, detail);
|
|
365
|
+
}
|
|
366
|
+
} catch {
|
|
367
|
+
}
|
|
368
|
+
return [...found.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([path, detail]) => ({ path, detail }));
|
|
369
|
+
}
|
|
370
|
+
function choosePort(wanted, candidates) {
|
|
371
|
+
if (wanted) return wanted;
|
|
372
|
+
if (candidates.length === 1) return candidates[0].path;
|
|
373
|
+
if (!candidates.length)
|
|
374
|
+
throw new CliError(
|
|
375
|
+
"no K+DCAN cable found (looked for cu.usbserial*, cu.SLAB*, cu.wchusbserial*, ttyUSB*, ttyACM*); pass --port <device>"
|
|
376
|
+
);
|
|
377
|
+
throw new CliError(
|
|
378
|
+
`several ports found; pass --port: ${candidates.map((c) => c.path).join(", ")}`
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// src/ws.ts
|
|
383
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
384
|
+
import { createServer, connect } from "node:net";
|
|
385
|
+
var WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
386
|
+
var OP_CONT = 0;
|
|
387
|
+
var OP_TEXT = 1;
|
|
388
|
+
var OP_BINARY = 2;
|
|
389
|
+
var OP_CLOSE = 8;
|
|
390
|
+
var OP_PING = 9;
|
|
391
|
+
var OP_PONG = 10;
|
|
392
|
+
var MAX_MESSAGE = 1 << 20;
|
|
393
|
+
var CLOSE_NORMAL = 1e3;
|
|
394
|
+
var CLOSE_POLICY = 1008;
|
|
395
|
+
var WsConnection = class {
|
|
396
|
+
socket;
|
|
397
|
+
masked;
|
|
398
|
+
handlers = {};
|
|
399
|
+
/** bytes received and not yet parsed into a frame */
|
|
400
|
+
buf = Buffer.alloc(0);
|
|
401
|
+
/** the opcode of the message being assembled across continuation frames */
|
|
402
|
+
fragOp = 0;
|
|
403
|
+
/** the fragments of that message */
|
|
404
|
+
frags = [];
|
|
405
|
+
/** how many bytes those fragments hold */
|
|
406
|
+
fragLen = 0;
|
|
407
|
+
/** has a close frame been sent, so the next one is not sent twice */
|
|
408
|
+
closing = false;
|
|
409
|
+
/** has onClose already fired */
|
|
410
|
+
closed = false;
|
|
411
|
+
/**
|
|
412
|
+
* @param socket - the open TCP socket, past the handshake
|
|
413
|
+
* @param masked - true on the client end, which must mask what it sends
|
|
414
|
+
*/
|
|
415
|
+
constructor(socket, masked) {
|
|
416
|
+
this.socket = socket;
|
|
417
|
+
this.masked = masked;
|
|
418
|
+
socket.setNoDelay(true);
|
|
419
|
+
socket.on("data", (d) => this.feed(d));
|
|
420
|
+
socket.on("error", (e) => {
|
|
421
|
+
this.handlers.onError?.(e);
|
|
422
|
+
this.finish(1006, e.message);
|
|
423
|
+
});
|
|
424
|
+
socket.on("close", () => this.finish(1006, "socket closed"));
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Take the handlers. Set once, right after construction, so no frame is
|
|
428
|
+
* parsed before someone is listening.
|
|
429
|
+
* @param h - the handlers
|
|
430
|
+
*/
|
|
431
|
+
on(h) {
|
|
432
|
+
this.handlers = h;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Send a text frame.
|
|
436
|
+
* @param text - the payload
|
|
437
|
+
*/
|
|
438
|
+
sendText(text) {
|
|
439
|
+
this.send(OP_TEXT, Buffer.from(text, "utf8"));
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Send a binary frame.
|
|
443
|
+
* @param bytes - the payload
|
|
444
|
+
*/
|
|
445
|
+
sendBinary(bytes) {
|
|
446
|
+
this.send(OP_BINARY, Buffer.from(bytes));
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Send a close frame and end the socket.
|
|
450
|
+
* @param code - the close code
|
|
451
|
+
* @param reason - the human reason, at most 123 bytes on the wire
|
|
452
|
+
*/
|
|
453
|
+
close(code = CLOSE_NORMAL, reason = "") {
|
|
454
|
+
if (this.closing) return;
|
|
455
|
+
this.closing = true;
|
|
456
|
+
const r = Buffer.from(reason, "utf8").subarray(0, 123);
|
|
457
|
+
const body = Buffer.alloc(2 + r.length);
|
|
458
|
+
body.writeUInt16BE(code, 0);
|
|
459
|
+
r.copy(body, 2);
|
|
460
|
+
try {
|
|
461
|
+
this.send(OP_CLOSE, body);
|
|
462
|
+
} catch {
|
|
463
|
+
}
|
|
464
|
+
this.socket.end();
|
|
465
|
+
}
|
|
466
|
+
/** Drop the socket without a close frame (a handshake refusal). */
|
|
467
|
+
destroy() {
|
|
468
|
+
this.closing = true;
|
|
469
|
+
this.socket.destroy();
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Frame a payload and write it.
|
|
473
|
+
* @param op - the opcode
|
|
474
|
+
* @param payload - the bytes
|
|
475
|
+
*/
|
|
476
|
+
send(op, payload) {
|
|
477
|
+
if (this.socket.destroyed) return;
|
|
478
|
+
this.socket.write(encodeFrame(op, payload, this.masked));
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Take bytes off the socket and parse every whole frame in them.
|
|
482
|
+
* @param chunk - what arrived
|
|
483
|
+
*/
|
|
484
|
+
feed(chunk) {
|
|
485
|
+
this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk;
|
|
486
|
+
for (; ; ) {
|
|
487
|
+
const frame = decodeFrame(this.buf, !this.masked);
|
|
488
|
+
if (frame === null) return;
|
|
489
|
+
if (frame instanceof Error) {
|
|
490
|
+
this.handlers.onError?.(frame);
|
|
491
|
+
this.close(1002, frame.message);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
this.buf = this.buf.subarray(frame.size);
|
|
495
|
+
this.handle(frame.fin, frame.op, frame.payload);
|
|
496
|
+
if (this.closed) return;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* One parsed frame: control frames answered here, data frames assembled.
|
|
501
|
+
* @param fin - is this the message's last frame
|
|
502
|
+
* @param op - the opcode
|
|
503
|
+
* @param payload - the frame's bytes
|
|
504
|
+
*/
|
|
505
|
+
handle(fin, op, payload) {
|
|
506
|
+
if (op === OP_PING) {
|
|
507
|
+
this.send(OP_PONG, payload);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (op === OP_PONG) return;
|
|
511
|
+
if (op === OP_CLOSE) {
|
|
512
|
+
const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1005;
|
|
513
|
+
const reason = payload.length > 2 ? payload.subarray(2).toString() : "";
|
|
514
|
+
if (!this.closing) this.close(code === 1005 ? CLOSE_NORMAL : code, "");
|
|
515
|
+
this.finish(code, reason);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (op === OP_TEXT || op === OP_BINARY) {
|
|
519
|
+
if (this.frags.length) {
|
|
520
|
+
this.fail("a new message started before the last one finished");
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
this.fragOp = op;
|
|
524
|
+
} else if (op !== OP_CONT) {
|
|
525
|
+
this.fail(`unknown opcode 0x${op.toString(16)}`);
|
|
526
|
+
return;
|
|
527
|
+
} else if (!this.fragOp) {
|
|
528
|
+
this.fail("a continuation frame with nothing to continue");
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
this.fragLen += payload.length;
|
|
532
|
+
if (this.fragLen > MAX_MESSAGE) {
|
|
533
|
+
this.fail(`message longer than ${MAX_MESSAGE} bytes`);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
this.frags.push(payload);
|
|
537
|
+
if (!fin) return;
|
|
538
|
+
const body = this.frags.length === 1 ? this.frags[0] : Buffer.concat(this.frags);
|
|
539
|
+
const wasText = this.fragOp === OP_TEXT;
|
|
540
|
+
this.frags = [];
|
|
541
|
+
this.fragLen = 0;
|
|
542
|
+
this.fragOp = 0;
|
|
543
|
+
if (wasText) this.handlers.onText?.(body.toString("utf8"));
|
|
544
|
+
else this.handlers.onBinary?.(new Uint8Array(body));
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* A protocol violation: report it and close with 1002.
|
|
548
|
+
* @param why - what was wrong
|
|
549
|
+
*/
|
|
550
|
+
fail(why) {
|
|
551
|
+
this.handlers.onError?.(new Error(why));
|
|
552
|
+
this.close(1002, why);
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* The connection is over: tell the owner once.
|
|
556
|
+
* @param code - the close code
|
|
557
|
+
* @param reason - the peer's reason
|
|
558
|
+
*/
|
|
559
|
+
finish(code, reason) {
|
|
560
|
+
if (this.closed) return;
|
|
561
|
+
this.closed = true;
|
|
562
|
+
this.handlers.onClose?.(code, reason);
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
function encodeFrame(op, payload, mask) {
|
|
566
|
+
const len = payload.length;
|
|
567
|
+
const head = len < 126 ? 2 : len < 65536 ? 4 : 10;
|
|
568
|
+
const out = Buffer.alloc(head + (mask ? 4 : 0) + len);
|
|
569
|
+
out[0] = 128 | op;
|
|
570
|
+
if (len < 126) out[1] = len;
|
|
571
|
+
else if (len < 65536) {
|
|
572
|
+
out[1] = 126;
|
|
573
|
+
out.writeUInt16BE(len, 2);
|
|
574
|
+
} else {
|
|
575
|
+
out[1] = 127;
|
|
576
|
+
out.writeUInt32BE(0, 2);
|
|
577
|
+
out.writeUInt32BE(len, 6);
|
|
578
|
+
}
|
|
579
|
+
if (!mask) {
|
|
580
|
+
payload.copy(out, head);
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
out[1] = out[1] | 128;
|
|
584
|
+
const key = randomBytes(4);
|
|
585
|
+
key.copy(out, head);
|
|
586
|
+
for (let i = 0; i < len; i++) out[head + 4 + i] = payload[i] ^ key[i & 3];
|
|
587
|
+
return out;
|
|
588
|
+
}
|
|
589
|
+
function decodeFrame(buf, wantMask) {
|
|
590
|
+
if (buf.length < 2) return null;
|
|
591
|
+
const b0 = buf[0];
|
|
592
|
+
const b1 = buf[1];
|
|
593
|
+
if (b0 & 112) return new Error("reserved frame bits are set");
|
|
594
|
+
const fin = !!(b0 & 128);
|
|
595
|
+
const op = b0 & 15;
|
|
596
|
+
const masked = !!(b1 & 128);
|
|
597
|
+
if (masked !== wantMask)
|
|
598
|
+
return new Error(
|
|
599
|
+
wantMask ? "a client frame was not masked" : "a server frame was masked"
|
|
600
|
+
);
|
|
601
|
+
let len = b1 & 127;
|
|
602
|
+
let at = 2;
|
|
603
|
+
if (len === 126) {
|
|
604
|
+
if (buf.length < at + 2) return null;
|
|
605
|
+
len = buf.readUInt16BE(at);
|
|
606
|
+
at += 2;
|
|
607
|
+
} else if (len === 127) {
|
|
608
|
+
if (buf.length < at + 8) return null;
|
|
609
|
+
const hi = buf.readUInt32BE(at);
|
|
610
|
+
len = buf.readUInt32BE(at + 4);
|
|
611
|
+
at += 8;
|
|
612
|
+
if (hi) return new Error("frame longer than this end will read");
|
|
613
|
+
}
|
|
614
|
+
if (len > MAX_MESSAGE)
|
|
615
|
+
return new Error(`frame longer than ${MAX_MESSAGE} bytes`);
|
|
616
|
+
if (op >= 8 && (!fin || len > 125))
|
|
617
|
+
return new Error("a malformed control frame");
|
|
618
|
+
let key = null;
|
|
619
|
+
if (masked) {
|
|
620
|
+
if (buf.length < at + 4) return null;
|
|
621
|
+
key = buf.subarray(at, at + 4);
|
|
622
|
+
at += 4;
|
|
623
|
+
}
|
|
624
|
+
if (buf.length < at + len) return null;
|
|
625
|
+
const payload = Buffer.from(buf.subarray(at, at + len));
|
|
626
|
+
if (key) for (let i = 0; i < len; i++) payload[i] = payload[i] ^ key[i & 3];
|
|
627
|
+
return { fin, op, payload, size: at + len };
|
|
628
|
+
}
|
|
629
|
+
function acceptKey(key) {
|
|
630
|
+
return createHash("sha1").update(key + WS_GUID).digest("base64");
|
|
631
|
+
}
|
|
632
|
+
async function listenWs(host, port, onClient) {
|
|
633
|
+
const conns = /* @__PURE__ */ new Set();
|
|
634
|
+
const server = createServer((socket) => {
|
|
635
|
+
socket.setNoDelay(true);
|
|
636
|
+
let head = Buffer.alloc(0);
|
|
637
|
+
const onData = (chunk) => {
|
|
638
|
+
head = Buffer.concat([head, chunk]);
|
|
639
|
+
const end = head.indexOf("\r\n\r\n");
|
|
640
|
+
if (end < 0) {
|
|
641
|
+
if (head.length > 8192) socket.destroy();
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
socket.off("data", onData);
|
|
645
|
+
const req = head.subarray(0, end).toString("utf8");
|
|
646
|
+
const rest = head.subarray(end + 4);
|
|
647
|
+
const key = /\r\nsec-websocket-key:[ \t]*(\S+)/i.exec(req)?.[1];
|
|
648
|
+
const upgrade = /\r\nupgrade:[ \t]*websocket/i.test(req);
|
|
649
|
+
if (!key || !upgrade || !/^GET /i.test(req)) {
|
|
650
|
+
socket.end(
|
|
651
|
+
"HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 46\r\n\r\nthis port serves a WebSocket gateway, not HTTP\n"
|
|
652
|
+
);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
socket.write(
|
|
656
|
+
`HTTP/1.1 101 Switching Protocols\r
|
|
657
|
+
Upgrade: websocket\r
|
|
658
|
+
Connection: Upgrade\r
|
|
659
|
+
Sec-WebSocket-Accept: ${acceptKey(key)}\r
|
|
660
|
+
\r
|
|
661
|
+
`
|
|
662
|
+
);
|
|
663
|
+
const conn = new WsConnection(socket, false);
|
|
664
|
+
conns.add(conn);
|
|
665
|
+
socket.on("close", () => conns.delete(conn));
|
|
666
|
+
onClient({
|
|
667
|
+
conn,
|
|
668
|
+
from: `${socket.remoteAddress || "?"}:${socket.remotePort || 0}`
|
|
669
|
+
});
|
|
670
|
+
if (rest.length) setImmediate(() => socket.emit("data", rest));
|
|
671
|
+
};
|
|
672
|
+
socket.on("data", onData);
|
|
673
|
+
socket.on("error", () => socket.destroy());
|
|
674
|
+
});
|
|
675
|
+
await new Promise((res, rej) => {
|
|
676
|
+
server.once("error", rej);
|
|
677
|
+
server.listen(port, host, () => {
|
|
678
|
+
server.off("error", rej);
|
|
679
|
+
res();
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
const addr = server.address();
|
|
683
|
+
return {
|
|
684
|
+
port: typeof addr === "object" && addr ? addr.port : port,
|
|
685
|
+
host,
|
|
686
|
+
raw: server,
|
|
687
|
+
close: async () => {
|
|
688
|
+
for (const c of conns) c.close(CLOSE_NORMAL, "the gateway is stopping");
|
|
689
|
+
await new Promise((res) => server.close(() => res()));
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
async function connectWs(url) {
|
|
694
|
+
const u = new URL(url);
|
|
695
|
+
if (u.protocol !== "ws:")
|
|
696
|
+
throw new Error(
|
|
697
|
+
`${url}: only ws:// is supported here (wss:// needs a TLS front end)`
|
|
698
|
+
);
|
|
699
|
+
const port = Number(u.port || 80);
|
|
700
|
+
const key = randomBytes(16).toString("base64");
|
|
701
|
+
const socket = connect({ host: u.hostname, port });
|
|
702
|
+
socket.setNoDelay(true);
|
|
703
|
+
return new Promise((res, rej) => {
|
|
704
|
+
const fail = (e) => {
|
|
705
|
+
socket.destroy();
|
|
706
|
+
rej(e);
|
|
707
|
+
};
|
|
708
|
+
socket.once("error", fail);
|
|
709
|
+
socket.once("connect", () => {
|
|
710
|
+
socket.write(
|
|
711
|
+
`GET ${u.pathname || "/"}${u.search} HTTP/1.1\r
|
|
712
|
+
Host: ${u.host}\r
|
|
713
|
+
Upgrade: websocket\r
|
|
714
|
+
Connection: Upgrade\r
|
|
715
|
+
Sec-WebSocket-Key: ${key}\r
|
|
716
|
+
Sec-WebSocket-Version: 13\r
|
|
717
|
+
\r
|
|
718
|
+
`
|
|
719
|
+
);
|
|
720
|
+
let head = Buffer.alloc(0);
|
|
721
|
+
const onData = (chunk) => {
|
|
722
|
+
head = Buffer.concat([head, chunk]);
|
|
723
|
+
const end = head.indexOf("\r\n\r\n");
|
|
724
|
+
if (end < 0) {
|
|
725
|
+
if (head.length > 8192)
|
|
726
|
+
fail(new Error(`${url}: no handshake answer`));
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
socket.off("data", onData);
|
|
730
|
+
const res101 = head.subarray(0, end).toString("utf8");
|
|
731
|
+
if (!/^HTTP\/1\.1 101/i.test(res101)) {
|
|
732
|
+
fail(
|
|
733
|
+
new Error(
|
|
734
|
+
`${url}: the server refused the WebSocket upgrade (${res101.split("\r\n")[0]})`
|
|
735
|
+
)
|
|
736
|
+
);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const accept = /\r\nsec-websocket-accept:[ \t]*(\S+)/i.exec(
|
|
740
|
+
res101
|
|
741
|
+
)?.[1];
|
|
742
|
+
if (accept !== acceptKey(key)) {
|
|
743
|
+
fail(new Error(`${url}: the handshake answer did not match the key`));
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
socket.off("error", fail);
|
|
747
|
+
const rest = head.subarray(end + 4);
|
|
748
|
+
const conn = new WsConnection(socket, true);
|
|
749
|
+
if (rest.length) setImmediate(() => socket.emit("data", rest));
|
|
750
|
+
res(conn);
|
|
751
|
+
};
|
|
752
|
+
socket.on("data", onData);
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// src/gateway.ts
|
|
758
|
+
var DEFAULT_LISTEN = "127.0.0.1:6801";
|
|
759
|
+
function parseListen(listen) {
|
|
760
|
+
const [dh, dp] = DEFAULT_LISTEN.split(":");
|
|
761
|
+
const text = String(listen || "").trim();
|
|
762
|
+
if (!text) return { host: dh, port: Number(dp) };
|
|
763
|
+
if (/^\d+$/.test(text)) return { host: dh, port: Number(text) };
|
|
764
|
+
const at = text.lastIndexOf(":");
|
|
765
|
+
if (at < 0) return { host: text, port: Number(dp) };
|
|
766
|
+
const host = text.slice(0, at) || dh;
|
|
767
|
+
const port = Number(text.slice(at + 1));
|
|
768
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
769
|
+
throw new CliError(`--listen ${listen}: not a port number`);
|
|
770
|
+
return { host: host.replace(/^\[|\]$/g, ""), port };
|
|
771
|
+
}
|
|
772
|
+
async function startGateway(opts = {}) {
|
|
773
|
+
const log = opts.log || ((l) => process.stdout.write(`${l}
|
|
774
|
+
`));
|
|
775
|
+
const candidates = opts.ports || await listPorts();
|
|
776
|
+
const device = choosePort(opts.port, candidates);
|
|
777
|
+
const opener = opts.opener || openSerialportBinding;
|
|
778
|
+
const { host, port } = parseListen(opts.listen || DEFAULT_LISTEN);
|
|
779
|
+
let client2 = null;
|
|
780
|
+
let serial = null;
|
|
781
|
+
let portOpen = false;
|
|
782
|
+
let opens = 0;
|
|
783
|
+
let pumping = false;
|
|
784
|
+
const dropCable = async (forget = false) => {
|
|
785
|
+
const s = serial;
|
|
786
|
+
const wasOpen = portOpen;
|
|
787
|
+
portOpen = false;
|
|
788
|
+
pumping = false;
|
|
789
|
+
if (forget) serial = null;
|
|
790
|
+
if (!s || !wasOpen) return;
|
|
791
|
+
try {
|
|
792
|
+
await s.close();
|
|
793
|
+
} catch {
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
const pump = async (s, conn, generation) => {
|
|
797
|
+
const reader = s.readable.getReader();
|
|
798
|
+
pumping = true;
|
|
799
|
+
while (pumping && opens === generation) {
|
|
800
|
+
let r;
|
|
801
|
+
try {
|
|
802
|
+
r = await reader.read();
|
|
803
|
+
} catch {
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (r.done) return;
|
|
807
|
+
if (r.value && r.value.length && client2 === conn && opens === generation)
|
|
808
|
+
conn.sendBinary(r.value);
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
const handle = async (conn, req) => {
|
|
812
|
+
const id = req.id;
|
|
813
|
+
const reply = (body) => {
|
|
814
|
+
if (id === void 0) return;
|
|
815
|
+
conn.sendText(JSON.stringify({ id, ...body }));
|
|
816
|
+
};
|
|
817
|
+
try {
|
|
818
|
+
if (req.op === "open") {
|
|
819
|
+
await dropCable();
|
|
820
|
+
const s = serial || new NodeSerialPort(device, opener);
|
|
821
|
+
serial = s;
|
|
822
|
+
await s.open(req.config);
|
|
823
|
+
portOpen = true;
|
|
824
|
+
const generation = ++opens;
|
|
825
|
+
reply({ ok: true });
|
|
826
|
+
void pump(s, conn, generation);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (req.op === "close") {
|
|
830
|
+
await dropCable();
|
|
831
|
+
reply({ ok: true });
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (req.op === "setSignals") {
|
|
835
|
+
if (!serial || !portOpen) throw new Error(`${device} is not open`);
|
|
836
|
+
await serial.setSignals(req.signals || {});
|
|
837
|
+
reply({ ok: true });
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (req.op === "getSignals") {
|
|
841
|
+
if (!serial || !portOpen) {
|
|
842
|
+
reply({ ok: true, signals: null });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
reply({ ok: true, signals: await serial.getSignals() });
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
throw new Error(`unknown op ${String(req.op)}`);
|
|
849
|
+
} catch (e) {
|
|
850
|
+
reply({ ok: false, error: e.message || String(e) });
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
const server = await listenWs(host, port, (c) => {
|
|
854
|
+
if (client2) {
|
|
855
|
+
log(` refused ${c.from}: a client is already driving the cable`);
|
|
856
|
+
c.conn.on({});
|
|
857
|
+
c.conn.close(CLOSE_POLICY, "this gateway already has a client");
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
client2 = c.conn;
|
|
861
|
+
log(` client ${c.from} connected`);
|
|
862
|
+
let queue = Promise.resolve();
|
|
863
|
+
c.conn.on({
|
|
864
|
+
onText: (text) => {
|
|
865
|
+
let req;
|
|
866
|
+
try {
|
|
867
|
+
req = JSON.parse(text);
|
|
868
|
+
} catch {
|
|
869
|
+
c.conn.sendText(JSON.stringify({ ok: false, error: "not JSON" }));
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
queue = queue.then(() => handle(c.conn, req));
|
|
873
|
+
},
|
|
874
|
+
onBinary: (bytes) => {
|
|
875
|
+
const s = serial;
|
|
876
|
+
if (!s || !portOpen) {
|
|
877
|
+
log(` write of ${bytes.length} bytes with no port open, dropped`);
|
|
878
|
+
c.conn.sendText(
|
|
879
|
+
JSON.stringify({
|
|
880
|
+
event: "writeFailed",
|
|
881
|
+
error: `${device} is not open`
|
|
882
|
+
})
|
|
883
|
+
);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
const w = s.writable.getWriter();
|
|
887
|
+
w.write(bytes).catch((e) => {
|
|
888
|
+
const why = e.message || String(e);
|
|
889
|
+
log(` write failed: ${why}`);
|
|
890
|
+
if (client2 === c.conn)
|
|
891
|
+
c.conn.sendText(
|
|
892
|
+
JSON.stringify({ event: "writeFailed", error: why })
|
|
893
|
+
);
|
|
894
|
+
});
|
|
895
|
+
},
|
|
896
|
+
onClose: () => {
|
|
897
|
+
if (client2 !== c.conn) return;
|
|
898
|
+
client2 = null;
|
|
899
|
+
log(` client ${c.from} disconnected, cable closed`);
|
|
900
|
+
void dropCable(true);
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
c.conn.sendText(
|
|
904
|
+
JSON.stringify({ event: "hello", port: device, gateway: "bmweb" })
|
|
905
|
+
);
|
|
906
|
+
});
|
|
907
|
+
const shown = host === "0.0.0.0" || host === "::" ? host : host;
|
|
908
|
+
log(`gateway: ${device} served at ws://${shown}:${server.port}`);
|
|
909
|
+
log(
|
|
910
|
+
" this is a byte pipe with no gate of its own: anyone who can reach this port can drive the car."
|
|
911
|
+
);
|
|
912
|
+
log(
|
|
913
|
+
host === "127.0.0.1" || host === "localhost" || host === "::1" ? " listening on this machine only (--listen 0.0.0.0:PORT serves the LAN)" : " listening beyond this machine; the port is open to whoever can route to it"
|
|
914
|
+
);
|
|
915
|
+
log(" one client at a time; the cable is closed when the client goes.");
|
|
916
|
+
return {
|
|
917
|
+
port: server.port,
|
|
918
|
+
host,
|
|
919
|
+
device,
|
|
920
|
+
stop: async () => {
|
|
921
|
+
await server.close();
|
|
922
|
+
client2 = null;
|
|
923
|
+
await dropCable(true);
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
async function gatewayCommand(opts = {}) {
|
|
928
|
+
const g = await startGateway(opts);
|
|
929
|
+
await new Promise((res) => {
|
|
930
|
+
const bye = () => {
|
|
931
|
+
process.off("SIGINT", bye);
|
|
932
|
+
process.off("SIGTERM", bye);
|
|
933
|
+
process.stdout.write("\ngateway: stopping, the cable is closed\n");
|
|
934
|
+
g.stop().then(res, res);
|
|
935
|
+
};
|
|
936
|
+
process.on("SIGINT", bye);
|
|
937
|
+
process.on("SIGTERM", bye);
|
|
938
|
+
});
|
|
939
|
+
return [];
|
|
940
|
+
}
|
|
941
|
+
|
|
84
942
|
// src/ipo.ts
|
|
85
|
-
import { readdirSync, readFileSync as readFileSync3, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
943
|
+
import { readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
86
944
|
import { basename, dirname as dirname2, join as join4, resolve } from "node:path";
|
|
87
945
|
|
|
88
946
|
// src/table.ts
|
|
@@ -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 =
|
|
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
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
-
|
|
1723
|
+
wrote
|
|
845
1724
|
];
|
|
846
1725
|
}
|
|
847
1726
|
|
|
848
1727
|
// src/live.ts
|
|
849
1728
|
import { createInterface } from "node:readline";
|
|
850
1729
|
|
|
851
|
-
// src/
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
/^
|
|
857
|
-
/^
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
/**
|
|
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
|
-
/**
|
|
870
|
-
|
|
871
|
-
/**
|
|
872
|
-
|
|
873
|
-
/** the
|
|
874
|
-
|
|
875
|
-
/** the
|
|
876
|
-
|
|
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
|
|
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(
|
|
883
|
-
this.
|
|
884
|
-
this.opener = opener;
|
|
885
|
-
this.info = info;
|
|
1761
|
+
constructor(url) {
|
|
1762
|
+
this.url = url;
|
|
886
1763
|
}
|
|
887
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
907
|
-
this.binding = null;
|
|
908
|
-
this.stopKick();
|
|
1873
|
+
this.opened = false;
|
|
909
1874
|
this.wakeAll();
|
|
910
1875
|
this.chunks = [];
|
|
911
|
-
if (
|
|
1876
|
+
if (!this.gone) await this.call("close");
|
|
1877
|
+
}
|
|
1878
|
+
/** Drop the socket itself, once the bus is finished with the port. */
|
|
1879
|
+
hangUp() {
|
|
1880
|
+
this.opened = false;
|
|
1881
|
+
this.wakeAll();
|
|
1882
|
+
if (this.conn && !this.gone) this.conn.close();
|
|
1883
|
+
this.conn = null;
|
|
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
|
|
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.
|
|
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.
|
|
1013
|
-
*
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
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.
|
|
1020
|
-
|
|
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.
|
|
1024
|
-
*
|
|
1025
|
-
*
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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
|
|
1038
|
-
* @returns the lines, or null when the
|
|
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
|
-
|
|
1042
|
-
const
|
|
1043
|
-
|
|
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
|
|
1052
|
-
*
|
|
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
|
|
1987
|
+
return {};
|
|
1056
1988
|
}
|
|
1057
|
-
/** Web Serial's disconnect event;
|
|
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
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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.
|
|
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
|
|
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.
|
|
2815
|
-
summary: "compile an INPA source into the app's
|
|
3964
|
+
usage: "bmweb ipo compile <file.IPS|file.SRC> [-I dir]... [-o out.IPO] [--exec]",
|
|
3965
|
+
summary: "compile an INPA source into a real .IPO (or --exec for the app's token form); missing includes are named",
|
|
2816
3966
|
flags: {
|
|
2817
3967
|
...INCLUDE,
|
|
2818
3968
|
out: {
|
|
2819
3969
|
kind: "string",
|
|
2820
3970
|
alias: "o",
|
|
2821
|
-
help: "where to write (default: <stem>.
|
|
3971
|
+
help: "where to write (default: <stem>.IPO beside the source)"
|
|
3972
|
+
},
|
|
3973
|
+
exec: {
|
|
3974
|
+
kind: "bool",
|
|
3975
|
+
help: "write the app's exec form as JSON instead of .IPO bytes"
|
|
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
|
|
3016
|
-
"package, an optional dependency).
|
|
3017
|
-
"
|
|
3018
|
-
"
|
|
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
|
}
|