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.
- package/LICENSE +674 -0
- package/README.md +351 -0
- package/dist/bmweb.js +2790 -0
- package/package.json +53 -0
- package/runtime/core/bestvm/codec.js +285 -0
- package/runtime/core/bestvm/environment.js +116 -0
- package/runtime/core/bestvm/executor.js +1483 -0
- package/runtime/core/bestvm/index.js +52 -0
- package/runtime/core/bestvm/machine.js +491 -0
- package/runtime/core/bestvm/operands.js +356 -0
- package/runtime/core/bestvm/registers.js +152 -0
- package/runtime/core/bestvm/write-guard.js +111 -0
- package/runtime/core/ipofile/compile.js +364 -0
- package/runtime/core/ipofile/decls.js +187 -0
- package/runtime/core/ipofile/emit.js +708 -0
- package/runtime/core/ipofile/exec.js +164 -0
- package/runtime/core/ipofile/lex.js +243 -0
- package/runtime/core/ipofile/parse.js +550 -0
- package/runtime/core/ipofile/pool.js +404 -0
- package/runtime/core/ipofile/walk.js +431 -0
- package/runtime/core/ipovm/builtin-helpers.js +182 -0
- package/runtime/core/ipovm/builtins-api.js +610 -0
- package/runtime/core/ipovm/builtins-screen.js +493 -0
- package/runtime/core/ipovm/builtins-table.js +166 -0
- package/runtime/core/ipovm/builtins-text.js +166 -0
- package/runtime/core/ipovm/emissions.js +138 -0
- package/runtime/core/ipovm/hosts.js +191 -0
- package/runtime/core/ipovm/operators.js +229 -0
- package/runtime/core/ipovm/structures.js +250 -0
- package/runtime/core/ipovm/suspensions.js +241 -0
- package/runtime/core/ipovm/tape.js +206 -0
- package/runtime/core/ipovm/values.js +241 -0
- package/runtime/core/ipovm/vm.js +1166 -0
- package/runtime/core/translate.js +526 -0
- package/runtime/core/webshim/api-router.js +592 -0
- package/runtime/core/webshim/bus.js +95 -0
- package/runtime/core/webshim/coding.js +82 -0
- package/runtime/core/webshim/data-fetch.js +66 -0
- package/runtime/core/webshim/exchange.js +288 -0
- package/runtime/core/webshim/framing.js +331 -0
- package/runtime/core/webshim/install.js +30 -0
- package/runtime/core/webshim/job-runner.js +319 -0
- package/runtime/core/webshim/native-bus.js +108 -0
- package/runtime/core/webshim/timers.js +82 -0
- package/runtime/core/webshim/trace.js +205 -0
- package/runtime/core/webshim/transport-base.js +128 -0
- package/runtime/core/webshim/variant-resolver.js +249 -0
- package/runtime/core/webshim/web-serial-bus.js +734 -0
- package/runtime/home/bmweb-home.ips +76 -0
- package/runtime/home/bmweb.h +26 -0
- package/runtime/screens/activations.js +258 -0
- package/runtime/screens/garage/diff.js +331 -0
- package/runtime/screens/garage/share.js +276 -0
- package/runtime/screens/garage/store.js +547 -0
- package/runtime/screens/ipo-runtime/cells.js +176 -0
- package/runtime/screens/ipo-runtime/dialogs.js +254 -0
- package/runtime/screens/ipo-runtime/home.js +358 -0
- package/runtime/screens/ipo-runtime/open.js +393 -0
- package/runtime/screens/ipo-runtime/paint-grid.js +106 -0
- package/runtime/screens/ipo-runtime/paint-modern.js +424 -0
- package/runtime/screens/ipo-runtime/print.js +281 -0
- package/runtime/screens/ipo-runtime/program.js +1337 -0
- package/runtime/screens/ipo-runtime/protocol.js +464 -0
- package/runtime/screens/ipo-runtime/script-scan.js +225 -0
- package/runtime/screens/ipo-runtime/translate-sets.js +130 -0
- package/runtime/screens/ipo-runtime/ui.js +249 -0
- package/runtime/screens/ipo-runtime/wire-policy.js +113 -0
- package/runtime/screens/ir.js +324 -0
- package/runtime/screens/search/data.js +153 -0
- package/runtime/screens/search/match.js +285 -0
- package/runtime/screens/search/open.js +66 -0
- 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
|
+
}
|