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.
@@ -0,0 +1,91 @@
1
+ /*
2
+ * Use this script to convert optolink-splitter poll items into a optolink-bridge data points format.
3
+ *
4
+ * See 'convert_poll_items_example.txt' and use the filename as an input to this script:
5
+ *
6
+ * yarn node convert_poll_items.js convert_poll_items_example.txt
7
+ *
8
+ * Input data format from:
9
+ *
10
+ * https://github.com/philippoo66/optolink-splitter/blob/main/settings_ini.py
11
+ */
12
+
13
+
14
+ import fs from 'node:fs/promises';
15
+ import readline from 'node:readline';
16
+ import { basename } from 'node:path';
17
+ import { exists } from '../utils.js';
18
+
19
+ const path = process.argv[2];
20
+ if (!path || !(await exists(path))) {
21
+ console.error(`Usage: ${basename(process.argv[1])} <dps-file>`);
22
+ process.exit(1);
23
+ }
24
+
25
+ const file = await fs.open(path, 'r');
26
+ const lines = readline.createInterface({
27
+ input: file.createReadStream(),
28
+ crlfDelay: Infinity,
29
+ });
30
+
31
+ const warnings = [];
32
+ for await (let line of lines) {
33
+ const item = line.match(/(?<=\().*(?=\))/g)?.[0]?.split(/\s*,\s*/);
34
+ if (!item) {
35
+ continue;
36
+ }
37
+
38
+ let [poll_cycle, name, address, length, byte_bit_filter, scale_or_type, signed] = item;
39
+ if (!isFinite(poll_cycle)) {
40
+ [name, address, length, byte_bit_filter, scale_or_type, signed] = item;
41
+ poll_cycle = undefined;
42
+ }
43
+
44
+ name = name.match(/(?<=\").*(?=\")/g)[0];
45
+
46
+ if (!/^\s*"b:/.test(byte_bit_filter)) {
47
+ [scale_or_type, signed] = [byte_bit_filter, scale_or_type];
48
+ byte_bit_filter = undefined;
49
+ } else {
50
+ byte_bit_filter = byte_bit_filter.match(/(?<=\").*(?=\")/g)[0];
51
+ }
52
+
53
+ let scale = 1, type;
54
+ if (isFinite(scale_or_type)) {
55
+ scale = parseFloat(scale_or_type);
56
+ } else {
57
+ type = scale_or_type.match(/(?<=\").*(?=\")/g)[0];
58
+ }
59
+
60
+ signed = signed === 'True';
61
+
62
+ if (!type) {
63
+ type = `int${(parseInt(length) * 8)}`;
64
+ if (!signed) {
65
+ type = `u${type}`;
66
+ }
67
+ }
68
+
69
+ if (byte_bit_filter) {
70
+ warnings.push(`Byte-bit filters are currently not supported by optolink-bridge, using "raw" type for "${name}"`);
71
+ type = 'raw';
72
+ }
73
+ if (poll_cycle) {
74
+ warnings.push(`Poll-cycles are not required for optolink-bridge, "${name}" will be published when it is sent via the Optolink interface`);
75
+ }
76
+
77
+ console.log(` ["${name}", ${address}, "${type}"${ scale !== 1 ? `, ${scale}` : '' }],`);
78
+ }
79
+
80
+ try {
81
+ await file.close();
82
+ } catch {
83
+ // nothing to do here
84
+ }
85
+
86
+ if (warnings.length) {
87
+ console.log();
88
+ for (const warning of warnings) {
89
+ console.warn(warning);
90
+ }
91
+ }
@@ -0,0 +1,42 @@
1
+ ("aussentemperatur", 0x0101, 2, 0.1, True),
2
+
3
+ ("betriebsart", 0xB000, 1, "raw", False,),
4
+
5
+ ("heizkreisumwalzpumpe", 0x048D, 1, 1, True),
6
+ ("heizkreisumwalzpumpe_betriebsstunden", 0x058D, 4, 0.0002777777777777778, False),
7
+
8
+ ("hkl_niveau", 0x2006, 2, 0.1, True),
9
+ ("hkl_neigung", 0x2007, 2, 0.1, True),
10
+ ("hysterese", 0x7203, 2, 0.1, True),
11
+
12
+ ("puffer_temperatur", 0x010B, 2, 0.1, True),
13
+
14
+ ("primarkreis_vorlauftemperatur", 0x0103, 2, 0.1, True),
15
+ ("sekundarkreis_vorlauftemperatur", 0x0105, 2, 0.1, True),
16
+ ("rucklauftemperatur", 0x0106, 2, 0.1, True),
17
+
18
+ ("ww_temperatur_speicher", 0x010D, 2, 0.1, True),
19
+ ("ww_temperatur_soll", 0x6000, 2, 0.1, True),
20
+ ("ww_temperatur_soll2", 0x600C, 2, 0.1, True),
21
+ ("ww_zirkulationspumpe", 0x0490, 1, 1, True),
22
+
23
+ ("raum_temperatur_soll_normal", 0x2000, 2, 0.1, True),
24
+ ("raum_temperatur_soll_reduziert", 0x2001, 2, 0.1, True),
25
+ ("raum_temperatur_soll_party", 0x2022, 2, 0.1, True),
26
+
27
+ ("kompressor", 0x0480, 1, 1, True),
28
+ ("kompressor_phase", 0x0E1A, 3, "raw", False),
29
+ ("kompressor_starts", 0x0500, 4, 1, True),
30
+ ("kompressor_betriebsstunden", 0x0580, 4, 0.0002777777777777778, False),
31
+
32
+ ("einmalig_ww", 0xB020, 1, "raw", False),
33
+
34
+ ("energie_faktor", 0x163F, 1, 1, True),
35
+ ("energie_heizen", 0x1640, 4, 1, False),
36
+ ("energie_ww", 0x1650, 4, 1, False),
37
+ ("energie_heizen_elektro", 0x1660, 4, 1, False),
38
+ ("energie_ww_elektro", 0x1670, 4, 1, False),
39
+
40
+ ("jahresarbeitszahl", 0x1680, 1, 0.1, True),
41
+ ("jahresarbeitszahl_heizen", 0x1681, 1, 0.1, True),
42
+ ("jahresarbeitszahl_ww", 0x1682, 1, 0.1, True),
package/utils.js ADDED
@@ -0,0 +1,141 @@
1
+ import fs from 'fs/promises';
2
+ import { watch } from 'chokidar';
3
+ import { parse as parseToml } from 'smol-toml';
4
+ import { parseValue } from './parse_vs2.js';
5
+ import { fromOptoToVito, fromVitoToOpto } from './serial.js';
6
+
7
+ export const fromLocalToOpto = 0b0100; // Local → Optolink
8
+ export const fromOptoToLocal = 0b1000; // Optolink → Local
9
+
10
+ export const toOpto = 0b0101; // → Optolink (check with & toOpto)
11
+ export const fromOpto = 0b1010; // Optolink → (check with & fromOpto)
12
+
13
+ /**
14
+ * Check if a file (/ directory) exists on the file system.
15
+ *
16
+ * @param {string} path the path to check to exist
17
+ * @returns {boolean} true if the file exists, false otherwise
18
+ */
19
+ export async function exists(path) {
20
+ try {
21
+ await fs.access(path);
22
+ return true;
23
+ } catch (err) {
24
+ if (err.code === 'ENOENT') {
25
+ return false;
26
+ } else {
27
+ throw err;
28
+ }
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Read the configuration file (config.toml / local_config.toml) and parse the TOML.
34
+ *
35
+ * @param {function} [watchCallback] a function to call when the configuration file changes
36
+ * @returns {object} the parsed TOML configuration
37
+ */
38
+ export async function readConfig(watchCallback) {
39
+ const configFile = (await exists('local_config.toml')) ?
40
+ 'local_config.toml' : 'config.toml';
41
+
42
+ if (typeof watchCallback === 'function') {
43
+ watch(configFile, {
44
+ awaitWriteFinish: true,
45
+ persistent: false, // do not keep the process running
46
+ encoding: 'utf8'
47
+ }).on('change', async () => {
48
+ await watchCallback(await readConfig());
49
+ });
50
+ }
51
+
52
+ return parseToml(await fs.readFile(configFile, 'utf8'));
53
+ }
54
+
55
+ /**
56
+ * Return a date / time string in the current timezone in ISO format w/o "TZ" separators.
57
+ *
58
+ * @returns {string} a date time string
59
+ */
60
+ const tzOffset = new Date().getTimezoneOffset();
61
+ export function dateTimeString(date = Date.now()) {
62
+ return new Date((+date) - tzOffset * 60 * 1000)
63
+ .toISOString().replaceAll(/[TZ]/g, ' ').trim();
64
+ }
65
+
66
+ /**
67
+ * Format a address by padding it to a 4 character hex string.
68
+ *
69
+ * @param {number} addr the address to format
70
+ * @returns {string} the formatted address
71
+ */
72
+ export function formatAddr(addr, prefix = '0x') {
73
+ if (typeof addr !== 'number') {
74
+ throw new TypeError(`Expected address to be a number, got ${typeof addr}`);
75
+ }
76
+ return `${prefix}${addr.toString(16).padStart(4, '0')}`;
77
+ }
78
+
79
+ /**
80
+ * Format the direction of data transfer to a human-readable string.
81
+ *
82
+ * @param {number} direction the direction to format
83
+ * @returns {string} the formatted direction
84
+ */
85
+ export function directionName(direction) {
86
+ switch (direction) {
87
+ case fromVitoToOpto:
88
+ return 'Vitoconnect → Optolink';
89
+ case fromOptoToVito:
90
+ return 'Optolink → Vitoconnect';
91
+ case fromLocalToOpto:
92
+ return 'Local → Optolink';
93
+ case fromOptoToLocal:
94
+ return 'Optolink → Local';
95
+ default:
96
+ return 'Unknown';
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Create a `Map` from a given array of data points, mapping the data point address to the data point.
102
+ *
103
+ * @param {Array<object>} dps the data points to create a map for
104
+ * @param {Map} [map] the map to add the data points to
105
+ * @returns {Map} the map of data points
106
+ */
107
+ export function mapDataPoints(dps, map = new Map()) {
108
+ for (const dp of (dps ?? [])) {
109
+ let [name, addr, type, scale] = dp;
110
+
111
+ if (typeof type === 'number') {
112
+ const signed = !!scale;
113
+ scale = type;
114
+ type = 'int';
115
+ if (!signed) {
116
+ type = `u${type}`;
117
+ }
118
+ }
119
+
120
+ if (map.has(addr)) {
121
+ throw new TypeError(`Duplicate data point "${name}" with address ${formatAddr(addr)}`);
122
+ }
123
+
124
+ map.set(addr, {
125
+ name, addr, type, scale, parse: data => {
126
+ let value = parseValue(type, data);
127
+ if (Number.isFinite(scale)) {
128
+ if (typeof value === 'number') {
129
+ value *= scale;
130
+ } else if (typeof value === 'bigint') {
131
+ value *= BigInt(scale);
132
+ }
133
+ }
134
+
135
+ return value;
136
+ }
137
+ });
138
+ }
139
+
140
+ return map;
141
+ }