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/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
+ }