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,352 @@
1
+ 'use strict';
2
+
3
+ const { pkt, rtrPkt, parsePkt } = require('../../lib/velbus-utils');
4
+ const { PIR_TYPES_20, PIR_TYPE_IDS_20 } = require('../../lib/pir-types-20');
5
+
6
+ // ─────────────────────────────────────────────────────────────────────────────
7
+ // Helpers
8
+ // ─────────────────────────────────────────────────────────────────────────────
9
+
10
+ function activeFromBitmask(byte, names) {
11
+ const active = [];
12
+ for (let i = 0; i < names.length; i++) {
13
+ if (byte & (1 << i)) active.push(names[i]);
14
+ }
15
+ return active;
16
+ }
17
+
18
+ function decodeFromBitmask(byte, names) {
19
+ const result = {};
20
+ for (let i = 0; i < names.length; i++) {
21
+ result[names[i]] = !!(byte & (1 << i));
22
+ }
23
+ return result;
24
+ }
25
+
26
+ function tempFrom16(hi, lo) {
27
+ const raw = (hi << 8) | lo;
28
+ const signed = raw > 32767 ? raw - 65536 : raw;
29
+ return signed / 16;
30
+ }
31
+
32
+ function signed05(b) {
33
+ return (b > 127 ? b - 256 : b) * 0.5;
34
+ }
35
+
36
+ // ─────────────────────────────────────────────────────────────────────────────
37
+ // Node
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+
40
+ module.exports = function(RED) {
41
+
42
+ function VelbusPir20Node(config) {
43
+ RED.nodes.createNode(this, config);
44
+ const node = this;
45
+
46
+ node.bridge = RED.nodes.getNode(config.bridge);
47
+ node.address = typeof config.address === 'number'
48
+ ? config.address // legacy saves stored decimal numbers
49
+ : (parseInt(config.address, 16) || 0); // editor stores hex strings
50
+ node.moduleName = config.moduleName || '';
51
+ node.typeId = config.typeId ? parseInt(config.typeId) : null;
52
+
53
+ if (!node.bridge) {
54
+ node.status({ fill: 'red', shape: 'ring', text: 'no bridge' });
55
+ node.error('velbus-pir-20: no bridge configured');
56
+ return;
57
+ }
58
+ if (!node.address || node.address < 1 || node.address > 254) {
59
+ node.status({ fill: 'red', shape: 'ring', text: 'invalid address' });
60
+ node.error('velbus-pir-20: invalid address ' + node.address);
61
+ return;
62
+ }
63
+
64
+ const typeDesc = node.typeId !== null ? (PIR_TYPES_20[node.typeId] || null) : null;
65
+ const hasTempSensor = typeDesc ? typeDesc.hasTempSensor : false;
66
+ const bitmask = typeDesc ? typeDesc.bitmask : [
67
+ 'dark', 'light', 'motion1', 'ldMotion1', 'motion2', 'ldMotion2', 'absence',
68
+ ];
69
+ // Lockable channel numbers (temp alarm bits are not lockable on VMBPIRO-20)
70
+ const lockableChannels = typeDesc ? Object.keys(typeDesc.channels).map(Number) : [1,2,3,4,5,6,7];
71
+
72
+ // Name retrieval state
73
+ let _nameParts = {};
74
+ let _nameTimer = null;
75
+
76
+ function assembleName() {
77
+ if (_nameTimer) { clearTimeout(_nameTimer); _nameTimer = null; }
78
+ const bytes = [
79
+ ...(_nameParts[0] || []),
80
+ ...(_nameParts[1] || []),
81
+ ...(_nameParts[2] || []),
82
+ ].filter(b => b !== 0 && b !== 0xFF);
83
+ const name = String.fromCharCode(...bytes).trim();
84
+ if (name) node.moduleName = name;
85
+ }
86
+
87
+ const _addrHex = '0x' + node.address.toString(16).padStart(2, '0').toUpperCase();
88
+
89
+ function setStatus(text, fill, shape) {
90
+ const label = node.moduleName ? node.moduleName + ' (' + _addrHex + ')' : _addrHex;
91
+ node.status({ fill: fill || 'green', shape: shape || 'dot', text: label + ' ' + text });
92
+ }
93
+
94
+ // ── Firmware check on 0xFF ────────────────────────────────────────────
95
+
96
+ function handleModuleType(body) {
97
+ if (body.length < 8) return; // V2 always 8 bytes
98
+ const typeId = body[1];
99
+ const mapVer = body[4];
100
+ const buildHi = body[5];
101
+ const buildLo = body[6];
102
+ const build = buildHi * 100 + buildLo;
103
+ const canFD = !!(body[7] & 0x20);
104
+
105
+ const desc = PIR_TYPES_20[typeId];
106
+ if (!desc) {
107
+ node.status({ fill: 'red', shape: 'ring', text: 'unknown type 0x' + typeId.toString(16) });
108
+ node.error('velbus-pir-20: unrecognised module type 0x' + typeId.toString(16));
109
+ return;
110
+ }
111
+
112
+ if (desc.minMapVer !== null && mapVer < desc.minMapVer) {
113
+ node.status({ fill: 'red', shape: 'ring',
114
+ text: desc.name + ' map v' + mapVer + ' < min v' + desc.minMapVer });
115
+ node.error('velbus-pir-20: firmware map version ' + mapVer + ' below minimum');
116
+ return;
117
+ }
118
+
119
+ node.moduleName = node.moduleName || desc.name;
120
+ setStatus('build ' + build + (canFD ? ' CAN FD' : ''), 'grey', 'dot');
121
+
122
+ // Request name
123
+ setTimeout(() => {
124
+ node.bridge.send(pkt(0xF8, node.address, [0xEF, 0xFF]));
125
+ _nameTimer = setTimeout(assembleName, 2000);
126
+ }, 100);
127
+ }
128
+
129
+ // ── Packet handler ────────────────────────────────────────────────────
130
+
131
+ function onPacket(raw) {
132
+ const p = parsePkt(raw);
133
+ if (!p) return;
134
+ // Note: no address re-check here. The bridge routes frames to this
135
+ // listener, including subaddress frames mapped to the primary address
136
+ // (e.g. GPO-20 thermostat status from subaddress 0x34).
137
+
138
+ const { cmd, body, rtr: isRtr } = p;
139
+
140
+ if (isRtr) return;
141
+
142
+ switch (cmd) {
143
+
144
+ // ── 0xFF Module type ─────────────────────────────────────────────
145
+ case 0xFF: {
146
+ handleModuleType(body);
147
+ break;
148
+ }
149
+
150
+ // ── 0x00 Channel status ──────────────────────────────────────────
151
+ case 0x00: {
152
+ if (body.length < 4) return;
153
+ // body[0]=cmd, body[1]=pressed, body[2]=released, body[3]=long
154
+ const pressed = activeFromBitmask(body[1], bitmask);
155
+ const released = activeFromBitmask(body[2], bitmask);
156
+ const longPressed = activeFromBitmask(body[3], bitmask);
157
+ const on = pressed.length > 0 || longPressed.length > 0;
158
+
159
+ const payload = { type: 'channel', on, pressed, released, longPressed };
160
+
161
+ if (pressed.length) {
162
+ setStatus(pressed.join(',') + ' on');
163
+ } else if (released.length) {
164
+ setStatus(released.join(',') + ' off');
165
+ }
166
+
167
+ node.send([{ payload }, null]);
168
+ break;
169
+ }
170
+
171
+ // ── 0xED Module status ───────────────────────────────────────────
172
+ case 0xED: {
173
+ if (body.length < 8) return;
174
+ // body[0]=cmd, body[1]=channel bitmask, body[2-3]=light hi/lo
175
+ // body[4]=locked/test, body[5]=prog disabled, body[6]=alarm&program
176
+ // body[7]=auto-send interval
177
+ const channels = decodeFromBitmask(body[1], bitmask);
178
+ const lightRaw = (body[2] << 8) | body[3];
179
+ const testMode = !!(body[4] & 0x80);
180
+
181
+ // Locked status only covers lockable channels (bits 0-5 on VMBPIRO-20)
182
+ const lockedBitmask = body[4] & 0x3F;
183
+ const locked = decodeFromBitmask(lockedBitmask, bitmask.slice(0, 6));
184
+
185
+ const progBitmask = body[5] & 0x3F;
186
+ const progDisabled = decodeFromBitmask(progBitmask, bitmask.slice(0, 6));
187
+
188
+ const progByte = body[6];
189
+ const program = ['none', 'summer', 'winter', 'holiday'][progByte & 0x03];
190
+ const alarm1Active = !!(progByte & 0x04);
191
+ const alarm2Active = !!(progByte & 0x10);
192
+ const sunriseEnabled = !!(progByte & 0x40);
193
+ const sunsetEnabled = !!(progByte & 0x80);
194
+
195
+ const on = Object.values(channels).some(v => v);
196
+
197
+ const payload = {
198
+ type: 'status',
199
+ on,
200
+ channels,
201
+ lightRaw,
202
+ testMode,
203
+ locked,
204
+ progDisabled,
205
+ program,
206
+ alarms: { alarm1Active, alarm2Active },
207
+ sunrise: sunriseEnabled,
208
+ sunset: sunsetEnabled,
209
+ autoSendInterval: body[7],
210
+ };
211
+ node.send([{ payload }, null]);
212
+ break;
213
+ }
214
+
215
+ // ── 0xA9 Light raw value ─────────────────────────────────────────
216
+ case 0xA9: {
217
+ if (body.length < 3) return;
218
+ const raw = (body[1] << 8) | body[2];
219
+ const payload = { type: 'light', on: raw > 0, raw };
220
+ node.send([{ payload }, null]);
221
+ break;
222
+ }
223
+
224
+ // ── 0xE6 Temperature ─────────────────────────────────────────────
225
+ case 0xE6: {
226
+ if (!hasTempSensor) return;
227
+ if (body.length < 3) return;
228
+ const current = tempFrom16(body[1], body[2]);
229
+ const min = body.length >= 5 ? tempFrom16(body[3], body[4]) : null;
230
+ const max = body.length >= 7 ? tempFrom16(body[5], body[6]) : null;
231
+
232
+ const payload = { type: 'temperature', current };
233
+ if (min !== null) payload.min = min;
234
+ if (max !== null) payload.max = max;
235
+ node.send([null, { payload }]);
236
+ break;
237
+ }
238
+
239
+ // ── 0xE8 Temperature settings ────────────────────────────────────
240
+ // VMBPIRO-20 uses multi-part glass-panel-style format but only
241
+ // calibration and alarm fields are meaningful. Parse part 1 only.
242
+ case 0xE8: {
243
+ if (!hasTempSensor) return;
244
+ if (body.length < 8) return;
245
+ // body[0]=cmd, body[1]=current set, body[2]=comfort heat,
246
+ // body[3]=day heat, body[4]=night heat, body[5]=safe heat,
247
+ // body[6]=boost diff, body[7]=hysteresis
248
+ // For VMBPIRO-20 only body[5] (safe=lowAlarm) and body[2] (comfort=highAlarm)
249
+ // are actually meaningful as alarm thresholds — but we emit all received fields
250
+ // and let the user decide. The calibration is in 0xC6.
251
+ const payload = {
252
+ type: 'temp_settings',
253
+ current: signed05(body[1]),
254
+ comfort: signed05(body[2]),
255
+ day: signed05(body[3]),
256
+ night: signed05(body[4]),
257
+ safe: signed05(body[5]),
258
+ };
259
+ node.send([null, { payload }]);
260
+ break;
261
+ }
262
+
263
+ // ── 0xF0/F1/F2 Sensor name ───────────────────────────────────────
264
+ case 0xF0:
265
+ case 0xF1:
266
+ case 0xF2: {
267
+ if (!hasTempSensor) return;
268
+ // body[0]=cmd, body[1]=sensor bit number, body[2..]=chars
269
+ const part = cmd - 0xF0;
270
+ _nameParts[part] = Array.from(body).slice(2);
271
+ let text = '';
272
+ for (let i = 2; i < body.length; i++) {
273
+ if (body[i] === 0 || body[i] === 0xFF) break;
274
+ text += String.fromCharCode(body[i]);
275
+ }
276
+ if (part === 2) assembleName();
277
+ node.send([null, { payload: { type: 'name_part', part, text } }]);
278
+ break;
279
+ }
280
+
281
+ default:
282
+ break;
283
+ }
284
+ }
285
+
286
+ node.bridge.register(node.address, onPacket);
287
+
288
+ // Startup RTR — triggers 0xFF response which drives firmware check and name retrieval
289
+ setTimeout(function() {
290
+ node.bridge.send(rtrPkt(node.address), true);
291
+ }, 500);
292
+
293
+ // ── Input commands ────────────────────────────────────────────────────
294
+
295
+ node.on('input', function(msg) {
296
+ const cmd = msg.payload && msg.payload.cmd;
297
+ if (!cmd) return;
298
+
299
+ if (cmd === 'get_status') {
300
+ node.bridge.send(pkt(0xFB, node.address, [0xFA, 0x00]));
301
+ return;
302
+ }
303
+ if (cmd === 'get_light') {
304
+ const interval = parseInt(msg.payload.interval) || 0;
305
+ node.bridge.send(pkt(0xF8, node.address, [0xAA, interval]));
306
+ return;
307
+ }
308
+ if (cmd === 'get_temp') {
309
+ if (!hasTempSensor) { node.warn('velbus-pir-20: this module has no temperature sensor'); return; }
310
+ const interval = parseInt(msg.payload.interval) || 0;
311
+ node.bridge.send(pkt(0xF8, node.address, [0xE5, interval]));
312
+ return;
313
+ }
314
+ if (cmd === 'get_temp_settings') {
315
+ if (!hasTempSensor) { node.warn('velbus-pir-20: this module has no temperature sensor'); return; }
316
+ node.bridge.send(pkt(0xF8, node.address, [0xE7, 0x00]));
317
+ return;
318
+ }
319
+ if (cmd === 'test_on') { node.bridge.send(pkt(0xF8, node.address, [0xB5, 0x01])); return; }
320
+ if (cmd === 'test_off') { node.bridge.send(pkt(0xF8, node.address, [0xB5, 0x00])); return; }
321
+
322
+ // Lock / unlock by channel number
323
+ if (cmd === 'lock') {
324
+ const ch = parseInt(msg.payload.channel) || 0xFF;
325
+ const duration = parseInt(msg.payload.duration) || 0xFFFFFF;
326
+ const hi = (duration >> 16) & 0xFF;
327
+ const mid = (duration >> 8) & 0xFF;
328
+ const lo = duration & 0xFF;
329
+ node.bridge.send(pkt(0xF8, node.address, [0x12, ch, hi, mid, lo]));
330
+ return;
331
+ }
332
+ if (cmd === 'unlock') {
333
+ const ch = parseInt(msg.payload.channel) || 0xFF;
334
+ node.bridge.send(pkt(0xF8, node.address, [0x13, ch]));
335
+ return;
336
+ }
337
+
338
+ node.warn('velbus-pir-20: unknown cmd: ' + cmd);
339
+ });
340
+
341
+ // ── Cleanup ───────────────────────────────────────────────────────────
342
+
343
+ node.on('close', function() {
344
+ if (_nameTimer) { clearTimeout(_nameTimer); _nameTimer = null; }
345
+ node.bridge.deregister(node.address, onPacket);
346
+ });
347
+
348
+ setStatus('ready', 'grey', 'dot');
349
+ }
350
+
351
+ RED.nodes.registerType('velbus-pir-20', VelbusPir20Node);
352
+ };
@@ -0,0 +1,215 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('velbus-relay', {
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: 1, 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 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']
26
+ );
27
+ }
28
+ });
29
+
30
+ // ── Shared address dropdown helper ──────────────────────────────────────
31
+ // Queries the bridge's scan results endpoint and populates a select element.
32
+ // Falls back to plain text input if no scan results are available.
33
+ // nodeTypes: array of suggestedNode values to filter by (or null for all)
34
+
35
+ function velbusPopulateAddressDropdown(nodeCtx, bridgeSelector, addrSelector, nodeTypes) {
36
+ const addrField = $(addrSelector);
37
+ const currentVal = addrField.val() || nodeCtx.moduleAddr || '';
38
+
39
+ function buildDropdown(modules) {
40
+ const filtered = nodeTypes
41
+ ? modules.filter(m => nodeTypes.includes(m.suggestedNode))
42
+ : modules;
43
+
44
+ if (filtered.length === 0) {
45
+ // No matching modules — keep plain text input
46
+ addrField.show();
47
+ return;
48
+ }
49
+
50
+ // Replace input with select, preserving current value
51
+ const select = $('<select>', { id: addrSelector.replace('#',''), style: 'width:100%' });
52
+
53
+ // Blank option — manual entry fallback
54
+ select.append($('<option>', { value: '', text: '— select or type address —' }));
55
+
56
+ for (const m of filtered) {
57
+ const label = m.address + ' ' + m.module +
58
+ (m.module !== (m.velbusName || '') && m.velbusName ? ' "' + m.velbusName + '"' : '') +
59
+ ' (build ' + m.build + ', map v' + m.memoryMapVersion + ')';
60
+ const opt = $('<option>', { value: m.address.replace('0x',''), text: label });
61
+ if (m.address.replace('0x','').toUpperCase() === currentVal.toUpperCase()) {
62
+ opt.prop('selected', true);
63
+ }
64
+ select.append(opt);
65
+ }
66
+
67
+ // Manual entry option at bottom
68
+ select.append($('<option>', { value: '__manual__', text: '— enter address manually —' }));
69
+
70
+ addrField.replaceWith(select);
71
+
72
+ // Store module data on each option for auto-population
73
+ select.find('option').each(function() {
74
+ const val = $(this).val();
75
+ if (val && val !== '__manual__') {
76
+ const m = filtered.find(x => x.address.replace('0x','').toUpperCase() === val.toUpperCase());
77
+ if (m) $(this).data('module', m);
78
+ }
79
+ });
80
+
81
+ // On selection: auto-populate channelCount if available
82
+ select.on('change', function() {
83
+ if ($(this).val() === '__manual__') {
84
+ const input = $('<input>', {
85
+ type: 'text', id: addrSelector.replace('#',''),
86
+ placeholder: 'e.g. A0', style: 'width:80px'
87
+ });
88
+ $(this).replaceWith(input);
89
+ return;
90
+ }
91
+ const m = $(this).find(':selected').data('module');
92
+ if (m && m.channels) {
93
+ const ccField = $('#node-input-channelCount');
94
+ if (ccField.length) ccField.val(m.channels);
95
+ }
96
+ });
97
+ }
98
+
99
+ function loadResults() {
100
+ const bridgeId = $(bridgeSelector).val();
101
+ if (!bridgeId) return;
102
+
103
+ $.getJSON('velbus/scan-results?bridge=' + bridgeId, function(data) {
104
+ if (data && data.modules && data.modules.length > 0) {
105
+ buildDropdown(data.modules);
106
+ }
107
+ // If no results, plain text input stays as-is
108
+ }).fail(function() {
109
+ // Endpoint not reachable — plain text input stays
110
+ });
111
+ }
112
+
113
+ // Load on open, and reload if bridge selection changes
114
+ loadResults();
115
+ $(bridgeSelector).on('change', loadResults);
116
+ }
117
+ </script>
118
+
119
+ <script type="text/html" data-template-name="velbus-relay">
120
+ <div class="form-row">
121
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name override</label>
122
+ <input type="text" id="node-input-name"
123
+ placeholder="Leave blank to use module name from VelbusLink">
124
+ </div>
125
+ <div class="form-row">
126
+ <label for="node-input-bridge"><i class="fa fa-server"></i> Bridge</label>
127
+ <input type="text" id="node-input-bridge">
128
+ </div>
129
+ <div class="form-row">
130
+ <label for="node-input-moduleAddr"><i class="fa fa-microchip"></i> Address</label>
131
+ <input type="text" id="node-input-moduleAddr" placeholder="e.g. A0 — run scan first for dropdown" style="width:100%">
132
+ <div class="form-tips" style="margin-top:4px">Run a <b>velbus-scan</b> node first to populate the dropdown with discovered modules.</div>
133
+ </div>
134
+ <div class="form-row">
135
+ <label for="node-input-startChannel"><i class="fa fa-list-ol"></i> Start channel</label>
136
+ <input type="number" id="node-input-startChannel" min="1" max="8" style="width:60px">
137
+ </div>
138
+ <div class="form-row">
139
+ <label for="node-input-channelCount"><i class="fa fa-hashtag"></i> Channel count</label>
140
+ <input type="number" id="node-input-channelCount" min="1" max="8" style="width:60px">
141
+ </div>
142
+ </script>
143
+
144
+ <script type="text/html" data-help-name="velbus-relay">
145
+ <p style="background:#fff3cd; border-left:4px solid #ffc107; padding:8px 12px; margin-bottom:12px; font-size:0.85em;">
146
+ <strong>&#9888; Testing status:</strong> This node was generated with Claude.ai and is in
147
+ need of extensive field testing before being deployed into a commercial project.
148
+ It is presented "as is" &mdash; any use beyond testing is entirely at your own risk and liability.
149
+ Constructive feedback is welcomed, accompanied by as many examples and debug captures as possible.
150
+ <a href="https://github.com/MDAR/node-red-contrib-velbus-2026/issues" target="_blank">File an issue on GitHub</a>.
151
+ </p>
152
+ <p><strong>Original series only (pre V2.0)</strong></p>
153
+ <p>For V2.0 relay modules (VMB4RYLD-20, VMB4RYNO-20, VMB1RYS-20) use the <b>velbus-relay-20</b> node instead.</p>
154
+
155
+ <p>Interprets original series Velbus relay module packets and encodes commands.</p>
156
+ <p>Supports: <b>VMB1RY</b>, <b>VMB4RY</b>, <b>VMB4RYLD</b>, <b>VMB4RYNO</b>,
157
+ <b>VMB1RYNO</b>, <b>VMB1RYNOS</b>, <b>VMB1RYS</b>, <b>VMB4RYLD-10</b>, <b>VMB4RYNO-10</b></p>
158
+
159
+ <h3>Address dropdown</h3>
160
+ <p>Run a <b>velbus-scan</b> node first. The address field will show a dropdown of
161
+ discovered relay modules from the most recent scan. Select any to configure, or
162
+ choose "enter address manually" to type a hex address directly.</p>
163
+
164
+ <h3>Output 1 — State / Events</h3>
165
+ <pre>
166
+ {
167
+ "topic": "relay_status",
168
+ "address": "0xA0",
169
+ "module": "Living Room",
170
+ "channel": 1,
171
+ "channelBit": "0x01",
172
+ "state": "on", // off | on | timer_running | forced_on | forced_off | inhibited
173
+ "on": true,
174
+ "timerRemaining": 0,
175
+ "ledState": "off",
176
+ "timestamp": 1719100000000
177
+ }
178
+
179
+ // relay_switched — fires on every pulse edge during an interval_timer blink,
180
+ // and on every local push-button-driven switch. Note this is separate from
181
+ // relay_status above: relay_status only reports off/on/timer_running as
182
+ // three fixed states and does NOT toggle per pulse — relay_switched is
183
+ // where the per-pulse on/off transitions actually show up.
184
+ {
185
+ "topic": "relay_switched",
186
+ "address": "0xA0",
187
+ "module": "Living Room",
188
+ "channel": 1,
189
+ "channelBit": "0x01",
190
+ "state": "on" // or "off"
191
+ }
192
+ </pre>
193
+
194
+ <h3>Input — Commands</h3>
195
+ <pre>
196
+ { "channel": 1, "cmd": "on" }
197
+ { "channel": 1, "cmd": "off" }
198
+ { "channel": 1, "cmd": "toggle" }
199
+ { "channel": 1, "cmd": "timer", "duration": 60 }
200
+ { "channel": 1, "cmd": "interval_timer", "duration": 3600 }
201
+ { "channel": 1, "cmd": "forced_on", "duration": 3600 }
202
+ { "channel": 1, "cmd": "forced_off", "duration": 3600 }
203
+ { "channel": 1, "cmd": "cancel_forced_on" }
204
+ { "channel": 1, "cmd": "cancel_forced_off" }
205
+ { "channel": 1, "cmd": "inhibit", "duration": 1800 }
206
+ { "channel": 1, "cmd": "cancel_inhibit" }
207
+ { "channel": 1, "cmd": "status" }
208
+ </pre>
209
+ <p>Duration in seconds. Use <code>-1</code> for permanent.</p>
210
+ <p><b>interval_timer</b> makes the relay blink at the module's own fixed rate for
211
+ <code>duration</code> seconds (<code>-1</code> = blinks permanently until cancelled).
212
+ There is no bus command to set a custom on/off rate &mdash; the module's blink speed
213
+ is fixed in firmware, not configurable via this command. Status reports this as
214
+ <code>timer_running</code> (protocol term: "interval timer on") on Output 1.</p>
215
+ </script>