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/.editorconfig +10 -0
- package/.gitattributes +4 -0
- package/.vscode/extensions.json +5 -0
- package/.vscode/settings.json +11 -0
- package/.yarn/sdks/integrations.yml +5 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE +13 -0
- package/README.md +429 -0
- package/config.toml +292 -0
- package/discovery.js +74 -0
- package/docs/device_list.pdf +0 -0
- package/docs/intercept-mode.png +0 -0
- package/docs/logo.png +0 -0
- package/docs/packet-flow.png +0 -0
- package/docs/pass-through-mode.png +0 -0
- package/docs/raspberry-pi-cp2102.png +0 -0
- package/index.js +317 -0
- package/package.json +63 -0
- package/parse_vs2.js +249 -0
- package/serial.js +88 -0
- package/tools/analyze_trace.js +486 -0
- package/tools/analyze_trace_example.txt +671 -0
- package/tools/convert_data_points.js +86 -0
- package/tools/convert_data_points_example.txt +3198 -0
- package/tools/convert_poll_items.js +91 -0
- package/tools/convert_poll_items_example.txt +42 -0
- package/utils.js +141 -0
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
|
+
}
|