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,404 @@
1
+ 'use strict';
2
+
3
+ const { pkt, rtrPkt, parsePkt } = require('../../lib/velbus-utils');
4
+ const { RELAY_TYPES, RELAY_TYPE_IDS } = require('../../lib/relay-types');
5
+
6
+ module.exports = function(RED) {
7
+
8
+ function VelbusRelayNode(config) {
9
+ RED.nodes.createNode(this, config);
10
+ const node = this;
11
+
12
+ // Config
13
+ node.bridge = RED.nodes.getNode(config.bridge);
14
+ node.moduleAddr = parseInt(config.moduleAddr, 16);
15
+ node.startChannel = parseInt(config.startChannel) || 1;
16
+ node.channelCount = parseInt(config.channelCount) || 4;
17
+ node.nameOverride = config.name || ''; // Node-RED label override
18
+
19
+ if (!node.bridge) {
20
+ node.error('velbus-relay: no bridge configured');
21
+ return;
22
+ }
23
+ if (isNaN(node.moduleAddr) || node.moduleAddr < 1 || node.moduleAddr > 0xFE) {
24
+ node.error('velbus-relay: invalid module address');
25
+ return;
26
+ }
27
+
28
+ // State
29
+ let _channelState = {}; // { [chBit]: { relayState, ledState, timerRemaining } }
30
+ let _moduleInfo = null; // populated on 0xFF
31
+ let _velbusName = ''; // name retrieved from hardware via 0xEF
32
+ let _nameParts = {}; // { 1: chars1-6, 2: chars7-12, 3: chars13-16 }
33
+ let _blocked = false; // true if firmware check failed — hard block
34
+ let _nameTimer = null;
35
+
36
+ // ── Helpers ───────────────────────────────────────────────────────────
37
+
38
+ function addrHex(a) {
39
+ return '0x' + a.toString(16).padStart(2, '0').toUpperCase();
40
+ }
41
+
42
+ function displayName() {
43
+ if (node.nameOverride) return node.nameOverride;
44
+ if (_velbusName) return _velbusName;
45
+ if (_moduleInfo) return _moduleInfo.name;
46
+ return addrHex(node.moduleAddr);
47
+ }
48
+
49
+ function statusPrefix() {
50
+ return displayName() + ' (' + addrHex(node.moduleAddr) + ')';
51
+ }
52
+
53
+ function setStatus(text, fill, shape) {
54
+ node.status({ fill: fill || 'grey', shape: shape || 'dot', text });
55
+ }
56
+
57
+ function modeStr(mode, status) {
58
+ const m = mode & 0x03;
59
+ if (m === 0x03) return 'forced_off';
60
+ if (m === 0x02) return 'forced_on';
61
+ if (m === 0x01) return 'inhibited';
62
+ const s = status & 0x03;
63
+ if (s === 0x03) return 'timer_running';
64
+ if (s === 0x01) return 'on';
65
+ return 'off';
66
+ }
67
+
68
+ function decodeLed(ledByte) {
69
+ if (ledByte & 0x80) return 'on';
70
+ if (ledByte & 0x40) return 'slow_blink';
71
+ if (ledByte & 0x20) return 'fast_blink';
72
+ if (ledByte & 0x10) return 'very_fast_blink';
73
+ return 'off';
74
+ }
75
+
76
+ function buildStateMsg(chBit, state) {
77
+ return {
78
+ topic: 'relay_status',
79
+ address: addrHex(node.moduleAddr),
80
+ module: displayName(),
81
+ channel: Math.log2(chBit) + 1,
82
+ channelBit: addrHex(chBit),
83
+ state: state.relayState,
84
+ on: state.relayState === 'on' || state.relayState === 'timer_running' || state.relayState === 'forced_on',
85
+ timerRemaining: state.timerRemaining || 0,
86
+ ledState: state.ledState || 'off',
87
+ timestamp: Date.now()
88
+ };
89
+ }
90
+
91
+ // ── Name retrieval ────────────────────────────────────────────────────
92
+
93
+ function requestName() {
94
+ // Send 0xEF with channel 0xFF = module name request
95
+ // Priority 0xF8 (command) not 0xFB (status) — critical
96
+ const p = pkt(0xF8, node.moduleAddr, [0xEF, 0xFF]);
97
+ node.bridge.send(p);
98
+ // Timeout — use whatever was received after 2s
99
+ _nameTimer = setTimeout(assembleName, 2000);
100
+ }
101
+
102
+ function assembleName() {
103
+ if (_nameTimer) { clearTimeout(_nameTimer); _nameTimer = null; }
104
+ const raw = [
105
+ ...(_nameParts[1] || []),
106
+ ...(_nameParts[2] || []),
107
+ ...(_nameParts[3] || [])
108
+ ];
109
+ const name = raw
110
+ .filter(c => c !== 0xFF && c !== 0x00)
111
+ .map(c => String.fromCharCode(c))
112
+ .join('')
113
+ .trim();
114
+ if (name) {
115
+ _velbusName = name;
116
+ node.log('velbus-relay: module name retrieved: ' + name);
117
+ }
118
+ // Now request status
119
+ requestStatus();
120
+ }
121
+
122
+ function requestStatus() {
123
+ for (let i = 0; i < node.channelCount; i++) {
124
+ const ch = node.startChannel + i;
125
+ const chBit = 1 << (ch - 1);
126
+ node.bridge.send(pkt(0xFB, node.moduleAddr, [0xFA, chBit]));
127
+ }
128
+ setStatus(statusPrefix() + ' online', 'grey');
129
+ }
130
+
131
+ // ── 0xFF Firmware check ───────────────────────────────────────────────
132
+
133
+ function handleModuleType(body) {
134
+ const typeId = body[1];
135
+
136
+ // Stage 1: type recognised?
137
+ if (!RELAY_TYPE_IDS.has(typeId)) {
138
+ const msg = '⚠ ' + statusPrefix() + ' unknown type ' + addrHex(typeId);
139
+ setStatus(msg, 'red');
140
+ node.error('velbus-relay: ' + msg);
141
+ node.send([null, { payload: {
142
+ topic: 'module_unknown',
143
+ address: addrHex(node.moduleAddr),
144
+ typeId: addrHex(typeId),
145
+ message: 'Unrecognised module type — is this the right node for this module?'
146
+ }}]);
147
+ _blocked = true;
148
+ return;
149
+ }
150
+
151
+ const typeInfo = RELAY_TYPES[typeId];
152
+ const serial = body.length >= 4 ? (body[2] << 8 | body[3]) : null;
153
+ const mapVer = body.length >= 5 ? body[4] : null;
154
+ const buildHi = body.length >= 6 ? body[5] : null;
155
+ const buildLo = body.length >= 7 ? body[6] : null;
156
+ const build = (buildHi !== null && buildLo !== null)
157
+ ? (buildHi * 100 + buildLo) : null;
158
+
159
+ _moduleInfo = { ...typeInfo, typeId, serial, build, memoryMapVersion: mapVer };
160
+
161
+ // Stage 2: memory map version check
162
+ const minVer = typeInfo.minMemoryMapVersion;
163
+ if (minVer !== null && mapVer !== null && mapVer < minVer) {
164
+ const msg = '⚠ ' + statusPrefix() + ' firmware too old (map v' + mapVer + ', need v' + minVer + ')';
165
+ setStatus(msg, 'red');
166
+ node.error('velbus-relay: ' + msg);
167
+ node.send([null, { payload: {
168
+ topic: 'firmware_incompatible',
169
+ address: addrHex(node.moduleAddr),
170
+ module: typeInfo.name,
171
+ memoryMapVersion: mapVer,
172
+ minimumRequired: minVer,
173
+ message: 'Firmware too old — update module firmware before use'
174
+ }}]);
175
+ _blocked = true;
176
+ return;
177
+ }
178
+
179
+ // Stage 2b: unverified (null minimum)
180
+ if (minVer === null) {
181
+ setStatus(statusPrefix() + ' map v' + mapVer + ' (unverified)', 'yellow');
182
+ node.warn('velbus-relay: ' + addrHex(node.moduleAddr) + ' ' + typeInfo.name +
183
+ ' map v' + mapVer + ' — minimum version unverified, proceeding');
184
+ }
185
+
186
+ // Stage 3: pass — emit module_online, retrieve name
187
+ _blocked = false;
188
+
189
+ node.send([{ payload: {
190
+ topic: 'module_online',
191
+ address: addrHex(node.moduleAddr),
192
+ module: typeInfo.name,
193
+ typeId: addrHex(typeId),
194
+ serial,
195
+ build,
196
+ memoryMapVersion: mapVer
197
+ }}, null]);
198
+
199
+ node.log('velbus-relay: ' + typeInfo.name + ' at ' + addrHex(node.moduleAddr) +
200
+ ' serial ' + (serial ? serial.toString(16).toUpperCase() : '?') +
201
+ ' build ' + build + ' map v' + mapVer);
202
+
203
+ requestName();
204
+ }
205
+
206
+ // ── Packet handler ────────────────────────────────────────────────────
207
+
208
+ function onPacket(raw) {
209
+ const p = parsePkt(raw);
210
+ if (!p) return;
211
+ const { pri, body, cmd, rtr } = p;
212
+
213
+ // ── 0xFF Module type ─────────────────────────────────────────────────
214
+ if (cmd === 0xFF && !rtr && body.length >= 2) {
215
+ handleModuleType(body);
216
+ return;
217
+ }
218
+
219
+ // All remaining packets blocked if firmware check failed
220
+ if (_blocked) return;
221
+
222
+ // ── 0xF0/0xF1/0xF2 Channel name parts ───────────────────────────────
223
+ if ((cmd === 0xF0 || cmd === 0xF1 || cmd === 0xF2) && body.length >= 2) {
224
+ const part = cmd - 0xEF; // 0xF0=1, 0xF1=2, 0xF2=3
225
+ _nameParts[part] = body.slice(2); // skip cmd + channel bytes
226
+ if (part === 3) assembleName();
227
+ return;
228
+ }
229
+
230
+ // ── 0xFB Relay status (original series per-channel format) ───────────
231
+ if (cmd === 0xFB && body.length >= 8) {
232
+ const chBit = body[1];
233
+ const modeVal = body[2];
234
+ const statusVal = body[3];
235
+ const ledByte = body[4];
236
+ const timerSec = (body[5] << 16) | (body[6] << 8) | body[7];
237
+ const relayState = modeStr(modeVal, statusVal);
238
+ const ledState = decodeLed(ledByte);
239
+
240
+ const ch = Math.log2(chBit) + 1;
241
+ if (ch < node.startChannel || ch >= node.startChannel + node.channelCount) return;
242
+
243
+ _channelState[chBit] = { relayState, ledState, timerRemaining: timerSec };
244
+
245
+ const stateMsg = buildStateMsg(chBit, _channelState[chBit]);
246
+ let warnMsg = null;
247
+
248
+ if (['forced_on', 'forced_off', 'inhibited'].includes(relayState)) {
249
+ warnMsg = {
250
+ topic: 'relay_state_warning',
251
+ address: addrHex(node.moduleAddr),
252
+ module: displayName(),
253
+ channel: ch,
254
+ state: relayState,
255
+ message: 'Channel ' + ch + ' is in ' + relayState + ' — direct commands will be ignored by module'
256
+ };
257
+ setStatus(statusPrefix() + ' ch' + ch + ' ' + relayState, 'yellow');
258
+ } else {
259
+ setStatus(statusPrefix() + ' ch' + ch + ' ' + relayState,
260
+ relayState === 'on' ? 'green' : 'grey');
261
+ }
262
+
263
+ node.send([{ payload: stateMsg }, warnMsg ? { payload: warnMsg } : null]);
264
+ return;
265
+ }
266
+
267
+ // ── 0x00 Channel switched broadcast ──────────────────────────────────
268
+ // Fires on every pulse edge during interval_timer blinking, and on
269
+ // every local push-button-driven switch. Confirmed against
270
+ // protocol_vmb4ryld_10.pdf: SID10-SID9=00 = highest priority = 0xF8
271
+ // (see packetprotocol README priority table). The previous `pri ===
272
+ // 0xFB` check required the opposite (lowest priority) and silently
273
+ // dropped every one of these broadcasts — nothing else in this file
274
+ // gates on priority, and this cmd byte has only one defined meaning
275
+ // for a relay module, so no filter is needed at all.
276
+ if (cmd === 0x00 && body.length >= 4) {
277
+ const onBits = body[1];
278
+ const offBits = body[2];
279
+
280
+ for (let i = 0; i < 8; i++) {
281
+ const b = 1 << i;
282
+ const ch = i + 1;
283
+ if (ch < node.startChannel || ch >= node.startChannel + node.channelCount) continue;
284
+
285
+ if (onBits & b) {
286
+ node.send([{ payload: {
287
+ topic: 'relay_switched', address: addrHex(node.moduleAddr),
288
+ module: displayName(), channel: ch, channelBit: addrHex(b), state: 'on'
289
+ }}, null]);
290
+ }
291
+ if (offBits & b) {
292
+ node.send([{ payload: {
293
+ topic: 'relay_switched', address: addrHex(node.moduleAddr),
294
+ module: displayName(), channel: ch, channelBit: addrHex(b), state: 'off'
295
+ }}, null]);
296
+ }
297
+ }
298
+ }
299
+ }
300
+
301
+ // ── Input: command encoder ────────────────────────────────────────────
302
+
303
+ node.on('input', function(msg) {
304
+ if (_blocked) {
305
+ node.warn('velbus-relay: commands blocked — firmware incompatible or unknown module');
306
+ return;
307
+ }
308
+
309
+ const inp = msg.payload;
310
+ if (!inp || typeof inp !== 'object') return;
311
+
312
+ const addr = inp.address !== undefined
313
+ ? (typeof inp.address === 'string' ? parseInt(inp.address, 16) : inp.address)
314
+ : node.moduleAddr;
315
+
316
+ let chBit;
317
+ if (typeof inp.channel === 'number' && inp.channel >= 1 && inp.channel <= 8) {
318
+ chBit = 1 << (inp.channel - 1);
319
+ } else if (typeof inp.channelBit === 'number') {
320
+ chBit = inp.channelBit;
321
+ } else {
322
+ node.warn('velbus-relay: missing channel in command');
323
+ return;
324
+ }
325
+
326
+ const cmd = inp.cmd || '';
327
+ const dur = inp.duration || 0;
328
+
329
+ const cs = _channelState[chBit];
330
+ if (cs && ['forced_on', 'forced_off', 'inhibited'].includes(cs.relayState)) {
331
+ const cancelCmds = ['cancel_forced_on', 'cancel_forced_off', 'cancel_inhibit',
332
+ 'forced_on', 'forced_off', 'inhibit'];
333
+ if (!cancelCmds.includes(cmd)) {
334
+ node.warn('velbus-relay ' + addrHex(addr) + ' ch' + inp.channel +
335
+ ': channel is in ' + cs.relayState + ' — command may be ignored by module');
336
+ }
337
+ }
338
+
339
+ function timer24(sec) {
340
+ if (!sec || sec === 0) return [0, 0, 0];
341
+ if (sec < 0) return [0xFF, 0xFF, 0xFF];
342
+ return [(sec >> 16) & 0xFF, (sec >> 8) & 0xFF, sec & 0xFF];
343
+ }
344
+
345
+ const t = timer24(dur);
346
+ let packet = null;
347
+
348
+ switch (cmd) {
349
+ case 'on': packet = pkt(0xF8, addr, [0x02, chBit]); break;
350
+ case 'off': packet = pkt(0xF8, addr, [0x01, chBit]); break;
351
+ case 'toggle': {
352
+ const isOn = cs && cs.relayState === 'on';
353
+ packet = pkt(0xF8, addr, [isOn ? 0x01 : 0x02, chBit]);
354
+ break;
355
+ }
356
+ case 'timer': packet = pkt(0xF8, addr, [0x03, chBit, ...t]); break;
357
+ case 'interval_timer': {
358
+ // 'Start relay blinking timer' (0x0D) — confirmed against the official
359
+ // protocol PDFs for every module this node covers (VMB4RYLD/-10,
360
+ // VMB4RYNO/-10, VMB1RYNO, VMB1RYNOS, VMB1RYS): DLC=5, body is
361
+ // [0x0D, chBit, 24-bit delay time] — ONE time parameter, not three.
362
+ // There is no live bus command for a configurable pulse/pause rate —
363
+ // the module blinks at its own fixed rate for the given duration.
364
+ // duration: 0=skip (module ignores), -1 or 0xFFFFFF=permanent blinking.
365
+ packet = pkt(0xF8, addr, [0x0D, chBit, ...t]);
366
+ break;
367
+ }
368
+ case 'forced_on': packet = pkt(0xF8, addr, [0x14, chBit, ...t]); break;
369
+ case 'forced_off': packet = pkt(0xF8, addr, [0x12, chBit, ...t]); break;
370
+ case 'cancel_forced_on': packet = pkt(0xF8, addr, [0x15, chBit]); break;
371
+ case 'cancel_forced_off': packet = pkt(0xF8, addr, [0x13, chBit]); break;
372
+ case 'inhibit': packet = pkt(0xF8, addr, [0x16, chBit, ...t]); break;
373
+ case 'cancel_inhibit': packet = pkt(0xF8, addr, [0x17, chBit]); break;
374
+ case 'status': packet = pkt(0xFB, addr, [0xFA, chBit]); break;
375
+ default:
376
+ node.warn('velbus-relay: unknown command: ' + cmd);
377
+ return;
378
+ }
379
+
380
+ if (packet) node.bridge.send(packet);
381
+ });
382
+
383
+ // ── Startup ───────────────────────────────────────────────────────────
384
+
385
+ node.bridge.register(node.moduleAddr, onPacket);
386
+
387
+ // Send RTR scan after short delay — triggers 0xFF and 0xB0 from module
388
+ // startup=true flags this as a startup packet — will be queued if scan in progress
389
+ setTimeout(() => {
390
+ node.bridge.send(rtrPkt(node.moduleAddr), true);
391
+ }, 500);
392
+
393
+ setStatus('connecting…', 'grey');
394
+
395
+ // ── Cleanup ───────────────────────────────────────────────────────────
396
+
397
+ node.on('close', function() {
398
+ if (_nameTimer) clearTimeout(_nameTimer);
399
+ node.bridge.deregister(node.moduleAddr, onPacket);
400
+ });
401
+ }
402
+
403
+ RED.nodes.registerType('velbus-relay', VelbusRelayNode);
404
+ };
@@ -0,0 +1,123 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('velbus-relay-20', {
3
+ category: 'Velbus (outputs)',
4
+ color: '#4A90D9',
5
+ defaults: {
6
+ name: { value: '' },
7
+ bridge: { value: '', type: 'velbus-bridge', required: true },
8
+ moduleAddr: { value: '', required: true },
9
+ startChannel: { value: 1, required: true, validate: RED.validators.number() },
10
+ channelCount: { value: 4, required: true, validate: RED.validators.number() }
11
+ },
12
+ inputs: 1,
13
+ outputs: 2,
14
+ icon: 'font-awesome/fa-toggle-on',
15
+ label: function() {
16
+ if (this.name) return this.name;
17
+ return 'relay-20 0x' + (this.moduleAddr || '??');
18
+ },
19
+ outputLabels: ['state / events', 'warnings'],
20
+ oneditprepare: function() {
21
+ velbusPopulateAddressDropdown(
22
+ this,
23
+ '#node-input-bridge',
24
+ '#node-input-moduleAddr',
25
+ ['velbus-relay-20']
26
+ );
27
+ }
28
+ });
29
+ </script>
30
+
31
+ <script type="text/html" data-template-name="velbus-relay-20">
32
+ <div class="form-row">
33
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name override</label>
34
+ <input type="text" id="node-input-name"
35
+ placeholder="Leave blank to use module name from VelbusLink">
36
+ </div>
37
+ <div class="form-row">
38
+ <label for="node-input-bridge"><i class="fa fa-server"></i> Bridge</label>
39
+ <input type="text" id="node-input-bridge">
40
+ </div>
41
+ <div class="form-row">
42
+ <label for="node-input-moduleAddr"><i class="fa fa-microchip"></i> Address</label>
43
+ <input type="text" id="node-input-moduleAddr" placeholder="e.g. B9 — run scan first for dropdown" style="width:100%">
44
+ <div class="form-tips" style="margin-top:4px">Run a <b>velbus-scan</b> node first to populate the dropdown with discovered modules.</div>
45
+ </div>
46
+ <div class="form-row">
47
+ <label for="node-input-startChannel"><i class="fa fa-list-ol"></i> Start channel</label>
48
+ <input type="number" id="node-input-startChannel" min="1" max="8" style="width:60px">
49
+ </div>
50
+ <div class="form-row">
51
+ <label for="node-input-channelCount"><i class="fa fa-hashtag"></i> Channel count</label>
52
+ <input type="number" id="node-input-channelCount" min="1" max="8" style="width:60px">
53
+ </div>
54
+ </script>
55
+
56
+ <script type="text/html" data-help-name="velbus-relay-20">
57
+ <p style="background:#fff3cd; border-left:4px solid #ffc107; padding:8px 12px; margin-bottom:12px; font-size:0.85em;">
58
+ <strong>&#9888; Testing status:</strong> This node was generated with Claude.ai and is in
59
+ need of extensive field testing before being deployed into a commercial project.
60
+ It is presented "as is" &mdash; any use beyond testing is entirely at your own risk and liability.
61
+ Constructive feedback is welcomed, accompanied by as many examples and debug captures as possible.
62
+ <a href="https://github.com/MDAR/node-red-contrib-velbus-2026/issues" target="_blank">File an issue on GitHub</a>.
63
+ </p>
64
+ <p><strong>V2.0 series only (-20 suffix modules)</strong></p>
65
+ <p>For original series relay modules use the <b>velbus-relay</b> node instead.</p>
66
+
67
+ <p>Supports: <b>VMB4RYLD-20</b>, <b>VMB4RYNO-20</b>, <b>VMB1RYS-20</b></p>
68
+
69
+ <h3>Address dropdown</h3>
70
+ <p>Run a <b>velbus-scan</b> node first to populate the address dropdown.</p>
71
+
72
+ <h3>Output 1 — State / Events</h3>
73
+ <pre>
74
+ {
75
+ "topic": "relay_status",
76
+ "address": "0xB9",
77
+ "module": "Garage",
78
+ "channel": 1,
79
+ "state": "on",
80
+ "on": true,
81
+ "inhibited": false,
82
+ "forcedOn": false,
83
+ "forcedOff": false,
84
+ "programDisabled":false,
85
+ "timerRunning": false,
86
+ "alarmProgram": { "program":0, "alarm1":false, ... },
87
+ "timestamp": 1719100000000
88
+ }
89
+
90
+ // relay_switched — fires on every local push-button-driven switch. Separate
91
+ // from relay_status above, which reports the overall channel mode rather
92
+ // than individual switch edges.
93
+ {
94
+ "topic": "relay_switched",
95
+ "address": "0xB9",
96
+ "module": "Garage",
97
+ "channel": 1,
98
+ "state": "on" // or "off"
99
+ }
100
+ </pre>
101
+
102
+ <h3>Input — Commands</h3>
103
+ <pre>
104
+ { "channel": 1, "cmd": "on" }
105
+ { "channel": "all", "cmd": "off" }
106
+ { "channel": 1, "cmd": "toggle" }
107
+ { "channel": 1, "cmd": "timer", "duration": 60 }
108
+ { "channel": 1, "cmd": "forced_on", "duration": -1 }
109
+ { "channel": 1, "cmd": "forced_off", "duration": 3600 }
110
+ { "channel": 1, "cmd": "cancel_forced_on" }
111
+ { "channel": 1, "cmd": "cancel_forced_off" }
112
+ { "channel": 1, "cmd": "inhibit", "duration": 1800 }
113
+ { "channel": 1, "cmd": "cancel_inhibit" }
114
+ { "channel": 1, "cmd": "status" }
115
+ </pre>
116
+ <p>Set <code>channel</code> to <code>"all"</code> to send to all channels simultaneously.</p>
117
+ <p>Duration in seconds. Use <code>-1</code> for permanent.</p>
118
+ <p><b>No interval/blink timer command.</b> Unlike the original series
119
+ (<b>velbus-relay</b>), V2 relays have no live bus command for a blink/interval
120
+ timer. It can only be achieved by writing a Program Step (Action code 22) to
121
+ module memory, linked to a button or scenario &mdash; a commissioning task, not
122
+ a live command this node sends.</p>
123
+ </script>