bmweb-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +351 -0
  3. package/dist/bmweb.js +2790 -0
  4. package/package.json +53 -0
  5. package/runtime/core/bestvm/codec.js +285 -0
  6. package/runtime/core/bestvm/environment.js +116 -0
  7. package/runtime/core/bestvm/executor.js +1483 -0
  8. package/runtime/core/bestvm/index.js +52 -0
  9. package/runtime/core/bestvm/machine.js +491 -0
  10. package/runtime/core/bestvm/operands.js +356 -0
  11. package/runtime/core/bestvm/registers.js +152 -0
  12. package/runtime/core/bestvm/write-guard.js +111 -0
  13. package/runtime/core/ipofile/compile.js +364 -0
  14. package/runtime/core/ipofile/decls.js +187 -0
  15. package/runtime/core/ipofile/emit.js +708 -0
  16. package/runtime/core/ipofile/exec.js +164 -0
  17. package/runtime/core/ipofile/lex.js +243 -0
  18. package/runtime/core/ipofile/parse.js +550 -0
  19. package/runtime/core/ipofile/pool.js +404 -0
  20. package/runtime/core/ipofile/walk.js +431 -0
  21. package/runtime/core/ipovm/builtin-helpers.js +182 -0
  22. package/runtime/core/ipovm/builtins-api.js +610 -0
  23. package/runtime/core/ipovm/builtins-screen.js +493 -0
  24. package/runtime/core/ipovm/builtins-table.js +166 -0
  25. package/runtime/core/ipovm/builtins-text.js +166 -0
  26. package/runtime/core/ipovm/emissions.js +138 -0
  27. package/runtime/core/ipovm/hosts.js +191 -0
  28. package/runtime/core/ipovm/operators.js +229 -0
  29. package/runtime/core/ipovm/structures.js +250 -0
  30. package/runtime/core/ipovm/suspensions.js +241 -0
  31. package/runtime/core/ipovm/tape.js +206 -0
  32. package/runtime/core/ipovm/values.js +241 -0
  33. package/runtime/core/ipovm/vm.js +1166 -0
  34. package/runtime/core/translate.js +526 -0
  35. package/runtime/core/webshim/api-router.js +592 -0
  36. package/runtime/core/webshim/bus.js +95 -0
  37. package/runtime/core/webshim/coding.js +82 -0
  38. package/runtime/core/webshim/data-fetch.js +66 -0
  39. package/runtime/core/webshim/exchange.js +288 -0
  40. package/runtime/core/webshim/framing.js +331 -0
  41. package/runtime/core/webshim/install.js +30 -0
  42. package/runtime/core/webshim/job-runner.js +319 -0
  43. package/runtime/core/webshim/native-bus.js +108 -0
  44. package/runtime/core/webshim/timers.js +82 -0
  45. package/runtime/core/webshim/trace.js +205 -0
  46. package/runtime/core/webshim/transport-base.js +128 -0
  47. package/runtime/core/webshim/variant-resolver.js +249 -0
  48. package/runtime/core/webshim/web-serial-bus.js +734 -0
  49. package/runtime/home/bmweb-home.ips +76 -0
  50. package/runtime/home/bmweb.h +26 -0
  51. package/runtime/screens/activations.js +258 -0
  52. package/runtime/screens/garage/diff.js +331 -0
  53. package/runtime/screens/garage/share.js +276 -0
  54. package/runtime/screens/garage/store.js +547 -0
  55. package/runtime/screens/ipo-runtime/cells.js +176 -0
  56. package/runtime/screens/ipo-runtime/dialogs.js +254 -0
  57. package/runtime/screens/ipo-runtime/home.js +358 -0
  58. package/runtime/screens/ipo-runtime/open.js +393 -0
  59. package/runtime/screens/ipo-runtime/paint-grid.js +106 -0
  60. package/runtime/screens/ipo-runtime/paint-modern.js +424 -0
  61. package/runtime/screens/ipo-runtime/print.js +281 -0
  62. package/runtime/screens/ipo-runtime/program.js +1337 -0
  63. package/runtime/screens/ipo-runtime/protocol.js +464 -0
  64. package/runtime/screens/ipo-runtime/script-scan.js +225 -0
  65. package/runtime/screens/ipo-runtime/translate-sets.js +130 -0
  66. package/runtime/screens/ipo-runtime/ui.js +249 -0
  67. package/runtime/screens/ipo-runtime/wire-policy.js +113 -0
  68. package/runtime/screens/ir.js +324 -0
  69. package/runtime/screens/search/data.js +153 -0
  70. package/runtime/screens/search/match.js +285 -0
  71. package/runtime/screens/search/open.js +66 -0
  72. package/runtime/vendor/fflate.min.js +1 -0
@@ -0,0 +1,76 @@
1
+ //**********************************************************************
2
+ //*
3
+ //* BMWeb home: the app's front door as an INPA script, so a terminal (or
4
+ //* any host that runs .IPO) starts where the app starts. Pick a chassis,
5
+ //* pick a module, and scriptchange hands the screen to that module's own
6
+ //* INPA script, exactly as INPA's vehicle selection does.
7
+ //*
8
+ //* Written by the BMWeb project.
9
+ //*
10
+ //**********************************************************************
11
+ #include "bmweb.h"
12
+
13
+ string chassis = "";
14
+ string module = "";
15
+ string status = "";
16
+
17
+ inpainit()
18
+ {
19
+ settitle("BMWeb");
20
+ setscreen(s_main, TRUE);
21
+ setmenu(m_main);
22
+ }
23
+
24
+ MENU m_main()
25
+ {
26
+ INIT {
27
+ setmenutitle("BMWeb");
28
+ }
29
+ ITEM( 1 ,"Vehicle") {
30
+ bmweb_pick("chassis", "", chassis);
31
+ if (chassis != "")
32
+ {
33
+ bmweb_pick("module", chassis, module);
34
+ if (module != "")
35
+ {
36
+ scriptchange(module);
37
+ }
38
+ }
39
+ }
40
+ ITEM( 2 ,"Error scan") {
41
+ bmweb_pick("chassis", "", chassis);
42
+ if (chassis != "")
43
+ {
44
+ bmweb_pick("vehicle", chassis, module);
45
+ if (module != "")
46
+ {
47
+ scriptchange(module);
48
+ }
49
+ else
50
+ {
51
+ messagebox("Error scan", "INPA ships no whole-vehicle script for this chassis.");
52
+ }
53
+ }
54
+ }
55
+ ITEM( 9 ,"Print") {
56
+ printscreen();
57
+ }
58
+ ITEM( 20 ,"Exit") {
59
+ exit();
60
+ }
61
+ }
62
+
63
+ SCREEN s_main()
64
+ {
65
+ bmweb_status(status);
66
+ ftextout("BMWeb", 1, 0, 1, 0);
67
+ ftextout("", 3, 0, 0, 0);
68
+ LINE("","")
69
+ {
70
+ ftextout("< F1 > Vehicle: pick a chassis, then a module", 4, 5, 0, 1);
71
+ ftextout("< F2 > Error scan: INPA's whole-vehicle script", 6, 5, 0, 1);
72
+ ftextout("< F9 > Print", 20, 5, 0, 1);
73
+ ftextout("<Shift> + < F10> Exit", 22, 45, 0, 1);
74
+ ftextout(status, 12, 5, 0, 1);
75
+ }
76
+ }
@@ -0,0 +1,26 @@
1
+ // BMWeb's own include for scripts of its own: the INPA builtins those
2
+ // scripts call, declared the way inpa.h declares them (parameter modes are
3
+ // what the compiler needs to pass out-parameters by reference), plus the
4
+ // two builtins BMWeb adds so a script can ask the app for a pick.
5
+ //
6
+ // Written by the BMWeb project. Nothing here is copied from BMW's headers.
7
+
8
+ // INPA builtins used by the home script
9
+ extern setmenutitle( in: string title);
10
+ extern settitle( in: string title);
11
+ extern setmenu( in: menu name);
12
+ extern setscreen( in: screen name, in: bool flag);
13
+ extern scriptchange( in: string NewScriptFile);
14
+ extern printscreen();
15
+ extern exit();
16
+ extern messagebox( in: string Title, in: string Text);
17
+ extern ftextout( in: string text, in: int row, in: int col, in: int attr, in: int mode);
18
+
19
+ // BMWeb builtins (0xE0 and up; unused by INPA)
20
+ // bmweb_pick("chassis", "", chassis) the app lists its chassis, the user picks one
21
+ // bmweb_pick("module", chassis, sgbd) the modules of that chassis, picked one's SGBD
22
+ // bmweb_pick("vehicle", chassis, sgbd) the chassis's whole-vehicle script, "" if none
23
+ // A cancelled pick leaves the out-string empty.
24
+ extern bmweb_pick( in: string what, in: string arg, out: string choice);
25
+ // bmweb_status(text) one line from the app: cable state, version
26
+ extern bmweb_status( out: string text);
@@ -0,0 +1,258 @@
1
+ // Actuator release, INPA's way: we do NOT synthesize a stop telegram.
2
+ //
3
+ // An actuator menu in the .IPO releases (or does not) through its OWN keys.
4
+ // On the MS45 MIL menu the release is a key -- "Ansteuerung zurück an DME"
5
+ // (STEUERN_MIL_ENDE) -- and its BACK key just navigates: INPA leaves the MIL
6
+ // commanded and lets the ECU's own actuator timeout end it. On kombi's
7
+ // STEUERN_46 menu the BACK key itself carries DIAGNOSE_ENDE. Either way the
8
+ // truth is in the bytecode the IR already carries, so the ONE honest release
9
+ // is: run the leaving menu's Back-item job, and only that.
10
+ //
11
+ // The old registry (activeTests / activeDrives / an "<arg>;0" off form
12
+ // replayed on leave) invented releases INPA never sends and guessed the wrong
13
+ // telegram when it did -- firing STEUERN_MIL?arg=0, itself a drive command,
14
+ // on back-out. Gone. What a menu owes on the way out is what its Back key
15
+ // runs; what it owes on ECU exit is inpaexit's DIAGNOSE_ENDE (registerSessionEnd
16
+ // below), which is likewise read from the script, not made up.
17
+
18
+ // ---- the menu's on-leave job -----------------------------------------------
19
+ //
20
+ // Set by ir.js as it renders a menu: the job the current menu's Back item
21
+ // runs (null for a menu whose Back only navigates). Sent once, when a render
22
+ // for a DIFFERENT menu (or a real leave) follows -- a same-menu repaint must
23
+ // not fire it.
24
+ /** The ECU whose menu is open. @type {{sgbd: string}|null} */
25
+ let leaveEcu = null;
26
+ /** The job the current menu's Back item runs on leave. @type {string|null} */
27
+ let leaveJob = null;
28
+ /** The Back key's argument (STEUERN_DISPLAY "0"): sent with it. @type {string|null} */
29
+ let leaveArg = null;
30
+ /** "sgbd:menu", so a repaint of the same menu is a no-op. @type {string|null} */
31
+ let leaveKey = null;
32
+ /** Did THIS menu fire a drive? (for pagehide only) @type {boolean} */
33
+ let _leftEnergized = false;
34
+
35
+ /**
36
+ * Register what the menu being drawn owes on the way out, first settling what
37
+ * the PREVIOUS menu owed (its composite neutral word and/or its Back job).
38
+ * Called by the runtime as it renders a menu; a same-menu repaint keeps the
39
+ * key and fires nothing.
40
+ * @param {{sgbd: string}|null} ecu - The ECU the menu belongs to.
41
+ * @param {string|null} menuKey - "sgbd:menu" identity of the menu.
42
+ * @param {string|null} job - The Back item's job, or null when Back only navigates.
43
+ * @param {string|number|null} [arg] - The Back item's argument, if any.
44
+ * @returns {void}
45
+ */
46
+ function registerMenuLeave(ecu, menuKey, job, arg) {
47
+ // a different menu is being set up: run what the PREVIOUS one owed
48
+ if (leaveKey && leaveKey !== menuKey) {
49
+ if (compEcu?.sgbd && compJob) {
50
+ try {
51
+ api(
52
+ `/api/ecu/${compEcu.sgbd}/run/${compJob}` +
53
+ `?arg=${encodeURIComponent(compArg)}`,
54
+ { method: 'POST' }
55
+ ).catch(() => {});
56
+ } catch (e) {
57
+ /* leaving */
58
+ }
59
+ _clearComposite();
60
+ }
61
+ if (leaveJob && leaveEcu) _sendLeave(leaveEcu, leaveJob, leaveArg);
62
+ }
63
+ leaveEcu = ecu || null;
64
+ leaveJob = job || null;
65
+ leaveArg = arg != null && arg !== '' ? String(arg) : null;
66
+ leaveKey = menuKey || null;
67
+ _leftEnergized = false;
68
+ }
69
+
70
+ // ir.js calls this when a drive is fired in the current menu, so a tab-close
71
+ // can run the Back job even for a menu whose release is a separate key.
72
+ /**
73
+ * Note that the current menu energized something (drives the pagehide release).
74
+ * @returns {void}
75
+ */
76
+ function markEnergized() {
77
+ _leftEnergized = true;
78
+ }
79
+
80
+ // A composite actuator word (LSZ-style: several outputs in one job) releases
81
+ // by being RE-COMMANDED to neutral -- INPA's own behavior, not a synthetic
82
+ // _ENDE. ir.js registers the neutral word here; runMenuLeave re-sends it.
83
+ /** The ECU / job / neutral argument of a registered composite word. */
84
+ let compEcu = null,
85
+ compJob = null,
86
+ compArg = null;
87
+
88
+ /**
89
+ * Register a composite actuator word's neutral form, re-sent on leave.
90
+ * @param {{sgbd: string}|null} ecu - The ECU.
91
+ * @param {string|null} job - The composite job.
92
+ * @param {string} neutralArg - The argument that commands every output to neutral.
93
+ * @returns {void}
94
+ */
95
+ function registerCompositeNeutral(ecu, job, neutralArg) {
96
+ compEcu = ecu || null;
97
+ compJob = job || null;
98
+ compArg = neutralArg;
99
+ }
100
+
101
+ /**
102
+ * Forget the registered composite word.
103
+ * @returns {void}
104
+ */
105
+ function _clearComposite() {
106
+ compEcu = compJob = compArg = null;
107
+ }
108
+
109
+ /**
110
+ * Fire a leave/end job, best effort: errors are swallowed, we are leaving anyway.
111
+ * @param {{sgbd: string}|null} ecu - The ECU.
112
+ * @param {string|null} job - The job.
113
+ * @param {string|null} [arg] - The argument, if any.
114
+ * @returns {void}
115
+ */
116
+ function _sendLeave(ecu, job, arg) {
117
+ if (!ecu?.sgbd || !job) return;
118
+ try {
119
+ const q = arg != null ? `?arg=${encodeURIComponent(arg)}` : '';
120
+ api(`/api/ecu/${ecu.sgbd}/run/${job}${q}`, { method: 'POST' }).catch(
121
+ () => {}
122
+ );
123
+ } catch (e) {
124
+ /* leaving anyway */
125
+ }
126
+ }
127
+
128
+ // Called from the setActions leave hook (core.js). A same-menu repaint keeps
129
+ // leaveKey unchanged and is held, so nothing fires; a real navigation has
130
+ // already re-registered (or cleared) leaveKey via ir.js, so run what the menu
131
+ // we are leaving owed.
132
+ /**
133
+ * Run what the menu being left owed: the composite neutral word, then the
134
+ * Back item's job; then forget both.
135
+ * @returns {void}
136
+ */
137
+ function runMenuLeave() {
138
+ // a composite word: re-command it to neutral, then forget the flags
139
+ if (compEcu?.sgbd && compJob) {
140
+ try {
141
+ api(
142
+ `/api/ecu/${compEcu.sgbd}/run/${compJob}` +
143
+ `?arg=${encodeURIComponent(compArg)}`,
144
+ { method: 'POST' }
145
+ ).catch(() => {});
146
+ } catch (e) {
147
+ /* leaving */
148
+ }
149
+ _clearComposite();
150
+ }
151
+ if (leaveJob && leaveEcu) {
152
+ const ecu = leaveEcu,
153
+ job = leaveJob,
154
+ arg = leaveArg;
155
+ _sendLeave(ecu, job, arg);
156
+ }
157
+ leaveEcu = leaveJob = leaveArg = leaveKey = null;
158
+ }
159
+
160
+ // ---- ECU session end (inpaexit's DIAGNOSE_ENDE) ----------------------------
161
+ // Unchanged in spirit: read from the script, sent on ECU exit however it
162
+ // happens. Registered on ENTRY so it fires even when the user leaves by a
163
+ // path we do not draw.
164
+ /** The ECU whose session-end job is registered. @type {{sgbd: string}|null} */
165
+ let sessionEndEcu = null;
166
+ /** The registered session-end job (inpaexit's DIAGNOSE_ENDE). @type {string|null} */
167
+ let sessionEndJob = null;
168
+ /** An end deferred to the microtask, so a submenu hop can cancel it. @type {{ecu: {sgbd: string}, job: string}|null} */
169
+ let _pendingEnd = null;
170
+
171
+ /**
172
+ * Register the ECU's session-end job on ENTRY, so it fires however the user
173
+ * leaves.
174
+ * @param {{sgbd: string}|null} ecu - The ECU.
175
+ * @param {string|null} job - The session-end job.
176
+ * @returns {void}
177
+ */
178
+ function registerSessionEnd(ecu, job) {
179
+ if (!ecu || !job) return;
180
+ sessionEndEcu = ecu;
181
+ sessionEndJob = job;
182
+ }
183
+
184
+ /**
185
+ * Send the registered session-end job, deferred one microtask so a submenu
186
+ * hop that re-registers the same ECU cancels it.
187
+ * @returns {void}
188
+ */
189
+ function endActivationSession() {
190
+ const ecu = sessionEndEcu;
191
+ const job = sessionEndJob;
192
+ sessionEndEcu = null;
193
+ sessionEndJob = null;
194
+ if (!ecu?.sgbd || !job) return;
195
+ // a submenu hop re-registers the same ECU before this microtask runs, so
196
+ // only send once we are truly off this ECU.
197
+ _pendingEnd = { ecu, job };
198
+ queueMicrotask(() => {
199
+ const p = _pendingEnd;
200
+ _pendingEnd = null;
201
+ if (!p || (sessionEndJob === p.job && sessionEndEcu?.sgbd === p.ecu.sgbd)) {
202
+ return;
203
+ }
204
+ _sendLeave(p.ecu, p.job);
205
+ });
206
+ }
207
+
208
+ // ---- same-screen repaint hold ----------------------------------------------
209
+ // ir.js reopens its menu after a drive so each row shows its armed state.
210
+ // That repaint must not count as leaving: registerMenuLeave keeps the same
211
+ // leaveKey, and this hold stops the leave hook from running mid-repaint.
212
+ /** True while a same-menu repaint runs, so the leave hook stays quiet. @type {boolean} */
213
+ let _activationsHeld = false;
214
+
215
+ /**
216
+ * Run a same-menu repaint with the leave hook held off.
217
+ * @param {() => void} fn - The repaint.
218
+ * @returns {void}
219
+ */
220
+ function keepActivationsDuring(fn) {
221
+ _activationsHeld = true;
222
+ try {
223
+ fn();
224
+ } finally {
225
+ _activationsHeld = false;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Whether a repaint hold is active (read by the action bar's leave hook).
231
+ * @returns {boolean}
232
+ */
233
+ const activationsHeld = () => _activationsHeld;
234
+
235
+ // ---- page teardown ---------------------------------------------------------
236
+ // Tab close / reload / navigation: the setActions leave hook never fires.
237
+ // Run the leaving menu's Back job (if it had one and we energized something)
238
+ // and the ECU session end, synchronously -- microtasks never run on unload.
239
+ window.addEventListener('pagehide', () => {
240
+ if (_leftEnergized) {
241
+ if (compEcu?.sgbd && compJob) {
242
+ _sendLeave(compEcu, compJob); // best effort; arg lost on unload, but neutral job runs
243
+ }
244
+ if (leaveEcu && leaveJob) _sendLeave(leaveEcu, leaveJob, leaveArg);
245
+ }
246
+ const ecu = sessionEndEcu,
247
+ job = sessionEndJob;
248
+ sessionEndEcu = sessionEndJob = _pendingEnd = null;
249
+ _sendLeave(ecu, job);
250
+ });
251
+
252
+ // Warn before closing the tab while an actuator is energized -- only prompts;
253
+ // the actual release rides pagehide so a cancelled close does not kill it.
254
+ window.addEventListener('beforeunload', (e) => {
255
+ if (!_leftEnergized) return;
256
+ e.preventDefault();
257
+ e.returnValue = '';
258
+ });
@@ -0,0 +1,331 @@
1
+ /**
2
+ * @file Comparing two stored scans. Pure data in, pure data out -- the screen
3
+ * draws what this returns and the harness pins it without a DOM.
4
+ *
5
+ * What a fault is matched on: the module it sits in (SGBD, the variant that
6
+ * answered) plus the fault's own identity within that module. A stored fault
7
+ * carries both a hex code (F_HEX_CODE) and a location number (F_ORT_NR); the
8
+ * hex is the DTC and is preferred, and the location number is the fallback
9
+ * for the older modules that report no hex. Matching on the text would make a
10
+ * translation change look like a different fault.
11
+ *
12
+ * Presence (F_VORHANDEN / "currently present" vs merely stored) is NOT part of
13
+ * the identity: the same fault going from stored-only to present is the same
14
+ * fault, and is reported as unchanged rather than as one cleared and one new.
15
+ */
16
+
17
+ /* exported garageFaultKeys, garageDiffScans, garageFaultKey, garageDiffCounts */
18
+
19
+ /**
20
+ * What changed between two scans.
21
+ * @typedef {object} GarageDiff
22
+ * @property {GarageScan} from - the older scan
23
+ * @property {GarageScan} to - the newer scan
24
+ * @property {'faults'|'ident'|'mixed'} kind - what the two reads were
25
+ * @property {GarageDiffModule[]} modules - per module, in the newer scan's order
26
+ * @property {GarageDiffSilence[]} silence - modules whose answering changed
27
+ */
28
+
29
+ /**
30
+ * One module's changes.
31
+ * @typedef {object} GarageDiffModule
32
+ * @property {string} sgbd
33
+ * @property {string} label
34
+ * @property {object[]} added - faults only the newer scan has
35
+ * @property {object[]} cleared - faults only the older scan has
36
+ * @property {object[]} same - faults in both
37
+ * @property {object[]} recurred - always empty now: a fault in both reads
38
+ * is unchanged whatever its counter or freeze frame did (kept so the
39
+ * screen and print paths need no shape change)
40
+ * @property {GarageDiffField[]} fields - ident fields whose value changed
41
+ * @property {boolean} [unread] - the newer read has no record of the module: nothing cleared, nothing known
42
+ * @property {boolean} changed - anything at all differs
43
+ */
44
+
45
+ /**
46
+ * One ident field that moved.
47
+ * @typedef {object} GarageDiffField
48
+ * @property {string} key - the EDIABAS result name (ID_SW_NR, ...)
49
+ * @property {string} label - its caption
50
+ * @property {string} from - the older value ('' when absent)
51
+ * @property {string} to - the newer value ('' when absent)
52
+ */
53
+
54
+ /**
55
+ * A module whose answering changed between the two reads.
56
+ * @typedef {object} GarageDiffSilence
57
+ * @property {string} target - the address or SGBD
58
+ * @property {string} label
59
+ * @property {'silent'|'answering'} state - 'silent' = answered before, quiet (or unread) now
60
+ * @property {string} [error] - the failure the newer scan recorded
61
+ */
62
+
63
+ /**
64
+ * The identity of one fault inside its module: the DTC where the module
65
+ * reports one, else its location number, else the code its text leads with.
66
+ * @param {object} code - an FS_LESEN entry
67
+ * @returns {string} a stable key, '' when the entry names none of them
68
+ */
69
+ function garageFaultKey(code) {
70
+ return garageFaultKeys(code)[0] || '';
71
+ }
72
+
73
+ /**
74
+ * EVERY identity a fault carries, strongest first. Two reads of the same
75
+ * module do not always carry the same fields for the same fault: one read
76
+ * has F_HEX_CODE and the next has only F_ORT_NR, one F_ORT_TEXT leads with
77
+ * the DTC ("27C3 DMTL ...") and the next is the bare description -- so a
78
+ * fault is matched on ANY identity it shares with the other read, or the
79
+ * one fault shows up as cleared AND new.
80
+ * @param {object} code - an FS_LESEN entry
81
+ * @returns {string[]} keys: 'H:<hex digits>' (from F_HEX_CODE or the text's leading DTC), 'N:<location nr>'
82
+ */
83
+ function garageFaultKeys(code) {
84
+ const c = code || {};
85
+ const out = [];
86
+ // F_HEX_CODE is the whole fault word: the two-byte DTC and then status
87
+ // and environment bytes ("27-C3-22" one read, "27-C3-62" the next, once
88
+ // the fault's status moved). Only the code bytes are the identity, the
89
+ // same four digits the fault text leads with; and "27C3" / "27-C3" (bytes
90
+ // joined by hexText) are one spelling once the separators go.
91
+ const hex = String(c.F_HEX_CODE == null ? '' : c.F_HEX_CODE)
92
+ .trim()
93
+ .toUpperCase()
94
+ .replace(/^0X/, '')
95
+ .replace(/[^0-9A-F]/g, '')
96
+ .slice(0, 4);
97
+ if (hex) out.push('H:' + hex);
98
+ const nr = c.F_ORT_NR;
99
+ if (nr != null && String(nr).trim() !== '' && Number.isFinite(Number(nr)))
100
+ out.push('N:' + String(Number(nr)));
101
+ // the DTC a fault text leads with IS the hex code, so it shares the hex
102
+ // namespace: a read that carries only the text still meets one that
103
+ // carries only F_HEX_CODE
104
+ const m = /^([0-9A-F]{3,5})\b/i.exec(String(c.F_ORT_TEXT || '').trim());
105
+ if (m) {
106
+ const t = 'H:' + m[1].toUpperCase();
107
+ if (!out.includes(t)) out.push(t);
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /**
113
+ * Index a report's modules by SGBD.
114
+ * @param {object} report - a stored report
115
+ * @returns {Map<string, object>}
116
+ */
117
+ function garageModuleMap(report) {
118
+ const m = new Map();
119
+ for (const mod of (report && report.modules) || [])
120
+ m.set(String(mod.sgbd || '').toLowerCase(), mod);
121
+ return m;
122
+ }
123
+
124
+ /**
125
+ * Index a module's faults by identity. An entry naming neither a hex nor a
126
+ * location number cannot be matched, so it is left out of the comparison
127
+ * rather than being reported as both added and cleared every time.
128
+ * @param {object} mod - a module record
129
+ * @returns {Map<string, object>}
130
+ */
131
+ function garageFaultMap(mod) {
132
+ const m = new Map();
133
+ for (const c of (mod && mod.codes) || []) {
134
+ for (const k of garageFaultKeys(c)) if (!m.has(k)) m.set(k, c);
135
+ }
136
+ return m;
137
+ }
138
+
139
+ /**
140
+ * The counterpart of a fault in the other read: the entry sharing any of
141
+ * its identities.
142
+ * @param {Map<string, object>} map - the other read's fault map
143
+ * @param {object} code - the fault to find
144
+ * @returns {object|null}
145
+ */
146
+ function garageFaultMatch(map, code) {
147
+ for (const k of garageFaultKeys(code)) {
148
+ const hit = map.get(k);
149
+ if (hit) return hit;
150
+ }
151
+ return null;
152
+ }
153
+
154
+ /**
155
+ * The ident fields that differ between two module records, in the order the
156
+ * protocol prints them.
157
+ * @param {object} a - the older module record
158
+ * @param {object} b - the newer module record
159
+ * @returns {GarageDiffField[]}
160
+ */
161
+ function garageIdentFields(a, b) {
162
+ const ia = (a && a.ident) || null;
163
+ const ib = (b && b.ident) || null;
164
+ if (!ia && !ib) return [];
165
+ const rows =
166
+ typeof IPO_IDENT_ROWS !== 'undefined'
167
+ ? IPO_IDENT_ROWS
168
+ : [[['VARIANTE'], 'Variant']];
169
+ const out = [];
170
+ const seen = new Set();
171
+ const val = (id, k) => (id && id[k] != null ? String(id[k]).trim() : '');
172
+ for (const [keys, cap] of rows) {
173
+ for (const k of keys) {
174
+ seen.add(k);
175
+ const from = val(ia, k);
176
+ const to = val(ib, k);
177
+ if (from !== to)
178
+ out.push({
179
+ key: k,
180
+ label: keys.length > 1 ? `${cap} (${k})` : cap,
181
+ from,
182
+ to,
183
+ });
184
+ }
185
+ }
186
+ // anything the module answered that the printed table does not cover still
187
+ // counts as a change -- a new coding index matters whether or not it has a
188
+ // caption
189
+ const extra = new Set([...Object.keys(ia || {}), ...Object.keys(ib || {})]);
190
+ for (const k of extra) {
191
+ if (seen.has(k) || k.startsWith('_')) continue;
192
+ const from = val(ia, k);
193
+ const to = val(ib, k);
194
+ if (from !== to) out.push({ key: k, label: k, from, to });
195
+ }
196
+ return out;
197
+ }
198
+
199
+ /**
200
+ * Compare two stored scans, older against newer.
201
+ * @param {GarageScan} from - the older scan
202
+ * @param {GarageScan} to - the newer scan
203
+ * @returns {GarageDiff}
204
+ */
205
+ function garageDiffScans(from, to) {
206
+ const ra = (from && from.report) || { modules: [], silent: [] };
207
+ const rb = (to && to.report) || { modules: [], silent: [] };
208
+ const A = garageModuleMap(ra);
209
+ const B = garageModuleMap(rb);
210
+ const kind = ra.kind === rb.kind ? rb.kind || 'faults' : 'mixed';
211
+
212
+ /** @type {GarageDiffModule[]} */
213
+ const modules = [];
214
+ // the newer scan's order first, then anything only the older one saw
215
+ const order = [...B.keys(), ...[...A.keys()].filter((k) => !B.has(k))];
216
+ /** @type {GarageDiffSilence[]} */
217
+ const silence = [];
218
+ const noted = new Set();
219
+ const note = (target, label, state, error) => {
220
+ const t = String(target || '').toLowerCase();
221
+ if (!t || noted.has(t)) return;
222
+ noted.add(t);
223
+ silence.push({ target: t, label: label || t.toUpperCase(), state, error });
224
+ };
225
+ for (const sgbd of order) {
226
+ const a = A.get(sgbd);
227
+ const b = B.get(sgbd);
228
+ // a module the newer read has no record of did not answer (or was not
229
+ // asked) this time: its faults are not cleared, they are unread, and
230
+ // that is an answering change, not a fault change
231
+ if (a && !b) {
232
+ note(a.via || sgbd, a.label, 'silent', 'not read this time');
233
+ modules.push({
234
+ sgbd,
235
+ label: a.label || sgbd,
236
+ added: [],
237
+ cleared: [],
238
+ same: [],
239
+ recurred: [],
240
+ fields: [],
241
+ unread: true,
242
+ changed: false,
243
+ });
244
+ continue;
245
+ }
246
+ if (!a && b) note(b.via || sgbd, b.label, 'answering');
247
+ const fa = garageFaultMap(a);
248
+ const added = [];
249
+ const cleared = [];
250
+ const same = [];
251
+ // a fault present in both reads is unchanged, full stop: the occurrence
252
+ // counter and the freeze frame are not consulted (the DME keeps the
253
+ // frame from the first occurrence and bumps the counter on its own, and
254
+ // neither is a change the owner did anything about)
255
+ const recurred = [];
256
+ const matched = new Set(); // the older read's entries that found a partner
257
+ for (const c of (b && b.codes) || []) {
258
+ if (!garageFaultKeys(c).length) continue; // nothing to match on
259
+ const old = garageFaultMatch(fa, c);
260
+ if (!old) {
261
+ added.push(c);
262
+ continue;
263
+ }
264
+ matched.add(old);
265
+ same.push(c);
266
+ }
267
+ for (const c of (a && a.codes) || [])
268
+ if (garageFaultKeys(c).length && !matched.has(c)) cleared.push(c);
269
+ const fields = kind === 'faults' ? [] : garageIdentFields(a, b);
270
+ modules.push({
271
+ sgbd,
272
+ label: (b && b.label) || (a && a.label) || sgbd,
273
+ added,
274
+ cleared,
275
+ same,
276
+ recurred,
277
+ fields,
278
+ changed: !!(
279
+ added.length ||
280
+ cleared.length ||
281
+ fields.length ||
282
+ recurred.length
283
+ ),
284
+ });
285
+ }
286
+
287
+ // answering changed: a module that answered before and is quiet now (or the
288
+ // other way round). Reading the silent lists alone would miss a module that
289
+ // simply stopped being asked, so both sides are checked.
290
+ const silentA = new Map(
291
+ (ra.silent || []).map((s) => [String(s.target || '').toLowerCase(), s])
292
+ );
293
+ const silentB = new Map(
294
+ (rb.silent || []).map((s) => [String(s.target || '').toLowerCase(), s])
295
+ );
296
+ for (const [t, s] of silentB)
297
+ if (!silentA.has(t)) note(t, s.label, 'silent', s.error);
298
+ for (const [t, s] of silentA)
299
+ if (!silentB.has(t)) note(t, s.label, 'answering');
300
+
301
+ return { from, to, kind, modules, silence };
302
+ }
303
+
304
+ /**
305
+ * The headline counts of a diff.
306
+ * @param {GarageDiff} diff - the comparison
307
+ * @returns {{added: number, cleared: number, same: number, fields: number, silence: number, modules: number}}
308
+ */
309
+ function garageDiffCounts(diff) {
310
+ const mods = (diff && diff.modules) || [];
311
+ const n = (pick) => mods.reduce((t, m) => t + m[pick].length, 0);
312
+ return {
313
+ added: n('added'),
314
+ cleared: n('cleared'),
315
+ same: n('same'),
316
+ recurred: n('recurred'),
317
+ fields: n('fields'),
318
+ silence: ((diff && diff.silence) || []).length,
319
+ modules: mods.filter((m) => m.changed).length,
320
+ };
321
+ }
322
+
323
+ if (typeof module !== 'undefined' && module.exports) {
324
+ module.exports = {
325
+ garageDiffScans,
326
+ garageFaultKey,
327
+ garageFaultKeys,
328
+ garageDiffCounts,
329
+ garageIdentFields,
330
+ };
331
+ }