node-red-contrib-velbus-2026 0.8.1

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.
Files changed (56) hide show
  1. package/CHANGELOG_FORUM.md +784 -0
  2. package/LICENSE +21 -0
  3. package/README.md +140 -0
  4. package/examples/velbus-basic-relay-dimmer.json +328 -0
  5. package/index.js +3 -0
  6. package/lib/blind-types-20.js +26 -0
  7. package/lib/blind-types-s.js +46 -0
  8. package/lib/blind-types.js +38 -0
  9. package/lib/dimmer-types-20.js +120 -0
  10. package/lib/dimmer-types.js +34 -0
  11. package/lib/glass-panel-types.js +296 -0
  12. package/lib/pir-types-20.js +51 -0
  13. package/lib/pir-types.js +61 -0
  14. package/lib/relay-types-20.js +43 -0
  15. package/lib/relay-types.js +39 -0
  16. package/lib/sensor-types-20.js +29 -0
  17. package/lib/sensor-types.js +49 -0
  18. package/lib/velbus-utils.js +80 -0
  19. package/nodes/velbus-blind/velbus-blind.html +165 -0
  20. package/nodes/velbus-blind/velbus-blind.js +251 -0
  21. package/nodes/velbus-blind-20/velbus-blind-20.html +179 -0
  22. package/nodes/velbus-blind-20/velbus-blind-20.js +300 -0
  23. package/nodes/velbus-blind-s/velbus-blind-s.html +181 -0
  24. package/nodes/velbus-blind-s/velbus-blind-s.js +282 -0
  25. package/nodes/velbus-bridge/velbus-bridge.html +72 -0
  26. package/nodes/velbus-bridge/velbus-bridge.js +304 -0
  27. package/nodes/velbus-button/velbus-button.html +172 -0
  28. package/nodes/velbus-button/velbus-button.js +117 -0
  29. package/nodes/velbus-clock/velbus-clock.html +198 -0
  30. package/nodes/velbus-clock/velbus-clock.js +273 -0
  31. package/nodes/velbus-dimmer/velbus-dimmer.html +174 -0
  32. package/nodes/velbus-dimmer/velbus-dimmer.js +457 -0
  33. package/nodes/velbus-dimmer-20/velbus-dimmer-20.html +271 -0
  34. package/nodes/velbus-dimmer-20/velbus-dimmer-20.js +602 -0
  35. package/nodes/velbus-glass-panel/velbus-glass-panel.html +337 -0
  36. package/nodes/velbus-glass-panel/velbus-glass-panel.js +492 -0
  37. package/nodes/velbus-meteo/velbus-meteo.html +120 -0
  38. package/nodes/velbus-meteo/velbus-meteo.js +279 -0
  39. package/nodes/velbus-pir/velbus-pir.html +187 -0
  40. package/nodes/velbus-pir/velbus-pir.js +269 -0
  41. package/nodes/velbus-pir-20/velbus-pir-20.html +186 -0
  42. package/nodes/velbus-pir-20/velbus-pir-20.js +352 -0
  43. package/nodes/velbus-relay/velbus-relay.html +215 -0
  44. package/nodes/velbus-relay/velbus-relay.js +404 -0
  45. package/nodes/velbus-relay-20/velbus-relay-20.html +123 -0
  46. package/nodes/velbus-relay-20/velbus-relay-20.js +434 -0
  47. package/nodes/velbus-scan/velbus-scan.html +87 -0
  48. package/nodes/velbus-scan/velbus-scan.js +313 -0
  49. package/nodes/velbus-sensor/velbus-sensor.html +154 -0
  50. package/nodes/velbus-sensor/velbus-sensor.js +259 -0
  51. package/nodes/velbus-sensor-20/velbus-sensor-20.html +158 -0
  52. package/nodes/velbus-sensor-20/velbus-sensor-20.js +314 -0
  53. package/nodes/velbus-thermostat/velbus-thermostat.html +191 -0
  54. package/nodes/velbus-thermostat/velbus-thermostat.js +322 -0
  55. package/package.json +55 -0
  56. package/velbus-2026-repo.bundle +0 -0
@@ -0,0 +1,282 @@
1
+ 'use strict';
2
+
3
+ const { pkt, parsePkt } = require('../../lib/velbus-utils');
4
+ const { BLIND_TYPES_S, BLIND_TYPE_IDS_S } = require('../../lib/blind-types-s');
5
+
6
+ // ─────────────────────────────────────────────────────────────────────────────
7
+ // Helpers
8
+ // ─────────────────────────────────────────────────────────────────────────────
9
+
10
+ const BLIND_STATUS = { 0: 'off', 1: 'up', 2: 'down' };
11
+
12
+ const LOCK_STATUS = {
13
+ 0: 'normal', 1: 'inhibited', 2: 'inhibit_preset_down',
14
+ 3: 'inhibit_preset_up', 4: 'forced_down', 5: 'forced_up', 6: 'locked',
15
+ };
16
+
17
+ function make24(duration) {
18
+ return [(duration >> 16) & 0xFF, (duration >> 8) & 0xFF, duration & 0xFF];
19
+ }
20
+
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+ // Node
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+
25
+ module.exports = function(RED) {
26
+
27
+ function VelbusBlindSNode(config) {
28
+ RED.nodes.createNode(this, config);
29
+ const node = this;
30
+
31
+ node.bridge = RED.nodes.getNode(config.bridge);
32
+ node.address = typeof config.address === 'number'
33
+ ? config.address // legacy saves stored decimal numbers
34
+ : (parseInt(config.address, 16) || 0); // editor stores hex strings
35
+ node.moduleName = config.moduleName || '';
36
+ node.typeId = config.typeId ? parseInt(config.typeId) : null;
37
+ node.channel = parseInt(config.channel) || 1;
38
+
39
+ if (!node.bridge) {
40
+ node.status({ fill: 'red', shape: 'ring', text: 'no bridge' });
41
+ node.error('velbus-blind-s: no bridge configured');
42
+ return;
43
+ }
44
+ if (!node.address || node.address < 1 || node.address > 254) {
45
+ node.status({ fill: 'red', shape: 'ring', text: 'invalid address' });
46
+ node.error('velbus-blind-s: invalid address ' + node.address);
47
+ return;
48
+ }
49
+
50
+ const typeDesc = node.typeId !== null ? (BLIND_TYPES_S[node.typeId] || null) : null;
51
+ // Channel bit: ch1=0x01, ch2=0x02 for both VMB1BLS and VMB2BLE
52
+ const chBit = typeDesc ? (typeDesc.channelBits[node.channel] || 0x01) : 0x01;
53
+
54
+ // 0x00 relay event bitmask for this channel
55
+ // ch1: up=bit0, down=bit1; ch2: up=bit2, down=bit3
56
+ const relayUpBit = node.channel === 1 ? 0x01 : 0x04;
57
+ const relayDownBit = node.channel === 1 ? 0x02 : 0x08;
58
+
59
+ const _addrHex = '0x' + node.address.toString(16).padStart(2, '0').toUpperCase();
60
+ function setStatus(text, fill, shape) {
61
+ const label = node.moduleName
62
+ ? node.moduleName + ' ch' + node.channel + ' (' + _addrHex + ')'
63
+ : _addrHex + ' ch' + node.channel;
64
+ node.status({ fill: fill || 'green', shape: shape || 'dot', text: label + ' ' + text });
65
+ }
66
+
67
+ // ── Packet handler ────────────────────────────────────────────────────
68
+
69
+ function onPacket(raw) {
70
+ const p = parsePkt(raw);
71
+ if (!p || p.rtr) return;
72
+ if (p.addr !== node.address) return;
73
+
74
+ const { cmd, body } = p;
75
+
76
+ switch (cmd) {
77
+
78
+ // ── 0x00 Relay switch events ─────────────────────────────────────
79
+ case 0x00: {
80
+ if (body.length < 4) return;
81
+ // body[0]=cmd, body[1]=switched on, body[2]=switched off, body[3]=0x00
82
+ const pressedByte = body[1];
83
+ const releasedByte = body[2];
84
+
85
+ const upOn = !!(pressedByte & relayUpBit);
86
+ const upOff = !!(releasedByte & relayUpBit);
87
+ const downOn = !!(pressedByte & relayDownBit);
88
+ const downOff = !!(releasedByte & relayDownBit);
89
+
90
+ if (!upOn && !upOff && !downOn && !downOff) return;
91
+
92
+ const direction = upOn ? 'up' : downOn ? 'down' : null;
93
+ const stopped = upOff || downOff;
94
+ if (direction) setStatus(direction);
95
+ else if (stopped) setStatus('stopped');
96
+
97
+ const payload = {
98
+ type: 'blind_event',
99
+ channel: node.channel,
100
+ upOn,
101
+ upOff,
102
+ downOn,
103
+ downOff,
104
+ moving: direction,
105
+ };
106
+ node.send([{ payload }, null]);
107
+ break;
108
+ }
109
+
110
+ // ── 0xEC Blind status ────────────────────────────────────────────
111
+ case 0xEC: {
112
+ if (body.length < 8) return;
113
+ // body[0]=0xEC, body[1]=channel bit, body[2]=default timeout (seconds),
114
+ // body[3]=blind status, body[4]=LED status, body[5]=position (0-100%),
115
+ // body[6]=lock/inhibit/forced state, body[7]=alarm & auto mode
116
+
117
+ if (body[1] !== chBit) return;
118
+
119
+ const blindStatus = BLIND_STATUS[body[3]] || 'unknown';
120
+ const position = body[5]; // 0=up, 100=down
121
+ const lockState = LOCK_STATUS[body[6] & 0x07] || 'unknown';
122
+
123
+ const alarmByte = body[7];
124
+ const autoMode = alarmByte & 0x03;
125
+ const alarm1Active = !!(alarmByte & 0x04);
126
+ const alarm2Active = !!(alarmByte & 0x10);
127
+ const sunriseEnabled = !!(alarmByte & 0x40);
128
+ const sunsetEnabled = !!(alarmByte & 0x80);
129
+
130
+ const ledByte = body[4];
131
+ const led = {
132
+ downOn: !!(ledByte & 0x80),
133
+ downSlowBlink: !!(ledByte & 0x40),
134
+ downFastBlink: !!(ledByte & 0x20),
135
+ downVFastBlink:!!(ledByte & 0x10),
136
+ upOn: !!(ledByte & 0x08),
137
+ upSlowBlink: !!(ledByte & 0x04),
138
+ upFastBlink: !!(ledByte & 0x02),
139
+ upVFastBlink: !!(ledByte & 0x01),
140
+ };
141
+
142
+ const on = blindStatus !== 'off';
143
+ setStatus(blindStatus + ' ' + position + '%');
144
+
145
+ const payload = {
146
+ type: 'blind_status',
147
+ channel: node.channel,
148
+ on,
149
+ status: blindStatus,
150
+ position,
151
+ lockState,
152
+ autoMode,
153
+ alarms: { alarm1Active, alarm2Active },
154
+ sunrise: sunriseEnabled,
155
+ sunset: sunsetEnabled,
156
+ led,
157
+ };
158
+ node.send([null, { payload }]);
159
+ break;
160
+ }
161
+
162
+ // ── 0xF0/F1/F2 Blind name parts ─────────────────────────────────
163
+ case 0xF0:
164
+ case 0xF1:
165
+ case 0xF2: {
166
+ // body[0]=cmd, body[1]=channel bit, body[2..]=chars
167
+ if (body[1] !== chBit) return;
168
+ let text = '';
169
+ for (let i = 2; i < body.length; i++) {
170
+ if (body[i] === 0 || body[i] === 0xFF) break;
171
+ text += String.fromCharCode(body[i]);
172
+ }
173
+ if (text && cmd === 0xF0) node.moduleName = text;
174
+ break;
175
+ }
176
+
177
+ default:
178
+ break;
179
+ }
180
+ }
181
+
182
+ node.bridge.register(node.address, onPacket);
183
+
184
+ // ── Input commands ────────────────────────────────────────────────────
185
+
186
+ node.on('input', function(msg) {
187
+ const cmd = msg.payload && msg.payload.cmd;
188
+ if (!cmd) return;
189
+
190
+ if (cmd === 'get_status') {
191
+ node.bridge.send(pkt(0xFB, node.address, [0xFA, chBit]));
192
+ return;
193
+ }
194
+ if (cmd === 'stop') {
195
+ node.bridge.send(pkt(0xF8, node.address, [0x04, chBit]));
196
+ return;
197
+ }
198
+ if (cmd === 'up') {
199
+ const timeout = parseInt(msg.payload.timeout) || 0;
200
+ node.bridge.send(pkt(0xF8, node.address, [0x05, chBit, ...make24(timeout)]));
201
+ return;
202
+ }
203
+ if (cmd === 'down') {
204
+ const timeout = parseInt(msg.payload.timeout) || 0;
205
+ node.bridge.send(pkt(0xF8, node.address, [0x06, chBit, ...make24(timeout)]));
206
+ return;
207
+ }
208
+ if (cmd === 'position') {
209
+ const pos = Math.max(0, Math.min(100, parseInt(msg.payload.position) || 0));
210
+ node.bridge.send(pkt(0xF8, node.address, [0x1C, chBit, pos]));
211
+ return;
212
+ }
213
+ if (cmd === 'lock') {
214
+ const duration = parseInt(msg.payload.duration) || 0xFFFFFF;
215
+ node.bridge.send(pkt(0xF8, node.address, [0x1A, chBit, ...make24(duration)]));
216
+ return;
217
+ }
218
+ if (cmd === 'unlock') {
219
+ node.bridge.send(pkt(0xF8, node.address, [0x1B, chBit]));
220
+ return;
221
+ }
222
+ if (cmd === 'forced_up') {
223
+ const duration = parseInt(msg.payload.duration) || 0xFFFFFF;
224
+ node.bridge.send(pkt(0xF8, node.address, [0x12, chBit, ...make24(duration)]));
225
+ return;
226
+ }
227
+ if (cmd === 'cancel_forced_up') {
228
+ node.bridge.send(pkt(0xF8, node.address, [0x13, chBit]));
229
+ return;
230
+ }
231
+ if (cmd === 'forced_down') {
232
+ const duration = parseInt(msg.payload.duration) || 0xFFFFFF;
233
+ node.bridge.send(pkt(0xF8, node.address, [0x14, chBit, ...make24(duration)]));
234
+ return;
235
+ }
236
+ if (cmd === 'cancel_forced_down') {
237
+ node.bridge.send(pkt(0xF8, node.address, [0x15, chBit]));
238
+ return;
239
+ }
240
+ if (cmd === 'inhibit') {
241
+ const duration = parseInt(msg.payload.duration) || 0xFFFFFF;
242
+ node.bridge.send(pkt(0xF8, node.address, [0x16, chBit, ...make24(duration)]));
243
+ return;
244
+ }
245
+ if (cmd === 'cancel_inhibit') {
246
+ node.bridge.send(pkt(0xF8, node.address, [0x17, chBit]));
247
+ return;
248
+ }
249
+ if (cmd === 'inhibit_preset_up') {
250
+ const duration = parseInt(msg.payload.duration) || 0xFFFFFF;
251
+ node.bridge.send(pkt(0xF8, node.address, [0x18, chBit, ...make24(duration)]));
252
+ return;
253
+ }
254
+ if (cmd === 'inhibit_preset_down') {
255
+ const duration = parseInt(msg.payload.duration) || 0xFFFFFF;
256
+ node.bridge.send(pkt(0xF8, node.address, [0x19, chBit, ...make24(duration)]));
257
+ return;
258
+ }
259
+ if (cmd === 'auto_mode') {
260
+ const mode = parseInt(msg.payload.mode) || 0; // 0=disabled, 1-3=mode
261
+ node.bridge.send(pkt(0xF8, node.address, [0xB3, chBit, mode]));
262
+ return;
263
+ }
264
+ if (cmd === 'get_name') {
265
+ node.bridge.send(pkt(0xF8, node.address, [0xEF, chBit]));
266
+ return;
267
+ }
268
+
269
+ node.warn('velbus-blind-s: unknown cmd: ' + cmd);
270
+ });
271
+
272
+ // ── Cleanup ───────────────────────────────────────────────────────────
273
+
274
+ node.on('close', function() {
275
+ node.bridge.deregister(node.address, onPacket);
276
+ });
277
+
278
+ setStatus('ready', 'grey', 'dot');
279
+ }
280
+
281
+ RED.nodes.registerType('velbus-blind-s', VelbusBlindSNode);
282
+ };
@@ -0,0 +1,72 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('velbus-bridge', {
3
+ category: 'config',
4
+ defaults: {
5
+ name: { value: 'Velbus Bridge' },
6
+ host: { value: '127.0.0.1', required: true },
7
+ port: { value: '6000', required: true, validate: RED.validators.number() },
8
+ useTLS: { value: false },
9
+ useAuth: { value: false },
10
+ authKey: { value: '' },
11
+ certPath: { value: '' },
12
+ keyPath: { value: '' }
13
+ },
14
+ label: function() {
15
+ return this.name || (this.host + ':' + this.port);
16
+ }
17
+ });
18
+ </script>
19
+
20
+ <script type="text/html" data-template-name="velbus-bridge">
21
+ <div class="form-row">
22
+ <label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
23
+ <input type="text" id="node-config-input-name" placeholder="Velbus Bridge">
24
+ </div>
25
+ <div class="form-row">
26
+ <label for="node-config-input-host"><i class="fa fa-server"></i> Host</label>
27
+ <input type="text" id="node-config-input-host" placeholder="127.0.0.1">
28
+ </div>
29
+ <div class="form-row">
30
+ <label for="node-config-input-port"><i class="fa fa-plug"></i> Port</label>
31
+ <input type="number" id="node-config-input-port" placeholder="6000">
32
+ </div>
33
+ <div class="form-row">
34
+ <label for="node-config-input-useTLS"><i class="fa fa-lock"></i> TLS</label>
35
+ <input type="checkbox" id="node-config-input-useTLS" style="display:inline-block;width:auto;vertical-align:top;">
36
+ &nbsp;Enable TLS (python-velbustcp)
37
+ </div>
38
+ <div class="form-row">
39
+ <label for="node-config-input-useAuth"><i class="fa fa-key"></i> Auth</label>
40
+ <input type="checkbox" id="node-config-input-useAuth" style="display:inline-block;width:auto;vertical-align:top;">
41
+ &nbsp;Send auth key on connect
42
+ </div>
43
+ <div class="form-row">
44
+ <label for="node-config-input-authKey"><i class="fa fa-key"></i> Auth Key</label>
45
+ <input type="password" id="node-config-input-authKey" placeholder="Auth key for python-velbustcp">
46
+ </div>
47
+ <div class="form-row">
48
+ <label for="node-config-input-certPath"><i class="fa fa-certificate"></i> Cert path</label>
49
+ <input type="text" id="node-config-input-certPath" placeholder="/path/to/ca.crt">
50
+ </div>
51
+ <div class="form-row">
52
+ <label for="node-config-input-keyPath"><i class="fa fa-file"></i> Key path</label>
53
+ <input type="text" id="node-config-input-keyPath" placeholder="/path/to/client.key">
54
+ </div>
55
+ </script>
56
+
57
+ <script type="text/html" data-help-name="velbus-bridge">
58
+ <p style="background:#fff3cd; border-left:4px solid #ffc107; padding:8px 12px; margin-bottom:12px; font-size:0.85em;">
59
+ <strong>&#9888; Testing status:</strong> This node was generated with Claude.ai and is in
60
+ need of extensive field testing before being deployed into a commercial project.
61
+ It is presented "as is" &mdash; any use beyond testing is entirely at your own risk and liability.
62
+ Constructive feedback is welcomed, accompanied by as many examples and debug captures as possible.
63
+ <a href="https://github.com/MDAR/node-red-contrib-velbus-2026/issues" target="_blank">File an issue on GitHub</a>.
64
+ </p>
65
+ <p>Configuration node. Manages a single persistent TCP connection to a Velbus gateway
66
+ (velbus-tcp snap on port 6000, or python-velbustcp on port 27015).</p>
67
+ <p>All <b>velbus-relay</b> nodes sharing this bridge use the same connection.
68
+ The bridge auto-reconnects if the connection is lost.</p>
69
+ <h3>TLS / Auth</h3>
70
+ <p>Enable <b>TLS</b> and <b>Auth</b> for python-velbustcp connections.
71
+ The auth key is sent as plain text immediately after the TCP handshake, followed by a newline.</p>
72
+ </script>
@@ -0,0 +1,304 @@
1
+ 'use strict';
2
+
3
+ const net = require('net');
4
+ const tls = require('tls');
5
+ const fs = require('fs');
6
+ const { splitPackets, parsePkt } = require('../../lib/velbus-utils');
7
+
8
+ module.exports = function(RED) {
9
+
10
+ // ── Shared scan results store ───────────────────────────────────────────
11
+ // Keyed by bridge node ID → array of discovered module objects.
12
+ // Populated by velbus-scan node via bridge.storeScanResults().
13
+ // Served to config dialogs via RED.httpAdmin endpoint below.
14
+ // Persisted to a JSON file in the Node-RED user directory so that address
15
+ // dropdowns survive restarts and full deploys.
16
+ const path = require('path');
17
+ const _persistFile = path.join(RED.settings.userDir || '.', 'velbus-scan-results.json');
18
+ let _scanResults = {};
19
+ try {
20
+ _scanResults = JSON.parse(fs.readFileSync(_persistFile, 'utf8'));
21
+ RED.log.info('velbus-bridge: loaded persisted scan results (' +
22
+ Object.keys(_scanResults).length + ' bridge(s))');
23
+ } catch (e) {
24
+ _scanResults = {}; // No file yet, or unreadable — start empty
25
+ }
26
+
27
+ function _persistScanResults() {
28
+ fs.writeFile(_persistFile, JSON.stringify(_scanResults, null, 2), (err) => {
29
+ if (err) RED.log.warn('velbus-bridge: could not persist scan results — ' + err.message);
30
+ });
31
+ }
32
+
33
+ // ── HTTP Admin endpoint ─────────────────────────────────────────────────
34
+ // GET /velbus/scan-results?bridge=<nodeId>
35
+ // Returns the most recent scan results for a given bridge node.
36
+ // Called by config dialog dropdowns to populate address lists.
37
+ RED.httpAdmin.get('/velbus/scan-results', RED.auth.needsPermission(''), function(req, res) {
38
+ const bridgeId = req.query.bridge;
39
+ if (!bridgeId) {
40
+ return res.json({ modules: [], error: 'No bridge ID specified' });
41
+ }
42
+ const results = _scanResults[bridgeId] || [];
43
+ res.json({ modules: results, count: results.length });
44
+ });
45
+
46
+ // ── Bridge node constructor ─────────────────────────────────────────────
47
+
48
+ function VelbusBridgeNode(config) {
49
+ RED.nodes.createNode(this, config);
50
+ const node = this;
51
+
52
+ // Config
53
+ node.host = config.host || '127.0.0.1';
54
+ node.port = parseInt(config.port) || 6000;
55
+ node.useTLS = config.useTLS === true;
56
+ node.useAuth = config.useAuth === true;
57
+ node.authKey = config.authKey || '';
58
+ node.certPath = config.certPath || '';
59
+ node.keyPath = config.keyPath || '';
60
+
61
+ // Registered interpreter nodes: Map<address, Set<handlerFn>>
62
+ node._listeners = new Map();
63
+ node._subaddrMap = new Map();
64
+
65
+ // Rebuild subaddress routing from persisted scan results, so subaddress
66
+ // frames (e.g. GPO-20 thermostat events from 0x34) route to primary-address
67
+ // listeners without requiring a fresh scan after every restart or deploy.
68
+ for (const m of (_scanResults[node.id] || [])) {
69
+ if (m.subaddresses && m.address) {
70
+ const primary = parseInt(m.address, 16);
71
+ for (const s of m.subaddresses) {
72
+ const sub = parseInt(s, 16);
73
+ if (sub >= 0x01 && sub <= 0xFE) node._subaddrMap.set(sub, primary);
74
+ }
75
+ }
76
+ }
77
+ if (node._subaddrMap.size) {
78
+ node.log('velbus-bridge: restored ' + node._subaddrMap.size +
79
+ ' subaddress mapping(s) from persisted scan results');
80
+ }
81
+
82
+ // Scan lock
83
+ node._scanLocked = false;
84
+ node._scanQueue = [];
85
+
86
+ // TCP state
87
+ node._socket = null;
88
+ node._remainder = Buffer.alloc(0);
89
+ node._connected = false;
90
+ node._closing = false;
91
+ node._reconnectTimer = null;
92
+
93
+ // ── Scan results store API ────────────────────────────────────────────
94
+
95
+ node.storeScanResults = function(modules) {
96
+ _scanResults[node.id] = modules;
97
+ _persistScanResults();
98
+ node.log('velbus-bridge: stored ' + modules.length + ' scan results for address dropdown');
99
+ };
100
+
101
+ node.getScanResults = function() {
102
+ return _scanResults[node.id] || [];
103
+ };
104
+
105
+ // ── Scan lock API ─────────────────────────────────────────────────────
106
+
107
+ node.lockScan = function() {
108
+ node._scanLocked = true;
109
+ node._scanQueue = [];
110
+ node.log('velbus-bridge: scan lock engaged — interpreter startup queued');
111
+ };
112
+
113
+ node.unlockScan = function() {
114
+ node._scanLocked = false;
115
+ node.log('velbus-bridge: scan lock released — flushing ' +
116
+ node._scanQueue.length + ' queued startup packet(s)');
117
+ let delay = 500;
118
+ for (const item of node._scanQueue) {
119
+ setTimeout(() => {
120
+ if (!node._closing) node.send(item.buf);
121
+ }, delay);
122
+ delay += 1000;
123
+ }
124
+ node._scanQueue = [];
125
+ };
126
+
127
+ // ── Send packet ───────────────────────────────────────────────────────
128
+
129
+ node.send = function(buf, startup) {
130
+ if (node._scanLocked && startup) {
131
+ node._scanQueue.push({ buf: Buffer.from(buf) });
132
+ return;
133
+ }
134
+ if (node._socket && node._connected) {
135
+ node._socket.write(buf);
136
+ } else {
137
+ node.warn('velbus-bridge: not connected, packet dropped');
138
+ }
139
+ };
140
+
141
+ // ── Connection state (public accessor) ────────────────────────────────
142
+
143
+ node.isConnected = function() {
144
+ return !!node._connected;
145
+ };
146
+
147
+ // ── Listener registration ─────────────────────────────────────────────
148
+
149
+ node.register = function(addr, handler) {
150
+ const key = addr === 'all' ? 'all' : Number(addr);
151
+ if (!node._listeners.has(key)) node._listeners.set(key, new Set());
152
+ node._listeners.get(key).add(handler);
153
+ };
154
+
155
+ node.deregister = function(addr, handler) {
156
+ const key = addr === 'all' ? 'all' : Number(addr);
157
+ if (node._listeners.has(key)) {
158
+ node._listeners.get(key).delete(handler);
159
+ for (const [sub, primary] of node._subaddrMap.entries()) {
160
+ if (primary === key) node._subaddrMap.delete(sub);
161
+ }
162
+ }
163
+ };
164
+
165
+ // ── 0xB0 subtype handler ──────────────────────────────────────────────
166
+
167
+ function handleSubtype(pktBuf) {
168
+ const p = parsePkt(pktBuf);
169
+ if (!p || p.cmd !== 0xB0 || p.body.length < 8) return;
170
+
171
+ const primaryAddr = p.addr;
172
+ const subAddrs = [p.body[4], p.body[5], p.body[6], p.body[7]];
173
+
174
+ for (const [sub, primary] of node._subaddrMap.entries()) {
175
+ if (primary === primaryAddr) node._subaddrMap.delete(sub);
176
+ }
177
+
178
+ let registered = 0;
179
+ for (const sub of subAddrs) {
180
+ if (sub !== 0xFF && sub >= 0x01 && sub <= 0xFE) {
181
+ node._subaddrMap.set(sub, primaryAddr);
182
+ registered++;
183
+ }
184
+ }
185
+
186
+ if (registered > 0) {
187
+ node.log('velbus-bridge: 0x' + primaryAddr.toString(16).toUpperCase() +
188
+ ' subaddresses: ' + subAddrs
189
+ .filter(s => s !== 0xFF)
190
+ .map(s => '0x' + s.toString(16).toUpperCase())
191
+ .join(', '));
192
+ }
193
+ }
194
+
195
+ // ── Packet dispatch ───────────────────────────────────────────────────
196
+
197
+ function dispatch(pktBuf) {
198
+ if (!Buffer.isBuffer(pktBuf) || pktBuf.length < 6) return;
199
+
200
+ const rawAddr = pktBuf[2];
201
+ const cmd = pktBuf.length >= 5 ? pktBuf[4] : null;
202
+
203
+ if (cmd === 0xB0) handleSubtype(pktBuf);
204
+
205
+ const deliverAddr = node._subaddrMap.has(rawAddr)
206
+ ? node._subaddrMap.get(rawAddr)
207
+ : rawAddr;
208
+
209
+ const addrListeners = node._listeners.get(deliverAddr);
210
+ if (addrListeners) {
211
+ for (const h of addrListeners) {
212
+ try { h(pktBuf); } catch(e) { node.error('velbus-bridge dispatch error: ' + e.message); }
213
+ }
214
+ }
215
+
216
+ const allListeners = node._listeners.get('all');
217
+ if (allListeners) {
218
+ for (const h of allListeners) {
219
+ try { h(pktBuf); } catch(e) { node.error('velbus-bridge dispatch error: ' + e.message); }
220
+ }
221
+ }
222
+ }
223
+
224
+ // ── TCP connection ────────────────────────────────────────────────────
225
+
226
+ function connect() {
227
+ if (node._closing) return;
228
+
229
+ function onConnected() {
230
+ node._connected = true;
231
+ node._remainder = Buffer.alloc(0);
232
+ node.log(`velbus-bridge: connected to ${node.host}:${node.port}` +
233
+ (node.useTLS ? ' (TLS)' : ''));
234
+ if (node.useAuth && node.authKey) sock.write(node.authKey + '\n');
235
+ }
236
+
237
+ let sock;
238
+ if (node.useTLS) {
239
+ const tlsOpts = { host: node.host, port: node.port };
240
+ if (node.certPath) {
241
+ // CA supplied: verify the chain but skip hostname check, since
242
+ // velbus-tcp is almost always reached by bare IP address.
243
+ tlsOpts.ca = fs.readFileSync(node.certPath);
244
+ tlsOpts.checkServerIdentity = () => undefined;
245
+ } else {
246
+ // No CA supplied: accept the velbus-tcp snap's self-signed cert.
247
+ tlsOpts.rejectUnauthorized = false;
248
+ }
249
+ if (node.keyPath) tlsOpts.key = fs.readFileSync(node.keyPath);
250
+ sock = tls.connect(tlsOpts);
251
+ // TLS sockets emit 'secureConnect' when the handshake completes.
252
+ // They never emit 'connect' — that fires on the wrapped raw socket.
253
+ sock.on('secureConnect', onConnected);
254
+ } else {
255
+ sock = net.createConnection({ host: node.host, port: node.port });
256
+ sock.on('connect', onConnected);
257
+ }
258
+
259
+ node._socket = sock;
260
+ sock.setNoDelay(true);
261
+
262
+ sock.on('data', (data) => {
263
+ const combined = Buffer.concat([node._remainder, data]);
264
+ const { packets, remainder } = splitPackets(combined);
265
+ node._remainder = remainder;
266
+ for (const p of packets) dispatch(p);
267
+ });
268
+
269
+ sock.on('error', (err) => {
270
+ let msg = `velbus-bridge: socket error — ${err.message}`;
271
+ if (node.useTLS && /certificate|handshake|SSL|TLS/i.test(err.message)) {
272
+ msg += ' (TLS handshake failed — check the auth key and that the ' +
273
+ 'port is the TLS port; leave Cert path empty to accept ' +
274
+ 'velbus-tcp\'s self-signed certificate)';
275
+ }
276
+ node.warn(msg);
277
+ });
278
+
279
+ sock.on('close', () => {
280
+ node._connected = false;
281
+ node._socket = null;
282
+ if (!node._closing) {
283
+ node.warn('velbus-bridge: connection lost, reconnecting in 5s');
284
+ node._reconnectTimer = setTimeout(connect, 5000);
285
+ }
286
+ });
287
+ }
288
+
289
+ // ── Lifecycle ─────────────────────────────────────────────────────────
290
+
291
+ connect();
292
+
293
+ node.on('close', (done) => {
294
+ node._closing = true;
295
+ if (node._reconnectTimer) clearTimeout(node._reconnectTimer);
296
+ if (node._socket) { node._socket.destroy(); node._socket = null; }
297
+ // Scan results are intentionally kept — 'close' fires on every full
298
+ // deploy and restart, and dropdowns must survive both.
299
+ done();
300
+ });
301
+ }
302
+
303
+ RED.nodes.registerType('velbus-bridge', VelbusBridgeNode);
304
+ };