bmweb-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +351 -0
  3. package/dist/bmweb.js +2790 -0
  4. package/package.json +53 -0
  5. package/runtime/core/bestvm/codec.js +285 -0
  6. package/runtime/core/bestvm/environment.js +116 -0
  7. package/runtime/core/bestvm/executor.js +1483 -0
  8. package/runtime/core/bestvm/index.js +52 -0
  9. package/runtime/core/bestvm/machine.js +491 -0
  10. package/runtime/core/bestvm/operands.js +356 -0
  11. package/runtime/core/bestvm/registers.js +152 -0
  12. package/runtime/core/bestvm/write-guard.js +111 -0
  13. package/runtime/core/ipofile/compile.js +364 -0
  14. package/runtime/core/ipofile/decls.js +187 -0
  15. package/runtime/core/ipofile/emit.js +708 -0
  16. package/runtime/core/ipofile/exec.js +164 -0
  17. package/runtime/core/ipofile/lex.js +243 -0
  18. package/runtime/core/ipofile/parse.js +550 -0
  19. package/runtime/core/ipofile/pool.js +404 -0
  20. package/runtime/core/ipofile/walk.js +431 -0
  21. package/runtime/core/ipovm/builtin-helpers.js +182 -0
  22. package/runtime/core/ipovm/builtins-api.js +610 -0
  23. package/runtime/core/ipovm/builtins-screen.js +493 -0
  24. package/runtime/core/ipovm/builtins-table.js +166 -0
  25. package/runtime/core/ipovm/builtins-text.js +166 -0
  26. package/runtime/core/ipovm/emissions.js +138 -0
  27. package/runtime/core/ipovm/hosts.js +191 -0
  28. package/runtime/core/ipovm/operators.js +229 -0
  29. package/runtime/core/ipovm/structures.js +250 -0
  30. package/runtime/core/ipovm/suspensions.js +241 -0
  31. package/runtime/core/ipovm/tape.js +206 -0
  32. package/runtime/core/ipovm/values.js +241 -0
  33. package/runtime/core/ipovm/vm.js +1166 -0
  34. package/runtime/core/translate.js +526 -0
  35. package/runtime/core/webshim/api-router.js +592 -0
  36. package/runtime/core/webshim/bus.js +95 -0
  37. package/runtime/core/webshim/coding.js +82 -0
  38. package/runtime/core/webshim/data-fetch.js +66 -0
  39. package/runtime/core/webshim/exchange.js +288 -0
  40. package/runtime/core/webshim/framing.js +331 -0
  41. package/runtime/core/webshim/install.js +30 -0
  42. package/runtime/core/webshim/job-runner.js +319 -0
  43. package/runtime/core/webshim/native-bus.js +108 -0
  44. package/runtime/core/webshim/timers.js +82 -0
  45. package/runtime/core/webshim/trace.js +205 -0
  46. package/runtime/core/webshim/transport-base.js +128 -0
  47. package/runtime/core/webshim/variant-resolver.js +249 -0
  48. package/runtime/core/webshim/web-serial-bus.js +734 -0
  49. package/runtime/home/bmweb-home.ips +76 -0
  50. package/runtime/home/bmweb.h +26 -0
  51. package/runtime/screens/activations.js +258 -0
  52. package/runtime/screens/garage/diff.js +331 -0
  53. package/runtime/screens/garage/share.js +276 -0
  54. package/runtime/screens/garage/store.js +547 -0
  55. package/runtime/screens/ipo-runtime/cells.js +176 -0
  56. package/runtime/screens/ipo-runtime/dialogs.js +254 -0
  57. package/runtime/screens/ipo-runtime/home.js +358 -0
  58. package/runtime/screens/ipo-runtime/open.js +393 -0
  59. package/runtime/screens/ipo-runtime/paint-grid.js +106 -0
  60. package/runtime/screens/ipo-runtime/paint-modern.js +424 -0
  61. package/runtime/screens/ipo-runtime/print.js +281 -0
  62. package/runtime/screens/ipo-runtime/program.js +1337 -0
  63. package/runtime/screens/ipo-runtime/protocol.js +464 -0
  64. package/runtime/screens/ipo-runtime/script-scan.js +225 -0
  65. package/runtime/screens/ipo-runtime/translate-sets.js +130 -0
  66. package/runtime/screens/ipo-runtime/ui.js +249 -0
  67. package/runtime/screens/ipo-runtime/wire-policy.js +113 -0
  68. package/runtime/screens/ir.js +324 -0
  69. package/runtime/screens/search/data.js +153 -0
  70. package/runtime/screens/search/match.js +285 -0
  71. package/runtime/screens/search/open.js +66 -0
  72. package/runtime/vendor/fflate.min.js +1 -0
@@ -0,0 +1,734 @@
1
+ /**
2
+ * @file The Web Serial transport: a K+DCAN cable in a desktop browser, with
3
+ * the K-line line control (DTR transmit enable, fast init, ISO 9141 slow
4
+ * init) that only this bus can drive.
5
+ */
6
+ /* exported WebSerialBus */
7
+
8
+ /**
9
+ * Sentinel for a readSome() that ran out of time. A distinct object rather
10
+ * than null, so "no bytes yet" can never be confused with a real empty read.
11
+ */
12
+ const TIMED_OUT = Symbol('timed-out');
13
+
14
+ /**
15
+ * The reference interface's DtrTimeCorrCom: how much longer than the
16
+ * telegram's byte time DTR stays up, in ms. No more -- DTR is the transmit
17
+ * enable, so holding it longer keeps the cable talking while the ECU answers
18
+ * and the reply is lost. An earlier +4 ms margin here produced a perfect echo
19
+ * and no answer.
20
+ */
21
+ const DTR_TIME_CORR_MS = 0.3;
22
+ /** Fast init: the break is held until this long after it BEGAN. */
23
+ const FAST_INIT_BREAK_MS = 25;
24
+ /** Fast init: DTR drops this long after the break began (W4 is 25-50 ms). */
25
+ const FAST_INIT_DTR_MS = 50;
26
+ /** ISO 9141 slow init: one bit time at 5 baud. */
27
+ const SLOW_INIT_BIT_MS = 200;
28
+ /** ISO 9141 slow init: how long the 0x55 sync and two key bytes may take. */
29
+ const SLOW_INIT_SYNC_MS = 1200;
30
+ /** ISO 9141 slow init: the W4 pause before the tester's inverted key byte. */
31
+ const SLOW_INIT_W4_MS = 30;
32
+ /** ISO 9141 slow init: how long the ECU's address acknowledgement may take. */
33
+ const SLOW_INIT_ACK_MS = 400;
34
+ /** ISO 9141 sync pattern the ECU answers the 5-baud address with. */
35
+ const ISO9141_SYNC = 0x55;
36
+ /** drainBuffered: the most already-resolved reads it consumes in one go. */
37
+ const DRAIN_MAX_READS = 64;
38
+ /** drainBuffered: how long a pending read gets to prove it has settled. */
39
+ const DRAIN_PROBE_MS = 2;
40
+
41
+ /**
42
+ * The transport for a K+DCAN cable through the browser's Web Serial API.
43
+ * @extends SerialTransportBase
44
+ */
45
+ class WebSerialBus extends SerialTransportBase {
46
+ constructor() {
47
+ super();
48
+ /** @type {SerialPort|null} */
49
+ this.port = null;
50
+ /** @type {ReadableStreamDefaultReader<Uint8Array>|null} */
51
+ this.reader = null;
52
+ /** @type {WritableStreamDefaultWriter<Uint8Array>|null} */
53
+ this.writer = null;
54
+ /** @type {PortConfig|null} */
55
+ this.config = null;
56
+ /**
57
+ * The one outstanding reader.read(), kept across a timed-out readSome so
58
+ * its bytes are never orphaned.
59
+ * @type {Promise<ReadableStreamReadResult<Uint8Array>>|null}
60
+ */
61
+ this.pending = null;
62
+ }
63
+
64
+ /** @returns {boolean} Is a port open. */
65
+ get connected() {
66
+ return !!this.port;
67
+ }
68
+
69
+ /**
70
+ * Open a port the user picks. Must be called from a user gesture -- the
71
+ * browser will not show the port picker otherwise. app.js wires this to the
72
+ * "connect cable" control.
73
+ * @returns {Promise<string>} The port label.
74
+ * @throws {Error} When the browser has no Web Serial.
75
+ */
76
+ async connect() {
77
+ if (!('serial' in navigator)) {
78
+ throw new Error(
79
+ 'This browser has no Web Serial. Use Chrome or Edge ' +
80
+ '(desktop), or the macOS app.'
81
+ );
82
+ }
83
+ this.port = await navigator.serial.requestPort();
84
+ await this.port.open(KDCAN);
85
+ this.config = KDCAN;
86
+ this.writer = this.port.writable.getWriter();
87
+ this.reader = this.port.readable.getReader();
88
+ await this._applyIdleSignals(KDCAN);
89
+ this._resetWireState();
90
+ this._watchPort();
91
+ return this.portLabel();
92
+ }
93
+
94
+ /**
95
+ * Put the modem lines where the concept wants them for the whole session:
96
+ * DTR at the config's idle level, RTS always low.
97
+ *
98
+ * The reference interface does this in the same switch that picks baud and
99
+ * parity: on a plain FTDI/COM cable (an "echoing adapter" to it) DTR is
100
+ * held HIGH for BMW-FAST and D-CAN and LOW for every K-line concept, and
101
+ * RTS is never raised. What the browser leaves on the lines after open()
102
+ * is platform-dependent (Windows asserts both, WICG/serial#177), and a
103
+ * K-line exchange here ends with DTR dropped -- so without this step a
104
+ * BMW-FAST/D-CAN module was driven with DTR low after the first K-line
105
+ * probe of the session, which is the one way this transport differed
106
+ * from the reference on an E60/E65/E90 bus. Ports without setSignals
107
+ * (nothing to drive) are left alone.
108
+ * @param {PortConfig} cfg - The settings the session runs on.
109
+ * @returns {Promise<void>}
110
+ */
111
+ async _applyIdleSignals(cfg) {
112
+ if (!this.port || !this.port.setSignals) return;
113
+ const dtr = !!(cfg && cfg.dtr);
114
+ await this.port.setSignals({
115
+ dataTerminalReady: dtr,
116
+ requestToSend: false,
117
+ });
118
+ busTrace.add('kline', null, `idle DTR=${dtr ? 'high' : 'low'} RTS=low`);
119
+ }
120
+
121
+ /**
122
+ * Hear the cable being pulled. Web Serial fires `disconnect` on the port
123
+ * when its device goes away; until then a pulled cable looked connected
124
+ * (`this.port` was still set) and the chip said so until a reload. Drop
125
+ * the wire state and tell the app (a `bmweb-cable` event on window), then
126
+ * watch for the device coming back and reopen it silently.
127
+ * @returns {void}
128
+ */
129
+ _watchPort() {
130
+ const port = this.port;
131
+ if (!port || typeof port.addEventListener !== 'function') return;
132
+ port.addEventListener('disconnect', () => this._portGone(port), {
133
+ once: true,
134
+ });
135
+ if (!this._replugWatched && typeof navigator !== 'undefined') {
136
+ const serial = navigator.serial;
137
+ if (serial && typeof serial.addEventListener === 'function') {
138
+ this._replugWatched = true;
139
+ serial.addEventListener('connect', () => {
140
+ if (this.connected) return;
141
+ this.reconnect()
142
+ .then((label) => {
143
+ if (label) this._announce(true);
144
+ })
145
+ .catch(() => {});
146
+ });
147
+ }
148
+ }
149
+ }
150
+
151
+ /**
152
+ * The port's device is gone: forget it without trying to close it (there
153
+ * is nothing to close), then announce.
154
+ * @param {SerialPort} port - the port that fired
155
+ * @returns {void}
156
+ */
157
+ _portGone(port) {
158
+ if (this.port !== port) return; // already replaced
159
+ console.info('[serial] the cable was unplugged');
160
+ this._releaseStreams().catch(() => {});
161
+ this.port = this.reader = this.writer = null;
162
+ this._resetWireState();
163
+ this._announce(false);
164
+ }
165
+
166
+ /**
167
+ * Tell the app the cable state changed.
168
+ * @param {boolean} connected - whether a port is open now
169
+ * @returns {void}
170
+ */
171
+ _announce(connected) {
172
+ if (typeof window === 'undefined' || !window.dispatchEvent) return;
173
+ try {
174
+ window.dispatchEvent(
175
+ new CustomEvent('bmweb-cable', { detail: { connected } })
176
+ );
177
+ } catch {
178
+ /* no CustomEvent: nothing to announce to */
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Reconnect WITHOUT a user gesture, on page load. Web Serial remembers a
184
+ * granted port across reloads (the permission survives; only the first
185
+ * requestPort() needs a click), so getPorts() returns it and open()
186
+ * succeeds silently. This is what keeps the cable "connected through a
187
+ * reload" -- the reopen-and-it-unlocks flow depends on it.
188
+ * @returns {Promise<string|null>} The label on success, or null when
189
+ * nothing was previously granted (first run, or the user revoked it) so
190
+ * the caller leaves the chip as "no cable".
191
+ */
192
+ async reconnect() {
193
+ if (!('serial' in navigator) || this.connected) return null;
194
+ let ports;
195
+ try {
196
+ ports = await navigator.serial.getPorts();
197
+ } catch (e) {
198
+ console.info('[serial] getPorts() threw:', e.message);
199
+ return null;
200
+ }
201
+ if (!ports.length) {
202
+ // The browser remembers a granted port PER ORIGIN, but the grant is lost
203
+ // if the origin changes, the device re-enumerated (some FTDI adapters do
204
+ // on replug), or the user cleared site permissions. Nothing to reopen
205
+ // silently -- the next connect() will ask once and it sticks again.
206
+ console.info(
207
+ '[serial] getPorts() returned no previously-granted port ' +
208
+ '(first run here, or the grant was lost) -- a one-time pick is needed'
209
+ );
210
+ return null;
211
+ }
212
+ // Newer browsers expose SerialPort.connected = is the device physically
213
+ // present. Prefer a present one; a remembered-but-unplugged port would just
214
+ // fail to open. Fall back to the first if the flag is unavailable.
215
+ this.port = ports.find((p) => p.connected !== false) || ports[0];
216
+ try {
217
+ await this.port.open(KDCAN);
218
+ } catch (e) {
219
+ // The commonest cause is the port being held by another tab/app, or the
220
+ // device unplugged. Say which, rather than a silent "no cable".
221
+ console.info(
222
+ `[serial] reopen of a granted port failed: ${e.message} ` +
223
+ `(unplugged, or another tab/app holds it?)`
224
+ );
225
+ this.port = null;
226
+ return null;
227
+ }
228
+ this.config = KDCAN;
229
+ this.writer = this.port.writable.getWriter();
230
+ this.reader = this.port.readable.getReader();
231
+ await this._applyIdleSignals(KDCAN);
232
+ this._resetWireState();
233
+ this._watchPort();
234
+ console.info(
235
+ '[serial] reconnected to a previously-granted port, no picker'
236
+ );
237
+ return this.portLabel();
238
+ }
239
+
240
+ /**
241
+ * Give the port's streams back (cancel the reader, release both locks) so
242
+ * the port can be closed. Every failure is swallowed: the port is being
243
+ * closed or reopened either way.
244
+ */
245
+ async _releaseStreams() {
246
+ try {
247
+ if (this.reader) {
248
+ await this.reader.cancel();
249
+ this.reader.releaseLock();
250
+ }
251
+ } catch {
252
+ /* closing or reopening */
253
+ }
254
+ try {
255
+ if (this.writer) this.writer.releaseLock();
256
+ } catch {
257
+ /* closing or reopening */
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Close the port and open it again on `cfg`, taking fresh stream locks.
263
+ * @param {PortConfig} cfg - The settings to reopen with.
264
+ */
265
+ async _reopenStreams(cfg) {
266
+ await this.port.close();
267
+ await this.port.open(cfg);
268
+ this.config = cfg;
269
+ this.writer = this.port.writable.getWriter();
270
+ this.reader = this.port.readable.getReader();
271
+ // a reopened port comes back with whatever lines the platform asserts
272
+ await this._applyIdleSignals(cfg);
273
+ }
274
+
275
+ /**
276
+ * Close/reopen with a concept's wire settings. Reopening an already-
277
+ * granted port needs no user gesture, only the first requestPort() does.
278
+ * A change in the idle DTR level alone (BMW-FAST after a K-line probe, or
279
+ * back) does not drop the port: only the lines move.
280
+ * @param {PortConfig} cfg - The settings the next telegram needs.
281
+ */
282
+ async ensureConfig(cfg) {
283
+ if (this._configUnchanged(cfg)) {
284
+ const want = !!(cfg && cfg.dtr);
285
+ if (!!(this.config && this.config.dtr) !== want) {
286
+ this.config = { ...this.config, dtr: want };
287
+ await this._applyIdleSignals(this.config);
288
+ }
289
+ return;
290
+ }
291
+ await this._releaseStreams();
292
+ await this._reopenStreams(cfg);
293
+ }
294
+
295
+ /** @returns {string} 'USB vid:pid' when the port says, else 'serial'. */
296
+ portLabel() {
297
+ const i = this.port && this.port.getInfo ? this.port.getInfo() : {};
298
+ return i.usbVendorId
299
+ ? `USB ${i.usbVendorId.toString(16)}:${(i.usbProductId || 0).toString(16)}`
300
+ : 'serial';
301
+ }
302
+
303
+ /** Release the streams, close the port and forget the wire state. */
304
+ async disconnect() {
305
+ await this._releaseStreams();
306
+ try {
307
+ if (this.port) await this.port.close();
308
+ } catch {
309
+ /* closing */
310
+ }
311
+ this.port = this.reader = this.writer = null;
312
+ this._resetWireState();
313
+ }
314
+
315
+ /**
316
+ * KL30/KL15, exactly the way INPA gets them on this same K+DCAN cable.
317
+ *
318
+ * The reference serial interface (plain serial, which is what an FTDI
319
+ * K+DCAN cable is) derives BOTH battery and ignition from ONE modem line,
320
+ * DSR: BatteryVoltage = IgnitionVoltage = DSR asserted ? nominal : 0. A
321
+ * genuine K+DCAN cable wires KL15 through to the DSR pin, so DSR asserted
322
+ * means the ignition is on (report the nominal voltage), DSR low means off.
323
+ * UTILITY's STATUS_UBATT / STATUS_ZUENDUNG then just compare that against
324
+ * 10000 mV. So the honest reading is the DSR line, not a fixed nominal --
325
+ * and Web Serial exposes it as getSignals().dataSetReady.
326
+ *
327
+ * The separate ignition-status read (the 82 F1 F1 FA FA command) exists
328
+ * ONLY for the BT/WiFi/ELM adapters; for a serial FTDI cable EDIABAS falls
329
+ * to the DSR path and both lamps track that one line together. So do we:
330
+ * no cable can tell battery-on-ignition-off apart on this hardware, and
331
+ * pretending it can would be the invention, not the fidelity.
332
+ *
333
+ * Only when the port cannot report signals at all do we fall back to the
334
+ * old nominal-on (a cable we cannot query is not evidence of ignition off).
335
+ * @returns {Promise<BusState>}
336
+ */
337
+ async readState() {
338
+ if (!this.connected) return { battery: null, ignition: null };
339
+ const on = await this._ignitionAsserted();
340
+ if (on === null) {
341
+ // the port cannot report its signals: fall back to the nominal "on",
342
+ // the same thing EDIABAS shows for an interface it cannot sample.
343
+ return {
344
+ battery: UTILITY_NOMINAL_MV / 1000,
345
+ ignition: true,
346
+ derived: true,
347
+ };
348
+ }
349
+ return {
350
+ battery: on ? UTILITY_NOMINAL_MV / 1000 : null,
351
+ ignition: on,
352
+ sensed: true,
353
+ };
354
+ }
355
+
356
+ /**
357
+ * KL15 arrives on a modem line of the K+DCAN cable. The reference interface
358
+ * reads DSR; on real FTDI clones the ignition drives DSR AND DCD together
359
+ * (both seen going true with the key on and false with it out on this
360
+ * hardware), while RI sits high regardless (stuck, even unplugged) and CTS
361
+ * stays low. So take ignition as asserted when EITHER DSR or DCD is high --
362
+ * covering both wirings -- and ignore RI/CTS, which carry no ignition here.
363
+ * @returns {Promise<boolean|null>} true = ignition on, false = off, null =
364
+ * the port reports no signals (caller falls back to "on").
365
+ */
366
+ async _ignitionAsserted() {
367
+ if (!this.port || !this.port.getSignals) return null;
368
+ try {
369
+ const s = await this.port.getSignals();
370
+ if (!s) return null;
371
+ const dsr = typeof s.dataSetReady === 'boolean' ? s.dataSetReady : null;
372
+ const dcd =
373
+ typeof s.dataCarrierDetect === 'boolean' ? s.dataCarrierDetect : null;
374
+ if (dsr === null && dcd === null) return null;
375
+ return !!dsr || !!dcd;
376
+ } catch {
377
+ return null;
378
+ }
379
+ }
380
+
381
+ // exchange() is inherited from SerialTransportBase (identical for every
382
+ // transport -- it delegates to the shared runExchange).
383
+
384
+ /**
385
+ * BMW K-line fast init: a 25 ms break with DTR held, the wake a KWP2000
386
+ * module needs before it listens.
387
+ *
388
+ * THE THING THAT WAS MISSING. An E46 K-line module ignores every telegram
389
+ * until it is woken, and the wake is NOT the 2-second 5-baud ISO 9141 init
390
+ * -- it is a 25 ms break. The reference interface does exactly this:
391
+ *
392
+ * DTR on -> break 25 ms -> break off -> wait to 50 ms total -> DTR off
393
+ *
394
+ * Verified against a real E46 M54/MS45: with the wake the DME answers
395
+ * 82 12 F1 1A 80 with its ident string ("754472129001060300400..."), and
396
+ * without it every telegram at every baud and parity is met with silence.
397
+ * That silence is what surfaced as IFH-0009 and looked like a wiring fault.
398
+ * The wake runs at 10400 8N1, which is also where the answer comes back.
399
+ *
400
+ * DTR is this cable's transmit enable: the reference interface asserts it
401
+ * for the wake and for the duration of every telegram it writes.
402
+ *
403
+ * THE 50 ms IS MEASURED FROM THE START OF THE BREAK, not added after it.
404
+ * The reference takes one timestamp, holds the break until start+25 ms,
405
+ * releases it, then waits until start+50 ms and drops DTR -- so the
406
+ * telegram follows ~50 ms after the break BEGAN. Sleeping 25 then another
407
+ * 25 makes that 50 ms of sleep PLUS the four awaits' own latency, and the
408
+ * trace showed 61 ms from break to write. A module with a strict post-wake
409
+ * window (W4 is 25-50 ms) has stopped listening by then. Deadline-based, so
410
+ * the wall clock matches whatever the awaits cost.
411
+ *
412
+ * Currently no concept calls this (see exchangeRaw: DS2 is never broken,
413
+ * ISO 9141 slow-inits); it stays for the KWP2000 modules that need it.
414
+ * @param {CommParams} comm - The telegram's wire parameters (unused; kept
415
+ * for symmetry with slowInit).
416
+ * @throws {Error} IFH-0018 when the port cannot drive its signals.
417
+ */
418
+ async fastInit(comm) {
419
+ if (!this.port.setSignals) {
420
+ throw ifhError(
421
+ 'IFH-0018',
422
+ 'this browser cannot drive the K line ' +
423
+ '(no setSignals); use the macOS app for this ECU'
424
+ );
425
+ }
426
+ const t0 = Date.now();
427
+ const until = async (ms) => {
428
+ const left = ms - (Date.now() - t0);
429
+ if (left > 0) await bmwSleep(left);
430
+ };
431
+ await this.port.setSignals({ dataTerminalReady: true, break: true });
432
+ await until(FAST_INIT_BREAK_MS);
433
+ await this.port.setSignals({ break: false });
434
+ await until(FAST_INIT_DTR_MS);
435
+ await this.port.setSignals({ dataTerminalReady: false });
436
+ busTrace.add('kline', null, `fastInit done in ${Date.now() - t0}ms`);
437
+ this.inited = true;
438
+ }
439
+
440
+ /**
441
+ * ISO 9141-2 slow init. The module sleeps. Waking it means holding the K
442
+ * line low/high by hand at 5 BITS PER SECOND -- one start bit, eight
443
+ * address bits LSB first, one stop bit, 200 ms each, 2 seconds in total. No
444
+ * UART can frame that, so it is bit-banged with setSignals({break}) and the
445
+ * port is reopened afterwards to discard the framing garbage the break
446
+ * generates.
447
+ *
448
+ * The ECU then answers 0x55 (sync) and two key bytes. The tester echoes
449
+ * back the SECOND key byte inverted, and the ECU replies with the address
450
+ * inverted -- at which point the session is live and normal requests work.
451
+ *
452
+ * Proven against a real E46 M54/MS45:
453
+ * addr 0x33 @ 10400 -> 55 08 08, ack f7 cc, then
454
+ * mode01 pid00 -> 48 6b 12 41 00 bf 9f e8 91 (0x12 = the DME)
455
+ *
456
+ * Init is per-session: `this.inited` holds the concept it was done for, so
457
+ * a job run does not re-init on every exchange (each one costs 2+ seconds)
458
+ * but switching ECU or concept does.
459
+ * @param {CommParams} comm - The telegram's wire parameters (its baud).
460
+ * @returns {Promise<{keyBytes: number[], ack: number[]}>} The ECU's key
461
+ * bytes and whatever came back after the tester's inverted key.
462
+ * @throws {Error} IFH-0018 without setSignals; IFH-0009 when no sync came.
463
+ */
464
+ async slowInit(comm) {
465
+ const baud = (comm && comm.baud) || ISO9141_BAUD;
466
+ const addr = ISO9141_INIT_ADDR;
467
+ /** @type {PortConfig} */
468
+ const cfg = { baudRate: baud, dataBits: 8, stopBits: 1, parity: 'none' };
469
+
470
+ // Break signalling needs the port open; parity/data bits are irrelevant
471
+ // while the line is driven by hand.
472
+ await this.ensureConfig(cfg);
473
+ if (!this.port.setSignals) {
474
+ throw ifhError(
475
+ 'IFH-0018',
476
+ 'this browser cannot bit-bang the K line ' +
477
+ '(no setSignals); use the macOS app for this ECU'
478
+ );
479
+ }
480
+
481
+ // start bit (low), 8 data bits LSB first, stop bit (high) -- 200 ms each
482
+ const bits = [0];
483
+ for (let i = 0; i < 8; i++) bits.push((addr >> i) & 1);
484
+ bits.push(1);
485
+ for (const b of bits) {
486
+ await this.port.setSignals({ break: b === 0 });
487
+ await bmwSleep(SLOW_INIT_BIT_MS);
488
+ }
489
+ await this.port.setSignals({ break: false });
490
+
491
+ // Reopen so the break's framing errors are not read as data.
492
+ await this.reopen(cfg);
493
+
494
+ // 0x55 then two key bytes, within ~300 ms of the stop bit
495
+ const hdr = await this._readUpTo(3, Date.now() + SLOW_INIT_SYNC_MS);
496
+ const sync = hdr.indexOf(ISO9141_SYNC);
497
+ if (sync < 0 || hdr.length < sync + 3) {
498
+ throw ifhError(
499
+ 'IFH-0009',
500
+ 'the ECU did not answer the slow init ' +
501
+ '(no 0x55 sync). Ignition on, engine off?'
502
+ );
503
+ }
504
+ const kb2 = hdr[sync + 2];
505
+
506
+ // Tester sends ~KB2; the ECU replies ~addr. W4 is 25-50 ms.
507
+ await bmwSleep(SLOW_INIT_W4_MS);
508
+ await this.writer.write(new Uint8Array([~kb2 & 0xff]));
509
+ const ack = await this._readUpTo(2, Date.now() + SLOW_INIT_ACK_MS);
510
+ // The ack carries our own echo plus ~addr; a missing one is not fatal --
511
+ // the E46 answered f7 cc where only cc is the ECU's. Requests that follow
512
+ // are the real proof, so do not fail the session on a fussy ack.
513
+ this.inited = true;
514
+ return { keyBytes: [hdr[sync + 1], kb2], ack };
515
+ }
516
+
517
+ /**
518
+ * Collect up to `n` bytes before `deadline`, stopping early if the port
519
+ * closes. Used by the slow init's sync and ack reads.
520
+ * @param {number} n - How many bytes to wait for.
521
+ * @param {number} deadline - Absolute Date.now() cut-off.
522
+ * @returns {Promise<number[]>} Whatever arrived (possibly fewer than n).
523
+ */
524
+ async _readUpTo(n, deadline) {
525
+ const got = [];
526
+ while (got.length < n && Date.now() < deadline) {
527
+ const { value, done } = await this.readSome(deadline);
528
+ if (done) break;
529
+ if (value && value.length) got.push(...value);
530
+ }
531
+ return got;
532
+ }
533
+
534
+ /**
535
+ * Close and reopen the port, dropping anything buffered. Used after the
536
+ * slow init, whose break signalling leaves framing errors in the stream.
537
+ * @param {PortConfig} cfg - The settings to reopen with.
538
+ */
539
+ async reopen(cfg) {
540
+ await this._releaseStreams();
541
+ this.pending = null;
542
+ await this._reopenStreams(cfg);
543
+ }
544
+
545
+ /**
546
+ * How long DTR stays up for a K-line write: exactly the telegram's byte
547
+ * time plus DtrTimeCorrCom, never less than 1 ms.
548
+ * @param {number[]} framed - The bytes about to be written.
549
+ * @returns {number} Whole milliseconds.
550
+ */
551
+ _dtrHoldMs(framed) {
552
+ const bits = this.config && this.config.parity === 'none' ? 10 : 11;
553
+ const ms =
554
+ (framed.length * bits * 1000) /
555
+ ((this.config && this.config.baudRate) || KLINE_DEFAULT_BAUD);
556
+ return Math.max(1, Math.round(ms + DTR_TIME_CORR_MS));
557
+ }
558
+
559
+ /**
560
+ * Write one framed request (waking the module first when its concept needs
561
+ * it, with DTR as transmit enable on the K line) and read its answer.
562
+ *
563
+ * WHICH concepts need waking at all: ISO 9141 sleeps until its 5-baud
564
+ * address arrives; DS2 does not, and BMW-FAST over a D-CAN cable does not.
565
+ * `inited` gates the one that does, and survives until the port is
566
+ * reopened (which is what clears it): an SGBD's session init can run on
567
+ * one concept while its jobs declare another, so keying this to the
568
+ * per-telegram concept would wake for the init and not for the job that
569
+ * follows.
570
+ *
571
+ * DS2 IS NOT WOKEN. The reference interface's fast-init wake has exactly
572
+ * ONE call site -- inside its KWP2000 transmit -- and its DS2 transmit
573
+ * contains no wake, no break, no 5-baud address at all; the concept-5/6
574
+ * setup marks the ECU connected outright so nothing can trigger one.
575
+ * We were sending a 25 ms break before the first telegram to every K-line
576
+ * address, DS2 included. That is 240 bit-times of dominant K line at 9600
577
+ * -- to a module that never expected a fast init it is either framing
578
+ * garbage to resync through, or, on a module that also speaks KWP2000, a
579
+ * genuine wake pattern that arms a different session in which a raw DS2
580
+ * telegram is not valid and gets dropped without reply. The E46 cluster
581
+ * (0x80) forgives it; the EGS (0x32) answered EDIABAS and never us.
582
+ * ISO 9141 genuinely does need its 5-baud address, so that stays.
583
+ *
584
+ * DTR IS THE TRANSMIT ENABLE on a K+DCAN cable, and it is what was
585
+ * missing. The reference interface raises it for the duration of every
586
+ * telegram it writes, and DS2 sets its "send with DTR" flag. Without it the
587
+ * bytes are framed correctly, leave the UART, and never reach the K line --
588
+ * which is exactly the silence that looked like a dead ECU.
589
+ * @param {number[]|null} framed - The request with its checksum, or null
590
+ * to read a continuation frame without writing.
591
+ * @param {number} timeoutMs - ParTimeoutStd for this read.
592
+ * @param {CommParams} comm - The request's wire parameters.
593
+ * @returns {Promise<number[]>} The answer frame.
594
+ */
595
+ async exchangeRaw(framed, timeoutMs, comm) {
596
+ const concept = conceptOf(comm);
597
+ const kline = isKline(concept) || isKline(this.sessionConcept);
598
+ // Which ECU this telegram addresses (DS2: the first byte). Kept for the
599
+ // trace and for the ISO 9141 wake, which IS per module.
600
+ const addr = framed && framed.length ? framed[0] & 0xff : null;
601
+ busTrace.add(
602
+ 'kline',
603
+ null,
604
+ `addr=0x${addr == null ? '??' : addr.toString(16)}` +
605
+ ` concept=0x${concept.toString(16)} kline=${kline}` +
606
+ ` session=${this.sessionConcept} inited=${this.inited}` +
607
+ ` initedAddr=${this.initedAddr}` +
608
+ ` cfg=${this.config && this.config.baudRate}/${this.config && this.config.parity}`
609
+ );
610
+ const wantsWake = isIso9141(concept) || isIso9141(this.sessionConcept);
611
+ if (framed && !this.inited && wantsWake) {
612
+ await this.slowInit(comm);
613
+ if (addr != null) this.initedAddr = addr;
614
+ }
615
+ if (framed) {
616
+ // Drain anything stale before a fresh write -- the same start-clean
617
+ // the native path gets from serialFlush(). A late answer from a
618
+ // timed-out exchange would otherwise be read as this request's echo,
619
+ // fail the compare, and cascade IFH-0003 until the stream happens to
620
+ // run dry. readSome reports {done:false, value:null} when it merely ran
621
+ // out of time (done means the PORT closed), so the drain stops on
622
+ // either, bounded so a chattering bus cannot spin forever.
623
+ // (K-line writes drain INSIDE the DTR window instead -- see below.)
624
+ if (!kline) await this.drainBuffered();
625
+ if (kline && this.port.setSignals) {
626
+ // DTR up, THEN drain, THEN write -- the order the reference uses
627
+ // (its input discard sits inside the DTR block, right before the
628
+ // write). Draining earlier let bytes arrive in the gap and be read
629
+ // as this request's echo.
630
+ await this.port.setSignals({ dataTerminalReady: true });
631
+ await this.drainBuffered();
632
+ }
633
+ await this.writer.write(new Uint8Array(framed));
634
+ if (kline && this.port.setSignals) {
635
+ const holdMs = this._dtrHoldMs(framed);
636
+ busTrace.add(
637
+ 'kline',
638
+ null,
639
+ `DTR held ${holdMs}ms for ${framed.length}B` +
640
+ ` @${this.config && this.config.baudRate}/${this.config && this.config.parity}`
641
+ );
642
+ await bmwSleep(holdMs);
643
+ await this.port.setSignals({ dataTerminalReady: false });
644
+ }
645
+ }
646
+ const deadline = Date.now() + timeoutMs;
647
+ return readFrame(
648
+ framed,
649
+ timeoutMs,
650
+ async () => {
651
+ const { value, done } = await this.readSome(deadline);
652
+ return done ? null : value;
653
+ },
654
+ comm
655
+ );
656
+ }
657
+
658
+ /**
659
+ * Drain what is already buffered WITHOUT arming a new read.
660
+ *
661
+ * THE BUG THIS FIXES: the old drain called readSome() with a 2 ms deadline.
662
+ * readSome keeps a timed-out read alive on this.pending (that is what stops
663
+ * bytes being lost), so the drain's last call left a live read armed. The
664
+ * write then went out and THAT read swallowed the first bytes of the echo --
665
+ * every answer arrived missing its head ("12 04 00 16" came back as
666
+ * "00 16"), which readFrame then failed to match.
667
+ *
668
+ * NEVER CREATE A READ HERE. An earlier version probed with
669
+ * this.reader.read() when nothing was outstanding; if the line was quiet
670
+ * that probe stayed armed, the telegram went out, and the probe swallowed
671
+ * the first bytes of the echo. The tell was unmistakable in a wire trace:
672
+ * the FIRST attempt of every exchange came back missing its head
673
+ * ("82 12 f1 1a 80 1f" as "12 f1 1a 80 1f") while the retry -- which found
674
+ * a pending read already in place and so created none -- was perfect.
675
+ *
676
+ * Only an ALREADY-OUTSTANDING read is consumed, and only while it keeps
677
+ * resolving immediately. A quiet line leaves this a no-op.
678
+ */
679
+ async drainBuffered() {
680
+ for (let i = 0; i < DRAIN_MAX_READS && this.pending; i++) {
681
+ const settled = await Promise.race([
682
+ this.pending.then((r) => ({ hit: true, r })),
683
+ bmwSleep(DRAIN_PROBE_MS).then(() => ({ hit: false })),
684
+ ]);
685
+ if (!settled.hit) return; // still outstanding: leave it be
686
+ const { value, done } = settled.r || {};
687
+ if (done || !value || !value.length) return;
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Read with a deadline, WITHOUT losing bytes to an abandoned read.
693
+ *
694
+ * THE BUG THIS FIXES: racing reader.read() against a timeout and walking
695
+ * away leaves that read outstanding. Web Serial still delivers the next
696
+ * chunk to it, and because nothing held the promise those bytes were gone
697
+ * for good. The pre-write drain loop used to run with a 2 ms deadline and
698
+ * so ALWAYS ended by timing out -- meaning every exchange armed an orphaned
699
+ * read immediately before writing, which then swallowed the K-line echo.
700
+ * readFrame waited the full timeout for bytes already eaten and threw
701
+ * IFH-0003 "no echo from the cable" on a cable that echoes perfectly.
702
+ *
703
+ * Keeping the single outstanding read on `this.pending` and awaiting that
704
+ * same promise next time means a timed-out read is resumed, not discarded.
705
+ * @param {number} deadline - Absolute Date.now() cut-off for this call.
706
+ * @returns {Promise<{value: Uint8Array|null|undefined, done: boolean}>}
707
+ * The read result; {value:null, done:false} when it merely timed out
708
+ * (done:true is reserved for the port closing).
709
+ */
710
+ async readSome(deadline) {
711
+ const ms = Math.max(1, deadline - Date.now());
712
+ if (!this.pending) {
713
+ // Tag the read so a resolved value can be told from a stale handle.
714
+ this.pending = this.reader.read().then(
715
+ (r) => {
716
+ this.pending = null;
717
+ return r;
718
+ },
719
+ (e) => {
720
+ this.pending = null;
721
+ throw e;
722
+ }
723
+ );
724
+ }
725
+ // a worker-timed race: a late wake resolves an orphaned promise, nothing
726
+ // else, so there is no timer to clear
727
+ const timeout = bmwSleep(ms).then(() => TIMED_OUT);
728
+ const r = await Promise.race([this.pending, timeout]);
729
+ // Timed out: the read stays on this.pending for the next call. Report
730
+ // "nothing yet" rather than done -- done means the port closed.
731
+ if (r === TIMED_OUT) return { value: null, done: false };
732
+ return r;
733
+ }
734
+ }