bmweb-cli 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -309,15 +309,19 @@ status line and the script's progress window are the two bottom lines.
309
309
  With no arguments it starts on the app's own home, an INPA script of the
310
310
  project's own (`home/bmweb-home.ips`): F1 picks a chassis then a module,
311
311
  F2 the chassis's whole-vehicle script, and `scriptchange` hands the screen
312
- to that script. The picks are numbered lists on the terminal; typing text
313
- filters, a number opens, Enter cancels. The home starts with or without a
314
- cable.
312
+ to that script. A pick is a list the keyboard walks: Up/Down move the bar,
313
+ typing narrows the list to the rows containing the text, Enter picks, Esc
314
+ cancels. The home starts with or without a cable.
315
+
316
+ The TUI runs on the terminal's alternate screen (the buffer vim and htop
317
+ use), so the shell's scrollback is never touched and quitting restores it;
318
+ a screen redraws in place, only the lines that changed.
315
319
 
316
320
  Every dialog INPA opens is a prompt: a message waits for Enter, an input
317
321
  asks for the number (or hex, or text) within the declared range, the
318
- two-word box takes y/n, the component picker (togglelist) lists the
319
- screen's lines by number, Select lists the named lines, save-as asks for a
320
- file name. **Every write is asked first**, exactly as the app asks: a key
322
+ two-word box takes y/n, the component picker (togglelist) and Select are
323
+ the same list picker (Space marks several where several may be picked),
324
+ save-as asks for a file name. **Every write is asked first**, exactly as the app asks: a key
321
325
  whose body can send a write names the jobs and waits for y; a screen that
322
326
  sends one on every refresh asks once for as long as it is open; n or
323
327
  Enter abandons the key. On quit the leaving menu's Back job (the script's
package/dist/bmweb.js CHANGED
@@ -1892,6 +1892,7 @@ function nodeTerminal() {
1892
1892
  };
1893
1893
  raw(true);
1894
1894
  input.resume();
1895
+ output.write("\x1B[?1049h\x1B[H");
1895
1896
  const subs = /* @__PURE__ */ new Set();
1896
1897
  let paused = false;
1897
1898
  input.on(
@@ -1923,6 +1924,12 @@ function nodeTerminal() {
1923
1924
  subs.delete(fn);
1924
1925
  };
1925
1926
  },
1927
+ onResize(fn) {
1928
+ output.on("resize", fn);
1929
+ return () => {
1930
+ output.off("resize", fn);
1931
+ };
1932
+ },
1926
1933
  async readLine(prompt) {
1927
1934
  paused = true;
1928
1935
  raw(false);
@@ -1939,6 +1946,7 @@ function nodeTerminal() {
1939
1946
  }
1940
1947
  },
1941
1948
  close() {
1949
+ output.write("\x1B[?1049l");
1942
1950
  raw(false);
1943
1951
  input.pause();
1944
1952
  }
@@ -1952,6 +1960,12 @@ var TuiUi = class {
1952
1960
  stopRequested = false;
1953
1961
  writeKeys = /* @__PURE__ */ new Set();
1954
1962
  program = null;
1963
+ /** the lines of the frame on screen, when the cursor sits right below it */
1964
+ frame = [];
1965
+ /** a picker owns the keyboard: the program's key handler must stand back */
1966
+ modal = false;
1967
+ /** the terminal size the frame was drawn for; a change draws fresh */
1968
+ frameSize = "";
1955
1969
  leftResolve = null;
1956
1970
  /** resolves once the program reports it left the module */
1957
1971
  leftPromise;
@@ -1970,6 +1984,16 @@ var TuiUi = class {
1970
1984
  this.leftPromise = new Promise((res) => {
1971
1985
  this.leftResolve = res;
1972
1986
  });
1987
+ if (term.onResize) term.onResize(() => this.resized());
1988
+ }
1989
+ /**
1990
+ * The terminal changed size: the frame on screen no longer fits its
1991
+ * rows, so it is erased (as far as the cursor can climb back over it)
1992
+ * and drawn again for the new size.
1993
+ */
1994
+ resized() {
1995
+ this.frame = [];
1996
+ if (this.program) this.paint(this.program);
1973
1997
  }
1974
1998
  /**
1975
1999
  * BMWeb's own picker (the home script's bmweb_pick): the host's list as
@@ -1991,32 +2015,133 @@ var TuiUi = class {
1991
2015
  return null;
1992
2016
  }
1993
2017
  const title = step.what === "module" ? `Modules of ${step.arg}` : "Vehicles";
1994
- let shown = options;
1995
- for (; ; ) {
1996
- const rows = shown.map(
1997
- (o, i) => ` ${String(i + 1).padStart(3)}. ${o.label}${o.meta ? ` (${o.meta})` : ""}`
1998
- ).join("\n");
1999
- const a = await this.term.readLine(
2000
- `
2001
- ${title}${shown.length !== options.length ? ` (${shown.length} of ${options.length})` : ""}
2002
- ${rows}
2003
- Number to open, text to filter, Enter to cancel: `
2004
- );
2005
- if (a == null || !a.trim()) return null;
2006
- const n = Number(a.trim());
2007
- if (Number.isInteger(n) && n >= 1 && n <= shown.length)
2008
- return shown[n - 1].value;
2009
- const q = a.trim().toLowerCase();
2010
- const next = options.filter(
2018
+ const picked = await this.pickList(title, options);
2019
+ return picked ? picked[0] : null;
2020
+ }
2021
+ /**
2022
+ * The picker: a list the keyboard walks. Up/Down (and PageUp/PageDown)
2023
+ * move the bar, typing narrows the list to the rows containing the text,
2024
+ * Backspace widens it again, Enter picks the row under the bar (Space
2025
+ * marks a row when several may be picked, Enter then takes the marked
2026
+ * ones, or the bar's row when none is marked), Esc cancels. Drawn as the
2027
+ * frame, so it repaints in place like a screen.
2028
+ * @param title - what is being picked
2029
+ * @param options - the rows
2030
+ * @param multiple - whether several rows may be picked
2031
+ * @returns the picked values (one, unless multiple), or null for cancel
2032
+ */
2033
+ async pickList(title, options, multiple = false) {
2034
+ let filter = "";
2035
+ let cursor = 0;
2036
+ let top = 0;
2037
+ const marked = /* @__PURE__ */ new Set();
2038
+ const shown = () => {
2039
+ const q = filter.toLowerCase();
2040
+ return q ? options.filter(
2011
2041
  (o) => `${o.label} ${o.meta || ""} ${o.value}`.toLowerCase().includes(q)
2042
+ ) : options;
2043
+ };
2044
+ const draw = () => {
2045
+ const rows = shown();
2046
+ const w = this.term.columns;
2047
+ const window = Math.max(3, this.term.rows - 7);
2048
+ if (cursor >= rows.length) cursor = Math.max(0, rows.length - 1);
2049
+ if (cursor < top) top = cursor;
2050
+ if (cursor >= top + window) top = cursor - window + 1;
2051
+ const count = rows.length === options.length ? `${options.length}` : `${rows.length} of ${options.length}`;
2052
+ const lines = [
2053
+ `${title} \x1B[2m(${count})\x1B[0m`,
2054
+ `\x1B[2mFilter:\x1B[0m ${filter}\x1B[7m \x1B[0m`,
2055
+ ""
2056
+ ];
2057
+ for (const o of rows.slice(top, top + window)) {
2058
+ const i = rows.indexOf(o);
2059
+ const mark = multiple ? marked.has(o.value) ? "[x] " : "[ ] " : "";
2060
+ const meta = o.meta ? ` ${o.meta}` : "";
2061
+ const text = `${mark}${o.label}${meta}`.slice(0, w - 4);
2062
+ lines.push(
2063
+ i === cursor ? `\x1B[7m > ${text.padEnd(w - 4)} \x1B[0m` : ` ${o.meta ? `${mark}${o.label}\x1B[2m${meta}\x1B[0m` : text}`
2064
+ );
2065
+ }
2066
+ if (!rows.length) lines.push(" \x1B[2m(nothing matches)\x1B[0m");
2067
+ if (rows.length > top + window)
2068
+ lines.push(` \x1B[2m... ${rows.length - top - window} more\x1B[0m`);
2069
+ lines.push("");
2070
+ lines.push(
2071
+ `\x1B[2m\u2191\u2193 move type to filter Enter picks${multiple ? " Space marks" : ""} Esc cancels\x1B[0m`
2012
2072
  );
2013
- shown = next.length ? next : options;
2073
+ this.flush(lines);
2074
+ };
2075
+ this.modal = true;
2076
+ try {
2077
+ return await new Promise((resolve2) => {
2078
+ const off = this.term.onKey((k) => {
2079
+ const rows = shown();
2080
+ const finish = (v) => {
2081
+ off();
2082
+ resolve2(v);
2083
+ };
2084
+ if (k.name === "escape" || k.ctrl && k.name === "c") {
2085
+ finish(null);
2086
+ return;
2087
+ }
2088
+ if (k.name === "return" || k.name === "enter") {
2089
+ if (multiple && marked.size) {
2090
+ finish(
2091
+ options.filter((o2) => marked.has(o2.value)).map((o2) => o2.value)
2092
+ );
2093
+ return;
2094
+ }
2095
+ const o = rows[cursor];
2096
+ finish(o ? [o.value] : null);
2097
+ return;
2098
+ }
2099
+ if (k.name === "up" || k.ctrl && k.name === "p")
2100
+ cursor = Math.max(0, cursor - 1);
2101
+ else if (k.name === "down" || k.ctrl && k.name === "n")
2102
+ cursor = Math.min(rows.length - 1, cursor + 1);
2103
+ else if (k.name === "pageup")
2104
+ cursor = Math.max(0, cursor - (this.term.rows - 7));
2105
+ else if (k.name === "pagedown")
2106
+ cursor = Math.min(rows.length - 1, cursor + (this.term.rows - 7));
2107
+ else if (k.name === "home") cursor = 0;
2108
+ else if (k.name === "end") cursor = Math.max(0, rows.length - 1);
2109
+ else if (k.name === "backspace") filter = filter.slice(0, -1);
2110
+ else if (multiple && k.name === "space") {
2111
+ const o = rows[cursor];
2112
+ if (o) {
2113
+ if (marked.has(o.value)) marked.delete(o.value);
2114
+ else marked.add(o.value);
2115
+ }
2116
+ } else if (k.ch && !k.ctrl && k.ch >= " " && k.ch !== "\x7F") {
2117
+ filter += k.ch;
2118
+ cursor = 0;
2119
+ } else return;
2120
+ draw();
2121
+ });
2122
+ draw();
2123
+ });
2124
+ } finally {
2125
+ this.modal = false;
2126
+ this.frame = [];
2127
+ if (this.program) this.paint(this.program);
2014
2128
  }
2015
2129
  }
2016
2130
  /** Note the program once it exists, for the key handler. */
2017
2131
  attach(p) {
2018
2132
  this.program = p;
2019
2133
  }
2134
+ /**
2135
+ * A typed prompt under the frame (readline, raw mode off). It moves the
2136
+ * cursor, so the frame is forgotten and the next paint clears the screen
2137
+ * and draws fresh.
2138
+ * @param prompt - the prompt text
2139
+ * @returns the answer, or null when cancelled
2140
+ */
2141
+ async ask(prompt) {
2142
+ this.frame = [];
2143
+ return this.term.readLine(prompt);
2144
+ }
2020
2145
  /** Esc during a parked state machine: stop it at the next tick. */
2021
2146
  requestStop() {
2022
2147
  this.stopRequested = true;
@@ -2038,7 +2163,7 @@ Number to open, text to filter, Enter to cancel: `
2038
2163
  this.drawFooter();
2039
2164
  }
2040
2165
  async message(title, body) {
2041
- await this.term.readLine(
2166
+ await this.ask(
2042
2167
  `
2043
2168
  ${title}${body ? `
2044
2169
  ${body}` : ""}
@@ -2064,7 +2189,7 @@ ${body}` : ""}
2064
2189
  if (name === "inputdigital") {
2065
2190
  const f = prompts[prompts.length - 2] || "OFF";
2066
2191
  const t = prompts[prompts.length - 1] || "ON";
2067
- const a = await this.term.readLine(
2192
+ const a = await this.ask(
2068
2193
  `
2069
2194
  ${p0}
2070
2195
  ${p1}
@@ -2074,12 +2199,10 @@ ${p1}
2074
2199
  return /^y/i.test(a.trim()) ? 1 : 0;
2075
2200
  }
2076
2201
  if (name === "builtin_3f" && prompts.length <= 2 && refs === 1) {
2077
- const a = await this.term.readLine(
2078
- `
2202
+ const a = await this.ask(`
2079
2203
  ${p0}
2080
2204
  ${p1}
2081
- [OK = y, cancel = n] `
2082
- );
2205
+ [OK = y, cancel = n] `);
2083
2206
  return a != null && /^y/i.test(a.trim()) ? 0 : null;
2084
2207
  }
2085
2208
  const hex = /hex/i.test(name);
@@ -2089,7 +2212,7 @@ ${p1}
2089
2212
  for (let k = 0; k < refs; k++) {
2090
2213
  const cap = refs > 1 ? prompts[2 + k] || `${p0} (${k + 1}/${refs})` : p1;
2091
2214
  const range = step.lo != null && step.hi != null && !hex ? ` [${step.lo}..${step.hi}]` : "";
2092
- const a = await this.term.readLine(`
2215
+ const a = await this.ask(`
2093
2216
  ${p0}
2094
2217
  ${cap}${range}: `);
2095
2218
  if (a == null) return null;
@@ -2111,7 +2234,7 @@ ${cap}${range}: `);
2111
2234
  return refs > 1 ? vals : vals[0];
2112
2235
  }
2113
2236
  async confirmKey(_p, it, jobs, writes) {
2114
- const a = await this.term.readLine(
2237
+ const a = await this.ask(
2115
2238
  `
2116
2239
  Run "${it.label || it.legendLabel || `F${it.nr}`}"? It can send ${jobs.join(", ")} and ${writes.join(", ")} write${writes.length === 1 ? "s" : ""} to the module. [y/N] `
2117
2240
  );
@@ -2119,7 +2242,7 @@ Run "${it.label || it.legendLabel || `F${it.nr}`}"? It can send ${jobs.join(", "
2119
2242
  }
2120
2243
  async confirmWrite(_p, job, arg, ctx) {
2121
2244
  const every = String(ctx.scope || "").startsWith("screen:") ? " This screen sends it on every refresh; yes allows it while the screen is open." : "";
2122
- const a = await this.term.readLine(
2245
+ const a = await this.ask(
2123
2246
  `
2124
2247
  Send ${job}${arg ? ` ${arg}` : ""} to the module (${ctx.label})?${every} [y/N] `
2125
2248
  );
@@ -2131,8 +2254,8 @@ Send ${job}${arg ? ` ${arg}` : ""} to the module (${ctx.label})?${every} [y/N] `
2131
2254
  * flag, every picked key ';'-joined).
2132
2255
  */
2133
2256
  async pickComponent(p, step) {
2134
- const rows = this.R.ipoScreenComponents(p.exec, p.screen).map((l, i2) => ({
2135
- key: step.argnum ? String(i2 + 1) : String(l.keys).split(";")[0] || "",
2257
+ const rows = this.R.ipoScreenComponents(p.exec, p.screen).map((l, i) => ({
2258
+ key: step.argnum ? String(i + 1) : String(l.keys).split(";")[0] || "",
2136
2259
  caption: l.label || String(l.keys).split(";")[0] || ""
2137
2260
  }));
2138
2261
  if (!rows.length) {
@@ -2142,32 +2265,27 @@ Send ${job}${arg ? ` ${arg}` : ""} to the module (${ctx.label})?${every} [y/N] `
2142
2265
  );
2143
2266
  return null;
2144
2267
  }
2145
- const list = rows.map((r, i2) => ` ${i2 + 1}. ${r.caption} (${r.key})`).join("\n");
2268
+ const options = rows.map((r) => ({
2269
+ value: r.key,
2270
+ label: r.caption,
2271
+ meta: r.key !== r.caption ? r.key : ""
2272
+ }));
2146
2273
  if (step.multiple) {
2147
- const a2 = await this.term.readLine(
2148
- `
2149
- ${list}
2150
- Components, comma-separated (Enter cancels): `
2151
- );
2152
- const picked = pickNumbers(a2, rows.length).map(
2153
- (i2) => rows[i2]
2154
- );
2155
- if (!picked.length) return null;
2156
- return { ort: picked.map((r) => r.key).join(";"), ein: 0 };
2157
- }
2158
- const a = await this.term.readLine(
2159
- `
2160
- ${list}
2161
- Component number (Enter cancels): `
2274
+ const picked2 = await this.pickList("Components", options, true);
2275
+ if (!picked2 || !picked2.length) return null;
2276
+ return { ort: picked2.join(";"), ein: 0 };
2277
+ }
2278
+ const picked = await this.pickList("Component", options);
2279
+ if (!picked) return null;
2280
+ const onOff = await this.pickList(
2281
+ `${options.find((o) => o.value === picked[0])?.label || picked[0]}`,
2282
+ [
2283
+ { value: "on", label: "On" },
2284
+ { value: "off", label: "Off" }
2285
+ ]
2162
2286
  );
2163
- const [i] = pickNumbers(a, rows.length);
2164
- if (i == null) return null;
2165
- const onOff = await this.term.readLine(`On or off? [on/off] `);
2166
- if (onOff == null || !onOff.trim()) return null;
2167
- return {
2168
- ort: rows[i].key,
2169
- ein: /^on/i.test(onOff.trim()) ? 0 : 1
2170
- };
2287
+ if (!onOff) return null;
2288
+ return { ort: picked[0], ein: onOff[0] === "on" ? 0 : 1 };
2171
2289
  }
2172
2290
  /** INPA's Select: which named logical lines to keep on screen. */
2173
2291
  async pickLines(_p, names, multiple, _current, hints) {
@@ -2179,20 +2297,18 @@ Component number (Enter cancels): `
2179
2297
  );
2180
2298
  return null;
2181
2299
  }
2182
- const list = names.map((n, i) => ` ${i + 1}. ${n}`).join("\n");
2183
- const a = await this.term.readLine(
2184
- `
2185
- ${list}
2186
- Lines to show${multiple ? ", comma-separated" : ""} (a = all, Enter cancels): `
2187
- );
2188
- if (a == null) return null;
2189
- if (/^a(ll)?$/i.test(a.trim())) return [];
2190
- const picked = pickNumbers(a, names.length).map((i) => names[i]);
2191
- return picked.length ? picked : null;
2300
+ const options = [
2301
+ { value: "\0all", label: "(all lines)" },
2302
+ ...names.map((n) => ({ value: n, label: n }))
2303
+ ];
2304
+ const picked = await this.pickList("Lines to show", options, multiple);
2305
+ if (!picked || !picked.length) return null;
2306
+ if (picked.includes("\0all")) return [];
2307
+ return picked;
2192
2308
  }
2193
2309
  /** INPA's save-as dialog: a file name, written when the body ends. */
2194
2310
  async saveFile() {
2195
- const a = await this.term.readLine(`
2311
+ const a = await this.ask(`
2196
2312
  Save as [fault-memory.txt]: `);
2197
2313
  if (a == null) return null;
2198
2314
  return { name: a.trim() || "fault-memory.txt" };
@@ -2202,9 +2318,9 @@ Save as [fault-memory.txt]: `);
2202
2318
  this.status(_p, `wrote ${picked.name}`);
2203
2319
  }
2204
2320
  printScreen(p) {
2205
- this.term.write(`
2206
- ${this.gridLines(p).join("\n")}
2207
- `);
2321
+ const name = `bmweb-screen-${Date.now()}.txt`;
2322
+ writeFileSync4(name, this.gridLines(p).join("\n") + "\n", "utf8");
2323
+ this.status(p, `printed the screen to ${name}`);
2208
2324
  }
2209
2325
  /**
2210
2326
  * The module a scriptchange names. From the home script it is the car's
@@ -2258,23 +2374,63 @@ ${this.gridLines(p).join("\n")}
2258
2374
  }
2259
2375
  this.paint(p);
2260
2376
  }
2261
- /** Redraw everything: title, grid, keys, footer. */
2377
+ /** Redraw everything: title, grid, keys, footer -- in place. */
2262
2378
  paint(p) {
2379
+ this.flush(this.frameLines(p));
2380
+ }
2381
+ /**
2382
+ * The frame as lines: title, rule, the view or the grid, a blank, the
2383
+ * key bar, then the status and progress lines. Cut to the terminal's
2384
+ * width (a wrapped line would break the row count the redraw relies on)
2385
+ * and to its height, the body giving way first.
2386
+ * @param p - the program
2387
+ * @returns the lines, none wider than the terminal
2388
+ */
2389
+ frameLines(p) {
2263
2390
  const w = this.term.columns;
2264
- const out = [];
2265
2391
  const title = `${p.ecu.label || p.ecu.sgbd} ${p.ecu.sgbd}.prg ${p.title || ""}`.trim();
2266
- out.push(title.slice(0, w));
2267
- out.push("-".repeat(Math.min(w, 78)));
2268
- if (p.view) {
2269
- out.push(...(p.view.lines || []).slice(0, this.term.rows - 8));
2270
- } else {
2271
- out.push(...this.gridLines(p));
2392
+ const head = [title, "-".repeat(Math.min(w, 78))];
2393
+ let body = p.view ? [...p.view.lines || []] : this.gridLines(p);
2394
+ const tail = ["", ...this.keyLines(p), this.statusText, this.progressText];
2395
+ const room = Math.max(1, this.term.rows - 1 - head.length - tail.length);
2396
+ if (body.length > room) body = body.filter((l, i) => l || body[i - 1]);
2397
+ if (body.length > room) {
2398
+ const hidden = body.length - (room - 1);
2399
+ body = [
2400
+ ...body.slice(0, room - 1),
2401
+ `(${hidden} more rows: enlarge the terminal)`
2402
+ ];
2272
2403
  }
2273
- out.push("");
2274
- out.push(...this.keyLines(p));
2275
- this.term.write(`\x1B[H\x1B[2J${out.join("\n")}
2276
- `);
2277
- this.drawFooter();
2404
+ return [...head, ...body, ...tail].map(
2405
+ (l) => String(l).replace(/[\r\n]/g, " ").slice(0, w)
2406
+ );
2407
+ }
2408
+ /**
2409
+ * Put a frame on the screen over the last one. The frame always starts
2410
+ * at the top-left of the alternate screen: go there, rewrite the lines
2411
+ * that differ (each erased to the end of the row), step over the ones
2412
+ * that match, and erase whatever the old frame had below the new one.
2413
+ * With no frame to build on (first paint, after a prompt, after a
2414
+ * resize) the screen is cleared first -- on the alternate screen that
2415
+ * costs no scrollback.
2416
+ * @param lines - the new frame
2417
+ */
2418
+ flush(lines) {
2419
+ const size = `${this.term.columns}x${this.term.rows}`;
2420
+ if (size !== this.frameSize) this.frame = [];
2421
+ this.frameSize = size;
2422
+ const prev = this.frame;
2423
+ if (prev.length === lines.length && prev.every((l, i) => l === lines[i]))
2424
+ return;
2425
+ let s = prev.length ? "\x1B[H" : "\x1B[2J\x1B[H";
2426
+ lines.forEach((line, i) => {
2427
+ if (i < prev.length && prev[i] === line) s += "\x1B[B";
2428
+ else s += `\r${line}\x1B[K\r
2429
+ `;
2430
+ });
2431
+ if (lines.length < prev.length) s += "\r\x1B[J";
2432
+ this.term.write(s);
2433
+ this.frame = lines;
2278
2434
  }
2279
2435
  /**
2280
2436
  * The grid as text rows: cells placed at their column, a lamp as a dot
@@ -2314,21 +2470,25 @@ ${this.gridLines(p).join("\n")}
2314
2470
  );
2315
2471
  return out;
2316
2472
  }
2317
- /** The two bottom lines: status and progress. */
2473
+ /**
2474
+ * The two bottom lines of the frame: status and progress. They are part
2475
+ * of the frame, so a change repaints it (which writes just those lines);
2476
+ * before a program exists the text is written on its own.
2477
+ */
2318
2478
  drawFooter() {
2319
- this.term.write(
2320
- `\x1B[s\x1B[${Math.max(1, this.term.rows - 1)};1H\x1B[K${this.statusText}
2321
- \x1B[K${this.progressText}\x1B[u`
2322
- );
2479
+ if (this.program) {
2480
+ this.paint(this.program);
2481
+ return;
2482
+ }
2483
+ this.frame = [];
2484
+ this.term.write(`${this.statusText}\r
2485
+ ${this.progressText}\r
2486
+ `);
2323
2487
  }
2324
2488
  left() {
2325
2489
  if (this.leftResolve) this.leftResolve();
2326
2490
  }
2327
2491
  };
2328
- function pickNumbers(a, n) {
2329
- if (a == null) return [];
2330
- return a.split(/[,\s]+/).map((s) => Number(s)).filter((i) => Number.isInteger(i) && i >= 1 && i <= n).map((i) => i - 1);
2331
- }
2332
2492
  function cellText(c) {
2333
2493
  const text = String(c.text || "");
2334
2494
  if (c.kind === "lamp") {
@@ -2417,6 +2577,7 @@ async function tuiCommand(chassis, sgbd, opts = {}) {
2417
2577
  }
2418
2578
  const program = p;
2419
2579
  const unsubscribe = term.onKey((k) => {
2580
+ if (ui.modal) return;
2420
2581
  const what = keyToPress(k);
2421
2582
  if (what === null) return;
2422
2583
  if (what === "quit") {
@@ -2446,7 +2607,7 @@ async function tuiCommand(chassis, sgbd, opts = {}) {
2446
2607
  }
2447
2608
 
2448
2609
  // src/bmweb.ts
2449
- var VERSION = true ? "0.1.0" : "0.0.0-dev";
2610
+ var VERSION = true ? "0.1.2" : "0.0.0-dev";
2450
2611
  var INCLUDE = {
2451
2612
  include: {
2452
2613
  kind: "list",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bmweb-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "BMWeb's tools as a command line: read INPA .IPO scripts, compile .IPS sources, search the corpus job index, decode and diff shared Garage reports, and, over a K+DCAN cable, run jobs, whole-car scans and INPA screens in the terminal.",
5
5
  "license": "GPL-3.0-only",
6
6
  "type": "module",
@@ -41,9 +41,7 @@ if (typeof module !== 'undefined' && module.exports) {
41
41
  JUMP_TESTS,
42
42
  REG_BYTES,
43
43
  isWriteJob,
44
- // the classifier's parts, exported so test_write_gate.js
45
- // can compare each against its Python twin in
46
- // tools/verify/sgbd_bulk_verify.py pattern-by-pattern
44
+ // the classifier's parts, exported for tests and tooling
47
45
  READ_TOKEN,
48
46
  CONFIG_READ_TOKEN,
49
47
  WRITE_TOKEN,
@@ -6,8 +6,9 @@
6
6
  * strings); executing the program handles all of it, which is why EDIABAS is
7
7
  * flawless. Input is tools/sgbd_code.py output (ops array, jumps as indices);
8
8
  * telegram I/O is a callback, so one VM runs live cable / .sim / fixture.
9
- * Semantics ported from vendored EdiabasLib (EdOperations.cs, EdiabasNet.cs),
10
- * the engine tools/sgbd_bulk_verify.py diffs against and test_bestvm.js checks.
9
+ * Semantics ported from EdiabasLib (EdOperations.cs, EdiabasNet.cs); the
10
+ * engine's own result sets for 460 E46 jobs are the committed fixture
11
+ * data/sim-captures/vmfix.json that test_bestvm.js replays.
11
12
  *
12
13
  * THE REGISTER MODEL, which nothing else here makes sense without:
13
14
  * B/I/L/A are VIEWS over one 32-byte array, LITTLE-endian within a view, so
@@ -35,9 +35,9 @@
35
35
  * changed in the corpus -- but with INFO checked after writes (rule 3), a
36
36
  * hypothetical SYSTEMCHECK_STOP_INFO must hit the write tier, not fall
37
37
  * through to the INFO tier. Relaxing a WRITE token is the safe direction.
38
- * Kept identical to the classifier in tools/verify/sgbd_bulk_verify.py --
39
- * two different answers to "is this a write?" is worse than either alone.
40
- * test_write_gate.js check 3 enforces the twin token-by-token.
38
+ * This is the ONE classifier: the Python twin that the retired engine
39
+ * harness carried is gone, so there is no second answer to "is this a
40
+ * write?" to drift from.
41
41
  */
42
42
 
43
43
  /**
@@ -444,6 +444,18 @@ function bViewopen(vm, stack) {
444
444
  };
445
445
  }
446
446
 
447
+ /**
448
+ * printfile(->ErrorCode, FileName, PrinterName, PrinterPort, ErrorMsgFlag):
449
+ * INPA prints the named file (the fault protocol a read wrote) on the
450
+ * printer. The print itself is the runtime's (suspensions.js 'printfile');
451
+ * here the error code is 0, printed or not, so the script never takes its
452
+ * "could not print" branch over a sheet the browser handles.
453
+ * @type {IpoBuiltin}
454
+ */
455
+ function bPrintfile(vm, stack) {
456
+ storeOut(vm, stack, 0);
457
+ }
458
+
447
459
  /**
448
460
  * filewrite(text): append a line to the open file.
449
461
  * @type {IpoBuiltin}
@@ -601,6 +613,7 @@ if (typeof module !== 'undefined' && module.exports) {
601
613
  bInputDigital,
602
614
  bFileopen,
603
615
  bFileclose,
616
+ bPrintfile,
604
617
  bFilewrite,
605
618
  bFileread,
606
619
  bSettimer,
@@ -13,6 +13,12 @@ const IPO_ANALOG_FMT_RE = /^(\d+)\.(\d+)$/;
13
13
  /** toFixed()'s upper bound on decimals; a stray format never asks for more. */
14
14
  const IPO_MAX_DECIMALS = 20;
15
15
 
16
+ /** Bytes per line of INPA's hexdump. */
17
+ const IPO_HEXDUMP_LINE = 16;
18
+
19
+ /** The fewest hex digits a hexdump address column shows. */
20
+ const IPO_HEXDUMP_ADDR_MIN = 4;
21
+
16
22
  /**
17
23
  * settitle / setmenutitle: the first argument is the title.
18
24
  * @type {IpoBuiltin}
@@ -113,6 +119,38 @@ function bUserboxClear(vm) {
113
119
  if (vm.onUserbox) vm.onUserbox(vm.userbox);
114
120
  }
115
121
 
122
+ /**
123
+ * The foreground / background pair a colour builtin was handed: its last
124
+ * two integer arguments (userboxsetcolor leads with the box number).
125
+ * @param {IpoValue[]} stack - the call's arguments
126
+ * @returns {{fg: number, bk: number}|null} null when the call named no pair
127
+ */
128
+ function ipoColorArgs(stack) {
129
+ const n = allInts(stack);
130
+ if (n.length < 2) return null;
131
+ return { fg: n[n.length - 2], bk: n[n.length - 1] };
132
+ }
133
+
134
+ /**
135
+ * setcolor(FgColor, BkColor): the colour every text printed after it takes.
136
+ * Recorded on the run so a host that draws colour can honour it; the
137
+ * browser's skins keep their own palette.
138
+ * @type {IpoBuiltin}
139
+ */
140
+ function bSetcolor(vm, stack) {
141
+ const c = ipoColorArgs(stack);
142
+ if (c) vm.out.color = c;
143
+ }
144
+
145
+ /**
146
+ * userboxsetcolor(BoxNum, FgColor, BkColor): the progress window's colours.
147
+ * @type {IpoBuiltin}
148
+ */
149
+ function bUserboxSetcolor(vm, stack) {
150
+ const c = ipoColorArgs(stack);
151
+ if (c && vm.userbox) vm.userbox.color = c;
152
+ }
153
+
116
154
  /**
117
155
  * userboxftextout(text, row, col ...): a line of the progress window. It is
118
156
  * also a textout, so a body without a box paints it with the screen.
@@ -234,9 +272,69 @@ function bTextout(vm, stack) {
234
272
  el.row = ints[0];
235
273
  el.col = ints[1];
236
274
  }
275
+ // the colour setcolor chose for what follows rides on the live cell so a
276
+ // host that draws colour (the terminal UI) can; offline the element stays
277
+ // byte-identical to the Python twin's
278
+ if (vm.wireJobs && vm.out.color) el.color = vm.out.color;
237
279
  line.elements.push(el);
238
280
  }
239
281
 
282
+ /**
283
+ * The lines of INPA's hexdump: one per 16 bytes, the address (the start
284
+ * address plus the line's offset, in hex, at least as wide as the start
285
+ * address was written) then the bytes as two hex digits each. A start
286
+ * address that is not hex counts from 0.
287
+ * @param {string} startAdr - the address the bytes were read from ("0x1000")
288
+ * @param {number[]} bytes - the bytes to show
289
+ * @returns {string[]}
290
+ */
291
+ function ipoHexdumpLines(startAdr, bytes) {
292
+ const digits = String(startAdr || '')
293
+ .trim()
294
+ .replace(/^0x/i, '');
295
+ const base = /^[0-9a-f]+$/i.test(digits) ? parseInt(digits, 16) : 0;
296
+ const width = Math.max(IPO_HEXDUMP_ADDR_MIN, digits.length);
297
+ const out = [];
298
+ for (let i = 0; i < bytes.length; i += IPO_HEXDUMP_LINE) {
299
+ const addr = (base + i).toString(16).toUpperCase().padStart(width, '0');
300
+ const hex = bytes
301
+ .slice(i, i + IPO_HEXDUMP_LINE)
302
+ .map((b) => (b & 0xff).toString(16).toUpperCase().padStart(2, '0'));
303
+ out.push(`${addr} ${hex.join(' ')}`);
304
+ }
305
+ return out;
306
+ }
307
+
308
+ /**
309
+ * hexdump(StartAdr, numbytes, row, col): the bytes of the last
310
+ * INPAapiResultBinary painted as a hex table at (row, col), the way the
311
+ * Speicher-lesen screens show memory. The bytes come from the wire, so
312
+ * offline there is nothing to paint (the lift sees the call, not a table).
313
+ * Each line is printed as ftextout would print it, one row down from the
314
+ * last, so the grid, the sheet and Select treat it as text.
315
+ * @type {IpoBuiltin}
316
+ */
317
+ function bHexdump(vm, stack) {
318
+ if (!vm.wireJobs) return;
319
+ const key = vm.globals.get('__pending_binary__');
320
+ if (!key || !vm.host || typeof vm.host.raw !== 'function') return;
321
+ const set = vm.globals.get('__pending_binary_set__');
322
+ const hex = ipoBinaryHex(
323
+ vm.host.raw(key, { set: set == null ? undefined : set })
324
+ );
325
+ if (!hex) return;
326
+ let bytes = (hex.match(/[0-9A-F]{2}/g) || []).map((h) => parseInt(h, 16));
327
+ const args = stack.filter((x) => !isRef(x));
328
+ const count = args.length > 1 ? Math.trunc(num(args[1])) : bytes.length;
329
+ if (count > 0) bytes = bytes.slice(0, count);
330
+ const row = args.length > 2 ? Math.trunc(num(args[2])) : 0;
331
+ const col = args.length > 3 ? Math.trunc(num(args[3])) : 0;
332
+ const startAdr = args.length ? asStr(args[0]) : '';
333
+ ipoHexdumpLines(startAdr, bytes).forEach((text, i) =>
334
+ bTextout(vm, [text, row + i, col])
335
+ );
336
+ }
337
+
240
338
  /**
241
339
  * The element a lamp or bar builtin declares, placed on the current line.
242
340
  * Offline the row/col come from the int scan the Python twin uses (for
@@ -464,7 +562,20 @@ function bCallwin(vm, stack, item) {
464
562
  }
465
563
 
466
564
  /**
467
- * A builtin with no effect on the model (window chrome, colours, stop).
565
+ * viewclose(): INPA closes its viewer window. Live, the run says so and
566
+ * drops a view it opened earlier in the same body; a viewopen after it
567
+ * sets a new one, which wins (the runtime reads the view first, the close
568
+ * only when there is none). Offline nothing is lifted from a window.
569
+ * @type {IpoBuiltin}
570
+ */
571
+ function bViewclose(vm) {
572
+ if (!vm.wireJobs) return;
573
+ vm.out.view = null;
574
+ vm.out.viewClose = true;
575
+ }
576
+
577
+ /**
578
+ * A builtin with no effect on the model (window chrome, stop).
468
579
  * @type {IpoBuiltin}
469
580
  */
470
581
  function bNoop() {}
@@ -476,6 +587,11 @@ if (typeof module !== 'undefined' && module.exports) {
476
587
  bSetmenu,
477
588
  bSetscreen,
478
589
  bTextout,
590
+ ipoHexdumpLines,
591
+ bHexdump,
592
+ bSetcolor,
593
+ bUserboxSetcolor,
594
+ bViewclose,
479
595
  drawField,
480
596
  bAnalogout,
481
597
  bMultiAnalogout,
@@ -56,7 +56,7 @@ const BUILTINS = {
56
56
  userboxopen: bUserboxOpen,
57
57
  userboxclose: bUserboxClose,
58
58
  viewopen: bViewopen,
59
- viewclose: bNoop,
59
+ viewclose: bViewclose,
60
60
  setstate: bSetstate,
61
61
  start: bSetstate,
62
62
  select: bSelect,
@@ -81,8 +81,8 @@ const BUILTINS = {
81
81
  fileclose: bFileclose,
82
82
  filewrite: bFilewrite,
83
83
  fileread: bFileread,
84
- hexdump: bNoop,
85
- printfile: bNoop,
84
+ hexdump: bHexdump,
85
+ printfile: bPrintfile,
86
86
  setstatemachine: bNoop,
87
87
  StrArrayCreate: bStrArrayCreate,
88
88
  StrArrayDestroy: bNoop,
@@ -147,7 +147,8 @@ const BUILTINS = {
147
147
  builtin_87: bUnavailable,
148
148
  builtin_93: bUnavailable,
149
149
  builtin_90: bStrArraySize, // string array length, out-param
150
- builtin_1a: bNoop, // setcolor
150
+ builtin_1a: bSetcolor, // setcolor
151
+ setcolor: bSetcolor,
151
152
  builtin_51: bBlankscreen, // blankscreen
152
153
  blankscreen: bBlankscreen,
153
154
  settimer: bSettimer,
@@ -158,7 +159,8 @@ const BUILTINS = {
158
159
  bmweb_pick: bBmwebPick,
159
160
  bmweb_status: bBmwebStatus,
160
161
  builtin_57: bUserboxClear, // userboxclear
161
- builtin_58: bNoop, // userboxsetcolor
162
+ builtin_58: bUserboxSetcolor, // userboxsetcolor
163
+ userboxsetcolor: bUserboxSetcolor,
162
164
  };
163
165
 
164
166
  if (typeof module !== 'undefined' && module.exports) {
@@ -24,6 +24,15 @@
24
24
  * @property {string} [fmt] - analogout display format ("6.2")
25
25
  * @property {string} [on] - digitalout's TrueText
26
26
  * @property {string} [off] - digitalout's FalseText
27
+ * @property {IpoColor} [color] - the setcolor in force when a live run printed it
28
+ */
29
+
30
+ /**
31
+ * A colour pair as INPA's setcolor / userboxsetcolor name it: two of
32
+ * INPA's palette indices. Recorded for hosts that draw colour.
33
+ * @typedef {object} IpoColor
34
+ * @property {number} fg - foreground index
35
+ * @property {number} bk - background index
27
36
  */
28
37
 
29
38
  /**
@@ -111,6 +120,18 @@ class Emissions {
111
120
  * @type {{path: string, lines: string[]}|null}
112
121
  */
113
122
  this.view = null;
123
+ /**
124
+ * viewclose(): a live body closed INPA's viewer window. Read only when
125
+ * no viewopen in the same body left a view behind.
126
+ * @type {boolean}
127
+ */
128
+ this.viewClose = false;
129
+ /**
130
+ * setcolor(fg, bk): the colour in force for the text printed after it,
131
+ * null until a body sets one.
132
+ * @type {IpoColor|null}
133
+ */
134
+ this.color = null;
114
135
  /**
115
136
  * setscreen's second argument: TRUE = a frequent screen, re-run its cycle
116
137
  * while it is current (INPA's WM_TIMER loop); null = no setscreen.
@@ -12,12 +12,14 @@
12
12
  * says why; `out` is the emissions so far (a parked machine's drawn screen
13
13
  * IS the picker).
14
14
  * @typedef {object} IpoStep
15
- * @property {'done'|'yield'|'job'|'wait'|'input'|'message'|'toggle'|'print'|'select'|'exit'} kind -
15
+ * @property {'done'|'yield'|'job'|'wait'|'input'|'message'|'toggle'|'print'|'printfile'|'select'|'exit'} kind -
16
16
  * done: the proc finished; yield: parked at a %STATE; job: a wire job to
17
17
  * run and feed back; wait: a timed wartezeit; input: an INPA prompt;
18
18
  * message: a blocking messagebox; toggle: the component picker; print:
19
- * printscreen; select: INPA's line filter; exit: the script ended itself
19
+ * printscreen; printfile: a written file to print; select: INPA's line
20
+ * filter; exit: the script ended itself
20
21
  * @property {Emissions} [out] - emissions so far
22
+ * @property {string} [file] - printfile / fsread: the file's name
21
23
  * @property {string} [name] - yield: the state label
22
24
  * @property {string} [job] - job: the job name
23
25
  * @property {string|null} [sgbd] - job: the SGBD the script addressed
@@ -46,7 +48,9 @@ const IPO_SUSPEND_KINDS = new Set([
46
48
  'message',
47
49
  'toggle',
48
50
  'pick', // bmweb_pick: the host lists, the user chooses (home script)
51
+ 'fsread', // INPAapiFsLesen: the renderer reads the fault memory and writes the file
49
52
  'print',
53
+ 'printfile', // printfile: the renderer prints a file the body wrote
50
54
  'select',
51
55
  'exit',
52
56
  ]);
@@ -184,6 +188,15 @@ function ipoDriveBuiltin(vm, t, stack) {
184
188
  if (vm.wireJobs && name === 'printscreen') {
185
189
  return { kind: 'print', out: vm.out };
186
190
  }
191
+ // INPA's printfile(->rc, file, printer, port, flag): the protocol file a
192
+ // read wrote goes to the printer. The builtin answers rc = 0 first (the
193
+ // script's own error branch must not fire), then the renderer prints the
194
+ // file's lines from the VM's files. Offline only the rc is stored.
195
+ if (vm.wireJobs && name === 'printfile') {
196
+ vm._builtin(t, stack, null);
197
+ const strs = stack.filter((x) => !isRef(x)).map((x) => asStr(x));
198
+ return { kind: 'printfile', file: strs[0] || '', out: vm.out };
199
+ }
187
200
  // A LIVE togglelist is INPA's component picker: park until the renderer
188
201
  // hands back the pick ({ort, ein}); resume re-runs the builtin with it.
189
202
  // togglelist(MultipleSelectFlag, ArgNumFlag, ->ApiToggleString)
@@ -196,6 +209,23 @@ function ipoDriveBuiltin(vm, t, stack) {
196
209
  out: vm.out,
197
210
  };
198
211
  }
212
+ // INPAapiFsLesen(sgbd, file): INPA's API reads the module's fault memory
213
+ // (FS_LESEN, then the detail of every entry) and writes the protocol file
214
+ // the script then viewopen()s. 161 shipped scripts read faults only this
215
+ // way. Live, the renderer does that read and hands the file back; offline
216
+ // it stays a noop (nothing to lift from the API's own work).
217
+ if (
218
+ vm.wireJobs &&
219
+ (name === 'INPAapiFsLesen' || name === 'INPAapiFsLesen2')
220
+ ) {
221
+ const strs = stack.filter((x) => !isRef(x)).map((x) => asStr(x));
222
+ return {
223
+ kind: 'fsread',
224
+ sgbd: strs[0] || '',
225
+ file: strs[1] || 'na_fs.tmp',
226
+ out: vm.out,
227
+ };
228
+ }
199
229
  // BMWeb's own picker: bmweb_pick("chassis"|"module"|"vehicle", arg,
200
230
  // ->choice). Park like a togglelist; the renderer asks the host for the
201
231
  // list and resumes with the choice, which the re-run builtin stores.
@@ -178,9 +178,9 @@ const PCODE_MAP = {
178
178
  '27C2': 'P2562',
179
179
  '27C4': 'P2564',
180
180
  };
181
- // Flatten an EDIABAS result value to the same text the native bridge produces
182
- // (src/EdiabasMac/Diag.cs Format): byte arrays become dashed hex ("27-DA"),
183
- // everything else its plain string. The web VM returns live typed values --
181
+ // Flatten an EDIABAS result value to the text EDIABAS itself would print:
182
+ // byte arrays become dashed hex ("27-DA"), everything else its plain string.
183
+ // The web VM returns live typed values --
184
184
  // `ergy` (binary) emits a byte Array, `ergi`/`ergb`/... emit numbers -- so
185
185
  // screens that only ever saw the native path's strings funnel through here.
186
186
  // An empty binary result ([]) is truthy but must read as "no code", which the
@@ -20,9 +20,9 @@
20
20
  * here): SerialProxy.cs is the byte-mover behind NativeSerialBus (open/write/
21
21
  * readAvailable/close/flush). Bytes cross that bridge as a JSON int[]
22
22
  * (BmacwBridge.cs AsNumberArray), NOT base64 -- base64 corrupted the
23
- * echo/checksum. src/EdiabasMac is LEGACY (its InpaMac.Api server is deleted);
24
- * it is reference for what JS reimplemented, not a transport, and is
25
- * deliberately NOT part of this interface.
23
+ * echo/checksum. The former C# engine (deleted with its InpaMac.Api server) was
24
+ * reference for what JS reimplemented, not a transport, and was never part
25
+ * of this interface.
26
26
  */
27
27
  /* exported SerialTransportBase */
28
28
 
@@ -269,6 +269,84 @@ function ipoPrintScreen(p, ecu, inpa) {
269
269
  return Promise.resolve();
270
270
  }
271
271
 
272
+ /**
273
+ * Whether a printfile names the file the viewer is showing: the same path,
274
+ * or the same text (the API's fault read and a save-as can leave the
275
+ * script holding a different name for the same protocol).
276
+ * @param {IpoProgram} p - the program
277
+ * @param {string} name - the file name printfile passed
278
+ * @param {string[]} lines - the file's lines
279
+ * @returns {boolean}
280
+ */
281
+ function ipoPrintFileIsView(p, name, lines) {
282
+ const v = p.view;
283
+ if (!v) return false;
284
+ if (v.path && v.path === name) return true;
285
+ const shown = v.lines || [];
286
+ return (
287
+ shown.length > 0 &&
288
+ shown.length === lines.length &&
289
+ shown.every((l, i) => l === lines[i])
290
+ );
291
+ }
292
+
293
+ /**
294
+ * The print document for INPA's printfile: the fault protocol as the
295
+ * report's tables plus INPA's text when the file is the one the viewer
296
+ * shows (what printscreen prints over that view), else the file's lines
297
+ * as a monospace sheet. Pure, so the harness can pin it.
298
+ * @param {IpoProgram} p - the program
299
+ * @param {EcuRecord} ecu - the module
300
+ * @param {string} name - the file's name
301
+ * @param {string[]} lines - the file's lines
302
+ * @returns {PrintDocOptions}
303
+ */
304
+ function ipoPrintFileDocument(p, ecu, name, lines) {
305
+ const isView = ipoPrintFileIsView(p, name, lines);
306
+ const sections = [];
307
+ if (
308
+ isView &&
309
+ p.view.report &&
310
+ typeof ipoProtocolPrintSections === 'function'
311
+ ) {
312
+ sections.push(...ipoProtocolPrintSections(p.view));
313
+ } else {
314
+ sections.push({
315
+ html: `<pre class="pr-screen">${esc((lines || []).join('\n'))}</pre>`,
316
+ });
317
+ }
318
+ const subtitle =
319
+ isView && p.view.title ? ipoText(p.view.title) : ipoText(p.title || '');
320
+ return {
321
+ title: ecu.label || ecu.sgbd,
322
+ subtitle: subtitle || name,
323
+ meta: [
324
+ ecu.chassis ? ['Chassis', String(ecu.chassis).toUpperCase()] : null,
325
+ ['SGBD', `${ecu.sgbd}.prg`],
326
+ ['File', name],
327
+ ['Printed', new Date().toLocaleString()],
328
+ ],
329
+ sections,
330
+ };
331
+ }
332
+
333
+ /**
334
+ * INPA's printfile for the module view: the clean sheet when the print
335
+ * builder is loaded, nothing otherwise (the browser's own print would show
336
+ * the screen, not the file).
337
+ * @param {IpoProgram} p - the program
338
+ * @param {EcuRecord} ecu - the module
339
+ * @param {string} name - the file's name
340
+ * @param {string[]} lines - the file's lines
341
+ * @returns {Promise<void>}
342
+ */
343
+ function ipoPrintFile(p, ecu, name, lines) {
344
+ if (typeof printDoc === 'function') {
345
+ return printDoc(ipoPrintFileDocument(p, ecu, name, lines));
346
+ }
347
+ return Promise.resolve();
348
+ }
349
+
272
350
  if (typeof module !== 'undefined' && module.exports) {
273
351
  module.exports = {
274
352
  IPO_PRINT_COLS,
@@ -277,5 +355,7 @@ if (typeof module !== 'undefined' && module.exports) {
277
355
  ipoPrintKeysHtml,
278
356
  ipoPrintDocument,
279
357
  ipoPrintScreen,
358
+ ipoPrintFileDocument,
359
+ ipoPrintFile,
280
360
  };
281
361
  }
@@ -103,6 +103,8 @@ const IPO_SILENT_RE = /IFH-0009|IFH-0019|no answer/i;
103
103
  * @property {(p: IpoProgram, step: IpoStep) => Promise<IpoPick|null>} pickComponent - the togglelist picker
104
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
105
  * @property {(p: IpoProgram) => void} printScreen - INPA's printscreen
106
+ * @property {(p: IpoProgram, name: string, lines: string[]) => void} [printFile] -
107
+ * INPA's printfile: a file the script wrote, by name, as its lines
106
108
  * @property {(ecu: EcuRecord, script: string, exec: IpoExec) => Promise<EcuRecord|null>} [resolveScriptEcu] -
107
109
  * the module a scriptchange target addresses, identified by the car
108
110
  * @property {(p: IpoProgram, step: IpoStep, guards: Set<number>) => Promise<'tick'|'press'|'stop'>} machineTick - a %STATE park
@@ -173,6 +175,8 @@ class IpoProgram {
173
175
  this.script = null;
174
176
  /** @type {string|null} the menu the entry set (the route's root) */
175
177
  this.rootMenu = null;
178
+ /** @type {string|null} the screen the entry set on the root menu */
179
+ this.rootScreen = null;
176
180
  this.answered = false; // INITIALISIERUNG returned any result
177
181
  this.silent = false; // INITIALISIERUNG got no answer at all
178
182
  this.noCable = false; // no adapter: the script cannot ask the car
@@ -276,6 +280,14 @@ class IpoProgram {
276
280
  this.messages.push({ title: step.title, body: step.body });
277
281
  await this.ui.message(step.title, step.body);
278
282
  step = vm.resume();
283
+ } else if (step.kind === 'fsread') {
284
+ // INPA's API fault read: FS_LESEN and every entry's detail on the
285
+ // wire, then the protocol file the script goes on to viewopen
286
+ const lines = await this.apiFaultRead(step.sgbd, step.file, ctx);
287
+ if (lines == null) return { done: false, cancelled: true };
288
+ vm.files.set(step.file, lines);
289
+ vm.lastWritten = step.file;
290
+ step = vm.resume();
279
291
  } else if (step.kind === 'pick') {
280
292
  // BMWeb's own picker (the home script): the host lists chassis,
281
293
  // modules or the whole-vehicle script; a cancel leaves the body
@@ -298,6 +310,9 @@ class IpoProgram {
298
310
  } else if (step.kind === 'print') {
299
311
  this.ui.printScreen(this);
300
312
  step = vm.resume();
313
+ } else if (step.kind === 'printfile') {
314
+ this.printFile(step.file);
315
+ step = vm.resume();
301
316
  } else if (step.kind === 'select') {
302
317
  // INPA's Select: which of the screen's named logical lines to show.
303
318
  // A screen with none still opens the box (INPA shows an empty
@@ -341,6 +356,23 @@ class IpoProgram {
341
356
  return { done: true, exit: !!(vm.out && vm.out.exit) };
342
357
  }
343
358
 
359
+ /**
360
+ * INPA's printfile: the named file's lines go to the UI's printer. The
361
+ * name is what the script computed; when the VM holds no file by it the
362
+ * last one written stands in, as viewopen's does (the API's fault read
363
+ * names its file for the script, and a save-as may have renamed it).
364
+ * @param {string} name - the file name the script passed
365
+ * @returns {void}
366
+ */
367
+ printFile(name) {
368
+ if (typeof this.ui.printFile !== 'function' || !this.vm) return;
369
+ const { files, lastWritten } = this.vm;
370
+ let path = name;
371
+ if (!files.has(path) && lastWritten && files.has(lastWritten))
372
+ path = lastWritten;
373
+ this.ui.printFile(this, path, [...(files.get(path) || [])]);
374
+ }
375
+
344
376
  /**
345
377
  * Select's choice: the names of the logical lines to show, or null for
346
378
  * all. The current screen is repainted through it; a screen change keeps
@@ -406,6 +438,79 @@ class IpoProgram {
406
438
  * @param {IpoRunContext} ctx - what is running
407
439
  * @returns {Promise<IpoFeed|null>} the feed, or null when the user declined
408
440
  */
441
+ /**
442
+ * What INPAapiFsLesen does inside INPA: read the module's fault memory
443
+ * (FS_LESEN), then the detail of every stored fault (FS_LESEN_DETAIL by
444
+ * its location number), and write the protocol file. The reads go through
445
+ * runJob, so wireReads carries them and the viewopen that follows draws
446
+ * the report joined with the fault lookup, as the whole-vehicle protocol
447
+ * does; the lines are INPA's own layout for the plain-text view.
448
+ * @param {string} sgbd - the module the script names
449
+ * @param {string} file - the file the script will viewopen
450
+ * @param {object} ctx - the body's context (scope, label)
451
+ * @returns {Promise<string[]|null>} the file's lines, or null when cancelled
452
+ */
453
+ async apiFaultRead(sgbd, file, ctx) {
454
+ const fed = await this.runJob(sgbd, 'FS_LESEN', null, ctx);
455
+ if (fed == null) return null;
456
+ const sets = (fed.sets || []).slice(1);
457
+ const faults = sets.filter(
458
+ (s) => s && (s.F_ORT_NR != null || s.F_HEX_CODE)
459
+ );
460
+ const name = String(sgbd || this.ecu.sgbd || '').toUpperCase();
461
+ const lines = [name, ''];
462
+ if (String(fed.get('JOB_STATUS') || 'OKAY') !== 'OKAY') {
463
+ lines.push(`Fehlerspeicher lesen: ${fed.get('JOB_STATUS')}`);
464
+ return lines;
465
+ }
466
+ if (!faults.length) {
467
+ lines.push('Kein Fehler im Fehlerspeicher');
468
+ return lines;
469
+ }
470
+ lines.push(
471
+ `${faults.length} Fehler im Fehlerspeicher`.replace(
472
+ /^1 Fehler/,
473
+ '1 Fehler'
474
+ ),
475
+ ''
476
+ );
477
+ for (const f of faults) {
478
+ const nr = f.F_ORT_NR != null ? String(f.F_ORT_NR) : '';
479
+ let det = f;
480
+ if (nr) {
481
+ // the detail pass, as INPA's API makes it; a module without the job
482
+ // answers with an error and the memory's own fields stand
483
+ const d = await this.runJob(sgbd, 'FS_LESEN_DETAIL', nr, ctx);
484
+ if (d == null) return null;
485
+ const ds = (d.sets || []).slice(1).find((s) => s && s.F_ORT_NR != null);
486
+ if (ds) det = Object.assign({}, f, ds);
487
+ }
488
+ const hex =
489
+ typeof hexText === 'function'
490
+ ? hexText(det.F_HEX_CODE)
491
+ : String(det.F_HEX_CODE || '');
492
+ lines.push(
493
+ `${nr}${hex ? ` (${hex})` : ''} ${det.F_ORT_TEXT || ''}`.trim()
494
+ );
495
+ const bits = [];
496
+ if (det.F_ART1_TEXT) bits.push(`Fehlerart: ${det.F_ART1_TEXT}`);
497
+ if (det.F_HFK != null) bits.push(`Häufigkeit: ${det.F_HFK}`);
498
+ if (det.F_LZ != null) bits.push(`Logistikzähler: ${det.F_LZ}`);
499
+ if (det.F_VORHANDEN_TEXT) bits.push(det.F_VORHANDEN_TEXT);
500
+ if (bits.length) lines.push(` ${bits.join(' ')}`);
501
+ const n = Number(det.F_UW_ANZ) || 0;
502
+ for (let i = 1; i <= n; i++) {
503
+ const t = det[`F_UW${i}_TEXT`];
504
+ if (t == null) continue;
505
+ const v = det[`F_UW${i}_WERT`];
506
+ const u = det[`F_UW${i}_EINH`];
507
+ lines.push(` ${t}: ${v != null ? v : ''}${u ? ` ${u}` : ''}`);
508
+ }
509
+ lines.push('');
510
+ }
511
+ return lines;
512
+ }
513
+
409
514
  async runJob(sgbd, job, arg, ctx) {
410
515
  const target = ipoWireTarget(this.ecu, sgbd);
411
516
  const entry = !!(ctx && (ctx.scope === 'entry' || ctx.scope === 'exit'));
@@ -561,6 +666,10 @@ class IpoProgram {
561
666
  this.menu = out.menu || null;
562
667
  this.screen = out.screen || null;
563
668
  this.frequent = !!out.screenFrequent;
669
+ // the script's own front: the menu and screen its entry set. Esc on
670
+ // that menu first returns to that screen (see back()).
671
+ this.rootMenu = this.menu;
672
+ this.rootScreen = this.screen;
564
673
  this.title = out.title || null;
565
674
  return { ok: true };
566
675
  }
@@ -1232,6 +1341,10 @@ class IpoProgram {
1232
1341
  const rep = ipoProtocolReport(this.wireReads, out.view.lines);
1233
1342
  this.view.report = rep.modules.length ? rep : null;
1234
1343
  }
1344
+ } else if (out.viewClose) {
1345
+ // viewclose without a viewopen after it: INPA's viewer window is
1346
+ // gone, the screen behind it shows again
1347
+ this.view = null;
1235
1348
  }
1236
1349
  // the body painted (userbox text, a result line): show it with the screen
1237
1350
  this.takeCells(out);
@@ -1281,6 +1394,21 @@ class IpoProgram {
1281
1394
  this.queued = 'back';
1282
1395
  return;
1283
1396
  }
1397
+ // On the root menu, a key like Ident or Code only swaps the screen
1398
+ // (setscreen without setmenu) and the menu's own F10 is "End", which
1399
+ // leaves the module. Esc there first returns to the screen the entry
1400
+ // set, so one Esc undoes the screen and the next one leaves -- INPA's
1401
+ // main menu never strands the user on a sub-screen.
1402
+ if (
1403
+ this.menu &&
1404
+ this.menu === this.rootMenu &&
1405
+ this.rootScreen &&
1406
+ this.screen !== this.rootScreen &&
1407
+ this.exec.procs[this.rootScreen]
1408
+ ) {
1409
+ await this.showScreen(this.rootScreen, false);
1410
+ return;
1411
+ }
1284
1412
  if (this.items.some((it) => it.nr === IPO_BACK_KEY))
1285
1413
  return this.press(IPO_BACK_KEY);
1286
1414
  return this.leaveModule();
@@ -165,6 +165,8 @@ function ipoMakeUi(ecu, container, back) {
165
165
  ipoPickLines(names, multiple, current, hints),
166
166
  // INPA's printscreen: the module view as a clean sheet (print.js)
167
167
  printScreen: (p) => ipoPrintScreen(p, p.ecu || ecu, inpa),
168
+ // INPA's printfile: the protocol file the script wrote, as a sheet
169
+ printFile: (p, name, lines) => ipoPrintFile(p, p.ecu || ecu, name, lines),
168
170
  resolveScriptEcu: (from, script, exec) =>
169
171
  // the home script names a module by its SGBD after a chassis pick:
170
172
  // that module is the car's own record, not a wire-resolved variant