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/index.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import async from 'async';
|
|
2
|
+
import { readConfig, mapDataPoints, dateTimeString, formatAddr, directionName, fromLocalToOpto, fromOptoToLocal, toOpto, fromOpto } from './utils.js';
|
|
3
|
+
import { toFormat as bufferToFormat } from 'buffer-to-str';
|
|
4
|
+
import Queue from 'yocto-queue';
|
|
5
|
+
|
|
6
|
+
import { connectAsync as mqttConnect } from 'mqtt';
|
|
7
|
+
|
|
8
|
+
import { connect, fromOptoToVito, fromVitoToOpto } from './serial.js';
|
|
9
|
+
import { default as parsePacket, parseValue, encodePacket } from './parse_vs2.js';
|
|
10
|
+
import { publishDevice } from './discovery.js';
|
|
11
|
+
|
|
12
|
+
let logLevel, trace, logger = {};
|
|
13
|
+
let dps, pollQueue = new Queue(), pollIntervals;
|
|
14
|
+
let busState = 0; // 0: syncing, 1: synced, 2: flowing
|
|
15
|
+
let deviceSerialNo = undefined, deviceDiscoveryOptions = { enabled: true }, deviceDiscovery;
|
|
16
|
+
|
|
17
|
+
async function applyConfig(config) {
|
|
18
|
+
logLevel = {
|
|
19
|
+
debug: 4, // debug log of all unknown data points
|
|
20
|
+
info: 3, // logs all known data points
|
|
21
|
+
warn: 2,
|
|
22
|
+
error: 1,
|
|
23
|
+
none: 0
|
|
24
|
+
}[config.log_level ?? 'warn'] || 2; // default to warn
|
|
25
|
+
trace = config.log_level === 'trace'; // trace all serial communication
|
|
26
|
+
Object.assign(logger, {
|
|
27
|
+
debug: logLevel >= 4 ? console.debug : () => {},
|
|
28
|
+
info: logLevel >= 3 ? console.log : () => {},
|
|
29
|
+
warn: logLevel >= 2 ? console.warn : () => {},
|
|
30
|
+
error: logLevel >= 1 ? console.error : () => {},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (!dps || (config.auto_reload_addr_items ?? true)) {
|
|
34
|
+
dps ??= new Map();
|
|
35
|
+
|
|
36
|
+
// clear all existing poll intervals
|
|
37
|
+
for (const pollInterval of (pollIntervals ?? [])) {
|
|
38
|
+
clearInterval(pollInterval);
|
|
39
|
+
}
|
|
40
|
+
pollIntervals = [];
|
|
41
|
+
|
|
42
|
+
// convert the data points to a structured array
|
|
43
|
+
mapDataPoints(config.data_points, (dps.clear(), dps));
|
|
44
|
+
|
|
45
|
+
// create poll intervals for each poll item
|
|
46
|
+
let pollsPerSecond = 0.;
|
|
47
|
+
for (const [ival, addr, dlen] of (config.poll_items ?? [])) {
|
|
48
|
+
if (ival <= 0) {
|
|
49
|
+
logger.error(`Poll interval for address ${formatAddr(addr)} is less than zero, skipping poll request`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const pollItem = (dupWarning = true) => {
|
|
54
|
+
// check if the address is already in the poll queue
|
|
55
|
+
for (const { addr: qaddr } of pollQueue) {
|
|
56
|
+
if (qaddr === addr) {
|
|
57
|
+
dupWarning && logger.warn(`Address ${formatAddr(addr)} is already in the poll queue, this could indicate that the poll queue is saturated, i.e. you are polling more items than Vitoconnect & the Optolink interface can handle (approx. 10 items by Vitoconnect and 10 poll items per second), or that polling has not yet started / is otherwise halted, resulting in the poll queue to overflow. Also check for duplicates in your poll_items list.`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
pollQueue.enqueue({ addr, dlen });
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
pollsPerSecond += 1. / ival;
|
|
66
|
+
pollIntervals.push(setInterval(pollItem, ival * 1000));
|
|
67
|
+
pollItem(false /* do not print a warning if it is already in the queue (on reload) */); // poll the item once immediately after startup
|
|
68
|
+
}
|
|
69
|
+
pollsPerSecond > 5. && logger.warn(`You are polling more than 5 items per second, which exceeds the (theoretical) limit of the low 4,800 bps Optolink bus baud rate. Please consider reducing the number of items in your poll_items list or the polling rate of the existing items (refer to the ival comment in the config.toml file for further information) and monitor the logs for saturation warnings of the poll queue.`);
|
|
70
|
+
|
|
71
|
+
if (deviceSerialNo === undefined) {
|
|
72
|
+
deviceSerialNo = null; // means we are waiting for the serial number to be sent by Vitoconnect, but do not allow to change it anymore if config.toml changes
|
|
73
|
+
|
|
74
|
+
const device = config.mqtt?.device_discovery?.overrides?.device;
|
|
75
|
+
deviceSerialNo = device?.serial_number || device?.identifiers || deviceSerialNo;
|
|
76
|
+
Array.isArray(deviceSerialNo) && (deviceSerialNo = deviceSerialNo[0]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
deviceDiscoveryOptions = config.mqtt?.device_discovery;
|
|
80
|
+
deviceDiscovery && await deviceDiscovery(); // (re-)publish the device discovery message, to publish any changes to the data points
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return config;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// read and apply the config and also use "applyConfig" when the config changes
|
|
87
|
+
const config = await applyConfig(await readConfig(applyConfig));
|
|
88
|
+
|
|
89
|
+
let mqttClient, mqttTopic, mqttAvailabilityTopic, mqttDpTopic = dp => {
|
|
90
|
+
const suffix = (dp ? (config.suffix ?? '<dpname>') : (config.unknown_dp_suffix ?? 'raw/<addr>'))
|
|
91
|
+
.replaceAll(/<(?:dp)?addr>/g, formatAddr(dp.addr))
|
|
92
|
+
.replaceAll(/<dpname>/g, dp?.name ?? 'unknown');
|
|
93
|
+
return `${mqttTopic}${mqttTopic.endsWith('/') || suffix.startsWith('/') ? '' : '/'}${suffix}`;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
deviceDiscovery = async function(options = (deviceDiscoveryOptions ?? { enabled: true })) {
|
|
97
|
+
if (!mqttClient?.connected || !deviceSerialNo || !(options?.enabled ?? true)) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
await publishDevice({
|
|
103
|
+
prefix: 'homeassistant',
|
|
104
|
+
...options, // prefix, overrides
|
|
105
|
+
mqttClient, mqttAvailabilityTopic, mqttDpTopic,
|
|
106
|
+
deviceSerialNo, dps
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
logger.info('Published device discovery payload via MQTT');
|
|
110
|
+
} catch (err) {
|
|
111
|
+
logger.error('Failed to publish device discovery via MQTT:', err);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (config.mqtt && config.mqtt.url) {
|
|
116
|
+
config.mqtt.online ??= true; // if not set, use a online topic
|
|
117
|
+
mqttTopic = config.mqtt.topic ?? 'Vito', mqttAvailabilityTopic =
|
|
118
|
+
`${mqttTopic}${mqttTopic.endsWith('/') || config.mqtt.online.startsWith?.('/') ? '' : '/'}${
|
|
119
|
+
typeof config.mqtt.online === 'string' ? config.mqtt.online : 'online'}`;
|
|
120
|
+
|
|
121
|
+
const mqttOptions = {
|
|
122
|
+
username: config.mqtt.username,
|
|
123
|
+
password: config.mqtt.password,
|
|
124
|
+
...(config.mqtt.options || {}),
|
|
125
|
+
...((config.mqtt.online ?? true) ? {
|
|
126
|
+
will: { // last will to set topic offline
|
|
127
|
+
topic: mqttAvailabilityTopic,
|
|
128
|
+
payload: `${false}`,
|
|
129
|
+
retain: true
|
|
130
|
+
}
|
|
131
|
+
} : {})
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// connect to MQTT broker
|
|
135
|
+
mqttClient = await mqttConnect(config.mqtt.url, mqttOptions);
|
|
136
|
+
const mqttConnected = async () => {
|
|
137
|
+
config.mqtt.online && await mqttClient.publishAsync(
|
|
138
|
+
mqttAvailabilityTopic, `${true}`, { retain: true });
|
|
139
|
+
await deviceDiscovery(); // when connected, publish the device / data points
|
|
140
|
+
};
|
|
141
|
+
await mqttConnected(); // set online topic
|
|
142
|
+
mqttClient.on('connect', mqttConnected);
|
|
143
|
+
} else {
|
|
144
|
+
logger.warn('No MQTT (URL) configuration found, not publishing to MQTT');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// the packet queue handles all fully received packets from opto. or vito. side, as well as already parsed packets received from the polling intercept
|
|
148
|
+
const polledAddr = new Set();
|
|
149
|
+
const packetQueue = async.queue(async task => {
|
|
150
|
+
trace && console.log(dateTimeString(), directionName(task.direction), (task.data ?? encodePacket(task.packet, task.direction & fromOpto)).toString('hex'));
|
|
151
|
+
|
|
152
|
+
const packet = task.packet ?? parsePacket(task.data, task.direction & fromOpto);
|
|
153
|
+
|
|
154
|
+
if (busState === 0 && task.direction & toOpto && packet.start === 0x16 && 'zero' in packet) {
|
|
155
|
+
busState = 1;
|
|
156
|
+
} else if (busState === 1 && task.direction & fromOpto && packet.res === 0x06 && !packet.peek?.length) {
|
|
157
|
+
busState = 2;
|
|
158
|
+
|
|
159
|
+
logger.info('Synchronization completed. Streams are now flowing');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (task.direction & toOpto && packet.addr && !polledAddr.has(packet.addr)) {
|
|
163
|
+
if (task.direction === fromVitoToOpto && (config.poll_items ?? []).some(([, addr]) => addr === packet.addr)) {
|
|
164
|
+
logger.info(`Vitoconnect sent a request for address ${formatAddr(packet.addr)}. This address is also on your poll_items list. In order to not waste any bandwidth on the Optolink bus, it is generally recommended to remove this address from the poll_items list and rely on Vitoconnect polling the item instead. This info is only printed once, even though Vitoconnect might continue pulling this item.`);
|
|
165
|
+
}
|
|
166
|
+
polledAddr.add(packet.addr);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (packet.id === 0x2) { // UNACK
|
|
170
|
+
logger.error(`Packet unacknowledged (${formatAddr(packet.addr)})`);
|
|
171
|
+
return;
|
|
172
|
+
} else if (packet.id === 0x3) { // ERRMSG
|
|
173
|
+
logger.error(`Packet with error message (${formatAddr(packet.addr)}):`, packet.data?.toString('hex'));
|
|
174
|
+
return;
|
|
175
|
+
} else if (!packet.data || !(
|
|
176
|
+
// for Virtual_READ (fn = 0x01) and RPCs (fn = 0x07), grab the data from the response (id = 0x1)
|
|
177
|
+
((packet.fn === 0x01 || packet.fn === 0x07) && packet.id === 0x1) ||
|
|
178
|
+
// for Virtual_WRITE (fn = 0x02), grab the data from the request (id = 0x0)
|
|
179
|
+
(packet.fn === 0x02 && packet.id === 0x0)
|
|
180
|
+
)) {
|
|
181
|
+
// ignore any packets that are not reading / writing data
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// special handling for mqtt device discovery, in case no identifiers (serial number) have been specified in the config
|
|
186
|
+
// we wait for the serial number to be sent by Vitoconnect (0xF010) and then publish the discovery message
|
|
187
|
+
if (!deviceSerialNo && packet.addr === 0xF010 && (config.mqtt?.device_discovery?.enabled ?? true)) {
|
|
188
|
+
deviceSerialNo = parseValue('string', packet.data)?.trim?.();
|
|
189
|
+
await deviceDiscovery();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const dp = dps.get(packet.addr);
|
|
193
|
+
if (!dp && !config.publish_unknown_dps) {
|
|
194
|
+
logger.debug(`Unknown data point (${formatAddr(packet.addr)}):`, packet.data?.toString('hex'));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// get the topic and parse the value based on the data point definition, or publish the raw data
|
|
199
|
+
const topic = mqttDpTopic(dp), value = dp ? dp.parse(packet.data) : packet.data;
|
|
200
|
+
logger.debug(`Publishing ${dp ? dp.name : 'unknown'} data point (${formatAddr(packet.addr)}) to ${topic}:`, value);
|
|
201
|
+
await mqttClient.publishAsync(topic, Buffer.isBuffer(value) ? bufferToFormat(value, config.buffer_format ?? 'hex') :
|
|
202
|
+
`${ typeof value === 'number' ? parseFloat(value.toFixed(config.max_decimals ?? 4 )) : value }`);
|
|
203
|
+
}, 1 /* no concurrency, packets should to be processed in order */);
|
|
204
|
+
packetQueue.error((err, task) => {
|
|
205
|
+
logger.error(`Error while processing packet: ${task?.data?.toString('hex')}`, err);
|
|
206
|
+
});
|
|
207
|
+
setTimeout(() => {
|
|
208
|
+
for (const { name, addr } of (dps?.values() ?? []).filter(({ addr }) => !polledAddr.has(addr) && !(config.poll_items ?? []).some(([, pollAddr]) => pollAddr === addr))) {
|
|
209
|
+
logger.info(`Even after one hour, your data point ${name} with address ${formatAddr(addr)} was never polled from Optolink. This could indicate that Vitoconnect never pulls this item proactively. Consider adding the address to your poll list, so it will be actively polled.`);
|
|
210
|
+
}
|
|
211
|
+
}, 60 * 60 * 1000); // after 1 hour
|
|
212
|
+
|
|
213
|
+
// the chunk queue handles each single chunk received from either vito. or opto. side and emit a packet, as soon as the transmit direction changes
|
|
214
|
+
let direction, chunks = [];
|
|
215
|
+
const chunkQueue = async.queue(async task => {
|
|
216
|
+
if (direction !== task.direction) {
|
|
217
|
+
if (chunks.length === 1) {
|
|
218
|
+
packetQueue.push({ data: chunks[0], direction });
|
|
219
|
+
} else if (chunks.length > 1) {
|
|
220
|
+
packetQueue.push({ data: Buffer.concat(chunks), direction });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
direction = task.direction;
|
|
224
|
+
chunks = [];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const { data, packet } = task;
|
|
228
|
+
if (data || packet) {
|
|
229
|
+
// special case: in order to keep the order of processed chunks / packets, we need to push pulled packets through the chunk queue as well
|
|
230
|
+
packetQueue.push({ data, packet, direction });
|
|
231
|
+
} else {
|
|
232
|
+
chunks.push(task.chunk);
|
|
233
|
+
}
|
|
234
|
+
}, 1 /* no concurrency, chunks need to be processed in order */);
|
|
235
|
+
chunkQueue.error((err, task) => {
|
|
236
|
+
logger.error('Error while processing chunk:', err);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// the intercept function is only called, in case poll_items have been defined. this intercept takes care of injecting poll requests to the message flow
|
|
240
|
+
let nextPoll, pollCallback, pollChunks = [];
|
|
241
|
+
function interceptPolling(chunk, direction, emitCallback, { vitoPort, optoPort, callback }) {
|
|
242
|
+
if (direction === fromOptoToVito) {
|
|
243
|
+
if (!pollCallback) {
|
|
244
|
+
// if we are in the response cycle (data from opto. to vito.),
|
|
245
|
+
// determine if (on next request cycle), we should inject a poll request
|
|
246
|
+
// attention: multiple chunks may flow in the same direction multiple times!
|
|
247
|
+
!nextPoll && busState === 2 && (nextPoll = pollQueue.dequeue());
|
|
248
|
+
emitCallback(); // then call the callback, so the data is actually forwarded to the vito. and the next request cycle can start
|
|
249
|
+
} else {
|
|
250
|
+
// there is a poll callback, meaning the previous request was a poll request,
|
|
251
|
+
// so this is our result, don't forward it and collect the chunks of the result
|
|
252
|
+
pollChunks.push(chunk);
|
|
253
|
+
// call the raw callback (not the emitCallback!) to continue the serial pipeline,
|
|
254
|
+
// but do not pass any data back to the vito., as this is our packet!
|
|
255
|
+
callback(null, Buffer.alloc(0));
|
|
256
|
+
|
|
257
|
+
try { // continue reading until we received a valid packet
|
|
258
|
+
const packet = parsePacket(Buffer.concat(pollChunks), true);
|
|
259
|
+
if (packet.res === 0x06 && !packet.peek?.length) {
|
|
260
|
+
return; // it is just the ACK so far, continue reading
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// put the (already parsed) packet into the packet queue
|
|
264
|
+
// note: we pass the packet through the chunk queue, in order to uphold the order of packets
|
|
265
|
+
chunkQueue.push({ packet, direction: fromOptoToLocal });
|
|
266
|
+
|
|
267
|
+
// continue by sending the next call from vito. to opto.
|
|
268
|
+
const contCallback = pollCallback;
|
|
269
|
+
nextPoll = pollCallback = undefined;
|
|
270
|
+
pollChunks = []; // reset the chunks, as we have received a full packet
|
|
271
|
+
contCallback();
|
|
272
|
+
} catch (err) {
|
|
273
|
+
// nothing to do here, assume we have not received the full packet yet
|
|
274
|
+
logger.debug('Parsing polled packet failed:', err.message);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} else { // vito. to opto.
|
|
278
|
+
if (!nextPoll) {
|
|
279
|
+
// there is nothing to poll, so callback immediately to initiate the next vito. to opto. request
|
|
280
|
+
emitCallback();
|
|
281
|
+
} else {
|
|
282
|
+
// there is an item we should poll, so don't send the current request to optolink, but inject our request
|
|
283
|
+
// instead and remember the current / next poll callback
|
|
284
|
+
pollCallback = emitCallback;
|
|
285
|
+
|
|
286
|
+
const packet = {
|
|
287
|
+
start: 0x41, // VS2
|
|
288
|
+
id: 0x0, // REQ
|
|
289
|
+
seq: 0, // no sequence no.
|
|
290
|
+
fn: 0x01, // Virtual_READ
|
|
291
|
+
...nextPoll // addr and dlen
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// put the packet into the packet queue
|
|
295
|
+
// note: we pass the packet through the chunk queue, in order to uphold the order of packets
|
|
296
|
+
chunkQueue.push({ packet, direction: fromLocalToOpto });
|
|
297
|
+
|
|
298
|
+
// push the chunk to the reading side of the serial connection
|
|
299
|
+
optoPort.write(encodePacket(packet));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// this is the main "magic", connecting to both serial ports and handling the data flow
|
|
305
|
+
const serial = await connect(config.port_vito ?? '/dev/ttyS0', config.port_opto ?? '/dev/ttyUSB0',
|
|
306
|
+
config.poll_items?.length && interceptPolling);
|
|
307
|
+
serial.on('chunk', function(chunk, direction) {
|
|
308
|
+
chunkQueue.push({ chunk, direction });
|
|
309
|
+
});
|
|
310
|
+
serial.on('error', function(err, direction) {
|
|
311
|
+
logger.error(`Error on serial port ${directionName(direction)}:`, err);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
logger.info(`Started in ${config.poll_items?.length ? 'intercept' : 'pass-through'} mode, waiting for synchronization`);
|
|
315
|
+
|
|
316
|
+
// after registering for a chunk event listener, await the pipelines
|
|
317
|
+
await serial.pipeline;
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "optolink-bridge",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Safely bridge the Optolink bus of your Viessmann heating system and publish attributes via MQTT + Home Assistant Device Discovery",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"viessmann",
|
|
7
|
+
"vitoconnect",
|
|
8
|
+
"vitocal",
|
|
9
|
+
"vitotronic",
|
|
10
|
+
"vicare",
|
|
11
|
+
"optolink",
|
|
12
|
+
"listener",
|
|
13
|
+
"splitter",
|
|
14
|
+
"bridge",
|
|
15
|
+
"tap",
|
|
16
|
+
"relay",
|
|
17
|
+
"bridge",
|
|
18
|
+
"router",
|
|
19
|
+
"proxy",
|
|
20
|
+
"local",
|
|
21
|
+
"mqtt",
|
|
22
|
+
"safe",
|
|
23
|
+
"ha",
|
|
24
|
+
"home assistant",
|
|
25
|
+
"homeassistant",
|
|
26
|
+
"discovery",
|
|
27
|
+
"device discovery"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"bin": "./index.js",
|
|
31
|
+
"scripts": {
|
|
32
|
+
"start": "node ./index.js"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"author": {
|
|
38
|
+
"name": "Kristian Kraljic",
|
|
39
|
+
"email": "kris@kra.lc",
|
|
40
|
+
"url": "https://kra.lc/"
|
|
41
|
+
},
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/kristian/optolink-bridge.git"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/kristian/optolink-bridge",
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/kristian/optolink-bridge/issues"
|
|
49
|
+
},
|
|
50
|
+
"license": "Apache-2.0",
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"async": "^3.2.6",
|
|
53
|
+
"binary-parser-encoder-bump": "^2.2.1",
|
|
54
|
+
"buffer-to-str": "^1.0.0",
|
|
55
|
+
"change-case": "^5.4.4",
|
|
56
|
+
"chokidar": "^4.0.3",
|
|
57
|
+
"mqtt": "^5.14.1",
|
|
58
|
+
"serialport": "^13.0.0",
|
|
59
|
+
"smol-toml": "^1.4.2",
|
|
60
|
+
"yocto-queue": "^1.2.1"
|
|
61
|
+
},
|
|
62
|
+
"packageManager": "yarn@4.10.3"
|
|
63
|
+
}
|
package/parse_vs2.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { Parser } from 'binary-parser-encoder-bump';
|
|
3
|
+
|
|
4
|
+
function crc256(data, crc = 0) {
|
|
5
|
+
for (const byte of data) {
|
|
6
|
+
crc += byte;
|
|
7
|
+
crc %= 0x100;
|
|
8
|
+
}
|
|
9
|
+
return crc;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function when({ tag, type }) {
|
|
13
|
+
return {
|
|
14
|
+
tag: function() {
|
|
15
|
+
return tag.apply(this) ? 1 : 0;
|
|
16
|
+
},
|
|
17
|
+
defaultChoice: new Parser(), // nothing
|
|
18
|
+
choices: {
|
|
19
|
+
1: type
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @returns a function that can be used for readUntil, to only peek a single byte (if any)
|
|
26
|
+
*/
|
|
27
|
+
function peek() {
|
|
28
|
+
return function() {
|
|
29
|
+
if (!this.peeked) {
|
|
30
|
+
this.peeked = true;
|
|
31
|
+
return false;
|
|
32
|
+
} else {
|
|
33
|
+
delete this.peeked;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const parser = new Parser()
|
|
40
|
+
.useContextVars()
|
|
41
|
+
.uint8('start')
|
|
42
|
+
.choice({
|
|
43
|
+
tag: 'start',
|
|
44
|
+
choices: {
|
|
45
|
+
0x04: new Parser(), // EOT (end of transmission / sync. start)
|
|
46
|
+
0x16: new Parser() // SYN (synchronous idle / start of transmission)
|
|
47
|
+
.uint16('zero', {
|
|
48
|
+
assert: function(zero) {
|
|
49
|
+
return !zero; // has to start with 16 00 00 (zero!)
|
|
50
|
+
}
|
|
51
|
+
}),
|
|
52
|
+
0x41: new Parser() // VS2_DAP_STANDARD (packet start)
|
|
53
|
+
.uint8('len')
|
|
54
|
+
.saveOffset('crc')
|
|
55
|
+
.pointer('raw', {
|
|
56
|
+
offset: 'crc',
|
|
57
|
+
type: new Parser()
|
|
58
|
+
.buffer('hoist', { length: '$parent.len' }),
|
|
59
|
+
formatter: struct => struct.hoist
|
|
60
|
+
})
|
|
61
|
+
.bit4('unused')
|
|
62
|
+
.bit4('id')
|
|
63
|
+
.choice({
|
|
64
|
+
tag: 'id',
|
|
65
|
+
choices: {
|
|
66
|
+
0x0: new Parser(), // REQ
|
|
67
|
+
0x1: new Parser(), // RESP
|
|
68
|
+
0x2: new Parser(), // UNACK
|
|
69
|
+
0x3: new Parser() // ERRMSG
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
.bit3('seq')
|
|
73
|
+
.bit5('fn')
|
|
74
|
+
.choice({
|
|
75
|
+
tag: 'fn',
|
|
76
|
+
choices: {
|
|
77
|
+
0x01: new Parser(), // Virtual_READ
|
|
78
|
+
0x02: new Parser(), // Virtual_WRITE
|
|
79
|
+
0x07: new Parser() // Remote_Procedure_Call
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
.uint16be('addr') // addresses are specified in big-endian, while values are little-endian
|
|
83
|
+
.uint8('dlen') // data length
|
|
84
|
+
.choice(when({
|
|
85
|
+
tag: function() {
|
|
86
|
+
// when reading (fn = 0x01) grab data from the response (id = 0x1), when writing (fn = 0x02) from the request (id = 0x0),
|
|
87
|
+
// for rpcs (fn = 0x07) it is function input & output (so both req / res have data), for errors (0x3) the data is err. msg
|
|
88
|
+
return (this.fn === 0x01 && this.id === 0x1) || (this.fn === 0x02 && this.id === 0x0) || (this.fn === 0x07) || (this.id === 0x3);
|
|
89
|
+
},
|
|
90
|
+
type: new Parser()
|
|
91
|
+
.buffer('data', { length: 'dlen' })
|
|
92
|
+
}))
|
|
93
|
+
.uint8('crc', {
|
|
94
|
+
type: 'uint8',
|
|
95
|
+
assert: function(crc) {
|
|
96
|
+
// if raw is not set / empty, we are in the encoding case, CRC will be calculated in the encode function
|
|
97
|
+
return !this.raw?.length || crc256(this.raw, this.len) === crc;
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
.buffer('rest', {
|
|
101
|
+
readUntil: 'eof',
|
|
102
|
+
assert: function(rest) {
|
|
103
|
+
return !rest.length;
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const responseParser = new Parser()
|
|
110
|
+
.useContextVars()
|
|
111
|
+
.uint8('res')
|
|
112
|
+
.choice({
|
|
113
|
+
tag: 'res',
|
|
114
|
+
choices: {
|
|
115
|
+
0x05: new Parser(), // ENQ (enquiry / sync. end)
|
|
116
|
+
0x06: new Parser() // ACK
|
|
117
|
+
.saveOffset('peek')
|
|
118
|
+
.pointer('peek', { // corner case: during the handshake, single ACK (0x06) without any data, so peek for one byte
|
|
119
|
+
offset: 'peek',
|
|
120
|
+
type: new Parser()
|
|
121
|
+
.buffer('hoist', {
|
|
122
|
+
readUntil: peek(),
|
|
123
|
+
encoder: function() {
|
|
124
|
+
return Buffer.from(this.start ? [0xFF] : []);
|
|
125
|
+
}
|
|
126
|
+
}),
|
|
127
|
+
formatter: struct => struct.hoist
|
|
128
|
+
})
|
|
129
|
+
.choice({
|
|
130
|
+
tag: 'peek.length',
|
|
131
|
+
defaultChoice: parser,
|
|
132
|
+
choices: {
|
|
133
|
+
0: new Parser()
|
|
134
|
+
}
|
|
135
|
+
}),
|
|
136
|
+
0x15: new Parser() // NACK
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Parse the VS2 / 300 Optolink protocol packet.
|
|
142
|
+
*
|
|
143
|
+
* @param {Buffer} data the data of the packet to parse
|
|
144
|
+
* @param {boolean} [response] true in case it was a response (Optolink -> Vitoconnect) packet
|
|
145
|
+
* @returns {object} the parsed data packet
|
|
146
|
+
*/
|
|
147
|
+
export function parsePacket(data, response) {
|
|
148
|
+
return (response ? responseParser : parser).parse(data);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// make parsing the default
|
|
152
|
+
export default parsePacket;
|
|
153
|
+
|
|
154
|
+
const emptyBuffer = Buffer.allocUnsafe(0);
|
|
155
|
+
export function encodePacket(packet, response) {
|
|
156
|
+
const data = (response ? responseParser : parser).encode(Object.assign({}, packet, {
|
|
157
|
+
peek: 'start' in packet || 'zero' in packet ? Buffer.from([0xFF]) : emptyBuffer,
|
|
158
|
+
unused: packet.unused ?? 0,
|
|
159
|
+
len: packet.len ?? ((packet.data ? packet.data.length : 0) + /* 1/2 unused, 1/2 id, 3/8 seq, 5/8 fn, 2x addr, dlen */ 5),
|
|
160
|
+
dlen: packet.dlen ?? (packet.data ? packet.data.length : 0),
|
|
161
|
+
raw: emptyBuffer,
|
|
162
|
+
rest: emptyBuffer,
|
|
163
|
+
crc: 0
|
|
164
|
+
}));
|
|
165
|
+
|
|
166
|
+
return packet.start === 0x41 ? Buffer.concat([data.subarray(0, data.length - 1), Buffer.from([
|
|
167
|
+
crc256(data.subarray(/* start byte */ 1 + (response ? /* res byte */ 1 : 0), data.length - /* crc byte */ 1))
|
|
168
|
+
])]) : data;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* All types supported by binary-parser
|
|
173
|
+
*/
|
|
174
|
+
export const types = Object.fromEntries([
|
|
175
|
+
'uint8', 'int8',
|
|
176
|
+
...['u', ''].flatMap(prefix => [
|
|
177
|
+
...[16, 32, 64].flatMap(size => [
|
|
178
|
+
`${prefix}int${size}`, `${prefix}int${size}le`, `${prefix}int${size}be`
|
|
179
|
+
])
|
|
180
|
+
]),
|
|
181
|
+
...['float', 'double'].flatMap(type => [
|
|
182
|
+
`${type}le`, `${type}be`
|
|
183
|
+
]),
|
|
184
|
+
...Array.from({ length: 32 }, (value, length) => `bit${length + 1}`),
|
|
185
|
+
'string', 'buffer'
|
|
186
|
+
].map(type => [type, undefined]));
|
|
187
|
+
|
|
188
|
+
const valueParser = new Parser()
|
|
189
|
+
.uint8('type')
|
|
190
|
+
.choice({
|
|
191
|
+
tag: 'type',
|
|
192
|
+
choices: Object.fromEntries(Object.keys(types).map((type, index) => [
|
|
193
|
+
index, (() => {
|
|
194
|
+
const parser = new Parser().endianess('little');
|
|
195
|
+
|
|
196
|
+
let options;
|
|
197
|
+
if (type === 'string') {
|
|
198
|
+
options = { greedy: true };
|
|
199
|
+
} else if (type === 'buffer') {
|
|
200
|
+
options = { readUntil: 'eof' };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
parser[type].call(parser, 'value', options);
|
|
204
|
+
types[type] = parser.sizeOf(); // length of the data type / dlen
|
|
205
|
+
return parser;
|
|
206
|
+
})()]))
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Parses the given value of the specified type.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} [type] the type to parse, one of `types` / all supported types of binary-parser, or 'debug' for all possible types
|
|
213
|
+
* @param {Buffer} data the data to parse for the given type
|
|
214
|
+
* @returns {(any|object)} the parsed value, or in case no type was given, an object of all possible types matching the types length (fuzzy, types that are one byte short [e.g. int8 + status byte] will also match, ignoring the last byte of data)
|
|
215
|
+
*/
|
|
216
|
+
export function parseValue(type, data) {
|
|
217
|
+
if (type === 'debug' || typeof type !== 'string' && (data = type)) {
|
|
218
|
+
// debug mode: return all possible types fitting for the given data length
|
|
219
|
+
return Object.fromEntries(Object.entries(types).filter(([type, length]) =>
|
|
220
|
+
length === data.length || length === data.length - 1 || type === 'buffer' || type === 'string')
|
|
221
|
+
.map(([type]) => [type, parseValue(type, data)]));
|
|
222
|
+
} else if (type === 'raw' || type === 'byte') { // aliases for buffer
|
|
223
|
+
type = 'buffer';
|
|
224
|
+
} else if (type === 'utf8') { // alias for string
|
|
225
|
+
type = 'string';
|
|
226
|
+
} else if (type === 'int' || type === 'uint') {
|
|
227
|
+
let length = data.length;
|
|
228
|
+
if (length <= 0) {
|
|
229
|
+
throw new Error(`Minimum length for (u)int types is 1, got ${length}`)
|
|
230
|
+
} else if (length > 2 && (length % 2) !== 0) {
|
|
231
|
+
length--; // remove status bit, 3 -> 2, 5 -> 4, 9 -> 8
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if ((length & (length - 1)) !== 0) { // must be power of 2: 1, 2, 4, 8
|
|
235
|
+
throw new Error(`Length of (u)int types must be power of 2 (1 / 2 / 4 / 8), got ${length}`)
|
|
236
|
+
} else if (length > 8) {
|
|
237
|
+
throw new Error(`Maximum length for (u)int types is 8, got ${length}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
type = `${type}${length * 8}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const index = Object.keys(types).indexOf(type);
|
|
244
|
+
if (index === -1) {
|
|
245
|
+
throw new Error(`Unknown data type ${type}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return valueParser.parse(Buffer.from([Object.keys(types).indexOf(type), ...data])).value;
|
|
249
|
+
}
|