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,592 @@
1
+ /**
2
+ * @file The fetch shim: installs over window.fetch so core.js's api() needs
3
+ * no change at all, answering /api/* locally -- static routes from the
4
+ * cached .chassis archives, job runs through the VM -- and routing every
5
+ * genuine file read through the offline folder when one is picked.
6
+ */
7
+ /* exported WEB_API_BASE, WEB_BASE, installWebShim */
8
+
9
+ /** The static API directory the exporter writes (tools/web_export.py). */
10
+ const WEB_API_BASE = 'api';
11
+
12
+ /**
13
+ * WHERE THIS PAGE LIVES. A project site served from a subpath (/BMacW/), not
14
+ * the domain root, would resolve "/api/chassis" to the host's root -- off the
15
+ * site entirely. Derive the base from the document's own URL and hang every
16
+ * static path off it. Empty at a domain root and inside the macOS app, so
17
+ * both behave exactly as before.
18
+ */
19
+ const WEB_BASE = (
20
+ typeof location !== 'undefined'
21
+ ? location.pathname.replace(/\/[^/]*$/, '')
22
+ : ''
23
+ ).replace(/\/$/, '');
24
+
25
+ /**
26
+ * One unpacked .chassis archive.
27
+ * @typedef {object} ChassisData
28
+ * @property {any} config - Its config.json.
29
+ * @property {Map<string, Uint8Array>} ecuZips - sgbd (lowercased) -> .ecu bytes.
30
+ */
31
+
32
+ /**
33
+ * Cache of chassis configs and their ECU zip buffers, keyed by chassis id.
34
+ * @type {Map<string, ChassisData>}
35
+ */
36
+ const CHASSIS_CACHE = new Map();
37
+
38
+ /**
39
+ * Cache of parsed ECU files: sgbd (lowercased) -> Map(filename -> content).
40
+ * @type {Map<string, Map<string, any>>}
41
+ */
42
+ const ECU_CACHE = new Map();
43
+
44
+ /**
45
+ * A fetch that reaches the FILE rather than the shim's answer.
46
+ * @callback RealFetch
47
+ * @param {RequestInfo|string} input - The URL.
48
+ * @param {RequestInit} [init] - Fetch options.
49
+ * @returns {Promise<Response>}
50
+ */
51
+
52
+ /**
53
+ * The offline export's inlined data (data/inline.js), when this page is one.
54
+ * A file:// page gets an opaque origin where fetch() is blocked, so the
55
+ * exporter inlines each archive as base64 in a <script> instead -- which
56
+ * file:// loads happily.
57
+ * @returns {Record<string, any>|null} The BMACW_INLINE map, or null.
58
+ */
59
+ function inlineData() {
60
+ return typeof BMACW_INLINE === 'object' && BMACW_INLINE ? BMACW_INLINE : null;
61
+ }
62
+
63
+ /**
64
+ * Load and unpack a chassis archive, from the inlined data when it is there
65
+ * and only reaching for the network otherwise.
66
+ * @param {string} chassisId - The chassis id (any case).
67
+ * @param {RealFetch} realFetch - The unshimmed fetch.
68
+ * @returns {Promise<ChassisData>}
69
+ * @throws {Error} When the archive cannot be fetched.
70
+ */
71
+ async function loadChassis(chassisId, realFetch) {
72
+ const upperId = chassisId.toUpperCase();
73
+ if (CHASSIS_CACHE.has(upperId)) return CHASSIS_CACHE.get(upperId);
74
+
75
+ const inline = inlineData();
76
+ if (inline && inline[upperId]) {
77
+ const bin = atob(inline[upperId]);
78
+ const bytes = new Uint8Array(bin.length);
79
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
80
+ return cacheChassis(upperId, bytes);
81
+ }
82
+
83
+ const fileUrl = `${WEB_BASE}/api/chassis/${upperId}.chassis`;
84
+ const res = await realFetch(fileUrl);
85
+ if (!res.ok)
86
+ throw new Error(`Failed to load chassis ${upperId}: ${res.statusText}`);
87
+
88
+ const buffer = await res.arrayBuffer();
89
+ return cacheChassis(upperId, new Uint8Array(buffer));
90
+ }
91
+
92
+ /**
93
+ * Unpack one .chassis and remember it. Shared by the inline and network
94
+ * paths, which differ only in where the bytes came from.
95
+ * @param {string} upperId - The chassis id, upper-cased.
96
+ * @param {Uint8Array} bytes - The zip archive.
97
+ * @returns {ChassisData}
98
+ * @throws {Error} Without fflate, or when config.json is missing.
99
+ */
100
+ function cacheChassis(upperId, bytes) {
101
+ if (typeof fflate === 'undefined') {
102
+ throw new Error('fflate decompression library not loaded');
103
+ }
104
+ const unzipped = fflate.unzipSync(bytes);
105
+
106
+ const configBytes = unzipped['config.json'];
107
+ if (!configBytes)
108
+ throw new Error(`Missing config.json in chassis ${upperId}`);
109
+ const config = JSON.parse(new TextDecoder('utf-8').decode(configBytes));
110
+
111
+ const ecuZips = new Map();
112
+ for (const [name, b] of Object.entries(unzipped)) {
113
+ if (name.startsWith('ecu/') && name.endsWith('.ecu')) {
114
+ ecuZips.set(name.slice(4, -4).toLowerCase(), b);
115
+ }
116
+ }
117
+
118
+ const data = { config, ecuZips };
119
+ CHASSIS_CACHE.set(upperId, data);
120
+ return data;
121
+ }
122
+
123
+ /**
124
+ * The sgbd -> chassis owner index, inlined or fetched.
125
+ * @param {RealFetch} realFetch - The unshimmed fetch.
126
+ * @returns {Promise<Record<string, string>|null>}
127
+ */
128
+ async function loadEcuIndex(realFetch) {
129
+ const inline = inlineData();
130
+ if (inline && inline._index) return inline._index;
131
+ return (await realFetch(`${WEB_BASE}/${WEB_API_BASE}/ecu-index.json`))
132
+ .json()
133
+ .catch(() => null);
134
+ }
135
+
136
+ /**
137
+ * Load one ECU's files from its chassis archive (fetching the chassis that
138
+ * owns it when none open has it), parsed as JSON where they are JSON.
139
+ * @param {string} sgbd - The SGBD name (any case).
140
+ * @param {RealFetch} realFetch - The unshimmed fetch.
141
+ * @returns {Promise<Map<string, any>>} filename -> parsed JSON or text.
142
+ * @throws {Error} When no archive holds the SGBD, or fflate is absent.
143
+ */
144
+ async function loadEcu(sgbd, realFetch) {
145
+ const lowerSgbd = sgbd.toLowerCase();
146
+ if (ECU_CACHE.has(lowerSgbd)) return ECU_CACHE.get(lowerSgbd);
147
+
148
+ // Search cached chassis first
149
+ let ecuZipBytes = null;
150
+ for (const chassisData of CHASSIS_CACHE.values()) {
151
+ if (chassisData.ecuZips.has(lowerSgbd)) {
152
+ ecuZipBytes = chassisData.ecuZips.get(lowerSgbd);
153
+ break;
154
+ }
155
+ }
156
+
157
+ // Not in a chassis we have open yet. ECUs ship only inside their chassis
158
+ // archive -- loose copies duplicated all 310 for 47 MB and nothing read
159
+ // them -- so find the car that owns this SGBD and load that. Costs one
160
+ // chassis download, after which every ECU in the same car is already here.
161
+ if (!ecuZipBytes) {
162
+ const idx = await loadEcuIndex(realFetch);
163
+ const cid = idx && idx[lowerSgbd];
164
+ if (cid) {
165
+ const data = await loadChassis(cid, realFetch);
166
+ ecuZipBytes = data.ecuZips.get(lowerSgbd) || null;
167
+ }
168
+ }
169
+
170
+ if (!ecuZipBytes) {
171
+ throw new Error(`ECU archive not found for ${lowerSgbd}`);
172
+ }
173
+
174
+ if (typeof fflate === 'undefined') {
175
+ throw new Error('fflate decompression library not loaded');
176
+ }
177
+ const unzipped = fflate.unzipSync(ecuZipBytes);
178
+ const ecuFiles = new Map();
179
+ const decoder = new TextDecoder('utf-8');
180
+
181
+ for (const [name, bytes] of Object.entries(unzipped)) {
182
+ try {
183
+ const text = decoder.decode(bytes);
184
+ const parsed = JSON.parse(text);
185
+ ecuFiles.set(name, parsed);
186
+ } catch (e) {
187
+ ecuFiles.set(name, decoder.decode(bytes));
188
+ }
189
+ }
190
+
191
+ ECU_CACHE.set(lowerSgbd, ecuFiles);
192
+ return ecuFiles;
193
+ }
194
+
195
+ /**
196
+ * A 200 JSON Response.
197
+ * @param {any} body - Serialised as JSON.
198
+ * @returns {Response}
199
+ */
200
+ function jsonResponse(body) {
201
+ return new Response(JSON.stringify(body), {
202
+ status: 200,
203
+ headers: { 'Content-Type': 'application/json' },
204
+ });
205
+ }
206
+
207
+ /**
208
+ * An error Response with a {error} JSON body.
209
+ * @param {string} msg - The error text.
210
+ * @param {number} [status] - HTTP status (503 by default).
211
+ * @returns {Response}
212
+ */
213
+ function errorResponse(msg, status = 503) {
214
+ return new Response(JSON.stringify({ error: msg }), {
215
+ status,
216
+ headers: { 'Content-Type': 'application/json' },
217
+ });
218
+ }
219
+
220
+ /**
221
+ * The SGBD a data/<kind>/<sgbd>.json path names, lowercased.
222
+ * @param {string} rel - The site-relative path (query allowed).
223
+ * @returns {string}
224
+ */
225
+ function sgbdOfDataPath(rel) {
226
+ return rel
227
+ .split('?')[0]
228
+ .split('/')
229
+ .pop()
230
+ .replace(/\.json$/, '')
231
+ .toLowerCase();
232
+ }
233
+
234
+ /**
235
+ * Build the fetch every genuine file read funnels through. OFFLINE FOLDER
236
+ * (file:// double-click): the shim passes non-/api/ paths straight here, and
237
+ * chassis.json, ecu-index.json and the .chassis/.ecu archives load through
238
+ * it too. So routing THIS one function through the picked directory handle
239
+ * covers all data -- chassis, ECU, groups, coding-dispatch, sgbd-tables,
240
+ * job-code, ISTA -- with no other change. offlineFsActive() is false on
241
+ * http(s) and in the native app, where this is exactly nativeFetch.
242
+ * @param {RealFetch} nativeFetch - The browser's own fetch, bound to window.
243
+ * @returns {RealFetch}
244
+ */
245
+ function offlineAwareFetch(nativeFetch) {
246
+ return async (input, init) => {
247
+ if (
248
+ typeof offlineFsActive === 'function' &&
249
+ offlineFsActive() &&
250
+ typeof offlineFsReady === 'function' &&
251
+ offlineFsReady()
252
+ ) {
253
+ const url =
254
+ typeof input === 'string' ? input : (input && input.url) || '';
255
+ // ONLY same-origin paths belong to the folder. A genuine remote URL (the
256
+ // ETK/faults HF fallback) must still go to the network -- rerouting it to
257
+ // a folder read would 404 a file that was meant to come from the internet.
258
+ // An http(s):// URL to another host is remote; a file:// URL or a bare
259
+ // relative path is ours.
260
+ const isRemote = /^https?:\/\//i.test(url);
261
+ if (!isRemote) {
262
+ let rel = url.replace(/^file:\/\/[^/]*/, '');
263
+ if (WEB_BASE && rel.startsWith(WEB_BASE))
264
+ rel = rel.slice(WEB_BASE.length);
265
+ rel = rel.replace(/^\/+/, '');
266
+ if (rel) return offlineReadFile(rel);
267
+ }
268
+ }
269
+ return nativeFetch(input, init);
270
+ };
271
+ }
272
+
273
+ /**
274
+ * A fetched URL as a site-relative path: origin and WEB_BASE stripped, with
275
+ * a leading slash.
276
+ * @param {RequestInfo|string} input - What fetch was given.
277
+ * @returns {string}
278
+ */
279
+ function siteRelativePath(input) {
280
+ const url = typeof input === 'string' ? input : (input && input.url) || '';
281
+ let rel = url.replace(/^https?:\/\/[^/]+/, '');
282
+ if (WEB_BASE && rel.startsWith(WEB_BASE)) {
283
+ rel = rel.slice(WEB_BASE.length);
284
+ }
285
+ if (!rel.startsWith('/')) rel = '/' + rel;
286
+ return rel;
287
+ }
288
+
289
+ /**
290
+ * Serve one file out of an ECU's archive (the VM bytecode / sgbd-tables
291
+ * interception for data/job-code/* and data/sgbd-tables/*).
292
+ * @param {string} rel - The requested path.
293
+ * @param {RealFetch} real - The unshimmed fetch.
294
+ * @param {string} file - The archive member, e.g. 'job-code.json'.
295
+ * @param {string} what - The label for the 404 ('Job code', 'SGBD tables').
296
+ * @returns {Promise<Response>}
297
+ */
298
+ async function serveEcuMember(rel, real, file, what) {
299
+ const sgbd = sgbdOfDataPath(rel);
300
+ try {
301
+ const ecu = await loadEcu(sgbd, real);
302
+ const body = ecu.get(file);
303
+ if (!body) return errorResponse(`${what} not found for ${sgbd}`, 404);
304
+ return jsonResponse(body);
305
+ } catch (e) {
306
+ return errorResponse(e.message, 404);
307
+ }
308
+ }
309
+
310
+ /**
311
+ * /api/state: the cable's modem lines carry KL15; ask the bus that owns them.
312
+ * @returns {Promise<Response>}
313
+ */
314
+ async function routeState() {
315
+ if (webBus.connected && webBus.readState) {
316
+ try {
317
+ const st = await webBus.readState();
318
+ return jsonResponse({
319
+ battery: st.battery,
320
+ ignition: st.ignition,
321
+ connected: true,
322
+ derived: !!st.derived,
323
+ detail: st.sensed
324
+ ? 'ignition (KL15) read from the cable’s DSR line, as INPA does'
325
+ : st.derived
326
+ ? 'nominal: this cable does not report its KL15 line'
327
+ : null,
328
+ });
329
+ } catch {
330
+ /* adapter went away; report disconnected below */
331
+ }
332
+ }
333
+ return jsonResponse({
334
+ battery: null,
335
+ ignition: null,
336
+ connected: webBus.connected,
337
+ detail: webBus.connected ? null : 'no cable connected',
338
+ });
339
+ }
340
+
341
+ /** a group SGBD's name: D_ + the diagnostic address or a family (D_MOTOR) */
342
+ const GROUP_SGBD_RE = /^d_[a-z0-9_]+$/i;
343
+
344
+ /**
345
+ * /api/ecu/<sgbd>/run/<job>?arg=...: run the job in the VM over the bus.
346
+ *
347
+ * EDIABAS answers every job with a SYNTHETIC result set 0 the runtime
348
+ * itself fills: OBJECT (the loaded SGBD), VARIANTE (its variant name),
349
+ * JOBNAME and SAETZE. It never comes from the wire and no job declares
350
+ * it -- inpainit's variant check reads VARIANTE from set 0 to ask "which
351
+ * ECU file is loaded?", and without it the check compared against '' and
352
+ * stopped every module whose .ipo gates on it. bestvm returns data sets
353
+ * only (its set 0 is the engine's set 1), so carry the system record beside
354
+ * them rather than renumbering every consumer.
355
+ * @param {string} rel - The requested path with its query.
356
+ * @param {string} sgbd - The SGBD from the path.
357
+ * @param {string} jobRaw - The job name from the path, still URL-encoded.
358
+ * @returns {Promise<Response>}
359
+ */
360
+ async function routeRun(rel, sgbd, jobRaw) {
361
+ const q = new URLSearchParams(rel.split('?')[1] || '');
362
+ const arg = q.get('arg');
363
+ const job = decodeURIComponent(jobRaw);
364
+ // OBJECT is what the caller loaded, VARIANTE what answered: for a group
365
+ // SGBD (D_0044) the two differ, exactly as EDIABAS reports them
366
+ const systemSet = (sets, variant) => ({
367
+ OBJECT: sgbd.toLowerCase(),
368
+ VARIANTE: String(variant || sgbd).toUpperCase(),
369
+ JOBNAME: job.toUpperCase(),
370
+ SAETZE: (sets || []).length,
371
+ });
372
+ if (!webBus.connected) return errorResponse('no cable connected', 503);
373
+ try {
374
+ // One SGBD is "loaded" at a time, like the engine: moving to a
375
+ // different ECU ends the previous session (ENDE) before the new
376
+ // one initialises.
377
+ // A group SGBD is how INPA's whole-vehicle scripts address a module:
378
+ // EDIABAS runs the group's IDENTIFIKATION on the wire, loads the variant
379
+ // it names and hands the job to that. Same here (the variant is what
380
+ // gets loaded, so a run of jobs on one module keeps its session), and
381
+ // a silent address is a job error the script reports as such.
382
+ let variant = null;
383
+ if (GROUP_SGBD_RE.test(sgbd)) {
384
+ variant = await webResolveVariant(sgbd);
385
+ if (!variant) {
386
+ apiTrace.add({ sgbd, job, arg, error: 'no module answered' });
387
+ return errorResponse(`${sgbd}: no module answered on the wire`);
388
+ }
389
+ }
390
+ await switchSession(variant || sgbd);
391
+ const r = await webRunJob(variant || sgbd, job, arg);
392
+ apiTrace.add({
393
+ sgbd,
394
+ job,
395
+ arg,
396
+ sets: r.sets,
397
+ status: (r.sets[0] && r.sets[0].JOB_STATUS) || '',
398
+ });
399
+ return jsonResponse({
400
+ job: jobRaw,
401
+ sets: r.sets,
402
+ system: systemSet(r.sets, variant),
403
+ });
404
+ } catch (e) {
405
+ apiTrace.add({ sgbd, job, arg, error: e.message });
406
+ // A WIRE error (IFH-*) that reaches the user is where the telegram
407
+ // trace is worth seeing -- auto-dump the recent ring buffer so the
408
+ // failing exchange is on the console with no busTrace.start() needed.
409
+ if (e && e.ifh) busTrace.dumpRecent(`${e.ifh} on ${sgbd}/${jobRaw}`);
410
+ return errorResponse(e.message);
411
+ }
412
+ }
413
+
414
+ /**
415
+ * The per-ECU static kinds served straight from the archive.
416
+ * 'ipoexec' is the runnable execution-derived twin ({procs,byid}) the live
417
+ * .IPO interpreter (ipovm.js) executes, shipped beside ir.json. An ECU
418
+ * without one (an orphan, or a pre-phase-1 archive) 404s and the renderer
419
+ * falls back to the frozen IR -- so it is optional, not fatal.
420
+ */
421
+ const ECU_FILE_KINDS = new Set([
422
+ 'jobs',
423
+ 'ir',
424
+ 'tables',
425
+ 'ipoexec',
426
+ // the decoded screens, whose gauges carry the min/max/okMin/okMax band the
427
+ // script's author declared per result key. The garage reads those bands to
428
+ // say whether a freeze-frame value is outside what the script calls normal.
429
+ 'screens',
430
+ ]);
431
+ /** The per-ECU kinds that take a sub-name: results/<JOB>, arguments/<JOB>, table/<NAME>. */
432
+ const ECU_SUB_KINDS = new Set(['results', 'arguments', 'table']);
433
+
434
+ /**
435
+ * /api/ecu/<sgbd>/<kind>[/<name>]: one file from the ECU's archive.
436
+ * @param {string} sgbd - The SGBD (lowercased).
437
+ * @param {string[]} m - The path segments after /api/.
438
+ * @param {RealFetch} real - The unshimmed fetch.
439
+ * @returns {Promise<Response|null>} null when the kind is unknown.
440
+ */
441
+ async function routeEcuFile(sgbd, m, real) {
442
+ const kind = m[2];
443
+ try {
444
+ const ecu = await loadEcu(sgbd, real);
445
+ // the variant's own config record (label, section, group): the sweep
446
+ // names an identified variant by it when the menu lists no such row
447
+ if (kind === 'ecu') {
448
+ const info = ecu.get('ecu.json');
449
+ return info
450
+ ? jsonResponse(info)
451
+ : errorResponse(`No record for ${sgbd}`, 404);
452
+ }
453
+ if (ECU_FILE_KINDS.has(kind)) {
454
+ const res = ecu.get(`${kind}.json`);
455
+ if (!res) {
456
+ if (kind === 'jobs') return jsonResponse([]);
457
+ return errorResponse(`${kind} not found for ${sgbd}`, 404);
458
+ }
459
+ return jsonResponse(res);
460
+ }
461
+ if (ECU_SUB_KINDS.has(kind) && m[3]) {
462
+ const subName = decodeURIComponent(m[3]).toUpperCase();
463
+ const res = ecu.get(`${kind}/${subName}.json`);
464
+ if (!res)
465
+ return errorResponse(`${kind}/${subName} not found for ${sgbd}`, 404);
466
+ return jsonResponse(res);
467
+ }
468
+ } catch (e) {
469
+ // loadEcu THREW -- the archive is missing or failed to load, which
470
+ // is not the same as a healthy archive with no jobs.json. Answering
471
+ // ok([]) here made a broken export indistinguishable from an ECU
472
+ // that genuinely has no jobs.
473
+ return errorResponse(e.message, 404);
474
+ }
475
+ return null;
476
+ }
477
+
478
+ /**
479
+ * Everything under /api/ that is a static file route, served from the zip
480
+ * archives.
481
+ *
482
+ * SPLIT THE PATH, NOT THE QUERY. ecu.js asks for "/api/ecu/msv80/ir?code=
483
+ * MSV80" so the server can match a layout by INPA code, and splitting the
484
+ * whole string leaves the last segment as "ir?code=MSV80", which matches
485
+ * no kind. Every ECU then fell through to "no screen definition" while its
486
+ * archive sat there holding 161 screens.
487
+ * @param {string} rel - The requested path.
488
+ * @param {RealFetch} real - The unshimmed fetch.
489
+ * @param {RequestInit|undefined} init - The caller's fetch options.
490
+ * @returns {Promise<Response>}
491
+ */
492
+ async function routeStatic(rel, real, init) {
493
+ const m = rel
494
+ .split('?')[0]
495
+ .replace(/^\/api\//, '')
496
+ .split('/')
497
+ .filter(Boolean);
498
+ if (!m.length) return errorResponse('not found', 404);
499
+
500
+ if (m[0] === 'chassis') {
501
+ if (m.length === 1) {
502
+ // The LIST, not the directory. Passing the bare /api/chassis through
503
+ // asks the host for a path that is now a directory of .chassis
504
+ // archives, and a static server answers with an index page -- 200,
505
+ // text/html, and the renderer parses it as the chassis list. Name the
506
+ // file explicitly.
507
+ const inline = inlineData();
508
+ if (inline) {
509
+ return jsonResponse(Object.keys(inline).filter((k) => k !== '_index'));
510
+ }
511
+ return real(`${WEB_BASE}/${WEB_API_BASE}/chassis.json`, init);
512
+ }
513
+ const cid = m[1];
514
+ try {
515
+ const data = await loadChassis(cid, real);
516
+ return jsonResponse(data.config);
517
+ } catch (e) {
518
+ return errorResponse(e.message, 404);
519
+ }
520
+ }
521
+
522
+ // THE OWNER INDEX IS A STATIC FILE, NOT A ROUTE. Every /api/* path that
523
+ // matches nothing below falls to the catch-all error at the end, which
524
+ // answers a 404 whose BODY is {"error": ...} -- one key. loadEcu reads
525
+ // that as the index, finds no owner for the SGBD, and every job on a
526
+ // variant the page has not already cached fails with "archive not found".
527
+ //
528
+ // On a real E46 that meant the climate unit identified as ihka46_3 and
529
+ // then reported a CLEAN FAULT MEMORY, on a module holding two present
530
+ // faults. Serve the file.
531
+ if (m[0] === 'ecu-index.json') {
532
+ const inline = inlineData();
533
+ if (inline && inline._index) return jsonResponse(inline._index);
534
+ return real(`${WEB_BASE}/${WEB_API_BASE}/ecu-index.json`, init);
535
+ }
536
+
537
+ if (m[0] === 'ecu' && m.length >= 3) {
538
+ const served = await routeEcuFile(m[1].toLowerCase(), m, real);
539
+ if (served) return served;
540
+ }
541
+
542
+ return errorResponse(`no static route for ${rel}`, 404);
543
+ }
544
+
545
+ /**
546
+ * Install over window.fetch so core.js's api() needs no change at all.
547
+ * Also publishes window.webRealFetch: anything that needs the FILE rather
548
+ * than the shim's answer (the offline exporter zips the archives
549
+ * themselves) asks for this.
550
+ */
551
+ function installWebShim() {
552
+ const nativeFetch = window.fetch.bind(window);
553
+ const real = offlineAwareFetch(nativeFetch);
554
+ window.webRealFetch = real;
555
+ window.fetch = async (input, init) => {
556
+ const rel = siteRelativePath(input);
557
+
558
+ // --- VM bytecode / sgbd-tables files interception (from cached ECUs)
559
+ if (
560
+ rel.startsWith('/data/job-code/') &&
561
+ rel !== '/data/job-code/index.json'
562
+ ) {
563
+ return serveEcuMember(rel, real, 'job-code.json', 'Job code');
564
+ }
565
+ if (rel.startsWith('/data/sgbd-tables/')) {
566
+ return serveEcuMember(rel, real, 'sgbd-tables.json', 'SGBD tables');
567
+ }
568
+
569
+ // Only route to API if prefix matches /api/
570
+ if (!rel.startsWith('/api/')) return real(input, init);
571
+
572
+ // --- endpoints the server computed, answered locally
573
+ if (/^\/api\/health/.test(rel))
574
+ return jsonResponse({ ok: true, web: true });
575
+ if (/^\/api\/port/.test(rel)) {
576
+ return jsonResponse({
577
+ port: webBus.connected ? webBus.portLabel() : null,
578
+ });
579
+ }
580
+ if (/^\/api\/state/.test(rel)) return routeState();
581
+
582
+ // --- job execution. The web build used to refuse the clear/write/flash
583
+ // jobs outright; lifted at the owner's request so actuator tests work
584
+ // (see the note on Best2Vm.allowWrites). They run through the VM's own
585
+ // gate, which is now permissive by default rather than absent.
586
+ const run = /^\/api\/ecu\/([^/]+)\/run\/([^/?]+)/.exec(rel);
587
+ if (run) return routeRun(rel, run[1], run[2]);
588
+
589
+ // --- everything else is a static file route (served from the zip archives)
590
+ return routeStatic(rel, real, init);
591
+ };
592
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @file The one bus instance this host drives, and the lock that serialises
3
+ * everything that touches it.
4
+ */
5
+ /* exported webBus, withBusLock, webBusRawExchange, lockBus */
6
+
7
+ /**
8
+ * Which transport this host can do: the native serial bridge when the macOS
9
+ * shell injects one, else Web Serial. Both drive the same K+DCAN cable.
10
+ * @type {NativeSerialBus|WebSerialBus}
11
+ */
12
+ const webBus =
13
+ typeof window !== 'undefined' && window.bmacw && window.bmacw.serialOpen
14
+ ? new NativeSerialBus()
15
+ : new WebSerialBus();
16
+
17
+ /**
18
+ * The tail of the bus queue: every locked call chains onto it.
19
+ * @type {Promise<void>}
20
+ */
21
+ let busChain = Promise.resolve();
22
+
23
+ /**
24
+ * ONE EXCHANGE AT A TIME, BUS-WIDE. The K-line is half duplex: two
25
+ * concurrent callers interleave writes and steal each other's answers -- the
26
+ * 3-second topbar state poll was clobbering any job that took longer than a
27
+ * second. The old C# engine held a bus lock server-side; the VM migration
28
+ * lost it. Every entry point that can touch the wire queues here.
29
+ * @template T
30
+ * @param {() => Promise<T>|T} fn - The wire operation to run once the bus is free.
31
+ * @returns {Promise<T>} Its result, settled when it has finished.
32
+ */
33
+ function withBusLock(fn) {
34
+ const run = busChain.then(fn, fn);
35
+ busChain = run.then(
36
+ () => {},
37
+ () => {}
38
+ );
39
+ return run;
40
+ }
41
+
42
+ /**
43
+ * The bus's exchange BEFORE the lock wrapped it -- set by lockBus.
44
+ * @type {((out: ArrayLike<number>, comm: CommParams) => Promise<number[]>)|null}
45
+ */
46
+ let busRawExchange = null;
47
+
48
+ /**
49
+ * The raw exchange, unwrapped from the bus lock, for a caller that ALREADY
50
+ * holds the lock and must not queue behind itself: webWriteCoding runs a
51
+ * whole sequence inside one lock, and going through the locked
52
+ * webBus.exchange again would deadlock (the sequence's next exchange waits on
53
+ * a chain the sequence itself is blocking).
54
+ * @param {ArrayLike<number>} out - The request without its checksum.
55
+ * @param {CommParams} comm - Its wire parameters.
56
+ * @returns {Promise<number[]>} The answer frame.
57
+ * @throws {Error} When lockBus has not run yet.
58
+ */
59
+ function webBusRawExchange(out, comm) {
60
+ if (!busRawExchange) throw new Error('bus not initialised');
61
+ return busRawExchange(out, comm);
62
+ }
63
+
64
+ /**
65
+ * Wrap the bus's wire entry points (connect, exchange, readState,
66
+ * disconnect) in the bus lock, and run `onDisconnect` before the wire drops.
67
+ *
68
+ * A session must not outlive the cable: dropping the bus without clearing it
69
+ * would leave the next connection thinking INITIALISIERUNG had already run,
70
+ * and reuse shared data from a car that may not even be the same one. ENDE
71
+ * is skipped deliberately -- the wire is already going away.
72
+ * @param {NativeSerialBus|WebSerialBus} bus - The transport to wrap in place.
73
+ * @param {() => void} onDisconnect - Forgets everything known about the car.
74
+ */
75
+ function lockBus(bus, onDisconnect) {
76
+ const raw = {
77
+ disconnect: bus.disconnect.bind(bus),
78
+ exchange: bus.exchange.bind(bus),
79
+ connect: bus.connect.bind(bus),
80
+ readState: bus.readState ? bus.readState.bind(bus) : null,
81
+ };
82
+ bus.disconnect = async (...a) => {
83
+ onDisconnect();
84
+ return withBusLock(() => raw.disconnect(...a));
85
+ };
86
+ bus.exchange = (...a) => withBusLock(() => raw.exchange(...a));
87
+ // The unlocked exchange, for a caller that ALREADY holds the bus lock and
88
+ // must not queue behind itself. Kept so coding-write reaches the same raw
89
+ // wire reads do.
90
+ busRawExchange = (...a) => raw.exchange(...a);
91
+ bus.connect = (...a) => withBusLock(() => raw.connect(...a));
92
+ if (raw.readState) {
93
+ bus.readState = (...a) => withBusLock(() => raw.readState(...a));
94
+ }
95
+ }