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,82 @@
1
+ /**
2
+ * @file The ONE explicitly-coding write path.
3
+ *
4
+ * Everything else in this shim runs the VM read-mindedly (webRunJob, the
5
+ * probe with allowWrites:false). A coding write is different: it is the app's
6
+ * most safety-critical action, so it gets its own gate, its own
7
+ * confirmation, and prove-by-re-read.
8
+ *
9
+ * The write permission itself lives in coding-write.js, NOT here: that
10
+ * module is the only place that constructs the VM writeable, and it does so
11
+ * only after this function has checked opts.confirmed. Keeping the
12
+ * write-enabling VM construction out of the shim is deliberate -- webRunJob
13
+ * and every ordinary job run stay provably read-only (test_write_gate.js
14
+ * reads the shim and asserts what it builds).
15
+ *
16
+ * It runs over the SAME bus lock as reads (withBusLock), so a coding sequence
17
+ * cannot interleave with the topbar state poll or any job on the wire.
18
+ */
19
+ /* exported webWriteCoding */
20
+
21
+ /**
22
+ * Write coding data to a module through coding-write.js's confirmed path.
23
+ * @param {string} sgbd - The SGBD to code.
24
+ * @param {string} nettoHex - The netto coding bytes as hex.
25
+ * @param {{confirmed?: boolean, jobname?: string}} [opts] - `confirmed` must
26
+ * be true (the UI's explicit confirmation); `jobname` picks the dispatcher
27
+ * job when a derived dispatcher is shipped (default SG_CODIEREN).
28
+ * @returns {Promise<any>} Whatever writeCoding resolves with.
29
+ * @throws {Error} When coding-write.js is absent, the write is unconfirmed,
30
+ * no cable is connected, or no job code is shipped for the SGBD.
31
+ */
32
+ async function webWriteCoding(sgbd, nettoHex, opts = {}) {
33
+ if (typeof window.writeCoding !== 'function') {
34
+ throw new Error('coding-write.js is not loaded');
35
+ }
36
+ if (!opts.confirmed) {
37
+ throw new Error(
38
+ 'coding write requires an explicit confirmation ' +
39
+ '(opts.confirmed) from the UI before it can transmit'
40
+ );
41
+ }
42
+ if (!webBus.connected) throw new Error('no cable connected');
43
+
44
+ const key = String(sgbd).toLowerCase();
45
+ // The SGBD program, its tables, and its job list -- the same sources the
46
+ // read path uses, so the strategy sees exactly the jobs this module exposes.
47
+ const code = await webFetchJson(`data/job-code/${key}.json`);
48
+ if (!code) throw new Error(`no job code shipped for ${sgbd}`);
49
+ const tables = (await webFetchJson(`data/sgbd-tables/${key}.json`)) || {};
50
+
51
+ // DISPATCHER PROGRAM (optional). When a derived A_<cabd> coding dispatcher
52
+ // is shipped for this module, writeCoding runs BMW's own dispatcher instead
53
+ // of the hand-sequenced strategy (see coding-write.js writeViaDispatch). The
54
+ // exec ships next to the module's job-code as <sgbd>.ipoexec.json with a
55
+ // "coding":true marker; opts.dataOrg carries the CABD word width. Absent, the
56
+ // strategy path runs unchanged, so this is additive and safe.
57
+ const dispatch = await webFetchJson(`data/coding-dispatch/${key}.json`);
58
+ const dispatchOk = dispatch && dispatch.coding && dispatch.procs;
59
+
60
+ // One SGBD is loaded at a time, exactly like a read: end the previous
61
+ // session (its ENDE) before this one initialises, then run the whole write
62
+ // sequence under the bus lock so nothing else touches the wire mid-coding.
63
+ await switchSession(sgbd);
64
+ const session = sessionFor(sgbd);
65
+ return withBusLock(() =>
66
+ window.writeCoding(sgbd, nettoHex, {
67
+ confirmed: true,
68
+ code,
69
+ tables,
70
+ jobs: code.jobs,
71
+ // the bus-locked wire, called from inside the lock we already hold --
72
+ // webBus.exchange re-enters withBusLock, which the promise chain
73
+ // serialises, so pass the RAW exchange to avoid queuing behind ourselves
74
+ exchange: (out, comm) => webBusRawExchange(out, comm),
75
+ session,
76
+ // the dispatcher program + its word width, only when shipped for this CABD
77
+ dispatch: dispatchOk ? dispatch : null,
78
+ dataOrg: dispatchOk ? dispatch.dataOrg || null : null,
79
+ jobname: dispatchOk ? opts.jobname || 'SG_CODIEREN' : undefined,
80
+ })
81
+ );
82
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @file Data loaders the job runner, resolver and coding path share: plain
3
+ * JSON, gzipped JSON, and the shared SGBD tables.
4
+ *
5
+ * These go through the global `fetch`, which install.js has replaced with the
6
+ * shim -- so `data/job-code/<sgbd>.json` and friends are answered from the
7
+ * cached ECU archives, not the network.
8
+ */
9
+ /* exported webFetchJson, webFetchGz, loadSharedTables */
10
+
11
+ /**
12
+ * Fetch a JSON file, or null on any failure (404, bad JSON).
13
+ * @param {string} path - The app-relative path.
14
+ * @returns {Promise<any|null>} The parsed body, or null.
15
+ */
16
+ async function webFetchJson(path) {
17
+ const r = await fetch(path);
18
+ return r.ok ? r.json().catch(() => null) : null;
19
+ }
20
+
21
+ /**
22
+ * Fetch a gzipped JSON file. data/groups files are gzipped JSON served
23
+ * as-is; a host that transparently content-decodes hands us plain JSON, so
24
+ * both are taken. Null on any failure.
25
+ * @param {string} path - The app-relative path.
26
+ * @returns {Promise<any|null>} The parsed body, or null.
27
+ */
28
+ async function webFetchGz(path) {
29
+ try {
30
+ const r = await fetch(path);
31
+ if (!r.ok) return null;
32
+ const buf = new Uint8Array(await r.arrayBuffer());
33
+ const isGz = buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b;
34
+ if (isGz && typeof fflate === 'undefined') {
35
+ throw new Error('fflate decompression library not loaded');
36
+ }
37
+ const text = new TextDecoder('utf-8').decode(
38
+ isGz ? fflate.gunzipSync(buf) : buf
39
+ );
40
+ return JSON.parse(text);
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * The shared table files, loaded once (the PROMISE is cached so concurrent
48
+ * callers fetch once).
49
+ * @type {Promise<Record<string, any>>|null}
50
+ */
51
+ let sharedTablesPromise = null;
52
+
53
+ /**
54
+ * The shared table files (t_pcod, t_scod, t_ausb, t_grtb): an SGBD reads
55
+ * them with `tabsetex <table>, <file>` -- ms450ds0's FS_LESEN_DETAIL looks
56
+ * its P-code up in t_pcod's PCodeTexte. Loaded once, given to every VM.
57
+ * @returns {Promise<Record<string, any>>} file name -> tables ({} when absent).
58
+ */
59
+ function loadSharedTables() {
60
+ if (!sharedTablesPromise) {
61
+ sharedTablesPromise = webFetchGz('data/groups/shared-tables.json.gz')
62
+ .then((t) => (t && typeof t === 'object' ? t : {}))
63
+ .catch(() => ({}));
64
+ }
65
+ return sharedTablesPromise;
66
+ }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * @file One request/answer exchange: echo handling, frame assembly,
3
+ * response-pending polling, bus pacing and the single retransmission.
4
+ *
5
+ * Shared by both transports, because the protocol does not change with the
6
+ * plumbing -- each transport only supplies exchangeRaw (write + read) and
7
+ * ensureConfig (port settings); everything else lives here.
8
+ */
9
+ /* exported readFrame, runExchange */
10
+
11
+ /** How long readFrame idles between empty reads while waiting for bytes. */
12
+ const RX_POLL_MS = 4;
13
+ /**
14
+ * Once an answer has STARTED, the least it gets to finish. ParTimeoutStd is
15
+ * time-to-first-byte; a fault memory at 9600 baud needs ~250 ms of wire time
16
+ * after that and must not be cut off by a 500 ms answer timeout it already met.
17
+ */
18
+ const FRAME_COMPLETE_MIN_MS = 3000;
19
+ /** The answer timeout when an SGBD's CommParameter names none. */
20
+ const DEFAULT_TIMEOUT_MS = 2000;
21
+ /** The most a `wait` in the SGBD may stall the bus before a write. */
22
+ const WAIT_CAP_MS = 5000;
23
+ /** ParRegenTime above this is a bogus blob, not a pacing rule. */
24
+ const REGEN_MAX_MS = 1000;
25
+ /** The floor for ParTimeoutNr78 when the SGBD names none (a slow routine). */
26
+ const NR78_MIN_MS = 5000;
27
+ /** How many "response pending" polls a stuck ECU gets before it fails. */
28
+ const MAX_PENDING_POLLS = 30;
29
+ /** Send once, retransmit once: EDIABAS's single-glitch cover. */
30
+ const EXCHANGE_ATTEMPTS = 2;
31
+ /** KWP negative response service id. */
32
+ const NEGATIVE_RESPONSE = 0x7f;
33
+ /** The negative-response code meaning "still working, ask again". */
34
+ const RESPONSE_PENDING = 0x78;
35
+
36
+ /**
37
+ * Pull whatever the port has right now.
38
+ * @callback PumpFn
39
+ * @returns {Promise<ArrayLike<number>|null|undefined>} Bytes, or nothing yet.
40
+ */
41
+
42
+ /**
43
+ * Read one answer off the K line.
44
+ *
45
+ * The K line is HALF DUPLEX: one wire, so everything written is also heard
46
+ * back. Drop exactly as many bytes as were sent rather than pattern-matching
47
+ * the echo -- a request and its answer can legitimately share a prefix, and
48
+ * the reference interface drops by count for the same reason.
49
+ *
50
+ * A WIRED K LINE ALWAYS ECHOES. "Adapter echo" in the reference stack refers
51
+ * to a REMOTE adapter (Bluetooth/WiFi) that strips the echo for you; an FTDI
52
+ * cable on a half-duplex wire does not, so the echo is always here and is
53
+ * always dropped by count.
54
+ *
55
+ * @param {number[]|null} sent - The exact request written, or null when
56
+ * re-reading a continuation frame, which has no echo of its own.
57
+ * @param {number} timeoutMs - ParTimeoutStd: how long the ECU may take to
58
+ * START answering.
59
+ * @param {PumpFn} pump - Reads whatever the port has buffered.
60
+ * @param {CommParams} comm - The request's wire parameters (framing rules).
61
+ * @returns {Promise<number[]>} The complete, checksum-verified answer frame.
62
+ * @throws {Error} IFH-0003 when the echo never matches, IFH-0019 on a
63
+ * truncated or mis-signed frame, IFH-0009 on silence.
64
+ */
65
+ async function readFrame(sent, timeoutMs, pump, comm) {
66
+ const buf = [];
67
+ const echoLen = sent ? sent.length : 0;
68
+ const deadline = Date.now() + timeoutMs;
69
+
70
+ // Where does `sent` start inside buf? -1 while it is not (yet) all here.
71
+ const findEcho = () => {
72
+ for (let start = 0; start + echoLen <= buf.length; start++) {
73
+ let ok = true;
74
+ for (let i = 0; i < echoLen; i++) {
75
+ if (buf[start + i] !== sent[i]) {
76
+ ok = false;
77
+ break;
78
+ }
79
+ }
80
+ if (ok) return start;
81
+ }
82
+ return -1;
83
+ };
84
+
85
+ // READ UNTIL THE ECHO IS ACTUALLY THERE, not merely until enough bytes have
86
+ // arrived. A half-duplex K line delivers the echo split across reads and
87
+ // often with a leftover byte in front of it -- "12 04 00 16" came back as
88
+ // "00 12 04 00" then "16" on the next read. Stopping at buf.length >=
89
+ // echoLen left the echo one byte short, the compare failed, and a healthy
90
+ // exchange was reported as IFH-0003.
91
+ let at = -1;
92
+ while (Date.now() < deadline) {
93
+ if (echoLen && (at = findEcho()) >= 0) break;
94
+ if (!echoLen && buf.length) break;
95
+ const got = await pump();
96
+ if (got && got.length) buf.push(...got);
97
+ else await bmwSleep(RX_POLL_MS);
98
+ }
99
+ if (echoLen && at < 0) {
100
+ throw ifhError(
101
+ 'IFH-0003',
102
+ buf.length
103
+ ? 'echo did not match the request (bus collision?)'
104
+ : 'no echo from the cable (is it connected to the car?)'
105
+ );
106
+ }
107
+ buf.splice(0, at >= 0 ? at + echoLen : 0); // drop leading noise AND the echo
108
+ // timeoutMs is ParTimeoutStd: how long the ECU may take to START answering.
109
+ // EDIABAS ends the frame on inter-byte silence (ParTimeoutTelEnd), not on
110
+ // that budget -- so once the first byte is here, a long answer at 9600 baud
111
+ // (a fault memory: ~250 ms of wire time) must not be cut off by a 500 ms
112
+ // answer timeout that it already met. Judge silence at timeoutMs; give a
113
+ // started frame its own completion budget.
114
+ const completionBudget = () =>
115
+ Date.now() + Math.max(timeoutMs, FRAME_COMPLETE_MIN_MS);
116
+ let frameDeadline = deadline;
117
+ let started = buf.length > 0;
118
+ if (started) frameDeadline = completionBudget();
119
+ while (Date.now() < frameDeadline) {
120
+ const total = frameTotal(buf, comm);
121
+ if (total !== null && buf.length >= total) {
122
+ const frame = buf.slice(0, total);
123
+ verifyChecksum(frame, comm);
124
+ return frame;
125
+ }
126
+ const got = await pump();
127
+ if (got && got.length) {
128
+ buf.push(...got);
129
+ if (!started) {
130
+ started = true;
131
+ frameDeadline = completionBudget();
132
+ }
133
+ } else await bmwSleep(RX_POLL_MS);
134
+ }
135
+ // A half-received frame is NOT an answer -- handing it to the VM decodes
136
+ // garbage. Distinguish it from silence so the error means something.
137
+ throw buf.length
138
+ ? ifhError('IFH-0019', `incomplete answer from ECU (${buf.length} bytes)`)
139
+ : ifhError('IFH-0009', 'no answer from ECU (timeout)');
140
+ }
141
+
142
+ /**
143
+ * "Response pending": an ECU that needs longer than its declared timeout
144
+ * answers 7F <service> 78 and keeps the request alive. EDIABAS waits for
145
+ * the real answer instead of failing; a flash or a long routine depends on
146
+ * it. The payload offset follows the same framing rules as frameTotal:
147
+ * DS2 puts it at 2, KWP2000* behind its 4-byte header, BMW-FAST at 3 for
148
+ * the short form and 4 for the long form (len byte at [3]) -- a long-frame
149
+ * 7F..78 sliced at 3 was returned to the VM as the final answer.
150
+ * @param {number[]} frame - A complete answer frame.
151
+ * @param {CommParams} comm - The request's wire parameters.
152
+ * @returns {boolean} True when the ECU asked for more time.
153
+ */
154
+ function isResponsePending(frame, comm) {
155
+ const c = conceptOf(comm);
156
+ let body;
157
+ if (isDs2(c)) body = frame.slice(2);
158
+ else if (c === 0x10d) body = frame.slice(4);
159
+ else if (frame[0] === 0xb8) body = frame.slice(4);
160
+ else body = frame[0] & 0x3f ? frame.slice(3) : frame.slice(4);
161
+ return body[0] === NEGATIVE_RESPONSE && body[2] === RESPONSE_PENDING;
162
+ }
163
+
164
+ /**
165
+ * The quiet gap an SGBD demands between an answer and the next request.
166
+ * CommParameter dword 3 on the 0x1xx concepts (ParRegenTime); the DS2 case
167
+ * reads index 6 of its own 16-bit layout -- Best2Vm.decodeCommParams picks
168
+ * the concept's own index. Clamped, because a bogus blob must not stall the
169
+ * bus.
170
+ * @param {CommParams|null|undefined} comm - The telegram's wire parameters.
171
+ * @returns {number} Milliseconds of quiet, 0 when none or out of range.
172
+ */
173
+ function regenTimeOf(comm) {
174
+ const raw = comm && comm.regen != null ? comm.regen : 0;
175
+ return raw > 0 && raw <= REGEN_MAX_MS ? raw : 0;
176
+ }
177
+
178
+ /**
179
+ * Honour the pacing an exchange owes the bus before its write: a `wait` the
180
+ * SGBD issued, and ParRegenTime measured from the ECU's last answer.
181
+ *
182
+ * ParRegenTime is a MANDATORY quiet gap between the ECU's last answer and the
183
+ * next request, measured from the response. The SGBD names it in its
184
+ * CommParameter -- 25 ms for this MS45 on concept 0x10D. Without it a
185
+ * telegram sent immediately after a reply is ignored, which is exactly what
186
+ * made a repeated FS_LESEN come back empty while the identical first one was
187
+ * answered.
188
+ * @param {Transport} bus - The transport, for its lastResponseAt.
189
+ * @param {CommParams} comm - The telegram's wire parameters.
190
+ * @returns {Promise<void>}
191
+ */
192
+ async function paceBeforeWrite(bus, comm) {
193
+ // a `wait` in the SGBD paces the bus: honor it before writing
194
+ if (comm && comm.waitMs) {
195
+ await bmwSleep(Math.min(comm.waitMs, WAIT_CAP_MS));
196
+ }
197
+ const regenMs = regenTimeOf(comm);
198
+ if (regenMs && bus.lastResponseAt) {
199
+ const since = Date.now() - bus.lastResponseAt;
200
+ if (since < regenMs) {
201
+ await bmwSleep(regenMs - since);
202
+ }
203
+ }
204
+ }
205
+
206
+ /**
207
+ * One request/answer exchange with per-concept retry. EDIABAS retransmits
208
+ * on a bad or missing answer (xreps); one retry covers the single-glitch
209
+ * case without hammering a dead bus.
210
+ *
211
+ * EVERY xsetpar RECONFIGURES THE WIRE. That is what the opcode is for, and
212
+ * EDIABAS honours each one: tracing this car showed it set concept 0x10F at
213
+ * 115200, send the short telegram, get silence, then set concept 0x10D at
214
+ * 9600 and send the long one -- which is answered. An earlier revision here
215
+ * pinned the wire to the FIRST concept of a session, so the second xsetpar
216
+ * was ignored, the B8 telegram went out at 115200 instead of 9600, and the
217
+ * DME never heard it. The VM had reached the right branch all along.
218
+ * sessionConcept is still tracked, but only so the K-line wake knows it is
219
+ * on a K-line module; it no longer overrides the telegram's own wire.
220
+ *
221
+ * NO FRAMING FALLBACK HERE. The SGBD owns that: tracing EDIABAS showed
222
+ * ms450ds0's INITIALISIERUNG hold BOTH telegrams as constants and try them
223
+ * in turn -- xsend "82 12 F1 1A 80" gets IFH-0009, the bytecode carries on,
224
+ * and xsend "B8 12 F1 02 1A 80" is answered. So a timed-out exchange must be
225
+ * reported to the VM, not retried behind its back with a rewritten telegram.
226
+ *
227
+ * @param {Transport} bus - The transport to drive.
228
+ * @param {ArrayLike<number>} out - The request without its checksum.
229
+ * @param {CommParams} comm - The request's wire parameters.
230
+ * @returns {Promise<number[]>} The answer frame.
231
+ * @throws {Error} The last IFH error when both attempts fail, or the first
232
+ * error that is not a garbled answer.
233
+ */
234
+ async function runExchange(bus, out, comm) {
235
+ // An ADS-only concept (1, 2, 3) fails here with its reason, before the
236
+ // port is touched -- the reference refuses them the same way (IFH-0006).
237
+ assertReachable(comm);
238
+ bus.sessionConcept = conceptOf(comm);
239
+ await bus.ensureConfig(portConfig(comm));
240
+ const framed = withChecksum(out, comm);
241
+ const timeoutMs = (comm && comm.timeout) || DEFAULT_TIMEOUT_MS;
242
+ await paceBeforeWrite(bus, comm);
243
+ let lastErr;
244
+ for (let attempt = 0; attempt < EXCHANGE_ATTEMPTS; attempt++) {
245
+ try {
246
+ busTrace.add(
247
+ 'tx',
248
+ framed,
249
+ `${attempt ? 'retransmit' : 'tx'} timeout=${timeoutMs}ms`
250
+ );
251
+ let frame = await bus.exchangeRaw(framed, timeoutMs, comm);
252
+ // keep reading while the ECU says "still working" -- bounded, so a
253
+ // stuck ECU still fails instead of hanging the screen
254
+ for (
255
+ let pending = 0;
256
+ pending < MAX_PENDING_POLLS && isResponsePending(frame, comm);
257
+ pending++
258
+ ) {
259
+ // ParTimeoutNr78: how long the ECU may say "busy" between polls
260
+ frame = await bus.exchangeRaw(
261
+ null,
262
+ (comm && comm.timeoutNr78) || Math.max(timeoutMs, NR78_MIN_MS),
263
+ comm
264
+ );
265
+ }
266
+ busTrace.add('rx', frame, 'OK');
267
+ bus.lastResponseAt = Date.now();
268
+ return frame;
269
+ } catch (e) {
270
+ lastErr = e;
271
+ busTrace.add('err', null, `${e.ifh || ''} ${e.message}`.trim());
272
+ // Retransmit a GARBLED answer once (a K-line glitch), never a SILENT
273
+ // one: EDIABAS ships CommRepeats = 0, so a telegram nobody answers is
274
+ // sent exactly once and the bytecode moves on to its next protocol.
275
+ // Sending it twice doubled every probe step (ms450ds0's KWP2000* try
276
+ // before the BMW-FAST one that an MS45 actually answers).
277
+ // The error carries its IFH code; a garbled answer is IFH-0019
278
+ // (checksum / incomplete) or IFH-0003 (echo), silence is IFH-0009.
279
+ if (!(e && (e.ifh === 'IFH-0019' || e.ifh === 'IFH-0003'))) throw e;
280
+ // The reference retry is a PURE RETRANSMISSION: the same bytes with
281
+ // only the ParRegenTime wait, never a reinit. Re-arming the wake here
282
+ // made our retry a different, more disruptive operation than the one
283
+ // EDIABAS performs -- and on DS2 there is no wake to re-arm in the
284
+ // first place.
285
+ }
286
+ }
287
+ throw lastErr;
288
+ }