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,457 @@
1
+ 'use strict';
2
+
3
+ const { pkt, rtrPkt, parsePkt } = require('../../lib/velbus-utils');
4
+ const { DIMMER_TYPES, DIMMER_TYPE_IDS } = require('../../lib/dimmer-types');
5
+
6
+ module.exports = function(RED) {
7
+
8
+ function VelbusDimmerNode(config) {
9
+ RED.nodes.createNode(this, config);
10
+ const node = this;
11
+
12
+ node.bridge = RED.nodes.getNode(config.bridge);
13
+ node.moduleAddr = parseInt(config.moduleAddr, 16);
14
+ node.startChannel = parseInt(config.startChannel) || 1;
15
+ node.channelCount = parseInt(config.channelCount) || 1;
16
+ node.nameOverride = config.name || '';
17
+
18
+ if (!node.bridge) {
19
+ node.error('velbus-dimmer: no bridge configured');
20
+ return;
21
+ }
22
+ if (isNaN(node.moduleAddr) || node.moduleAddr < 1 || node.moduleAddr > 0xFE) {
23
+ node.error('velbus-dimmer: invalid module address');
24
+ return;
25
+ }
26
+
27
+ // State keyed by channel number (1-N)
28
+ // Original series: dim value is 0-100%
29
+ let _channelState = {};
30
+ // { on, level (0-100), percent (0.0-100.0), relayState, thermal }
31
+
32
+ let _moduleInfo = null;
33
+ let _velbusName = '';
34
+ let _nameParts = {};
35
+ let _blocked = false;
36
+ let _nameTimer = null;
37
+
38
+ // ── Helpers ───────────────────────────────────────────────────────────
39
+
40
+ function addrHex(a) {
41
+ return '0x' + a.toString(16).padStart(2, '0').toUpperCase();
42
+ }
43
+
44
+ function displayName() {
45
+ if (node.nameOverride) return node.nameOverride;
46
+ if (_velbusName) return _velbusName;
47
+ if (_moduleInfo) return _moduleInfo.name;
48
+ return addrHex(node.moduleAddr);
49
+ }
50
+
51
+ function statusPrefix() {
52
+ return displayName() + ' (' + addrHex(node.moduleAddr) + ')';
53
+ }
54
+
55
+ function setStatus(text, fill, shape) {
56
+ node.status({ fill: fill || 'grey', shape: shape || 'dot', text });
57
+ }
58
+
59
+ // Original series: dim value is 0-100 integer percentage
60
+ function decodeModeStatus(modeByte, statusByte) {
61
+ const mode = modeByte & 0x03;
62
+ if (mode === 0x03) return 'forced_off';
63
+ if (mode === 0x02) return 'forced_on';
64
+ if (mode === 0x01) return 'inhibited';
65
+ const s = statusByte & 0x03;
66
+ if (s === 0x03) return 'timer_running';
67
+ if (s === 0x01) return 'on';
68
+ return 'off';
69
+ }
70
+
71
+ function decodeThermal(statusByte) {
72
+ // bits 4-5: temp band (0=normal, 1=warm, 2=hot, 3=very hot)
73
+ // bit 3: load type (0=leading edge, 1=trailing edge)
74
+ // bits 1-2: error bits
75
+ return {
76
+ tempBand: (statusByte >> 4) & 0x03,
77
+ loadType: (statusByte >> 3) & 0x01,
78
+ error: (statusByte >> 1) & 0x03
79
+ };
80
+ }
81
+
82
+ // ── Name retrieval ────────────────────────────────────────────────────
83
+
84
+ function requestName() {
85
+ node.bridge.send(pkt(0xF8, node.moduleAddr, [0xEF, 0xFF]));
86
+ _nameTimer = setTimeout(assembleName, 2000);
87
+ }
88
+
89
+ function assembleName() {
90
+ if (_nameTimer) { clearTimeout(_nameTimer); _nameTimer = null; }
91
+ const raw = [
92
+ ...(_nameParts[1] || []),
93
+ ...(_nameParts[2] || []),
94
+ ...(_nameParts[3] || [])
95
+ ];
96
+ const name = raw
97
+ .filter(c => c !== 0xFF && c !== 0x00)
98
+ .map(c => String.fromCharCode(c))
99
+ .join('')
100
+ .trim();
101
+ if (name) {
102
+ _velbusName = name;
103
+ node.log('velbus-dimmer: module name: ' + name);
104
+ }
105
+ requestStatus();
106
+ }
107
+
108
+ function requestStatus() {
109
+ // Original series: 0xFA + channel bit
110
+ const typeInfo = _moduleInfo;
111
+ if (typeInfo && typeInfo.channelModel === 'single') {
112
+ node.bridge.send(pkt(0xFB, node.moduleAddr, [0xFA, 0x01]));
113
+ } else {
114
+ // VMB4DC — request each channel
115
+ for (let i = 0; i < node.channelCount; i++) {
116
+ const ch = node.startChannel + i;
117
+ const chBit = 1 << (ch - 1);
118
+ node.bridge.send(pkt(0xFB, node.moduleAddr, [0xFA, chBit]));
119
+ }
120
+ }
121
+ setStatus(statusPrefix() + ' online', 'grey');
122
+ }
123
+
124
+ // ── 0xFF Firmware check ───────────────────────────────────────────────
125
+
126
+ function handleModuleType(body) {
127
+ const typeId = body[1];
128
+
129
+ if (!DIMMER_TYPE_IDS.has(typeId)) {
130
+ const msg = '⚠ ' + statusPrefix() + ' unknown type ' + addrHex(typeId);
131
+ setStatus(msg, 'red');
132
+ node.error('velbus-dimmer: ' + msg);
133
+ node.send([null, { payload: {
134
+ topic: 'module_unknown',
135
+ address: addrHex(node.moduleAddr),
136
+ typeId: addrHex(typeId),
137
+ message: 'Unrecognised module type — is this the right node? For -20 series use velbus-dimmer-20'
138
+ }}]);
139
+ _blocked = true;
140
+ return;
141
+ }
142
+
143
+ const typeInfo = DIMMER_TYPES[typeId];
144
+ const serial = body.length >= 4 ? (body[2] << 8 | body[3]) : null;
145
+ const mapVer = body.length >= 5 ? body[4] : null;
146
+ const buildHi = body.length >= 6 ? body[5] : null;
147
+ const buildLo = body.length >= 7 ? body[6] : null;
148
+ const build = (buildHi !== null && buildLo !== null)
149
+ ? (buildHi * 100 + buildLo) : null;
150
+
151
+ _moduleInfo = { ...typeInfo, typeId, serial, build, memoryMapVersion: mapVer };
152
+
153
+ const minVer = typeInfo.minMemoryMapVersion;
154
+ if (minVer !== null && mapVer !== null && mapVer < minVer) {
155
+ const msg = '⚠ ' + statusPrefix() + ' firmware too old (map v' + mapVer + ', need v' + minVer + ')';
156
+ setStatus(msg, 'red');
157
+ node.error('velbus-dimmer: ' + msg);
158
+ node.send([null, { payload: {
159
+ topic: 'firmware_incompatible',
160
+ address: addrHex(node.moduleAddr),
161
+ module: typeInfo.name,
162
+ memoryMapVersion: mapVer,
163
+ minimumRequired: minVer,
164
+ message: 'Firmware too old — update module firmware before use'
165
+ }}]);
166
+ _blocked = true;
167
+ return;
168
+ }
169
+
170
+ if (minVer === null) {
171
+ setStatus(statusPrefix() + ' map v' + mapVer + ' (unverified)', 'yellow');
172
+ node.warn('velbus-dimmer: ' + addrHex(node.moduleAddr) + ' ' + typeInfo.name +
173
+ ' map v' + mapVer + ' — minimum version unverified, proceeding');
174
+ }
175
+
176
+ _blocked = false;
177
+
178
+ node.send([{ payload: {
179
+ topic: 'module_online',
180
+ address: addrHex(node.moduleAddr),
181
+ module: typeInfo.name,
182
+ typeId: addrHex(typeId),
183
+ channels: typeInfo.channels,
184
+ serial,
185
+ build,
186
+ memoryMapVersion: mapVer
187
+ }}, null]);
188
+
189
+ node.log('velbus-dimmer: ' + typeInfo.name + ' at ' + addrHex(node.moduleAddr) +
190
+ ' serial ' + (serial ? serial.toString(16).toUpperCase() : '?') +
191
+ ' build ' + build + ' map v' + mapVer);
192
+
193
+ requestName();
194
+ }
195
+
196
+ // ── Packet handler ────────────────────────────────────────────────────
197
+
198
+ function onPacket(raw) {
199
+ const p = parsePkt(raw);
200
+ if (!p) return;
201
+ const { body, cmd, rtr } = p;
202
+
203
+ if (cmd === 0xFF && !rtr && body.length >= 2) {
204
+ handleModuleType(body);
205
+ return;
206
+ }
207
+
208
+ if (_blocked) return;
209
+
210
+ if ((cmd === 0xF0 || cmd === 0xF1 || cmd === 0xF2) && body.length >= 2) {
211
+ const part = cmd - 0xEF;
212
+ _nameParts[part] = body.slice(2);
213
+ if (part === 3) assembleName();
214
+ return;
215
+ }
216
+
217
+ // ── 0xB8 Dimmer status (original series) ─────────────────────────
218
+ // byte 1: cmd (0xB8)
219
+ // byte 2: channel bit (VMBDMI/R: 0x01; VMB4DC: bitmask)
220
+ // byte 3: mode/status byte
221
+ // byte 4: dim value 0-100
222
+ // byte 5: status byte 2 (VMBDMI/R: thermal; VMB4DC: unused)
223
+ // bytes 6-7: timer high/low (16-bit seconds)
224
+ if (cmd === 0xB8 && body.length >= 5) {
225
+ const typeInfo = _moduleInfo;
226
+ const chByte = body[1];
227
+ const modeByte = body[2];
228
+ const dimValue = body[3]; // 0-100
229
+ const statusByte = body[4];
230
+ const timerSec = body.length >= 7 ? ((body[5] << 8) | body[6]) : 0;
231
+ const relayState = decodeModeStatus(modeByte, statusByte);
232
+
233
+ if (typeInfo && typeInfo.channelModel === 'single') {
234
+ // VMBDMI / VMBDMI-R — single channel, chByte always 0x01
235
+ const ch = 1;
236
+ if (ch < node.startChannel || ch >= node.startChannel + node.channelCount) return;
237
+
238
+ const thermal = typeInfo.hasThermal ? decodeThermal(statusByte) : null;
239
+ _channelState[ch] = {
240
+ on: dimValue > 0 && relayState === 'on',
241
+ level: dimValue,
242
+ percent: dimValue * 1.0,
243
+ relayState,
244
+ timerRemaining: timerSec,
245
+ thermal
246
+ };
247
+
248
+ const isWarning = ['forced_on', 'forced_off', 'inhibited'].includes(relayState);
249
+ setStatus(statusPrefix() + ' ' + (dimValue > 0 ? dimValue + '%' : 'off'),
250
+ isWarning ? 'yellow' : (dimValue > 0 ? 'green' : 'grey'));
251
+
252
+ node.send([{ payload: {
253
+ topic: 'dimmer_status',
254
+ address: addrHex(node.moduleAddr),
255
+ module: displayName(),
256
+ channel: ch,
257
+ state: relayState,
258
+ on: dimValue > 0 && relayState === 'on',
259
+ level: dimValue,
260
+ percent: dimValue * 1.0,
261
+ timerRemaining: timerSec,
262
+ thermal,
263
+ timestamp: Date.now()
264
+ }}, isWarning ? { payload: {
265
+ topic: 'dimmer_state_warning',
266
+ address: addrHex(node.moduleAddr),
267
+ module: displayName(),
268
+ channel: ch,
269
+ state: relayState,
270
+ message: 'Channel ' + ch + ' is in ' + relayState
271
+ }} : null]);
272
+
273
+ } else {
274
+ // VMB4DC — chByte is a bitmask
275
+ for (let i = 0; i < 4; i++) {
276
+ const b = 1 << i;
277
+ const ch = i + 1;
278
+ if (!(chByte & b)) continue;
279
+ if (ch < node.startChannel || ch >= node.startChannel + node.channelCount) continue;
280
+
281
+ _channelState[ch] = {
282
+ on: dimValue > 0 && relayState === 'on',
283
+ level: dimValue,
284
+ percent: dimValue * 1.0,
285
+ relayState,
286
+ timerRemaining: timerSec
287
+ };
288
+
289
+ const isWarning = ['forced_on', 'forced_off', 'inhibited'].includes(relayState);
290
+ setStatus(statusPrefix() + ' ch' + ch + ' ' + (dimValue > 0 ? dimValue + '%' : 'off'),
291
+ isWarning ? 'yellow' : (dimValue > 0 ? 'green' : 'grey'));
292
+
293
+ node.send([{ payload: {
294
+ topic: 'dimmer_status',
295
+ address: addrHex(node.moduleAddr),
296
+ module: displayName(),
297
+ channel: ch,
298
+ state: relayState,
299
+ on: dimValue > 0 && relayState === 'on',
300
+ level: dimValue,
301
+ percent: dimValue * 1.0,
302
+ timerRemaining: timerSec,
303
+ timestamp: Date.now()
304
+ }}, isWarning ? { payload: {
305
+ topic: 'dimmer_state_warning',
306
+ address: addrHex(node.moduleAddr),
307
+ module: displayName(),
308
+ channel: ch,
309
+ state: relayState,
310
+ message: 'Channel ' + ch + ' is in ' + relayState
311
+ }} : null]);
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ // ── Input: command encoder ────────────────────────────────────────────
318
+
319
+ node.on('input', function(msg) {
320
+ if (_blocked) {
321
+ node.warn('velbus-dimmer: commands blocked — firmware incompatible or unknown module');
322
+ return;
323
+ }
324
+
325
+ const inp = msg.payload;
326
+ if (!inp || typeof inp !== 'object') return;
327
+
328
+ const addr = inp.address !== undefined
329
+ ? (typeof inp.address === 'string' ? parseInt(inp.address, 16) : inp.address)
330
+ : node.moduleAddr;
331
+
332
+ const typeInfo = _moduleInfo;
333
+
334
+ // Channel handling:
335
+ // VMBDMI/R: single channel, chBit always 0x01
336
+ // VMB4DC: bitmask, or 0xFF for all
337
+ let chBit;
338
+ if (typeInfo && typeInfo.channelModel === 'single') {
339
+ chBit = 0x01;
340
+ } else if (inp.channel === 'all' || inp.channel === 0 || inp.channel === 255) {
341
+ chBit = 0x0F; // all 4 channels
342
+ } else if (typeof inp.channel === 'number' && inp.channel >= 1 && inp.channel <= 4) {
343
+ chBit = 1 << (inp.channel - 1);
344
+ } else {
345
+ node.warn('velbus-dimmer: missing or invalid channel');
346
+ return;
347
+ }
348
+
349
+ const cmd = inp.cmd || '';
350
+ const dur = inp.duration || 0;
351
+ const dimspeed = inp.dimspeed || 0; // 16-bit seconds
352
+
353
+ function clampPct(v) {
354
+ return Math.max(0, Math.min(100, Math.round(v)));
355
+ }
356
+
357
+ function timer16(sec) {
358
+ if (!sec || sec === 0) return [0, 0];
359
+ if (sec < 0) return [0xFF, 0xFF];
360
+ return [(sec >> 8) & 0xFF, sec & 0xFF];
361
+ }
362
+
363
+ const s16 = timer16(dimspeed);
364
+
365
+ function timer24(sec) {
366
+ if (!sec || sec === 0) return [0, 0, 0];
367
+ if (sec < 0) return [0xFF, 0xFF, 0xFF];
368
+ return [(sec >> 16) & 0xFF, (sec >> 8) & 0xFF, sec & 0xFF];
369
+ }
370
+
371
+ const t = timer24(dur);
372
+ let packet = null;
373
+
374
+ switch (cmd) {
375
+ case 'set': {
376
+ let level;
377
+ if (typeof inp.level === 'number') {
378
+ level = clampPct(inp.level);
379
+ } else if (typeof inp.percent === 'number') {
380
+ level = clampPct(inp.percent);
381
+ } else {
382
+ node.warn('velbus-dimmer: set command requires level or percent (0-100)');
383
+ return;
384
+ }
385
+ packet = pkt(0xF8, addr, [0x07, chBit, level, ...s16]);
386
+ break;
387
+ }
388
+ case 'on': {
389
+ if (inp.level !== undefined || inp.percent !== undefined) {
390
+ const level = clampPct(inp.level !== undefined ? inp.level : inp.percent);
391
+ packet = pkt(0xF8, addr, [0x07, chBit, level, ...s16]);
392
+ } else {
393
+ packet = pkt(0xF8, addr, [0x11, chBit, ...s16]);
394
+ }
395
+ break;
396
+ }
397
+ case 'off':
398
+ packet = pkt(0xF8, addr, [0x07, chBit, 0x00, ...s16]);
399
+ break;
400
+ case 'restore':
401
+ packet = pkt(0xF8, addr, [0x11, chBit, ...s16]);
402
+ break;
403
+ case 'timer':
404
+ packet = pkt(0xF8, addr, [0x08, chBit, ...t]);
405
+ break;
406
+ case 'forced_on':
407
+ packet = pkt(0xF8, addr, [0x14, chBit, ...t]);
408
+ break;
409
+ case 'forced_off':
410
+ packet = pkt(0xF8, addr, [0x12, chBit, ...t]);
411
+ break;
412
+ case 'cancel_forced_on':
413
+ packet = pkt(0xF8, addr, [0x15, chBit]);
414
+ break;
415
+ case 'cancel_forced_off':
416
+ packet = pkt(0xF8, addr, [0x13, chBit]);
417
+ break;
418
+ case 'inhibit':
419
+ packet = pkt(0xF8, addr, [0x16, chBit, ...t]);
420
+ break;
421
+ case 'cancel_inhibit':
422
+ packet = pkt(0xF8, addr, [0x17, chBit]);
423
+ break;
424
+ case 'stop':
425
+ packet = pkt(0xF8, addr, [0x10, chBit]);
426
+ break;
427
+ case 'status':
428
+ packet = pkt(0xFB, node.moduleAddr, [0xFA, chBit]);
429
+ break;
430
+ default:
431
+ node.warn('velbus-dimmer: unknown command: ' + cmd);
432
+ return;
433
+ }
434
+
435
+ if (packet) node.bridge.send(packet);
436
+ });
437
+
438
+ // ── Startup ───────────────────────────────────────────────────────────
439
+
440
+ node.bridge.register(node.moduleAddr, onPacket);
441
+
442
+ setTimeout(() => {
443
+ node.bridge.send(rtrPkt(node.moduleAddr), true);
444
+ }, 500);
445
+
446
+ setStatus('connecting…', 'grey');
447
+
448
+ // ── Cleanup ───────────────────────────────────────────────────────────
449
+
450
+ node.on('close', function() {
451
+ if (_nameTimer) clearTimeout(_nameTimer);
452
+ node.bridge.deregister(node.moduleAddr, onPacket);
453
+ });
454
+ }
455
+
456
+ RED.nodes.registerType('velbus-dimmer', VelbusDimmerNode);
457
+ };