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,1337 @@
1
+ /**
2
+ * @file IpoProgram: the driver that runs a module's script the way INPA
3
+ * does. Entry (startup + inpainit, following scriptchange), the root menu
4
+ * the script sets, key presses in the same persistent VM, screen cycles with
5
+ * their own jobs, the script's own Back as the release, inpaexit on leave.
6
+ * Every suspension the VM raises (a wire job, a wait, a prompt, a message, a
7
+ * picker, a %STATE park, an exit) is answered here through the UI adapter.
8
+ */
9
+
10
+ /** A frequent screen's cycle period, and a parked machine's tick. */
11
+ const IPO_TICK_MS = 600;
12
+
13
+ /** scriptchange chain bound: how many hand-offs runEntry follows. */
14
+ const IPO_MAX_HOPS = 4;
15
+
16
+ /** Suspensions per drive, a runaway guard. */
17
+ const IPO_MAX_STEPS = 4000;
18
+
19
+ /** The VM's step budget per run in the live runtime. */
20
+ const IPO_VM_BUDGET = 800000;
21
+
22
+ /** How many suspensions the startup proc may raise while its defaults run. */
23
+ const IPO_STARTUP_STEPS = 200;
24
+
25
+ /** The longest a scripted wait (wartezeit) is honoured, in ms. */
26
+ const IPO_WAIT_MAX_MS = 30000;
27
+
28
+ /** How many sent jobs the program's log keeps. */
29
+ const IPO_LOG_MAX = 200;
30
+
31
+ /** How many job names an action record carries for the remote owner. */
32
+ const IPO_ACTION_JOBS_MAX = 30;
33
+
34
+ /** INPA's Back key: F10 when the menu declares one. */
35
+ const IPO_BACK_KEY = 10;
36
+
37
+ /** A job the wire could not run at all. */
38
+ const IPO_STATUS_NO_ANSWER = 'ERROR_NO_ANSWER';
39
+
40
+ /**
41
+ * How a remote owner's refusal reads in the script's own JOB_STATUS box, so
42
+ * the reason shows in plain words rather than as a wire timeout it was not.
43
+ * @type {Array<[RegExp, string]>}
44
+ */
45
+ const IPO_REMOTE_STATUS = [
46
+ [/remote: .*declined/i, 'The host rejected your request'],
47
+ [/remote: .*read-only/i, 'The host shared this car read-only'],
48
+ [/remote: .*not admitted/i, 'The host has not admitted you yet'],
49
+ ];
50
+
51
+ /** A job that energises something (STEUERN_ or START prefixed)... */
52
+ const IPO_ENERGISE_RE = /^(STEUERN|START)/i;
53
+ /** ...unless it is the release half of the pair (an _AUS, _ENDE, _OFF, _STOP). */
54
+ const IPO_RELEASE_SUFFIX_RE = /(_AUS|_ENDE|_OFF|_STOP)$/i;
55
+
56
+ /** The job that identifies the module; its answer names the variant. */
57
+ const IPO_INIT_JOB_RE = /^INITIALISIERUNG$/i;
58
+
59
+ /** The wire errors that mean the module did not answer at all. */
60
+ const IPO_SILENT_RE = /IFH-0009|IFH-0019|no answer/i;
61
+
62
+ /**
63
+ * The context a run is driven in: what it is, for confirm dialogs and the
64
+ * confirm cache, and whether the user already confirmed it.
65
+ * @typedef {object} IpoRunContext
66
+ * @property {string} label - what is running (a key caption, a proc name)
67
+ * @property {string} scope - entry | exit | menu:<name> | screen:<name> |
68
+ * key:<menu>:<nr> | machine:<name>
69
+ * @property {boolean} [preConfirmed] - the key's confirm already covered its writes
70
+ */
71
+
72
+ /**
73
+ * What drive() reports when a run settles.
74
+ * @typedef {object} IpoDriveResult
75
+ * @property {boolean} done - the run finished
76
+ * @property {boolean} [exit] - the script ended itself
77
+ * @property {boolean} [cancelled] - the user declined a prompt, or the view closed
78
+ * @property {boolean} [noCable] - the entry run stopped because no adapter is connected
79
+ */
80
+
81
+ /**
82
+ * The USER ACTION a job belongs to: the key press (or the machine it
83
+ * started, or the screen it set) that caused it. Sent with every job so a
84
+ * remote owner approves the ACTION once, not each job it sends.
85
+ * @typedef {object} IpoAction
86
+ * @property {string} id - unique per press
87
+ * @property {string} label - the key's caption
88
+ * @property {string[]} jobs - the job names the body can send
89
+ */
90
+
91
+ /**
92
+ * The UI adapter the program drives (see ipoMakeUi for the app's).
93
+ * @typedef {object} IpoUi
94
+ * @property {(ms: number) => Promise<void>} sleep - a scripted wait
95
+ * @property {(sgbd: string) => Promise<object|null>} loadExec - another script's exec (scriptchange)
96
+ * @property {(p: IpoProgram) => void} route - reflect the menu in the URL
97
+ * @property {(p: IpoProgram, text: string) => void} status - the status line
98
+ * @property {(p: IpoProgram, text: string) => void} error - an error on the status line
99
+ * @property {(title: string, body: string|null) => Promise<void>} message - a blocking message box
100
+ * @property {(step: IpoStep, label: string|undefined) => Promise<*>} askInput - an INPA prompt
101
+ * @property {(p: IpoProgram, it: IpoMenuItem, jobs: string[], writes: string[]) => Promise<boolean>} confirmKey - confirm a key whose body can write
102
+ * @property {(p: IpoProgram, job: string, arg: string|null, ctx: IpoRunContext) => Promise<boolean>} confirmWrite - confirm one write
103
+ * @property {(p: IpoProgram, step: IpoStep) => Promise<IpoPick|null>} pickComponent - the togglelist picker
104
+ * @property {(p: IpoProgram, names: string[], multiple: boolean, current: Set<string>|null, hints?: Array<{key: string, label: string, lines: number}>) => Promise<string[]|null>} pickLines - INPA's Select
105
+ * @property {(p: IpoProgram) => void} printScreen - INPA's printscreen
106
+ * @property {(ecu: EcuRecord, script: string, exec: IpoExec) => Promise<EcuRecord|null>} [resolveScriptEcu] -
107
+ * the module a scriptchange target addresses, identified by the car
108
+ * @property {(p: IpoProgram, step: IpoStep, guards: Set<number>) => Promise<'tick'|'press'|'stop'>} machineTick - a %STATE park
109
+ * @property {(p: IpoProgram) => void} renderKeys - the F-key bar
110
+ * @property {(p: IpoProgram) => void} paint - the screen
111
+ * @property {(p: IpoProgram) => void} left - the module was left
112
+ */
113
+
114
+ /** One module's script, running. */
115
+ class IpoProgram {
116
+ /**
117
+ * @param {object} ecu - the module (sgbd, label, chassis, group, _variant, ...)
118
+ * @param {object} exec - the decoded script ({procs, byid})
119
+ * @param {IpoUi} ui - the UI adapter
120
+ */
121
+ constructor(ecu, exec, ui) {
122
+ this.ecu = ecu;
123
+ this.exec = exec;
124
+ this.ui = ui;
125
+ /** @type {IpoVm|null} */
126
+ this.vm = null;
127
+ /** @type {string|null} the current menu proc */
128
+ this.menu = null;
129
+ /** @type {IpoMenuItem[]} */
130
+ this.items = [];
131
+ /** @type {string|null} the current screen proc */
132
+ this.screen = null;
133
+ this.frequent = false;
134
+ /** @type {string|null} */
135
+ this.title = null;
136
+ /** @type {Map<string, IpoCell>} "row:col" -> cell */
137
+ this.cells = new Map();
138
+ /** @type {IpoLine[]} last painted lines (modern mode) */
139
+ this.lines = [];
140
+ this.cycleTimer = null;
141
+ this.busy = false; // a key body or a cycle is on the wire
142
+ /** @type {Map<number, number>} absolute row a logical line begins at -> its height */
143
+ this.bandTops = new Map();
144
+ /** @type {Set<string>|null} Select's choice of logical lines, null = all */
145
+ this.lineFilter = null;
146
+ /** @type {IpoAction|null} */
147
+ this.action = null;
148
+ this.actionSeq = 0;
149
+ this.cycleToken = 0; // bumps on every (re)schedule so a stale tick is dropped
150
+ this.filterChanged = false;
151
+ this.running = false; // a key press, from body to settled menu/screen
152
+ /** @type {number|'back'|null} the key pressed meanwhile */
153
+ this.queued = null;
154
+ this.gen = 0; // bumped on every navigation; stale cycles stop
155
+ this.closed = false;
156
+ this.leaving = false; // leaveModule is running inpaexit
157
+ /** @type {Set<string>} "scope:job" confirmed in this menu */
158
+ this.confirmedWrites = new Set();
159
+ /** @type {Array<{target: string, job: string, arg: string|null, status: string}>} sent jobs, newest last */
160
+ this.log = [];
161
+ /** @type {IpoWireRead[]} the current body's answers (protocol.js) */
162
+ this.wireReads = [];
163
+ /**
164
+ * Files the save-as dialog named this body, by name: the picker's
165
+ * handle (or null for a download) until the body's end writes them.
166
+ * @type {Map<string, {name: string, handle?: object}>}
167
+ */
168
+ this.pendingSaves = new Map();
169
+ /** @type {IpoMessage[]} messageboxes shown, in order */
170
+ this.messages = [];
171
+ this.hops = 0;
172
+ /** @type {string|null} the script scriptchange handed control to */
173
+ this.script = null;
174
+ /** @type {string|null} the menu the entry set (the route's root) */
175
+ this.rootMenu = null;
176
+ this.answered = false; // INITIALISIERUNG returned any result
177
+ this.silent = false; // INITIALISIERUNG got no answer at all
178
+ this.noCable = false; // no adapter: the script cannot ask the car
179
+ }
180
+
181
+ // ---- VM -----------------------------------------------------------------
182
+
183
+ /**
184
+ * A fresh VM for a script, its compiled-in defaults already run. Startup
185
+ * touches no wire, but a stray job must not park the machine.
186
+ * @param {object} exec - the decoded script
187
+ * @returns {IpoVm}
188
+ */
189
+ newVm(exec) {
190
+ const vm = new IpoVm(exec, {
191
+ budget: IPO_VM_BUDGET,
192
+ wireJobs: true,
193
+ host: new FeedHost(),
194
+ });
195
+ // INPA's progress window (userboxopen / userboxftextout): shown as the
196
+ // script fills it, so a 38-module read says which module it is on
197
+ vm.onUserbox = (box) => {
198
+ if (typeof this.ui.userbox === 'function') this.ui.userbox(this, box);
199
+ };
200
+ if (exec.procs.__inpa_startup__) {
201
+ try {
202
+ let st = vm.stepStart('__inpa_startup__');
203
+ for (
204
+ let n = 0;
205
+ n < IPO_STARTUP_STEPS && st && st.kind !== 'done';
206
+ n++
207
+ ) {
208
+ st = vm.resume(new Map());
209
+ }
210
+ } catch (e) {
211
+ /* defaults only */
212
+ }
213
+ }
214
+ vm.out = new vm.out.constructor();
215
+ return vm;
216
+ }
217
+
218
+ /**
219
+ * Start a run with empty emissions; state (globals, timers) persists.
220
+ * @returns {Emissions}
221
+ */
222
+ fresh() {
223
+ this.vm.out = new this.vm.out.constructor();
224
+ return this.vm.out;
225
+ }
226
+
227
+ // ---- the suspension loop --------------------------------------------------
228
+
229
+ /**
230
+ * Drive a started run to completion, honouring every suspension: a wire
231
+ * job is sent and its results fed back, a wait sleeps, a prompt asks, a
232
+ * message blocks, a picker picks, a state machine ticks, an exit ends the
233
+ * module.
234
+ * @param {IpoStep} step - the first pending action
235
+ * @param {IpoRunContext} ctx - what is running
236
+ * @returns {Promise<IpoDriveResult>}
237
+ * @throws {Error} when the run raises more suspensions than IPO_MAX_STEPS
238
+ */
239
+ async drive(step, ctx) {
240
+ const vm = this.vm;
241
+ let n = 0;
242
+ while (step && step.kind !== 'done') {
243
+ if (++n > IPO_MAX_STEPS) throw new Error('script did not settle');
244
+ if (this.closed) return { done: false, cancelled: true };
245
+ if (this.cancelRequested) {
246
+ this.cancelRequested = false;
247
+ return { done: false, cancelled: true };
248
+ }
249
+ if (step.kind === 'job') {
250
+ const fed = await this.runJob(step.sgbd, step.job, step.arg, ctx);
251
+ if (fed == null) return { done: false, cancelled: true };
252
+ // no adapter while identifying the module: stop here rather than let
253
+ // the script raise its own "Program will be stopped" box over a car
254
+ // it never reached -- the opener shows the no-cable notice instead
255
+ if (this.noCable && ctx && ctx.scope === 'entry')
256
+ return { done: false, cancelled: true, noCable: true };
257
+ step = vm.resume(fed);
258
+ } else if (step.kind === 'wait') {
259
+ await this.ui.sleep(Math.min(Number(step.ms) || 0, IPO_WAIT_MAX_MS));
260
+ step = vm.resume();
261
+ } else if (step.kind === 'input') {
262
+ const got = await this.ui.askInput(step, ctx && ctx.label);
263
+ if (got == null) return { done: false, cancelled: true };
264
+ step = vm.resume(got);
265
+ } else if (step.kind === 'file') {
266
+ // INPA's save-as dialog: the platform's picker names the file; the
267
+ // script writes it in the VM and the body's end hands it over
268
+ const picked =
269
+ typeof this.ui.saveFile === 'function'
270
+ ? await this.ui.saveFile(this, step)
271
+ : null;
272
+ if (picked && picked.name) this.pendingSaves.set(picked.name, picked);
273
+ step = vm.resume(picked ? picked.name : '');
274
+ } else if (step.kind === 'message') {
275
+ this.reflect(vm.out);
276
+ this.messages.push({ title: step.title, body: step.body });
277
+ await this.ui.message(step.title, step.body);
278
+ step = vm.resume();
279
+ } else if (step.kind === 'pick') {
280
+ // BMWeb's own picker (the home script): the host lists chassis,
281
+ // modules or the whole-vehicle script; a cancel leaves the body
282
+ this.reflect(vm.out);
283
+ const choice =
284
+ typeof this.ui.pickHome === 'function'
285
+ ? await this.ui.pickHome(this, step)
286
+ : null;
287
+ if (choice == null) return { done: false, cancelled: true };
288
+ // the chassis chosen is the car every later screen belongs to
289
+ if (step.what === 'chassis' && this.ecu) this.ecu.chassis = choice;
290
+ step = vm.resume(String(choice));
291
+ } else if (step.kind === 'toggle') {
292
+ // the picker lists the screen the machine just set (its prologue's
293
+ // setscreen names the component list)
294
+ this.reflect(vm.out);
295
+ const pick = await this.ui.pickComponent(this, step);
296
+ if (pick == null) return { done: false, cancelled: true };
297
+ step = vm.resume(pick);
298
+ } else if (step.kind === 'print') {
299
+ this.ui.printScreen(this);
300
+ step = vm.resume();
301
+ } else if (step.kind === 'select') {
302
+ // INPA's Select: which of the screen's named logical lines to show.
303
+ // A screen with none still opens the box (INPA shows an empty
304
+ // list), so the key visibly does something and says why.
305
+ const names = ipoScreenLineNames(this.exec, this.screen);
306
+ // ...and, when there is nothing here, which keys lead to a screen
307
+ // that has lines to choose from
308
+ const hints = names.length
309
+ ? []
310
+ : ipoSelectableKeys(this.exec, this.menu, this.items).map((h) => {
311
+ const it = this.items.find((x) => x.nr === h.nr);
312
+ return {
313
+ key: h.shift ? `Shift+F${h.nr - IPO_SHIFT_BASE}` : `F${h.nr}`,
314
+ label: it ? ipoKeyLabel(this, it) : h.screen,
315
+ lines: h.lines,
316
+ };
317
+ });
318
+ const pick = await this.ui.pickLines(
319
+ this,
320
+ names,
321
+ step.multiple,
322
+ this.lineFilter,
323
+ hints
324
+ );
325
+ if (pick != null) this.setLineFilter(pick.length ? pick : null);
326
+ step = vm.resume();
327
+ } else if (step.kind === 'exit') {
328
+ return { done: true, exit: true };
329
+ } else if (step.kind === 'yield') {
330
+ this.reflect(vm.out);
331
+ // a %STATE park: tick the machine, offering its own Continue key
332
+ const guards = vm.pendingGuards();
333
+ const go = await this.ui.machineTick(this, step, guards);
334
+ if (go === 'stop') return { done: false, cancelled: true };
335
+ if (go === 'press') guards.forEach((g) => vm.pressKey(g));
336
+ step = vm.resume();
337
+ } else {
338
+ break;
339
+ }
340
+ }
341
+ return { done: true, exit: !!(vm.out && vm.out.exit) };
342
+ }
343
+
344
+ /**
345
+ * Select's choice: the names of the logical lines to show, or null for
346
+ * all. The current screen is repainted through it; a screen change keeps
347
+ * it (INPA keeps the selection until Deselect).
348
+ * @param {string[]|null} names - the lines to keep
349
+ * @returns {void}
350
+ */
351
+ setLineFilter(names) {
352
+ this.lineFilter = names ? new Set(names) : null;
353
+ this.cells = new Map();
354
+ this.lines = [];
355
+ this.filterChanged = true;
356
+ }
357
+
358
+ /**
359
+ * A run that is still parked (a machine at its picker or a %STATE) may
360
+ * already have set a title and a screen; show them without cycling the
361
+ * screen (a cycle would clobber the suspension).
362
+ * @param {Emissions} out - the run's emissions so far
363
+ * @returns {void}
364
+ */
365
+ reflect(out) {
366
+ if (!out) return;
367
+ if (out.title) this.title = out.title;
368
+ if (
369
+ out.screen &&
370
+ out.screen !== this.screen &&
371
+ this.exec.procs[out.screen]
372
+ ) {
373
+ this.screen = out.screen;
374
+ this.frequent = !!out.screenFrequent;
375
+ this.cells = new Map();
376
+ this.lines = [];
377
+ }
378
+ this.takeCells(out);
379
+ this.ui.paint(this);
380
+ }
381
+
382
+ /**
383
+ * The JOB_STATUS a failed send reads as: a remote owner's refusal in plain
384
+ * words, else ERROR_NO_ANSWER.
385
+ * @param {string} message - the error's message
386
+ * @returns {string}
387
+ */
388
+ static failStatus(message) {
389
+ for (const [re, text] of IPO_REMOTE_STATUS)
390
+ if (re.test(message)) return text;
391
+ return IPO_STATUS_NO_ANSWER;
392
+ }
393
+
394
+ /**
395
+ * One job on the wire. Confirms a write the first time it appears in this
396
+ * context, feeds the answer (plus the engine's system set, where inpainit
397
+ * reads VARIANTE) back to the VM.
398
+ *
399
+ * Only what the USER activates asks. inpainit is the script identifying
400
+ * the module -- INPA sends its jobs without a word, and a default-deny
401
+ * classifier reading LWR_VORHANDEN ("is headlight levelling fitted?") as
402
+ * a write turned opening the LSZ into a dialog.
403
+ * @param {string|null} sgbd - the SGBD the script named
404
+ * @param {string} job - job name
405
+ * @param {string|null} arg - job argument
406
+ * @param {IpoRunContext} ctx - what is running
407
+ * @returns {Promise<IpoFeed|null>} the feed, or null when the user declined
408
+ */
409
+ async runJob(sgbd, job, arg, ctx) {
410
+ const target = ipoWireTarget(this.ecu, sgbd);
411
+ const entry = !!(ctx && (ctx.scope === 'entry' || ctx.scope === 'exit'));
412
+ const write = !entry && ipoNeedsConfirm(job);
413
+ const ckey = `${ctx && ctx.scope ? ctx.scope : '*'}:${job}`;
414
+ if (
415
+ write &&
416
+ ipoConfirmWanted(job) &&
417
+ !this.confirmedWrites.has(ckey) &&
418
+ !(ctx && ctx.preConfirmed)
419
+ ) {
420
+ const ok = await this.ui.confirmWrite(this, job, arg, ctx);
421
+ if (!ok) return null;
422
+ this.confirmedWrites.add(ckey);
423
+ }
424
+ if (
425
+ write &&
426
+ IPO_ENERGISE_RE.test(job) &&
427
+ !IPO_RELEASE_SUFFIX_RE.test(job) &&
428
+ typeof markEnergized === 'function'
429
+ ) {
430
+ markEnergized();
431
+ }
432
+ /** @type {IpoFeed} */
433
+ const fed = new Map();
434
+ let status;
435
+ try {
436
+ const q =
437
+ arg != null && arg !== '' ? `?arg=${encodeURIComponent(arg)}` : '';
438
+ const d = await api(`/api/ecu/${target}/run/${job}${q}`, {
439
+ method: 'POST',
440
+ // the action this job serves; a remote owner gates on it
441
+ action: this.action || undefined,
442
+ });
443
+ for (const [k, v] of Object.entries(d.system || {}))
444
+ fed.set(k, String(v));
445
+ // the *_TEXT results in English where the language setting asks and a
446
+ // dictionary carries them (see ipoTranslateSet): the script formats
447
+ // these itself, so the VM must see the English
448
+ const sets = ipoTranslating()
449
+ ? dataSets(d.sets).map((s) => ipoTranslateSet(s, target))
450
+ : dataSets(d.sets);
451
+ for (const set of sets) {
452
+ for (const [k, v] of Object.entries(set)) {
453
+ if (!k.startsWith('_')) fed.set(k, v);
454
+ }
455
+ }
456
+ // numbered as EDIABAS numbers them: 0 = system record, 1..n = the
457
+ // job's sets, so INPAapiResultInt(->x, "F_ORT_NR", i) reads fault i
458
+ fed.sets = [d.system || {}, ...sets];
459
+ // what the body put on the wire, kept so a protocol it writes can be
460
+ // shown as data too (protocol.js)
461
+ this.wireReads.push({
462
+ target,
463
+ variant: d.system && d.system.VARIANTE ? String(d.system.VARIANTE) : '',
464
+ job,
465
+ arg: arg == null ? null : String(arg),
466
+ sets,
467
+ });
468
+ if (!fed.has('JOB_STATUS')) fed.set('JOB_STATUS', 'OKAY');
469
+ status = String(fed.get('JOB_STATUS'));
470
+ // the group probe's variant outranks the engine's synthetic one
471
+ if (IPO_INIT_JOB_RE.test(job) && this.ecu._variant) {
472
+ fed.set('VARIANTE', String(this.ecu._variant));
473
+ }
474
+ if (IPO_INIT_JOB_RE.test(job)) {
475
+ this.answered =
476
+ this.answered ||
477
+ dataSets(d.sets).some((s) =>
478
+ Object.keys(s).some((k) => !k.startsWith('_'))
479
+ );
480
+ }
481
+ } catch (e) {
482
+ const m = String((e && e.message) || '');
483
+ status = IpoProgram.failStatus(m);
484
+ fed.set('JOB_STATUS', status);
485
+ fed.sets = [{}]; // no sets came back
486
+ this.wireReads.push({
487
+ target,
488
+ job,
489
+ arg: arg == null ? null : String(arg),
490
+ error: status,
491
+ });
492
+ // no adapter at all: the script cannot ask the car anything, and the
493
+ // module view says so instead of running inpainit's error branch
494
+ if (/no cable/i.test(m)) this.noCable = true;
495
+ if (IPO_INIT_JOB_RE.test(job) && IPO_SILENT_RE.test(e.message || ''))
496
+ this.silent = true;
497
+ }
498
+ this.log.push({ target, job, arg: arg || null, status });
499
+ if (this.log.length > IPO_LOG_MAX) this.log.shift();
500
+ const where =
501
+ target !== String(this.ecu.sgbd || '').toLowerCase()
502
+ ? `${target} > `
503
+ : '';
504
+ this.ui.status(this, `${where}${job}${arg ? ` ${arg}` : ''} · ${status}`);
505
+ return fed;
506
+ }
507
+
508
+ // ---- entry --------------------------------------------------------------
509
+
510
+ /**
511
+ * Run the script from its entry proc to its root menu.
512
+ * @returns {Promise<{ok: boolean, reason?: string, messages?: IpoMessage[]}>}
513
+ */
514
+ async start() {
515
+ this.vm = this.newVm(this.exec);
516
+ const r = await this.runEntry();
517
+ if (!r.ok) return r;
518
+ if (!this.menu) return { ok: false, reason: 'the script opened no menu' };
519
+ await this.openMenu(this.menu, { fromEntry: true });
520
+ return { ok: true };
521
+ }
522
+
523
+ /**
524
+ * inpainit (or SgbdInpaCheck) live, following a scriptchange chain. Leaves
525
+ * this.menu / this.screen as the script set them.
526
+ * @returns {Promise<{ok: boolean, reason?: string, messages?: IpoMessage[]}>}
527
+ */
528
+ async runEntry() {
529
+ const exec = this.exec;
530
+ const proc = exec.procs.inpainit
531
+ ? 'inpainit'
532
+ : exec.procs.SgbdInpaCheck
533
+ ? 'SgbdInpaCheck'
534
+ : null;
535
+ if (!proc) return { ok: false, reason: 'no entry proc' };
536
+ const out = this.fresh();
537
+ let step;
538
+ try {
539
+ step = this.vm.stepStart(proc);
540
+ const r = await this.drive(step, { label: proc, scope: 'entry' });
541
+ if (r.cancelled) return { ok: false, reason: 'cancelled' };
542
+ if (r.exit) {
543
+ // inpainit ended the script (variant mismatch, "Program will be
544
+ // stopped"): the messages say why
545
+ return { ok: false, reason: 'stopped', messages: this.messages };
546
+ }
547
+ } catch (e) {
548
+ return { ok: false, reason: e.message };
549
+ }
550
+ if (out.scriptChange && this.hops < IPO_MAX_HOPS) {
551
+ const next = String(out.scriptChange).toLowerCase();
552
+ const nexec = await this.ui.loadExec(next);
553
+ if (nexec && nexec.procs && Object.keys(nexec.procs).length) {
554
+ this.hops += 1;
555
+ this.exec = nexec;
556
+ this.script = next;
557
+ this.vm = this.newVm(nexec);
558
+ return this.runEntry();
559
+ }
560
+ }
561
+ this.menu = out.menu || null;
562
+ this.screen = out.screen || null;
563
+ this.frequent = !!out.screenFrequent;
564
+ this.title = out.title || null;
565
+ return { ok: true };
566
+ }
567
+
568
+ /**
569
+ * Follow a key's scriptchange: load the named script, let the car name the
570
+ * module it addresses (the script's inpainit lists the variants it
571
+ * accepts; the group that can identify one of them is asked live), then
572
+ * run the new script's entry and open the menu it names. The original
573
+ * module record stays on the UI adapter (title, route); the program
574
+ * itself talks to the new one from here on. A script this build does not
575
+ * carry, or a module that does not answer, is reported and the current
576
+ * screen stays.
577
+ * @param {string} name - the script the key named (any case)
578
+ * @param {number} gen - the generation the key press belongs to
579
+ * @returns {Promise<boolean>} true (the press was handled)
580
+ */
581
+ async _changeScript(name, gen) {
582
+ const next = String(name || '')
583
+ .trim()
584
+ .toLowerCase();
585
+ const nexec = next ? await this.ui.loadExec(next) : null;
586
+ if (!nexec || !nexec.procs || !Object.keys(nexec.procs).length) {
587
+ await this.ui.message(
588
+ 'Script not in this build',
589
+ `${name} is not shipped with this vehicle.`
590
+ );
591
+ this._rescheduleIfFrequent(gen);
592
+ return true;
593
+ }
594
+ const target = this.ui.resolveScriptEcu
595
+ ? await this.ui.resolveScriptEcu(this.ecu, next, nexec)
596
+ : null;
597
+ if (this.closed || gen !== this.gen) return true;
598
+ if (!target) {
599
+ await this.ui.message(
600
+ 'Module not answering',
601
+ `The module ${name} addresses did not identify itself.`
602
+ );
603
+ this._rescheduleIfFrequent(gen);
604
+ return true;
605
+ }
606
+ this.stopCycle();
607
+ this.ecu = target;
608
+ this.exec = nexec;
609
+ this.script = next;
610
+ this.hops = 0;
611
+ this.confirmedWrites.clear();
612
+ this.lineFilter = null;
613
+ this.messages = [];
614
+ this.vm = this.newVm(nexec);
615
+ this.ui.status(this, `${next}.ipo · starting`);
616
+ const r = await this.runEntry();
617
+ if (this.closed) return true;
618
+ if (!r.ok) {
619
+ if (r.reason !== 'stopped' && r.reason !== 'cancelled')
620
+ this.ui.error(this, r.reason);
621
+ return true;
622
+ }
623
+ if (!this.menu) return true;
624
+ await this.openMenu(this.menu, { fromEntry: true });
625
+ return true;
626
+ }
627
+
628
+ // ---- menus ----------------------------------------------------------------
629
+
630
+ /**
631
+ * Open a menu: run its prologue (title, defaults, often its first job),
632
+ * follow a menu switch it performs, pick its backdrop screen, register its
633
+ * release, paint.
634
+ * @param {string} name - the menu proc
635
+ * @param {{fromEntry?: boolean, screen?: string|null, frequent?: boolean}} [opts] -
636
+ * the screen the opening key set, or that this is the entry's menu
637
+ * @returns {Promise<boolean>} false when the menu does not exist
638
+ */
639
+ async openMenu(name, opts = {}) {
640
+ this.view = null;
641
+ if (!this.exec.procs[name]) return false;
642
+ this.stopCycle();
643
+ const gen = ++this.gen;
644
+ this.menu = name;
645
+ this.items = ipoMenuItems(this.exec, name);
646
+ const end = ipoPrologueEnd(this.exec, name);
647
+ const out = this.fresh();
648
+ if (end > 0) {
649
+ this.busy = true;
650
+ try {
651
+ const step = this.vm.stepStartRange(name, 0, end);
652
+ const r = await this.drive(step, {
653
+ label: name,
654
+ scope: `menu:${name}`,
655
+ });
656
+ if (r.exit) {
657
+ this.busy = false;
658
+ return this.leaveModule();
659
+ }
660
+ } catch (e) {
661
+ this.ui.error(this, e.message);
662
+ } finally {
663
+ this.busy = false;
664
+ }
665
+ }
666
+ if (gen !== this.gen) return true;
667
+ if (out.title) this.title = out.title;
668
+ // the address bar follows the menu, so a link copied mid-script lands
669
+ // where the user is, not where the module was opened
670
+ if (typeof routeSetCar === 'function' && this.ecu && this.ecu.chassis)
671
+ routeSetCar(
672
+ this.ecu.chassis,
673
+ this.ecu.sgbd,
674
+ name,
675
+ out.screen || opts.screen || null
676
+ );
677
+ if (out.menu && out.menu !== name && this.exec.procs[out.menu]) {
678
+ // the prologue itself switched menus
679
+ return this.openMenu(out.menu, {
680
+ screen: out.screen || opts.screen,
681
+ frequent: out.screen ? out.screenFrequent : opts.frequent,
682
+ });
683
+ }
684
+ // the key that opened this menu usually set its backdrop screen too
685
+ // (setscreen(s_x); setmenu(m_x)); the prologue's own setscreen wins
686
+ if (out.screen) {
687
+ this.screen = out.screen;
688
+ this.frequent = !!out.screenFrequent;
689
+ } else if (opts.screen) {
690
+ this.screen = opts.screen;
691
+ this.frequent = !!opts.frequent;
692
+ } else if (!opts.fromEntry) {
693
+ // a deep link: the backdrop the opening key would have set
694
+ const bd = ipoScreenForMenu(this.exec, name);
695
+ if (bd) {
696
+ this.screen = bd.screen;
697
+ this.frequent = bd.frequent;
698
+ }
699
+ }
700
+ this.confirmedWrites.clear();
701
+ // The key bar's leave hook (ActionBar.set -> runMenuLeave) fires on every
702
+ // repaint that is not held. Paint the keys held, THEN register this
703
+ // menu's release: registering first let the hook send the new menu's
704
+ // Back job the moment its keys appeared.
705
+ this.ui.renderKeys(this);
706
+ this.registerRelease();
707
+ this.ui.route(this);
708
+ if (this.screen) await this.showScreen(this.screen, this.frequent);
709
+ else this.ui.paint(this);
710
+ return true;
711
+ }
712
+
713
+ /**
714
+ * The Back ITEM's first confirm-worthy job and its argument, scanned from
715
+ * the body: the string constants pushed before the INPAapiJob call.
716
+ * @returns {{job: string|null, arg: string|null}}
717
+ */
718
+ _backItemJob() {
719
+ const back = this.items.find((it) => it.nr === IPO_BACK_KEY);
720
+ if (!back) return { job: null, arg: null };
721
+ const toks = this.exec.procs[this.menu];
722
+ for (let i = back.start; i < back.end; i++) {
723
+ const t = toks[i];
724
+ if (!(t.op === 'call' && /^INP.?apiJob/.test(t.name || ''))) continue;
725
+ const consts = [];
726
+ for (let j = i - 1; j >= Math.max(0, i - 8); j--) {
727
+ const c = toks[j];
728
+ if (c.op === 'frame') break;
729
+ if (c.op === 'const' && c.t === 's') consts.unshift(String(c.v));
730
+ }
731
+ const jn = consts.find((v) => /^[A-Z][A-Z0-9_]{3,}$/.test(v));
732
+ if (jn && ipoNeedsConfirm(jn)) {
733
+ const a = consts[consts.indexOf(jn) + 1];
734
+ return { job: jn, arg: a != null && a !== '' ? a : null };
735
+ }
736
+ return { job: null, arg: null };
737
+ }
738
+ return { job: null, arg: null };
739
+ }
740
+
741
+ /**
742
+ * The script's own release for this menu: the Back ITEM's first job, so a
743
+ * route change or page hide still sends it (a pressed Back runs the whole
744
+ * body itself).
745
+ * @returns {void}
746
+ */
747
+ registerRelease() {
748
+ if (typeof registerMenuLeave !== 'function') return;
749
+ const { job, arg } = this._backItemJob();
750
+ registerMenuLeave(this.ecu, `${this.ecu.sgbd}:${this.menu}`, job, arg);
751
+ }
752
+
753
+ /**
754
+ * Same key, no job: registerMenuLeave keeps the key and so sends nothing.
755
+ * @returns {void}
756
+ */
757
+ forgetRelease() {
758
+ if (typeof registerMenuLeave !== 'function') return;
759
+ registerMenuLeave(this.ecu, `${this.ecu.sgbd}:${this.menu}`, null, null);
760
+ }
761
+
762
+ // ---- screens --------------------------------------------------------------
763
+
764
+ /**
765
+ * Show a screen: cycle it once, then let a frequent one keep cycling.
766
+ * @param {string} name - the screen proc
767
+ * @param {boolean} frequent - re-run on a timer
768
+ * @returns {Promise<void>}
769
+ */
770
+ async showScreen(name, frequent) {
771
+ this.view = null;
772
+ if (!this.exec.procs[name]) {
773
+ this.ui.paint(this);
774
+ return;
775
+ }
776
+ this.stopCycle();
777
+ this.screen = name;
778
+ this.frequent = !!frequent;
779
+ this.cells = new Map();
780
+ this.lines = [];
781
+ await this.cycle(this.gen);
782
+ this.relabelFromLegend();
783
+ }
784
+
785
+ /**
786
+ * The files the body wrote under names the save-as dialog chose go to
787
+ * the platform: through the picker's handle, else as a download.
788
+ * @returns {Promise<void>}
789
+ */
790
+ async flushSavedFiles() {
791
+ for (const [name, picked] of this.pendingSaves) {
792
+ const lines = this.vm && this.vm.files ? this.vm.files.get(name) : null;
793
+ if (!lines || typeof this.ui.writeFile !== 'function') continue;
794
+ try {
795
+ await this.ui.writeFile(this, picked, lines);
796
+ } catch (e) {
797
+ this.ui.error(this, `save ${name}: ${e.message}`);
798
+ }
799
+ }
800
+ this.pendingSaves.clear();
801
+ }
802
+
803
+ /**
804
+ * The user pressed Cancel on the progress window: the running body ends
805
+ * before its next job (the one on the wire finishes first).
806
+ * @returns {void}
807
+ */
808
+ cancel() {
809
+ if (this.busy) this.cancelRequested = true;
810
+ }
811
+
812
+ /**
813
+ * A body that ended without reaching its userboxclose (cancelled, or it
814
+ * raised an error) leaves no progress window behind.
815
+ * @returns {void}
816
+ */
817
+ closeUserbox() {
818
+ if (this.vm && this.vm.userbox) {
819
+ this.vm.userbox = null;
820
+ if (typeof this.ui.userbox === 'function') this.ui.userbox(this, null);
821
+ }
822
+ }
823
+
824
+ /**
825
+ * setitem() from a body or a screen cycle: INPA relabels the key and shows
826
+ * or hides it while the menu is up (E46.IPO's read turns F9 into "FS
827
+ * drucken" once there is a protocol). The bar redraws when something
828
+ * changed.
829
+ * @param {IpoOut} out - the run's emissions
830
+ * @returns {void}
831
+ */
832
+ applySetitems(out) {
833
+ let changed = false;
834
+ for (const s of out.items || []) {
835
+ if (!s.fromSetitem) continue;
836
+ const it = this.items.find((x) => x.nr === s.nr);
837
+ if (!it) continue;
838
+ const cap = s.label == null ? '' : String(s.label);
839
+ if (cap.trim() && cap !== it.label) {
840
+ it.label = cap;
841
+ it.hidden = false;
842
+ changed = true;
843
+ }
844
+ if (s.on != null && it.hidden !== !s.on) {
845
+ it.hidden = !s.on;
846
+ changed = true;
847
+ }
848
+ }
849
+ if (changed) this.ui.renderKeys(this);
850
+ }
851
+
852
+ /**
853
+ * An ITEM with no caption is still a key (SHD46's Select, its quit-mode
854
+ * pair): INPA's bar shows it blank and the screen's legend names it. Take
855
+ * that legend line as the key's label.
856
+ * @returns {void}
857
+ */
858
+ relabelFromLegend() {
859
+ if (!this.items.some((it) => it.hidden)) return;
860
+ const legend = ipoLegendMap(this);
861
+ let changed = false;
862
+ for (const it of this.items) {
863
+ if (!it.hidden) continue;
864
+ const l = legend.get(it.nr) || '';
865
+ if (l && it.legendLabel !== l) {
866
+ it.legendLabel = l;
867
+ changed = true;
868
+ }
869
+ }
870
+ if (changed) this.ui.renderKeys(this);
871
+ }
872
+
873
+ /**
874
+ * One INIT -> LINE cycle of the current screen, painted at the end; a
875
+ * frequent screen schedules the next one.
876
+ * @param {number} gen - the navigation generation this cycle belongs to
877
+ * @returns {Promise<void|boolean>}
878
+ */
879
+ async cycle(gen) {
880
+ if (this.closed || gen !== this.gen || !this.screen) return;
881
+ if (this.busy) {
882
+ this.scheduleCycle(gen);
883
+ return;
884
+ }
885
+ this.busy = true;
886
+ const out = this.fresh();
887
+ try {
888
+ const step = this.vm.stepStart(this.screen);
889
+ const r = await this.drive(step, {
890
+ label: this.screen,
891
+ scope: `screen:${this.screen}`,
892
+ });
893
+ if (r.exit) {
894
+ this.busy = false;
895
+ return this.leaveModule();
896
+ }
897
+ } catch (e) {
898
+ this.ui.error(this, e.message);
899
+ this.busy = false;
900
+ return;
901
+ } finally {
902
+ this.busy = false;
903
+ }
904
+ if (gen !== this.gen || this.closed) return;
905
+ this.takeCells(out);
906
+ this.ui.paint(this);
907
+ if (this.queued != null) {
908
+ this._drain();
909
+ return;
910
+ }
911
+ // a LINE body may switch menu or screen (rare, but INPA allows it)
912
+ if (out.menu && out.menu !== this.menu && this.exec.procs[out.menu]) {
913
+ // the key's own body already ran whatever this menu owed (Back sends
914
+ // its release itself); forget the registration so switching menus
915
+ // does not send it again
916
+ this.forgetRelease();
917
+ return this.openMenu(out.menu, {
918
+ screen: out.screen || null,
919
+ frequent: out.screen ? out.screenFrequent : false,
920
+ });
921
+ }
922
+ if (out.screen && out.screen !== this.screen) {
923
+ return this.showScreen(out.screen, out.screenFrequent);
924
+ }
925
+ if (this.frequent) this.scheduleCycle(gen);
926
+ }
927
+
928
+ /**
929
+ * Schedule the next cycle of a frequent screen. The tick runs on the bus's
930
+ * worker-backed timer where one exists: a hidden tab's setTimeout fires
931
+ * once a second at best, which stretched a 600 ms screen cycle to a second
932
+ * or more for a remote helper.
933
+ * @param {number} gen - the navigation generation
934
+ * @returns {void}
935
+ */
936
+ scheduleCycle(gen) {
937
+ this.stopCycle();
938
+ const my = ++this.cycleToken;
939
+ if (typeof bmwSleep === 'function') {
940
+ this.cycleTimer = true;
941
+ bmwSleep(IPO_TICK_MS).then(() => {
942
+ if (this.cycleToken !== my || this.closed) return;
943
+ this.cycleTimer = null;
944
+ this.cycle(gen);
945
+ });
946
+ return;
947
+ }
948
+ this.cycleTimer = setTimeout(() => {
949
+ this.cycleTimer = null;
950
+ this.cycle(gen);
951
+ }, IPO_TICK_MS);
952
+ // a headless harness must not be kept alive by a refresh timer
953
+ if (this.cycleTimer && typeof this.cycleTimer.unref === 'function')
954
+ this.cycleTimer.unref();
955
+ }
956
+
957
+ /**
958
+ * Cancel a pending cycle (orphaning a worker-clock tick too).
959
+ * @returns {void}
960
+ */
961
+ stopCycle() {
962
+ this.cycleToken = (this.cycleToken || 0) + 1;
963
+ if (this.cycleTimer) {
964
+ clearTimeout(this.cycleTimer);
965
+ this.cycleTimer = null;
966
+ }
967
+ }
968
+
969
+ /**
970
+ * The next cycle of a frequent screen, when one is showing.
971
+ * @param {number} gen - the navigation generation
972
+ * @returns {void}
973
+ */
974
+ _rescheduleIfFrequent(gen) {
975
+ if (this.frequent && this.screen) this.scheduleCycle(gen);
976
+ }
977
+
978
+ /**
979
+ * The painted grid from a cycle's emissions. Cells keyed by position so a
980
+ * later cycle overwrites in place; blankscreen clears first.
981
+ *
982
+ * INPA's virtual screen is a stack of LOGICAL lines: every LINE block's
983
+ * coordinates are relative to its own top, and a LINE is as tall as the
984
+ * physical rows it printed (the manual's `text(2, 0, "")` pads one to
985
+ * three rows). A fault list prints each entry at rows 0-4 of its own LINE;
986
+ * placing them absolutely drew every fault over the first.
987
+ * @param {Emissions} out - the run's emissions
988
+ * @returns {void}
989
+ */
990
+ takeCells(out) {
991
+ this.applySetitems(out);
992
+ if (out.blank) {
993
+ this.cells = new Map();
994
+ this.lines = [];
995
+ }
996
+ // clearrect / ftextclear: the cells still held inside the blanked areas
997
+ // go (by the script's own coordinates, before any line stacking)
998
+ for (const r of out.clears || []) {
999
+ for (const [k, c] of this.cells) {
1000
+ const row = c.lrow != null ? c.lrow : c.row;
1001
+ if (
1002
+ row >= r.row &&
1003
+ row < r.row + r.h &&
1004
+ c.col >= r.col &&
1005
+ c.col < r.col + r.w
1006
+ )
1007
+ this.cells.delete(k);
1008
+ }
1009
+ }
1010
+ if (out.deselect && this.lineFilter) this.setLineFilter(null);
1011
+ // only the logical lines Select kept, plus the unnamed ones (the screen
1012
+ // function's own output has no name to select by)
1013
+ const lines = (out.lines || []).filter(
1014
+ (ln) => !this.lineFilter || !ln.label || this.lineFilter.has(ln.label)
1015
+ );
1016
+ // the LINE grouping the modern skin draws from: a screen cycle emits
1017
+ // every LINE, a key body usually none -- keep the last full cycle's
1018
+ if (lines.some((ln) => (ln.elements || []).some((el) => el.row != null)))
1019
+ this.lines = lines;
1020
+ let top = 0;
1021
+ // where each logical line starts and how tall it is: the grid breathes
1022
+ // between TALL logical lines (a fault entry), not between one-row ones
1023
+ // (a switch and its lamp), where the script's own blank rows suffice
1024
+ if (lines.length) this.bandTops = new Map();
1025
+ for (const ln of lines) {
1026
+ let height = 0;
1027
+ for (const el of ln.elements || []) {
1028
+ if (el.row == null || el.col == null) continue;
1029
+ if (el.lrow == null) el.lrow = el.row; // the script's own coordinate
1030
+ el.row = top + el.lrow;
1031
+ height = Math.max(height, el.lrow + 1);
1032
+ }
1033
+ if (height) this.bandTops.set(top, height);
1034
+ top += height;
1035
+ for (const el of ln.elements || []) {
1036
+ if (el.row == null || el.col == null) continue;
1037
+ this.cells.set(`${el.row}:${el.col}`, IpoProgram.cellOf(el));
1038
+ }
1039
+ }
1040
+ }
1041
+
1042
+ /**
1043
+ * A grid cell from a drawn element.
1044
+ * @param {IpoElement} el - the element (its row already absolute)
1045
+ * @returns {IpoCell}
1046
+ */
1047
+ static cellOf(el) {
1048
+ return {
1049
+ row: el.row,
1050
+ lrow: el.lrow != null ? el.lrow : el.row,
1051
+ col: el.col,
1052
+ text: el.s != null ? String(el.s) : '',
1053
+ key: el.key || null,
1054
+ kind: el.t,
1055
+ meta:
1056
+ el.t === 'gauge' || el.t === 'lamp'
1057
+ ? {
1058
+ min: el.min,
1059
+ max: el.max,
1060
+ lo: el.warnLo,
1061
+ hi: el.warnHi,
1062
+ fmt: el.fmt,
1063
+ on: el.on,
1064
+ off: el.off,
1065
+ }
1066
+ : null,
1067
+ };
1068
+ }
1069
+
1070
+ // ---- keys ---------------------------------------------------------------
1071
+
1072
+ /**
1073
+ * Press a key. A key pressed while a cycle or another key is on the wire
1074
+ * is QUEUED, not dropped: INPA takes the keypress after the current block.
1075
+ * One key waits (the last pressed), and Back too.
1076
+ * @param {number} nr - the F-key number
1077
+ * @returns {Promise<boolean>} false when the menu has no such key
1078
+ */
1079
+ async press(nr) {
1080
+ if (!this.items.some((x) => x.nr === nr)) return false;
1081
+ if (this.running || this.busy) {
1082
+ this.queued = nr;
1083
+ return true;
1084
+ }
1085
+ this.running = true;
1086
+ try {
1087
+ return await this._press(nr);
1088
+ } finally {
1089
+ this.running = false;
1090
+ this._drain();
1091
+ }
1092
+ }
1093
+
1094
+ /**
1095
+ * Run the queued key, if any, on the next turn.
1096
+ * @returns {void}
1097
+ */
1098
+ _drain() {
1099
+ const q = this.queued;
1100
+ if (q == null || this.closed) return;
1101
+ this.queued = null;
1102
+ setTimeout(() => {
1103
+ if (q === 'back') this.back();
1104
+ else this.press(q);
1105
+ }, 0);
1106
+ }
1107
+
1108
+ /**
1109
+ * The press itself: confirm once, naming what the body can send, then run
1110
+ * the body and follow where it went.
1111
+ * @param {number} nr - the F-key number
1112
+ * @returns {Promise<boolean>}
1113
+ */
1114
+ async _press(nr) {
1115
+ const it = this.items.find((x) => x.nr === nr);
1116
+ if (!it) return false;
1117
+ this.stopCycle();
1118
+ const gen = ++this.gen;
1119
+ const jobs =
1120
+ typeof irItemBodyJobs === 'function'
1121
+ ? irItemBodyJobs(
1122
+ this.exec,
1123
+ this.exec.procs[this.menu],
1124
+ it.start,
1125
+ it.end
1126
+ )
1127
+ : [];
1128
+ const writes = jobs.filter(ipoNeedsConfirm);
1129
+ this.action = {
1130
+ id: `${Date.now().toString(36)}-${++this.actionSeq}`,
1131
+ label: it.label || it.legendLabel || `F${it.nr}`,
1132
+ jobs: jobs.slice(0, IPO_ACTION_JOBS_MAX),
1133
+ };
1134
+ let preConfirmed = false;
1135
+ // "Send immediately" drops the prompt for actuator drives; a key whose
1136
+ // writes are all drives then runs like INPA's own keypress
1137
+ const asks = writes.filter(ipoConfirmWanted);
1138
+ if (asks.length) {
1139
+ const ok = await this.ui.confirmKey(this, it, jobs, asks);
1140
+ if (!ok) {
1141
+ this._rescheduleIfFrequent(gen);
1142
+ return true;
1143
+ }
1144
+ preConfirmed = true;
1145
+ }
1146
+ return this._runForKey(
1147
+ it,
1148
+ gen,
1149
+ { label: it.label, scope: `key:${this.menu}:${nr}`, preConfirmed },
1150
+ () => this.vm.stepStartItem(this.menu, nr),
1151
+ it.label,
1152
+ true
1153
+ );
1154
+ }
1155
+
1156
+ /**
1157
+ * A state machine a key started: setstate(&sm) means the key's work IS
1158
+ * the machine (SHD46 Select: togglelist -> STEUERN_DIGITAL -> quit-mode
1159
+ * box -> back to the menu screen).
1160
+ * @param {string} name - the state machine proc
1161
+ * @param {IpoMenuItem} it - the key that started it
1162
+ * @param {number} gen - the navigation generation
1163
+ * @param {boolean} preConfirmed - the key's confirm covered its writes
1164
+ * @returns {Promise<boolean>}
1165
+ */
1166
+ async runMachine(name, it, gen, preConfirmed) {
1167
+ return this._runForKey(
1168
+ it,
1169
+ gen,
1170
+ { label: it.label || name, scope: `machine:${name}`, preConfirmed },
1171
+ () => {
1172
+ this.vm.currentMachine = name; // callstatemachine returns here
1173
+ return this.vm.stepStart(name);
1174
+ },
1175
+ it.label || name,
1176
+ false
1177
+ );
1178
+ }
1179
+
1180
+ /**
1181
+ * Drive a key's run (its body, or the machine it started) and follow where
1182
+ * it went: an error is reported and the screen's cycle resumed, an exit
1183
+ * leaves the module, a cancel is announced, a setstate hands over to the
1184
+ * machine, anything else settles on the menu/screen the run set.
1185
+ * @param {IpoMenuItem} it - the key
1186
+ * @param {number} gen - the navigation generation
1187
+ * @param {IpoRunContext} ctx - what is running
1188
+ * @param {() => IpoStep} start - begins the run on the VM
1189
+ * @param {string} cancelLabel - what a cancel announces
1190
+ * @param {boolean} followMachine - honour a setstate the run emitted
1191
+ * @returns {Promise<boolean>}
1192
+ */
1193
+ async _runForKey(it, gen, ctx, start, cancelLabel, followMachine) {
1194
+ this.busy = true;
1195
+ this.cancelRequested = false;
1196
+ /** @type {IpoWireRead[]} every answer this body got, in order */
1197
+ this.wireReads = [];
1198
+ const out = this.fresh();
1199
+ let result;
1200
+ try {
1201
+ result = await this.drive(start(), ctx);
1202
+ } catch (e) {
1203
+ this.closeUserbox();
1204
+ this.ui.error(this, e.message);
1205
+ this.busy = false;
1206
+ this._rescheduleIfFrequent(gen);
1207
+ return true;
1208
+ } finally {
1209
+ this.busy = false;
1210
+ }
1211
+ if (this.closed || gen !== this.gen) return true;
1212
+ await this.flushSavedFiles();
1213
+ if (result.exit || out.exit) return this.leaveModule();
1214
+ if (result.cancelled) {
1215
+ this.closeUserbox();
1216
+ this.ui.status(this, `${cancelLabel} · cancelled`);
1217
+ this._rescheduleIfFrequent(gen);
1218
+ return true;
1219
+ }
1220
+ if (out.title) this.title = out.title;
1221
+ // scriptchange from a key: INPA drops this script for another one --
1222
+ // SM46's "change to passenger's side" hands the view to B_SM46.IPO, a
1223
+ // different module on its own address
1224
+ if (out.scriptChange)
1225
+ return this._changeScript(String(out.scriptChange), gen);
1226
+ // viewopen: the file the body wrote (a whole-vehicle fault protocol) is
1227
+ // the view now, until the script opens another menu or screen -- shown
1228
+ // as the data behind it where the body read fault memories
1229
+ if (out.view) {
1230
+ this.view = out.view;
1231
+ if (typeof ipoProtocolReport === 'function') {
1232
+ const rep = ipoProtocolReport(this.wireReads, out.view.lines);
1233
+ this.view.report = rep.modules.length ? rep : null;
1234
+ }
1235
+ }
1236
+ // the body painted (userbox text, a result line): show it with the screen
1237
+ this.takeCells(out);
1238
+ if (followMachine && out.stateEnter && this.exec.procs[out.stateEnter]) {
1239
+ return this.runMachine(out.stateEnter, it, gen, ctx.preConfirmed);
1240
+ }
1241
+ return this.settle(out, it, gen);
1242
+ }
1243
+
1244
+ /**
1245
+ * After a key or a machine ran: follow the menu/screen it set.
1246
+ * @param {Emissions} out - the run's emissions
1247
+ * @param {IpoMenuItem} it - the key (unused; kept for callers)
1248
+ * @param {number} gen - the navigation generation
1249
+ * @returns {Promise<boolean>}
1250
+ */
1251
+ async settle(out, it, gen) {
1252
+ if (this.filterChanged && !out.menu && !out.screen && this.screen) {
1253
+ this.filterChanged = false;
1254
+ await this.showScreen(this.screen, this.frequent);
1255
+ return true;
1256
+ }
1257
+ this.filterChanged = false;
1258
+ if (out.menu && out.menu !== this.menu && this.exec.procs[out.menu]) {
1259
+ return this.openMenu(out.menu, {
1260
+ screen: out.screen || null,
1261
+ frequent: out.screen ? out.screenFrequent : false,
1262
+ });
1263
+ }
1264
+ if (out.screen) {
1265
+ // a setscreen re-runs the screen even when it is the same one: INPA
1266
+ // restarts its cycle (the idle-actuator keys redraw their readout)
1267
+ await this.showScreen(out.screen, out.screenFrequent);
1268
+ return true;
1269
+ }
1270
+ this.ui.paint(this);
1271
+ this._rescheduleIfFrequent(gen);
1272
+ return true;
1273
+ }
1274
+
1275
+ /**
1276
+ * Navigation Back = INPA's F10 when the menu has one, else the root: exit.
1277
+ * @returns {Promise<boolean|void>}
1278
+ */
1279
+ async back() {
1280
+ if (this.running || this.busy) {
1281
+ this.queued = 'back';
1282
+ return;
1283
+ }
1284
+ if (this.items.some((it) => it.nr === IPO_BACK_KEY))
1285
+ return this.press(IPO_BACK_KEY);
1286
+ return this.leaveModule();
1287
+ }
1288
+
1289
+ /**
1290
+ * Leave the module: inpaexit is what the script owes the ECU on the way
1291
+ * out (DIAGNOSE_ENDE, INPAapiEnd) -- a release, so it does not prompt.
1292
+ * @returns {Promise<void>}
1293
+ */
1294
+ async leaveModule() {
1295
+ if (this.closed || this.leaving) return;
1296
+ // inpaexit runs BEFORE the program is marked closed: drive() answers a
1297
+ // closed program with "cancelled" at its first step, so marking it first
1298
+ // meant the exit proc's own job (DIAGNOSE_ENDE) never reached the wire
1299
+ // and the module was left mid-session. `leaving` keeps a second leave,
1300
+ // or a key pressed meanwhile, from starting anything.
1301
+ this.leaving = true;
1302
+ this.stopCycle();
1303
+ // the leaving menu's release (its Back job, registered on entry) goes
1304
+ // first, while the session is still open; the bus lock keeps that
1305
+ // order on the wire even though the release is not awaited
1306
+ if (typeof registerMenuLeave === 'function')
1307
+ registerMenuLeave(null, null, null, null);
1308
+ if (this.exec.procs.inpaexit) {
1309
+ try {
1310
+ this.fresh();
1311
+ const step = this.vm.stepStart('inpaexit');
1312
+ await this.drive(step, {
1313
+ label: 'inpaexit',
1314
+ scope: 'exit',
1315
+ preConfirmed: true,
1316
+ });
1317
+ } catch (e) {
1318
+ /* leaving anyway */
1319
+ }
1320
+ }
1321
+ this.closed = true;
1322
+ this.ui.left(this);
1323
+ }
1324
+
1325
+ /**
1326
+ * The view is being torn down by navigation: stop, keep the release.
1327
+ * @returns {void}
1328
+ */
1329
+ close() {
1330
+ this.closed = true;
1331
+ this.stopCycle();
1332
+ }
1333
+ }
1334
+
1335
+ if (typeof module !== 'undefined' && module.exports) {
1336
+ module.exports = { IPO_TICK_MS, IpoProgram };
1337
+ }