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,331 @@
1
+ /**
2
+ * @file Concept-aware framing: which checksum, which length rule and which
3
+ * port settings each EDIABAS concept uses.
4
+ *
5
+ * The SGBD's telegram EXCLUDES its trailing checksum -- appending it is the
6
+ * interface's job (learned the hard way: a request one byte short is silently
7
+ * discarded by a real ECU). Framing per concept, from each job's xsetpar
8
+ * CommParameter {concept, baud, timeout}, verified against data/sim-captures:
9
+ *
10
+ * 1/5/6 DS2 family 9600 8E1 XOR checksum answer[1] = total length
11
+ * 0x10D KWP2000* 9600 8E1 sum8 answer[3] + 5 = total
12
+ * 0x10F BMW-FAST 115200 8N1 sum8 header short/long form
13
+ * 0x110 D-CAN 115200 8N1 sum8 (CAN cable, BMW-FAST serial)
14
+ * 0x10C ISO 9141 10400 8N1 sum8 after a 5-baud slow init
15
+ *
16
+ * The wire is described by the SGBD's set_communication_pars and nothing
17
+ * else. A telegram with no CommParameter behind it has no baud, no checksum
18
+ * rule and no length rule -- EDIABAS refuses it (IFH-0056), so do we, rather
19
+ * than assume BMW-FAST and sign a DS2 request with the wrong checksum.
20
+ */
21
+ /* exported KDCAN, UTILITY_NOMINAL_MV, KLINE_DEFAULT_BAUD, conceptOf, isDs2, isIso9141, isKline, isBmwFast, assertReachable, ISO9141_INIT_ADDR, ISO9141_BAUD, ifhError, withChecksum, frameTotal, verifyChecksum, portConfig */
22
+
23
+ /**
24
+ * The wire parameters an SGBD declares with xsetpar (Best2Vm.decodeCommParams),
25
+ * carried beside every telegram so the transport can frame, sign and pace it.
26
+ * @typedef {object} CommParams
27
+ * @property {number} concept - EDIABAS concept: 1/5/6 DS2, 0x10C ISO 9141,
28
+ * 0x10D KWP2000*, 0x10F BMW-FAST, 0x110 D-CAN.
29
+ * @property {number} [baud] - The rate the SGBD names (CommParameter[1]).
30
+ * @property {number|null} [timeout] - ParTimeoutStd: ms the ECU may take to
31
+ * START answering.
32
+ * @property {number|null} [regen] - ParRegenTime: ms of quiet the ECU needs
33
+ * after its answer before the next request.
34
+ * @property {number|null} [telEnd] - ParTimeoutTelEnd (inter-byte silence).
35
+ * @property {number|null} [timeoutNr78] - ParTimeoutNr78: ms the ECU may say
36
+ * "busy" (7F xx 78) between polls.
37
+ * @property {number[]} [answerLen] - The DS2 xawlen rule: [offset|length, add].
38
+ * @property {number} [waitMs] - A `wait` the SGBD issued before this telegram.
39
+ * @property {number[]} [params] - The raw CommParameter words.
40
+ */
41
+
42
+ /**
43
+ * Web Serial / native port settings.
44
+ * @typedef {object} PortConfig
45
+ * @property {number} baudRate - Bits per second.
46
+ * @property {number} dataBits - Always 8 here.
47
+ * @property {number} stopBits - Always 1 here.
48
+ * @property {'none'|'even'} parity - 8N1 for BMW-FAST/ISO 9141, 8E1 on the K line.
49
+ * @property {boolean} [dtr] - The IDLE level of DTR for this concept: high on
50
+ * BMW-FAST/D-CAN, low on every K-line concept (see portConfig).
51
+ */
52
+
53
+ /**
54
+ * K+DCAN over Web Serial. Default until a job's SGBD declares its own via
55
+ * xsetpar: BMW-FAST 115200 8N1 (the USB cable's default concept), DTR high.
56
+ * @type {PortConfig}
57
+ */
58
+ const KDCAN = {
59
+ baudRate: 115200,
60
+ dataBits: 8,
61
+ stopBits: 1,
62
+ parity: 'none',
63
+ dtr: true,
64
+ };
65
+ /**
66
+ * UTILITY.PRG's own number, read out of its BEST/2 bytecode: UTILITY /
67
+ * INTERFACE substitutes 12000 mV when the interface cannot measure at all,
68
+ * and STATUS_UBATT / STATUS_ZUENDUNG both compare the adapter's sense reading
69
+ * against 10000 mV (`comp L0, 10000` / `jae`), so the nominal value reads as
70
+ * "on".
71
+ */
72
+ const UTILITY_NOMINAL_MV = 12000;
73
+ /** The K-line rate EDIABAS falls back to when an SGBD names none (8E1). */
74
+ const KLINE_DEFAULT_BAUD = 9600;
75
+
76
+ /**
77
+ * The concept a telegram rides on, or IFH-0056 when the SGBD never set one.
78
+ * @param {CommParams|null|undefined} comm - The telegram's wire parameters.
79
+ * @returns {number} The concept id.
80
+ * @throws {Error} IFH-0056 when no CommParameter precedes the telegram.
81
+ */
82
+ const conceptOf = (comm) => {
83
+ if (!(comm && comm.concept)) {
84
+ throw ifhError('IFH-0056', 'no CommParameter set before the telegram');
85
+ }
86
+ return comm.concept;
87
+ };
88
+ /**
89
+ * Is this a DS2-family concept (1, 5, 6)?
90
+ * @param {number} c - The concept id.
91
+ * @returns {boolean}
92
+ */
93
+ const isDs2 = (c) => c === 1 || c === 5 || c === 6;
94
+ /**
95
+ * ISO 9141-2: the module sleeps until a 5-baud address byte wakes it.
96
+ * @param {number} c - The concept id.
97
+ * @returns {boolean}
98
+ */
99
+ const isIso9141 = (c) => c === 0x10c;
100
+ /**
101
+ * Concepts that ride the K line and therefore need DTR as transmit enable.
102
+ * DS2 and KWP2000* are K-line; BMW-FAST/D-CAN (0x10F/0x110) are not.
103
+ * @param {number} c - The concept id.
104
+ * @returns {boolean}
105
+ */
106
+ const isKline = (c) => isDs2(c) || c === 0x10d;
107
+ /**
108
+ * BMW-FAST (0x10F) and D-CAN (0x110): the two concepts on which the reference
109
+ * interface holds DTR HIGH for the whole session on a plain FTDI/COM cable
110
+ * (`stateDtr = HasAdapterEcho`, set in exactly these two cases and nowhere
111
+ * else). Every other concept idles with DTR low.
112
+ * @param {number} c - The concept id.
113
+ * @returns {boolean}
114
+ */
115
+ const isBmwFast = (c) => c === 0x10f || c === 0x110;
116
+ /**
117
+ * Concepts that only the old BMW ADS interface can drive: concept 1, concept
118
+ * 2 (ISO 9141 / KWP1281 5-baud) and concept 3. They need the L line on OBD
119
+ * pin 20 and ADS-style line control; the reference interface refuses all
120
+ * three on any echoing adapter ("only with ADS adapter", IFH-0006), and a
121
+ * K+DCAN cable is one. The 76 SGBDs that declare them are the early E31,
122
+ * E34, E36, E38 and E39 modules (Motronic 1.7 to 5.2.1, DDE 2.1, ZF EGS,
123
+ * the first ABS and IHKA). Refusing here, with the reason, beats signing a
124
+ * request the cable can never deliver and reporting "no answer".
125
+ */
126
+ const ADS_ONLY_CONCEPTS = new Set([1, 2, 3]);
127
+ /**
128
+ * Refuse a concept this cable cannot physically reach, before anything is
129
+ * configured or written.
130
+ * @param {CommParams|null|undefined} comm - The telegram's wire parameters.
131
+ * @throws {Error} IFH-0006 for an ADS-only concept.
132
+ */
133
+ function assertReachable(comm) {
134
+ const c = conceptOf(comm);
135
+ if (ADS_ONLY_CONCEPTS.has(c)) {
136
+ throw ifhError(
137
+ 'IFH-0006',
138
+ `concept ${c} needs the ADS interface (L line, OBD pin 20); ` +
139
+ 'a K+DCAN cable cannot reach this module'
140
+ );
141
+ }
142
+ }
143
+ /**
144
+ * Verified on a real E46 (M54 / MS45): the DME answers the ISO 9141 generic
145
+ * tester address at 10400 baud, NOT its own KWP address at 9600. Sending a
146
+ * job to an unwoken module gets silence, which surfaced as IFH-0009 "no
147
+ * response" and looked for all the world like a wiring fault.
148
+ */
149
+ const ISO9141_INIT_ADDR = 0x33;
150
+ /**
151
+ * The rate an E46 K-line module actually answers on after the slow or fast
152
+ * init -- 9600 (either parity) stays silent even after a successful wake.
153
+ */
154
+ const ISO9141_BAUD = 10400;
155
+
156
+ /**
157
+ * Build an interface failure carrying its EDIABAS IFH identity, so
158
+ * explainError and a user comparing to real INPA see the same code (IFH-0009
159
+ * no answer, -0003 line/echo, -0019 truncated). SGBD-level ERROR_ECU_* stay
160
+ * the jobs' business.
161
+ * @param {string} code - The IFH code, e.g. 'IFH-0009'.
162
+ * @param {string} message - What went wrong, in plain words.
163
+ * @returns {Error & {ifh: string}} The error, tagged with `ifh`.
164
+ */
165
+ function ifhError(code, message) {
166
+ const e = new Error(`${code}: ${message}`);
167
+ e.ifh = code;
168
+ return e;
169
+ }
170
+
171
+ /**
172
+ * Which EDIABAS transmit function a concept runs, and therefore its checksum
173
+ * and its answer-length rule (the reference interface's `switch (concept)`
174
+ * that sets ParTransmitFunc):
175
+ * 1, 5, 6 TransDs2 XOR length from xawlen (TelLengthDs2)
176
+ * 0x10D KWP2000* TransKwp2000S XOR byte[3] + 4 (TelLengthKwp2000S)
177
+ * 0x10B/0x10C/0x10F TransKwp2000Bmw/ sum TelLengthBmwFast
178
+ * 0x110 D-CAN TransBmwFast
179
+ * Nothing here looks at the first byte of a frame to decide -- 0xB8 is just
180
+ * the tester address KWP2000* and BMW-FAST both use.
181
+ */
182
+ const XOR_CONCEPTS = new Set([1, 5, 6, 0x10d]);
183
+ /**
184
+ * The reference interface's per-concept CommAnswerLen seed, used when an
185
+ * SGBD never issues xawlen: [-o, k] = byte at offset o plus k.
186
+ * @type {Record<number, [number, number]>}
187
+ */
188
+ const DS_ANSWER_LEN_DEFAULT = { 1: [-2, 0], 5: [-1, 0], 6: [-1, 0] };
189
+ /** Concepts whose checksum is the 8-bit sum (see XOR_CONCEPTS). */
190
+ const SUM_CONCEPTS = new Set([0x10b, 0x10c, 0x10f, 0x110]);
191
+
192
+ /**
193
+ * The checksum a concept puts after its telegram.
194
+ * @param {ArrayLike<number>} bytes - The telegram without its checksum.
195
+ * @param {number} c - The concept id.
196
+ * @returns {number} XOR or sum8 of the bytes.
197
+ * @throws {Error} IFH-0018 for a concept this interface cannot sign.
198
+ */
199
+ function checksumOf(bytes, c) {
200
+ let sum = 0;
201
+ if (XOR_CONCEPTS.has(c)) {
202
+ for (const b of bytes) sum ^= b;
203
+ return sum;
204
+ }
205
+ if (SUM_CONCEPTS.has(c)) {
206
+ for (const b of bytes) sum = (sum + b) & 0xff;
207
+ return sum;
208
+ }
209
+ throw ifhError(
210
+ 'IFH-0018',
211
+ `concept 0x${c.toString(16)} is not supported on this interface`
212
+ );
213
+ }
214
+
215
+ /**
216
+ * Append the concept's checksum to an outgoing telegram.
217
+ * @param {ArrayLike<number>} out - The request as the SGBD built it.
218
+ * @param {CommParams} comm - Its wire parameters.
219
+ * @returns {number[]} The framed request, ready for the wire.
220
+ */
221
+ function withChecksum(out, comm) {
222
+ return [...out, checksumOf(out, conceptOf(comm))];
223
+ }
224
+
225
+ /**
226
+ * Total frame length INCLUDING the checksum byte, or null while too few
227
+ * bytes are in to know. Each rule is its EDIABAS TelLength* + 1.
228
+ * @param {number[]} buf - The bytes received so far.
229
+ * @param {CommParams} comm - The wire parameters of the request.
230
+ * @returns {number|null} The frame length, or null while undecidable.
231
+ * @throws {Error} IFH-0018 for an unsupported concept or a DS2 job with no
232
+ * answer-length rule at all.
233
+ */
234
+ function frameTotal(buf, comm) {
235
+ const c = conceptOf(comm);
236
+ if (XOR_CONCEPTS.has(c) && c !== 0x10d) {
237
+ // DS2: the rule the SGBD declared with xawlen (TelLengthDs2), or, when it
238
+ // never did, EDIABAS's own default for the concept -- every xsetpar seeds
239
+ // CommAnswerLen per concept (DS1/DS2 = [-1, 0] "byte 1 is the total
240
+ // length", concept 1 = [-2, 0]) and xawlen only overrides it. The E46
241
+ // steering-angle group d_0057 is 99 ops and never calls xawlen; refusing
242
+ // its exchange here made the resolver log "bus-silent" for a sensor that
243
+ // answers every time, and the scan printed "not installed".
244
+ let al = comm && comm.answerLen;
245
+ if (!al || !al.length) al = DS_ANSWER_LEN_DEFAULT[c];
246
+ if (!al) {
247
+ throw ifhError(
248
+ 'IFH-0018',
249
+ 'DS2 answer length not set by the SGBD (xawlen)'
250
+ );
251
+ }
252
+ if (al[0] > 0) return al[0];
253
+ const off = -al[0];
254
+ return buf.length > off ? buf[off] + (al[1] || 0) : null;
255
+ }
256
+ // KWP2000* (0x10d): these answers always carry the B8 F1 12 address header,
257
+ // so byte 0 (0xB8) is NOT a BMW-FAST length byte -- the length is byte 3,
258
+ // and the frame is byte3 + 4 header + 1 checksum. Verified against a real
259
+ // MS45 EDIABAS trace: byte3 0x1F -> 36 bytes, 0x41 -> 70, 0xFF -> 260. (Do
260
+ // NOT apply TelLengthBmwFast's byte0-&-0x3F rule here: 0xB8 & 0x3F = 56
261
+ // would force every answer to 60 bytes.)
262
+ if (c === 0x10d) return buf.length >= 4 ? buf[3] + 4 + 1 : null;
263
+ if (SUM_CONCEPTS.has(c)) {
264
+ // TelLengthBmwFast -- length in the low 6 bits of byte 0, with byte 3 (or
265
+ // bytes 4-5) as the long-form fallback.
266
+ if (!buf.length) return null;
267
+ const short = buf[0] & 0x3f;
268
+ if (short) return short + 3 + 1;
269
+ if (buf.length < 4) return null;
270
+ if (buf[3] === 0)
271
+ return buf.length >= 6 ? (buf[4] << 8) + buf[5] + 6 + 1 : null;
272
+ return buf[3] + 4 + 1;
273
+ }
274
+ throw ifhError(
275
+ 'IFH-0018',
276
+ `concept 0x${c.toString(16)} is not supported on this interface`
277
+ );
278
+ }
279
+
280
+ /**
281
+ * Check a complete frame's trailing checksum against its concept's rule.
282
+ * @param {number[]} frame - The whole answer including its checksum byte.
283
+ * @param {CommParams} comm - The wire parameters of the request.
284
+ * @throws {Error} IFH-0019 on a mismatch.
285
+ */
286
+ function verifyChecksum(frame, comm) {
287
+ const want = checksumOf(frame.slice(0, -1), conceptOf(comm));
288
+ if (want !== frame[frame.length - 1]) {
289
+ throw ifhError('IFH-0019', 'answer checksum mismatch');
290
+ }
291
+ }
292
+
293
+ /**
294
+ * The port settings a concept's telegrams travel on.
295
+ * @param {CommParams} comm - The telegram's wire parameters.
296
+ * @returns {PortConfig} Baud, bits and parity for the port.
297
+ */
298
+ function portConfig(comm) {
299
+ const c = conceptOf(comm);
300
+ if (isIso9141(c)) {
301
+ // 8N1 after the handshake -- the init itself is bit-banged, not framed.
302
+ // DTR idles low: the reference raises the idle level for BMW-FAST and
303
+ // D-CAN only, never for 0x10C.
304
+ return {
305
+ baudRate: (comm && comm.baud) || ISO9141_BAUD,
306
+ dataBits: 8,
307
+ stopBits: 1,
308
+ parity: 'none',
309
+ dtr: false,
310
+ };
311
+ }
312
+ if (isKline(c)) {
313
+ // DS2 and KWP2000* are 8E1 at the rate the SGBD names (concept 6 in the
314
+ // reference interface: parity = Even, baudRate = CommParameter[1]). An
315
+ // earlier 10400 8N1 override here came from an ISO 9141 experiment and
316
+ // does not belong on these concepts. DTR idles LOW: held high it keeps
317
+ // the cable transmitting and the answer is lost (real E46, MS45).
318
+ return {
319
+ baudRate: (comm && comm.baud) || KLINE_DEFAULT_BAUD,
320
+ dataBits: 8,
321
+ stopBits: 1,
322
+ parity: 'even',
323
+ dtr: false,
324
+ };
325
+ }
326
+ // BMW-FAST / D-CAN: 115200 8N1 with DTR held HIGH for the session, the way
327
+ // the reference drives a plain cable (stateDtr = HasAdapterEcho). Dropping
328
+ // it after a K-line probe and leaving it there is the one thing this
329
+ // transport did differently from the reference on an E60/E65/E90 bus.
330
+ return { ...KDCAN, dtr: isBmwFast(c) };
331
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @file The shim's composition root: lock the bus, publish the public
3
+ * surface on window, and install the fetch shim. Loads last, after every
4
+ * other core/webshim/ piece.
5
+ */
6
+
7
+ /**
8
+ * Forget everything known about the car on the other end of the cable:
9
+ * the EDIABAS sessions (without ENDE -- the wire is already going away) and
10
+ * the resolved variants. Run before the bus disconnects.
11
+ */
12
+ function forgetCar() {
13
+ forgetSessions();
14
+ forgetResolvedVariants();
15
+ }
16
+
17
+ lockBus(webBus, forgetCar);
18
+
19
+ if (typeof window !== 'undefined') {
20
+ window.webBus = webBus;
21
+ window.installWebShim = installWebShim;
22
+ // group -> variant resolution, for the sweep screen: which SGBD answers
23
+ // at this diagnostic address? (lowercased SGBD name, or null)
24
+ window.webResolveVariant = webResolveVariant;
25
+ window.webResolveVariantLast = webResolveVariantLast;
26
+ // The explicitly-confirmed coding write path (the UI's "code this module"
27
+ // action). Gated on opts.confirmed and its own re-read proof; see coding.js.
28
+ window.webWriteCoding = webWriteCoding;
29
+ installWebShim();
30
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * @file EDIABAS's session model and the job runner: run a job the way the
3
+ * server's /run endpoint did, but in the VM, over the live bus.
4
+ */
5
+ /* exported sessionFor, switchSession, forgetSessions, newTally, driveJobOverBus, webRunJob */
6
+
7
+ /**
8
+ * How many telegram exchanges a single job may need before it is declared
9
+ * stuck (one VM replay pass per exchange).
10
+ */
11
+ const MAX_JOB_PASSES = 64;
12
+
13
+ /**
14
+ * The VM's send callback: synchronous, returns a memoised answer or throws
15
+ * the needAnswer sentinel to unwind the pass.
16
+ * @callback SendFn
17
+ * @param {ArrayLike<number>} out - The request without its checksum.
18
+ * @param {CommParams} comm - Its wire parameters.
19
+ * @returns {number[]} The answer bytes.
20
+ */
21
+
22
+ /**
23
+ * One SGBD's session state.
24
+ * @typedef {object} Session
25
+ * @property {Map<string, any>} shared - shmset data carried across jobs.
26
+ * @property {boolean} inited - Has INITIALISIERUNG run.
27
+ * @property {CommParams|null} comm - The wire parameters xsetpar left behind.
28
+ */
29
+
30
+ /**
31
+ * What a VM job produced once every telegram it needed had been exchanged.
32
+ * @typedef {object} DriveResult
33
+ * @property {object[]} sets - The result sets.
34
+ * @property {Best2Vm} vm - The VM instance that completed the job.
35
+ */
36
+
37
+ /**
38
+ * How the wire answered a job's telegrams, kept OUTSIDE the drive so the
39
+ * counts survive a pass that throws (the resolver reports them beside the
40
+ * error).
41
+ * @typedef {object} AnswerTally
42
+ * @property {number} empty - Telegrams the wire could not answer.
43
+ * @property {number} real - Telegrams that came back with bytes.
44
+ */
45
+
46
+ /**
47
+ * A fresh, zeroed answer tally.
48
+ * @returns {AnswerTally}
49
+ */
50
+ function newTally() {
51
+ return { empty: 0, real: 0 };
52
+ }
53
+
54
+ /**
55
+ * EDIABAS's session model, which a fresh-VM-per-job does not have:
56
+ * INITIALISIERUNG runs ONCE when an SGBD is loaded, shared data (shmset)
57
+ * persists across that SGBD's jobs, and ENDE runs when it is unloaded.
58
+ * MS450 hands its AIF block from init to later jobs exactly this way.
59
+ * The session also carries COMM: xsetpar lives in INITIALISIERUNG, so a
60
+ * later job's fresh VM never executes it -- without the carry, every
61
+ * ordinary job transmitted with default BMW-FAST framing and every K-line
62
+ * module got 115200 8N1 line noise.
63
+ * Keyed by SGBD; switching ECUs ends the previous session.
64
+ * @type {Map<string, Session>}
65
+ */
66
+ const sessions = new Map();
67
+
68
+ /**
69
+ * The session for an SGBD, created empty on first sight.
70
+ * @param {string} sgbd - The SGBD name (any case).
71
+ * @returns {Session}
72
+ */
73
+ function sessionFor(sgbd) {
74
+ const key = String(sgbd).toLowerCase();
75
+ let s = sessions.get(key);
76
+ if (!s) {
77
+ s = { shared: new Map(), inited: false, comm: null };
78
+ sessions.set(key, s);
79
+ }
80
+ return s;
81
+ }
82
+
83
+ /**
84
+ * Run ENDE for a session being dropped. Fire-and-forget: the answer does
85
+ * not matter, but the ECU is entitled to the notification.
86
+ * @param {string} sgbd - The SGBD whose session ends.
87
+ */
88
+ async function endSession(sgbd) {
89
+ const key = String(sgbd).toLowerCase();
90
+ const s = sessions.get(key);
91
+ if (!s || !s.inited) {
92
+ sessions.delete(key);
93
+ return;
94
+ }
95
+ sessions.delete(key);
96
+ try {
97
+ const code = await webFetchJson(`data/job-code/${key}.json`);
98
+ if (code && code.jobs && code.jobs.ENDE !== undefined) {
99
+ await webRunJob(sgbd, 'ENDE', null, {
100
+ noInit: true,
101
+ shared: s.shared,
102
+ comm: s.comm,
103
+ });
104
+ }
105
+ } catch {
106
+ /* the session is over either way */
107
+ }
108
+ }
109
+
110
+ /**
111
+ * The currently-loaded SGBD (lowercased). EDIABAS holds one at a time;
112
+ * switching ends the old session so its ENDE runs while the bus is still up.
113
+ * @type {string|null}
114
+ */
115
+ let loadedSgbd = null;
116
+
117
+ /**
118
+ * Make `sgbd` the loaded SGBD, ending the previous one's session first.
119
+ *
120
+ * NOTE this must NOT run between an SGBD's session init and its jobs: those
121
+ * are the same session. Clearing there let the job's BMW-FAST concept
122
+ * become the session concept, the port reopened at 115200, and the wake
123
+ * performed at 10400 was undone before the telegram went out.
124
+ * @param {string} sgbd - The SGBD about to run.
125
+ */
126
+ async function switchSession(sgbd) {
127
+ const key = String(sgbd).toLowerCase();
128
+ if (loadedSgbd === key) return;
129
+ const prev = loadedSgbd;
130
+ loadedSgbd = key;
131
+ // Only a REAL switch clears the wire state. The early return above already
132
+ // skipped the no-op case, so reaching here means a different ECU -- which
133
+ // may live on a different wire, so the remembered concept and the wake
134
+ // that went with it do not carry over.
135
+ if (prev) {
136
+ await endSession(prev);
137
+ webBus.sessionConcept = null;
138
+ webBus.inited = null;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Forget every session and the loaded SGBD, without running ENDE. Called
144
+ * when the cable goes away: the wire is already gone.
145
+ */
146
+ function forgetSessions() {
147
+ sessions.clear();
148
+ loadedSgbd = null;
149
+ }
150
+
151
+ /**
152
+ * The sentinel a send() throws to unwind a VM pass whose answer is not yet
153
+ * known. bestvm's INITIALISIERUNG handling rethrows it instead of swallowing
154
+ * it -- an init whose telegrams were never fetched used to "succeed" having
155
+ * sent nothing.
156
+ * @returns {Error & {needAnswer: true}}
157
+ */
158
+ function needAnswerError() {
159
+ const need = new Error('__need_answer__');
160
+ need.needAnswer = true;
161
+ return need;
162
+ }
163
+
164
+ /**
165
+ * Did this wire error mean the telegram got nothing usable -- silence or a
166
+ * bad echo -- as opposed to a lying wire?
167
+ *
168
+ * A SILENT ECU IS AN ANSWER OF ZERO BYTES, NOT A DEAD JOB. The VM sets
169
+ * f.zero from the answer's length and the bytecode branches on it. Tracing
170
+ * EDIABAS proved the SGBD relies on exactly that: ms450ds0's INITIALISIERUNG
171
+ * holds two telegrams as constants, sends "82 12 F1 1A 80", gets IFH-0009 --
172
+ * and carries on to send "B8 12 F1 02 1A 80", which the ECU answers. Throwing
173
+ * here killed the job on the first telegram, so the second was never tried
174
+ * and a perfectly reachable DME looked silent.
175
+ *
176
+ * IFH-0009 (silence) and IFH-0003 (the echo did not come back cleanly) both
177
+ * mean THIS TELEGRAM GOT NOTHING USABLE. The SGBD's fallback branches on the
178
+ * answer's length via `slen`, so both must arrive as an empty answer or the
179
+ * bytecode never reaches its second telegram. A half-duplex K line genuinely
180
+ * produces both: an ECU that ignores a framing answers with silence, and the
181
+ * stray leftover byte that follows makes the NEXT echo compare fail.
182
+ * Treating only the timeout as "no answer" left the app dying on whichever
183
+ * of the two happened to occur first.
184
+ *
185
+ * Only those two are swallowed. A damaged frame, a checksum failure or a
186
+ * bus-level fault still throws: those mean the wire is lying, and continuing
187
+ * would decode garbage.
188
+ * @param {any} err - What webBus.exchange threw.
189
+ * @returns {boolean} True for IFH-0009 / IFH-0003.
190
+ */
191
+ function isNoUsableAnswer(err) {
192
+ return !!(err && (err.ifh === 'IFH-0009' || err.ifh === 'IFH-0003'));
193
+ }
194
+
195
+ /**
196
+ * Drive one VM job over the bus in passes.
197
+ *
198
+ * send() is synchronous but the wire is async, so the VM runs in passes:
199
+ * each pass runs until a send whose answer we lack, which we fetch, memoise,
200
+ * then retry from the top (the VM is deterministic, so replay is safe). The
201
+ * memo is keyed by request bytes AND occurrence index: a job that sends the
202
+ * same telegram twice (clear-then-verify) must get the second answer, not a
203
+ * replay. One clock serves all passes -- a time that ticked between passes
204
+ * would change the request bytes, miss the memo, and re-transmit an
205
+ * already-sent telegram.
206
+ *
207
+ * REMEMBER THAT THE WIRE FAILED. The empty answer is what the SGBD's
208
+ * fallback needs, but a job whose telegrams ALL came back empty has not read
209
+ * the car -- it has read nothing. The tally lets the caller say so: without
210
+ * it the fault screen rendered "No stored faults / clean fault memory" for a
211
+ * DME holding nine real faults, which is the worst thing a diagnostic tool
212
+ * can say.
213
+ * @param {(send: SendFn, now: Date) => Best2Vm} buildVm - Builds a fresh VM
214
+ * for one pass around the given send callback and clock.
215
+ * @param {string} job - The job name.
216
+ * @param {string} arg - The job's argument string.
217
+ * @param {AnswerTally} tally - Updated in place as telegrams are answered.
218
+ * @returns {Promise<DriveResult>} The result sets and the finished VM.
219
+ * @throws {Error} A VM error from the job itself, a wire error that is not
220
+ * silence, or 'did not settle' after MAX_JOB_PASSES exchanges.
221
+ */
222
+ async function driveJobOverBus(buildVm, job, arg, tally) {
223
+ const answers = new Map();
224
+ const jobNow = new Date();
225
+ for (let attempt = 0; attempt < MAX_JOB_PASSES; attempt++) {
226
+ let missing = null;
227
+ let sendSeq = 0;
228
+ const send = (out, comm) => {
229
+ const key = `${sendSeq++}:${Array.from(out)}`;
230
+ if (answers.has(key)) return answers.get(key);
231
+ // Carry the wire parameters along with the request: the exchange
232
+ // below needs the concept to frame, checksum and pace it. Unwind this
233
+ // pass: nothing sensible to return, and continuing would decode
234
+ // garbage.
235
+ missing = { key, out: Array.from(out), comm };
236
+ throw needAnswerError();
237
+ };
238
+ const vm = buildVm(send, jobNow);
239
+ try {
240
+ const sets = vm.run(job, arg);
241
+ return { sets, vm };
242
+ } catch (e) {
243
+ // Only the needAnswer sentinel may turn into a wire exchange. A real
244
+ // VM error thrown in the same pass must surface as itself, not be
245
+ // recycled into "did not settle".
246
+ if (!missing || !e.needAnswer) throw e;
247
+ let answer;
248
+ try {
249
+ answer = await webBus.exchange(missing.out, missing.comm);
250
+ } catch (err) {
251
+ if (!isNoUsableAnswer(err)) throw err;
252
+ answer = [];
253
+ tally.empty++;
254
+ }
255
+ if (answer && answer.length) tally.real++;
256
+ answers.set(missing.key, answer);
257
+ }
258
+ }
259
+ throw new Error(
260
+ `job did not settle after ${MAX_JOB_PASSES} telegram exchanges`
261
+ );
262
+ }
263
+
264
+ /**
265
+ * Run a job on an SGBD over the live bus, inside its EDIABAS session.
266
+ * @param {string} sgbd - The SGBD name.
267
+ * @param {string} job - The job name.
268
+ * @param {string|null} arg - The argument string (null for none).
269
+ * @param {{shared?: Map<string, any>, comm?: CommParams|null, noInit?: boolean}} [opts] -
270
+ * A detached session to run in (endSession's ENDE uses this).
271
+ * @returns {Promise<{sets: object[]}>} The job's result sets.
272
+ * @throws {Error} When no job code is shipped, when the ECU answered nothing
273
+ * at all (IFH-0009), or whatever the job or wire threw.
274
+ */
275
+ async function webRunJob(sgbd, job, arg, opts = {}) {
276
+ const code = await webFetchJson(`data/job-code/${sgbd.toLowerCase()}.json`);
277
+ if (!code) throw new Error(`no job code shipped for ${sgbd}`);
278
+ const sharedTables = await loadSharedTables();
279
+ const tables =
280
+ (await webFetchJson(`data/sgbd-tables/${sgbd.toLowerCase()}.json`)) || {};
281
+ const session = opts.shared
282
+ ? { shared: opts.shared, inited: true, comm: opts.comm || null }
283
+ : sessionFor(sgbd);
284
+ const argText = arg == null ? '' : String(arg);
285
+ const tally = newTally();
286
+
287
+ const { sets, vm } = await driveJobOverBus(
288
+ (send, now) =>
289
+ new Best2Vm(code, {
290
+ tables,
291
+ extTables: sharedTables,
292
+ args: argText,
293
+ // Writes permitted -- see the note on Best2Vm.allowWrites. This is
294
+ // the main job runner, so it is what lets an actuator test reach the
295
+ // wire.
296
+ allowWrites: true,
297
+ shared: session.shared,
298
+ inited: session.inited,
299
+ comm: session.comm,
300
+ now,
301
+ send,
302
+ }),
303
+ job,
304
+ argText,
305
+ tally
306
+ );
307
+ session.inited = true;
308
+ session.comm = vm.comm || session.comm;
309
+ // A job that transmitted and was answered by NOTHING did not read the
310
+ // car. Saying so is the only honest outcome: the alternative is a
311
+ // "clean fault memory" that is really a dead wire.
312
+ if (tally.empty && !tally.real) {
313
+ throw ifhError(
314
+ 'IFH-0009',
315
+ 'the ECU did not answer any telegram in this job'
316
+ );
317
+ }
318
+ return { sets };
319
+ }