optolink-bridge 1.0.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/serial.js ADDED
@@ -0,0 +1,88 @@
1
+ import { pipeline } from 'node:stream/promises';
2
+ import { PassThrough, Transform } from 'node:stream';
3
+ import { SerialPort } from 'serialport';
4
+ import { EventEmitter } from 'node:events';
5
+
6
+ export const fromVitoToOpto = 0b01; // Vitoconnect → Optolink
7
+ export const fromOptoToVito = 0b10; // Optolink → Vitoconnect
8
+
9
+ /**
10
+ * Connects to both the Vitoconnect and Optolink serials ports and establishes a bridge
11
+ * between them. All 'chunk's crossing the bridge are emitted via the `EventEmitter` returned.
12
+ *
13
+ * @param {string} [vitoPath = '/dev/ttyS0'] The serial port to connect to Vitoconnect.
14
+ * @param {string} [optoPath = '/dev/ttyUSB'] The serial port to connect to Optolink.
15
+ * @param {function} [transform] Whether to use `PassThrough` streams to directly
16
+ * connect both serial ports, or use a `Transform` stream to also intercept the data.
17
+ * @returns {EventEmitter} An `EventEmitter` emitting 'chunk's.
18
+ */
19
+ export async function connect(vitoPath = '/dev/ttyS0', optoPath = '/dev/ttyUSB0', transform) {
20
+ const eventEmitter = new EventEmitter();
21
+ function createBridgeStream(direction, vitoPort, optoPort) {
22
+ const emit = (chunk, callback) => {
23
+ try {
24
+ eventEmitter.emit('chunk', chunk, direction);
25
+ } finally {
26
+ callback?.(null, chunk);
27
+ }
28
+ };
29
+
30
+ if (typeof transform !== 'function') {
31
+ const stream = new PassThrough();
32
+ stream.on('data', emit);
33
+ return stream;
34
+ } else {
35
+ return new Transform({
36
+ transform(chunk, encoding, callback) {
37
+ try {
38
+ transform.call(this, chunk, direction, /* emitCallback */ newChunk => emit(newChunk ?? chunk, callback), {
39
+ vitoPort, optoPort, callback // note that the context object exposed the original callback as well, which is different to the emitCallback!
40
+ });
41
+ } catch (err) {
42
+ // uncaught exception, always continue emitting!
43
+ emit(chunk, callback);
44
+ throw err;
45
+ }
46
+ }
47
+ });
48
+ }
49
+ }
50
+
51
+ const portOptions = {
52
+ baudRate: 4800,
53
+ parity: 'even',
54
+ stopBits: 2,
55
+ dataBits: 8,
56
+ lock: true // exclusive
57
+ };
58
+
59
+ const vitoPort = new SerialPort({
60
+ path: vitoPath,
61
+ ...portOptions
62
+ });
63
+ const optoPort = new SerialPort({
64
+ path: optoPath,
65
+ ...portOptions
66
+ });
67
+
68
+ vitoPort.on('error', (...args) => eventEmitter.emit('error', ...args, fromVitoToOpto));
69
+ optoPort.on('error', (...args) => eventEmitter.emit('error', ...args, fromOptoToVito));
70
+
71
+ eventEmitter.close = async function() {
72
+ return Promise.all([
73
+ new Promise((resolve, reject) => vitoPort.close(err => {
74
+ err ? reject(err) : resolve();
75
+ })),
76
+ new Promise((resolve, reject) => optoPort.close(err => {
77
+ err ? reject(err) : resolve();
78
+ }))
79
+ ]);
80
+ };
81
+
82
+ eventEmitter.pipeline = Promise.all([
83
+ pipeline(vitoPort, createBridgeStream(fromVitoToOpto, vitoPort, optoPort), optoPort),
84
+ pipeline(optoPort, createBridgeStream(fromOptoToVito, vitoPort, optoPort), vitoPort)
85
+ ]);
86
+
87
+ return eventEmitter;
88
+ }
@@ -0,0 +1,486 @@
1
+ /**
2
+ * Analyzes a trace file and categorizes the data points.
3
+ *
4
+ * Use this script to analyze optolink-bridge trace files. This is helpful in case you would like to know what traffic is sent via the Optolink.
5
+ * The Optolink interface usually is quite busy, Vitoconnect utilizes essentially the 4800 baud rate, resulting in about 20-25 packets per second.
6
+ * By enabling the "log_level = trace" in your config.toml, and logging content over some time, you can generate a trace file. feeding this file in
7
+ * this script, allows you to analyze the traffic. In order to narrow down to the statistics are created by summarizing packets received from / sent to
8
+ * a given address. If a data point is already configured in config.toml, the data is output in the format of the data point. If not, the data is output
9
+ * as a raw / hex string. Also the script tries to categorize the addresses into useful categories, such as: no data recorded, addresses with mostly
10
+ * identical values recorded (e.g. enums), addresses with mostly variable values recorded (e.g. numerical / statistical values). This way of reporting
11
+ * allows for a great way of understanding the traffic on the Optolink interface and finding (previously unknown) attributes / addresses.
12
+ *
13
+ * yarn node analyze_trace.js analyze_trace_example.txt
14
+ *
15
+ * To narrow the analysis down to one address, specify the address as a second argument:
16
+ *
17
+ * yarn node analyze_trace.js analyze_trace_example.txt 0x1234
18
+ *
19
+ * Note that the trace analysis works on different formats of trace files. The regular format is the following:
20
+ *
21
+ * 2025-02-25 13:27:13.247 Vitoconnect → Optolink 410500212003014a
22
+ *
23
+ * A date / time stamp, followed by the direction the data was recorded and the hex binary data of the packet. This is the format that Optolink Bridge
24
+ * logs when put into "trace" mode. However this script works with less information as well, so removing the timestamp:
25
+ *
26
+ * Vitoconnect → Optolink 410500212003014a
27
+ *
28
+ * Or even the direction information, only logging the hex packet data:
29
+ *
30
+ * 410500212003014a
31
+ *
32
+ * Works essentially just as well. However it reduces the readability of the trace file, as well as if there is no timestamp recorded, you might be loosing
33
+ * crucial information required for your analysis, i.e. when certain changes to certain addresses happened.
34
+ */
35
+
36
+ import fs from 'node:fs/promises';
37
+ import readline from 'node:readline';
38
+ import { basename } from 'node:path';
39
+
40
+ import { default as parsePacket, parseValue } from '../parse_vs2.js';
41
+ import { exists, readConfig, formatAddr, mapDataPoints, fromLocalToOpto, fromOptoToLocal, fromOpto, directionName } from '../utils.js';
42
+ import { fromVitoToOpto, fromOptoToVito } from '../serial.js';
43
+
44
+ const path = process.argv[2];
45
+ if (!path || !(await exists(path))) {
46
+ console.error(`Usage: ${basename(process.argv[1])} <trace-file>`);
47
+ process.exit(1);
48
+ }
49
+
50
+ const config = await readConfig();
51
+ const dps = mapDataPoints(config.data_points);
52
+
53
+ const filterAddr = new Set(process.argv.slice(3).map(addr => {
54
+ addr = addr.toLowerCase();
55
+ if (addr.startsWith('0x')) {
56
+ addr = addr.substring(2);
57
+ }
58
+ if (!/^[0-9a-f]{4}$/.test(addr)) {
59
+ console.error(`Usage: ${basename(process.argv[1])} <trace-file> 0x[hex address]`);
60
+ process.exit(1);
61
+ }
62
+
63
+ return parseInt(addr, 16);
64
+ }));
65
+
66
+ const file = await fs.open(path, 'r');
67
+ const lines = readline.createInterface({
68
+ input: file.createReadStream(),
69
+ crlfDelay: Infinity,
70
+ });
71
+
72
+ function getNumber(value) {
73
+ const [int, frac] = value.toString().split('.');
74
+ if (frac?.length > 3) {
75
+ return `${int}.${frac.substring(0, 3)}…`;
76
+ }
77
+ return value;
78
+ }
79
+
80
+ /**
81
+ * Get the raw value of a (internal) type. This can be either:
82
+ * - a Buffer (raw value), returned 1:1
83
+ * - an Array, representing a RPC request / response
84
+ * - an object, representing a written value (with "write" property set to true)
85
+ *
86
+ * @param {Buffer|Array|object} value the (raw / internal) value
87
+ * @param {number} [index] the index of the value to return (for RPCs)
88
+ * @returns {Buffer} the raw hex / buffer value
89
+ */
90
+ function getRawValue(value, index) {
91
+ if (Buffer.isBuffer(value)) {
92
+ return value;
93
+ } else if (Array.isArray(value)) {
94
+ return value[index ?? 0];
95
+ } else if (typeof value === 'object' && value.data && value.write === true) {
96
+ return value.data;
97
+ } else {
98
+ throw new Error('Unsupported internal value type', value);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Prepare a given value that can be traced:
104
+ * - Buffers to hex strings
105
+ * - Numbers stay numbers
106
+ * - BigInts to its string representation
107
+ * - Everything else to a string
108
+ * @param {Buffer|any} value the (raw) value to output
109
+ * @param {object} [dp] the datapoint to parse the value
110
+ * @param {boolean} [scales] scale numbers to common factors (0.1, 0.01, 1/3600)
111
+ * @returns {string} to value formatted as string
112
+ */
113
+ function getValue(value, dp, scales) {
114
+ if (typeof dp === 'boolean') {
115
+ scales = dp;
116
+ dp = undefined
117
+ }
118
+
119
+ if (Array.isArray(value)) {
120
+ return `${getValue(value[0], dp)} → ${getValue(value[1], dp, scales)}`;
121
+ } else if (typeof value === 'object' && value.data && value.write === true) {
122
+ return `${getValue(value.data, dp, scales)} (!)`;
123
+ } else if (Buffer.isBuffer(value) && dp) {
124
+ try {
125
+ value = dp.parse(value);
126
+ } catch (err) {
127
+ // failed to parse data point, use raw instead
128
+ (dp.err || (dp.err = [])).push(err);
129
+ }
130
+ }
131
+
132
+ if (Buffer.isBuffer(value)) {
133
+ return `0x${value.toString('hex')}`;
134
+ }
135
+
136
+ if (typeof value === 'number') {
137
+ return scales ? [value, value / 10, value / 100, value / 3600].map(getNumber) : getNumber(value);
138
+ } else if (typeof value === 'bigint' && scales) {
139
+ return [value, value / BigInt(10), value / BigInt(100), value / BigInt(3600)].map(value => value.toString());
140
+ }
141
+
142
+ return value.toString();
143
+ }
144
+
145
+ /**
146
+ * Puts a given set of values into a category.
147
+ *
148
+ * @param {(Buffer|Array|Object)[]} values the values to categorize
149
+ * @returns {string} a value category
150
+ */
151
+ function getType(values) {
152
+ if (values.length === 0) {
153
+ return 'no_data';
154
+ }
155
+
156
+ const avgLength = values.reduce((total, value) => {
157
+ // if it is a RPC, use the response data length to determine the type
158
+ return total + getRawValue(value, 1 /* response */).length;
159
+ }, 0) / values.length;
160
+ if (avgLength > 6) {
161
+ return 'strings_or_arrays';
162
+ }
163
+
164
+ const valueSet = new Set(values.map(value =>
165
+ (Array.isArray(value) ? value : [value])
166
+ .map(value => getRawValue(value).toString('hex')).join('')));
167
+ if (valueSet.size === 1) {
168
+ return 'identical_values';
169
+ } else if (valueSet.size < 10) {
170
+ return 'mostly_identical_values';
171
+ } else {
172
+ return 'mostly_variable_values';
173
+ }
174
+ }
175
+
176
+ const directionStats = {}, addrStats = {}, rpcReq = {}, malformed = [], perSecond = { avg: 0, secs: 0 };
177
+ for await (let line of lines) {
178
+ let date, time;
179
+ if (line.match(/^\d/)) {
180
+ [date, time] = line.split(' ', 2);
181
+ line = line.substring(date.length + time.length + 2);
182
+
183
+ // calculate packets per second
184
+ const sec = date + time.split('.', 1)[0];
185
+ if (perSecond.curr !== sec) {
186
+ if (perSecond.cnt) {
187
+ // moving average of packets per second
188
+ perSecond.avg = perSecond.avg + ((perSecond.cnt - perSecond.avg) / ++perSecond.secs);
189
+ }
190
+
191
+ perSecond.curr = sec;
192
+ perSecond.cnt = 1;
193
+ } else {
194
+ perSecond.cnt++;
195
+ }
196
+ }
197
+
198
+ let direction;
199
+ if (line.startsWith('Local →')) {
200
+ direction = fromLocalToOpto;
201
+ } else if (line.startsWith('Vitoconnect →')) {
202
+ direction = fromVitoToOpto;
203
+ } else if (line.startsWith('Optolink →')) {
204
+ direction = fromOptoToVito;
205
+ if (line.startsWith('Optolink → Local')) {
206
+ direction = fromOptoToLocal;
207
+ }
208
+ } else if (line.match(/^[0-9a-f]+$/)) {
209
+ // try to derive the direction from the binary packet content
210
+ let offset = 0;
211
+ // first byte 06: ACK from Optolink
212
+ if (line.startsWith('06')) {
213
+ offset = 2;
214
+ }
215
+ // first (or following after 61) byte 41 to identify VS2 message format
216
+ if (line[offset] !== '4' || line[offset + 1] !== '1') { // VS2_DAP_STANDARD
217
+ malformed.push({ msg: 'Unknown direction / unexpected packet identifier, expected 41 / VS2_DAP_STANDARD', line });
218
+ continue;
219
+ }
220
+ // skip 41 and length of the packet, upper word of the 3rd byte is the protocol ID (request or response)
221
+ const id = line[offset + 5];
222
+ if (id === '0') {
223
+ direction = fromOptoToVito;
224
+ } else if (id === '1') {
225
+ direction = fromVitoToOpto;
226
+ } else {
227
+ malformed.push({ msg: 'Unknown direction / unexpected packet ID', line });
228
+ continue;
229
+ }
230
+ } else {
231
+ malformed.push({ msg: 'Unexpected line format', line });
232
+ continue;
233
+ }
234
+
235
+ const directionStat = directionStats[direction] ?? (directionStats[direction] = { direction, count: 0, ids: {}, fns: {} });
236
+
237
+ // statistic 1: count of packets per direction
238
+ directionStat.count++;
239
+
240
+ const data = Buffer.from(line.substring(line.lastIndexOf(' ') + 1), 'hex');
241
+ let packet;
242
+ try {
243
+ packet = parsePacket(data, direction & fromOpto);
244
+ } catch (err) {
245
+ malformed.push({ msg: 'Failed to parse', line, data, err });
246
+ continue;
247
+ }
248
+
249
+ // sync. packets
250
+ if (!packet.addr) {
251
+ continue;
252
+ }
253
+
254
+ // detail analysis of (a) specific address(es) only
255
+ const addr = formatAddr(packet.addr);
256
+ if (filterAddr.size) {
257
+ if (!filterAddr.has(packet.addr)) {
258
+ continue;
259
+ } else if (!packet.data || !packet.data.length) {
260
+ continue;
261
+ }
262
+
263
+ const addrStat = addrStats[addr] ?? (addrStats[addr] = {});
264
+ if (!addrStat.prevData || !packet.data.equals(addrStat.prevData)) {
265
+ if (addrStat.same > 0) {
266
+ console.log(`… ${addrStat.same}× identical value(s) ${ filterAddr.size > 1 ? `for address ${addr} ` : ''}…`);
267
+ }
268
+
269
+ addrStat.prevData = Buffer.from(packet.data);
270
+ addrStat.same = 0;
271
+ } else {
272
+ addrStat.same++;
273
+ continue;
274
+ }
275
+
276
+ let fn;
277
+ if (packet.fn === 0x01) {
278
+ fn = 'READ';
279
+ } else if (packet.fn === 0x02) {
280
+ fn = 'WRITE';
281
+ } else if (packet.fn === 0x07) {
282
+ fn = 'RPC/' + (packet.id === 0x0 ? 'REQ' : 'RES');
283
+ }
284
+
285
+ let value;
286
+ const dp = dps.get(packet.addr);
287
+ if (dp) {
288
+ value = `data point: ${getValue(packet.data, dp)}`;
289
+ } else {
290
+ value = `debug: ${JSON.stringify(Object.fromEntries(Object.entries(parseValue('debug', packet.data)).map(([key, value]) => [key, getValue(value)])))}`;
291
+ }
292
+
293
+ console.log(`${date && time ? `${date} ${time} ` : ''}${addr} (${fn}): ${getValue(packet.data)} (${value})`)
294
+
295
+ continue;
296
+ }
297
+
298
+ // statistic 2: number of reads / writes / rpcs and requests / responses traced for every direction
299
+ directionStat.fns[packet.fn] = (directionStat.fns[packet.fn] ?? 0) + 1;
300
+ directionStat.ids[packet.id] = (directionStat.ids[packet.id] ?? 0) + 1;
301
+
302
+ const addrStat = addrStats[addr] ?? (addrStats[addr] = { addr: packet.addr, count: 0, ids: {}, fns: {}, values: [], write: [] });
303
+
304
+ // statistic 3: number of times address was traced (id [req / res], fn [read / write / rpc])
305
+ addrStat.count++;
306
+ addrStat.fns[packet.fn] = (addrStat.fns[packet.fn] ?? 0) + 1;
307
+ addrStat.ids[packet.id] = (addrStat.ids[packet.id] ?? 0) + 1;
308
+
309
+ // statistic 4: found / not found data points
310
+ const dp = addrStats[addr].dp = dps.get(packet.addr);
311
+ dp && (dp.mark = true);
312
+
313
+ // statistic 5: written data / rpc data over time
314
+ if (packet.data) {
315
+ if (packet.fn === 0x07) { // remote procedure call
316
+ if (packet.id === 0x0) {
317
+ rpcReq.seq = packet.seq;
318
+ rpcReq.data = Buffer.from(packet.data);
319
+ } else if (rpcReq.seq === packet.seq) {
320
+ // for RPCs bundle request and response statistics
321
+ addrStats[addr].values.push([rpcReq.data, Buffer.from(packet.data)]);
322
+ }
323
+ } else if (packet.fn === 0x02 && packet.id === 0x0) { // write request
324
+ addrStats[addr].values.push({ data: Buffer.from(packet.data), write: true });
325
+ } else {
326
+ addrStats[addr].values.push(Buffer.from(packet.data));
327
+ }
328
+ }
329
+ }
330
+
331
+ try {
332
+ await file.close();
333
+ } catch {
334
+ // nothing to do here
335
+ }
336
+
337
+ if (filterAddr.size) {
338
+ for (const [addr, addrStat] of Object.entries(addrStats).filter(([addr, addrStat]) => addrStat.same > 0)) {
339
+ console.log(`… ${addrStat.same}× identical value(s) ${ filterAddr.size > 1 ? `for address ${addr} ` : ''}`);
340
+ }
341
+ process.exit(0);
342
+ }
343
+
344
+ // statistic 6: categorize values by type (mostly identical, mostly variable values, etc.)
345
+ const types = {};
346
+ for (const [addr, { dp, values }] of Object.entries(addrStats)) {
347
+ const type = addrStats[addr].type = getType(values, dp);
348
+ (types[type] = types[type] ?? []).push(addrStats[addr]);
349
+ }
350
+
351
+ if (Object.keys(directionStats).length) {
352
+ console.log('Number of packets:');
353
+ for (const { direction, count, fns } of Object.values(directionStats)) {
354
+ console.log(` ${directionName(direction)}: ${count}${direction === fromVitoToOpto ? ` (${fns[0x01] ?? 0} read, ${fns[0x02] ?? 0} write, ${fns[0x07] ?? 0} rpc, ${count - ((fns[0x01] ?? 0) + (fns[0x02] ?? 0) + (fns[0x07] ?? 0))} misc [e.g. sync.])` : ''}`);
355
+ }
356
+ }
357
+
358
+ if (perSecond.avg) {
359
+ console.log(`Average number of packets per second: ${perSecond.avg.toFixed(2)}`);
360
+ }
361
+ if (malformed.length) {
362
+ console.log();
363
+ console.log(`Malformed lines / packets: ${malformed.length}, e.g.:`, malformed[0].err ?? malformed[0]?.msg);
364
+ }
365
+
366
+ console.log();
367
+ function output(stats, valueFn) {
368
+ const withDps = stats.filter(({ dp }) => dp);
369
+ if (withDps.length) {
370
+ console.log();
371
+ console.log('With configured data points:');
372
+ for (const { addr, count, values, dp } of withDps) {
373
+ console.log(` ${count}× ${formatAddr(addr)} (${dp.name})${valueFn ? `: ${valueFn(values, dp)}` : ''}`);
374
+ }
375
+ }
376
+
377
+ const woDps = stats.filter(({ dp }) => !dp);
378
+ if (woDps.length) {
379
+ console.log();
380
+ console.log('Without configured data points (output as raw / hex):');
381
+ for (const { addr, count, values } of woDps) {
382
+ console.log(` ${count}× ${formatAddr(addr)}${valueFn ? `: ${valueFn(values)}` : ''}`);
383
+ }
384
+ }
385
+ }
386
+
387
+ const condenseRepeated = values => {
388
+ return values.reduce((acc, value, index) => {
389
+ if (index === 0 || (Array.isArray(value) ? (!value[0].equals(values[index - 1][0]) || !value[1].equals(values[index - 1][1])) : !getRawValue(value).equals(getRawValue(values[index - 1])))) {
390
+ acc.push({ value, count: 1 });
391
+ } else {
392
+ acc[acc.length - 1].count++;
393
+ }
394
+
395
+ return acc;
396
+ }, []);
397
+ };
398
+ function outputCondenseRepeated(values, dp) {
399
+ let condensedValues = condenseRepeated(values), suffix = '';
400
+ if (condensedValues.length > 10) {
401
+ condensedValues = condensedValues.slice(0, 10);
402
+ suffix = ', …'
403
+ }
404
+
405
+ let result = '';
406
+ for (const { value, count } of condensedValues) {
407
+ if (result) {
408
+ result += ', ';
409
+ }
410
+ result += `${getValue(value, dp)}`;
411
+ if (count > 1) {
412
+ result += `, [${count - 1}× …]`;
413
+ }
414
+ }
415
+ return result + suffix;
416
+ }
417
+
418
+ if (types.mostly_identical_values) {
419
+ console.log('Addresses that contained mostly identical values (e.g. enums / state variables):');
420
+ output(types.mostly_identical_values, outputCondenseRepeated);
421
+ console.log();
422
+ }
423
+
424
+ const filterRepeated = values => {
425
+ return values.filter((value, index) => index === 0 || (Array.isArray(value) ? (!value[0].equals(values[index - 1][0]) || !value[1].equals(values[index - 1][1])) : !getRawValue(value).equals(getRawValue(values[index - 1]))));
426
+ };
427
+ function outputFilterRepeated(values, dp) {
428
+ let filteredValues = filterRepeated(values), suffix = '';
429
+ if (filteredValues.length > 10) {
430
+ filteredValues = filteredValues.slice(0, 10);
431
+ suffix = ', …'
432
+ }
433
+ return `${filteredValues.map(value => getValue(value, dp)).join(', […], ')}${suffix}`
434
+ }
435
+
436
+ if (types.mostly_variable_values) {
437
+ console.log('Addresses with mostly variable values (e.g. numerical values / statistics):');
438
+ output(types.mostly_variable_values, outputFilterRepeated);
439
+ console.log();
440
+ }
441
+
442
+ if (types.identical_values) {
443
+ console.log('Addresses that never changed / always contained identical values (e.g. configurations):');
444
+ output(types.identical_values, (values, dp) => `${values.length}× ${getValue(values[0], dp, true)}`);
445
+ console.log();
446
+ }
447
+
448
+ if (types.strings_or_arrays) {
449
+ console.log('Addresses which contained mostly strings or arrays (e.g. labels & complex data types):');
450
+ output(types.strings_or_arrays, (values, dp) => `e.g. ${getValue(values[0], dp, true)}`);
451
+ console.log();
452
+ }
453
+
454
+ if (types.no_data) {
455
+ console.log('Addresses without any data traced (function calls w/o parameters):');
456
+ output(types.no_data);
457
+ console.log();
458
+ }
459
+
460
+ const dpsWithErr = Array.from(dps.values()).filter(dp => dp.err);
461
+ if (dpsWithErr.length) {
462
+ console.log('Data points with errors when parsing:');
463
+ for (const dp of dpsWithErr) {
464
+ console.log(` ${dp.name} (${formatAddr(dp.addr)}), ${dp.err.length} errors e.g.:`, dp.err[0]);
465
+ }
466
+ console.log();
467
+ }
468
+
469
+ const dpsTraced = Array.from(dps.values()).filter(dp => dp.mark);
470
+ if (dpsTraced.length) {
471
+ console.log('Data points that have been traced at least once:');
472
+ for (const dp of dpsTraced) {
473
+ const addr = formatAddr(dp.addr), addrStat = addrStats[addr];
474
+ console.log(` ${dp.name} (${addr}), traced ${addrStat.count}× e.g.:`, getValue(addrStat.values[0], dp));
475
+ }
476
+ console.log();
477
+ }
478
+
479
+ const dpsNotTraced = Array.from(dps.values()).filter(dp => !dp.mark);
480
+ if (dpsNotTraced.length) {
481
+ console.log('Data points that have not been traced:');
482
+ for (const dp of dpsNotTraced) {
483
+ console.log(` ${dp.name} (${formatAddr(dp.addr)})`);
484
+ }
485
+ console.log();
486
+ }