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,602 @@
1
+ 'use strict';
2
+
3
+ const { pkt, rtrPkt, parsePkt } = require('../../lib/velbus-utils');
4
+ const { DIMMER_TYPES_20, DIMMER_TYPE_IDS_20, LEDPWM_TYPE_IDS, DEVICE_TYPE_NAMES } = require('../../lib/dimmer-types-20');
5
+
6
+ module.exports = function(RED) {
7
+
8
+ function VelbusDimmer20Node(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) || 4;
16
+ node.nameOverride = config.name || '';
17
+ node.ledMode = config.ledMode || 'single'; // VMB4LEDPWM-20 only
18
+
19
+ if (!node.bridge) {
20
+ node.error('velbus-dimmer-20: no bridge configured');
21
+ return;
22
+ }
23
+ if (isNaN(node.moduleAddr) || node.moduleAddr < 1 || node.moduleAddr > 0xFE) {
24
+ node.error('velbus-dimmer-20: invalid module address');
25
+ return;
26
+ }
27
+
28
+ // State keyed by channel number (1-N)
29
+ let _channelState = {};
30
+ // { on, level, percent, inhibited, forcedOn, forcedOff, programDisabled, error }
31
+
32
+ let _moduleInfo = null;
33
+ let _velbusName = '';
34
+ let _nameParts = {};
35
+ let _blocked = false;
36
+ let _nameTimer = null;
37
+ let _alarmProgram = null;
38
+
39
+ // ── Helpers ───────────────────────────────────────────────────────────
40
+
41
+ function addrHex(a) {
42
+ return '0x' + a.toString(16).padStart(2, '0').toUpperCase();
43
+ }
44
+
45
+ function displayName() {
46
+ if (node.nameOverride) return node.nameOverride;
47
+ if (_velbusName) return _velbusName;
48
+ if (_moduleInfo) return _moduleInfo.name;
49
+ return addrHex(node.moduleAddr);
50
+ }
51
+
52
+ function statusPrefix() {
53
+ return displayName() + ' (' + addrHex(node.moduleAddr) + ')';
54
+ }
55
+
56
+ function setStatus(text, fill, shape) {
57
+ node.status({ fill: fill || 'grey', shape: shape || 'dot', text });
58
+ }
59
+
60
+ // Convert raw 0-254 to percentage 0.0-100.0
61
+ function rawToPercent(raw) {
62
+ return Math.round((raw / 254) * 1000) / 10; // one decimal place
63
+ }
64
+
65
+ function channelStateStr(s) {
66
+ if (!s) return 'unknown';
67
+ if (s.forcedOff) return 'forced_off';
68
+ if (s.forcedOn) return 'forced_on';
69
+ if (s.inhibited) return 'inhibited';
70
+ if (s.error) return 'error';
71
+ return s.on ? 'on' : 'off';
72
+ }
73
+
74
+ function decodeAlarmProgram(b) {
75
+ return {
76
+ program: b & 0x03,
77
+ alarm1: !!(b & 0x04),
78
+ alarm1Global: !!(b & 0x08),
79
+ alarm2: !!(b & 0x10),
80
+ alarm2Global: !!(b & 0x20),
81
+ sunrise: !!(b & 0x40),
82
+ sunset: !!(b & 0x80)
83
+ };
84
+ }
85
+
86
+ // ── Name retrieval ────────────────────────────────────────────────────
87
+
88
+ function requestName() {
89
+ node.bridge.send(pkt(0xF8, node.moduleAddr, [0xEF, 0xFF]));
90
+ _nameTimer = setTimeout(assembleName, 2000);
91
+ }
92
+
93
+ function assembleName() {
94
+ if (_nameTimer) { clearTimeout(_nameTimer); _nameTimer = null; }
95
+ const raw = [
96
+ ...(_nameParts[1] || []),
97
+ ...(_nameParts[2] || []),
98
+ ...(_nameParts[3] || [])
99
+ ];
100
+ const name = raw
101
+ .filter(c => c !== 0xFF && c !== 0x00)
102
+ .map(c => String.fromCharCode(c))
103
+ .join('')
104
+ .trim();
105
+ if (name) {
106
+ _velbusName = name;
107
+ node.log('velbus-dimmer-20: module name: ' + name);
108
+ }
109
+ requestStatus();
110
+ }
111
+
112
+ function requestStatus() {
113
+ // 0xFA + 0x00 requests full module status
114
+ node.bridge.send(pkt(0xFB, node.moduleAddr, [0xFA, 0x00]));
115
+ setStatus(statusPrefix() + ' online', 'grey');
116
+ }
117
+
118
+ // ── 0xFF Firmware check ───────────────────────────────────────────────
119
+
120
+ function handleModuleType(body) {
121
+ const typeId = body[1];
122
+
123
+ if (!DIMMER_TYPE_IDS_20.has(typeId)) {
124
+ const msg = '⚠ ' + statusPrefix() + ' unknown type ' + addrHex(typeId);
125
+ setStatus(msg, 'red');
126
+ node.error('velbus-dimmer-20: ' + msg);
127
+ node.send([null, { payload: {
128
+ topic: 'module_unknown',
129
+ address: addrHex(node.moduleAddr),
130
+ typeId: addrHex(typeId),
131
+ message: 'Unrecognised module type — is this a -20 series dimmer/output module?'
132
+ }}]);
133
+ _blocked = true;
134
+ return;
135
+ }
136
+
137
+ const typeInfo = DIMMER_TYPES_20[typeId];
138
+ const serial = body.length >= 4 ? (body[2] << 8 | body[3]) : null;
139
+ const mapVer = body.length >= 5 ? body[4] : null;
140
+ const buildHi = body.length >= 6 ? body[5] : null;
141
+ const buildLo = body.length >= 7 ? body[6] : null;
142
+ const build = (buildHi !== null && buildLo !== null)
143
+ ? (buildHi * 100 + buildLo) : null;
144
+ const props = body.length >= 8 ? body[7] : null;
145
+ const canFD = props !== null ? !!(props & 0x20) : false;
146
+
147
+ _moduleInfo = { ...typeInfo, typeId, serial, build, memoryMapVersion: mapVer, canFD };
148
+
149
+ const minVer = typeInfo.minMemoryMapVersion;
150
+ if (minVer !== null && mapVer !== null && mapVer < minVer) {
151
+ const msg = '⚠ ' + statusPrefix() + ' firmware too old (map v' + mapVer + ', need v' + minVer + ')';
152
+ setStatus(msg, 'red');
153
+ node.error('velbus-dimmer-20: ' + msg);
154
+ node.send([null, { payload: {
155
+ topic: 'firmware_incompatible',
156
+ address: addrHex(node.moduleAddr),
157
+ module: typeInfo.name,
158
+ memoryMapVersion: mapVer,
159
+ minimumRequired: minVer,
160
+ message: 'Firmware too old — update module firmware before use'
161
+ }}]);
162
+ _blocked = true;
163
+ return;
164
+ }
165
+
166
+ if (minVer === null) {
167
+ setStatus(statusPrefix() + ' map v' + mapVer + ' (unverified)', 'yellow');
168
+ node.warn('velbus-dimmer-20: ' + addrHex(node.moduleAddr) + ' ' + typeInfo.name +
169
+ ' map v' + mapVer + ' — minimum version unverified, proceeding');
170
+ }
171
+
172
+ _blocked = false;
173
+
174
+ node.send([{ payload: {
175
+ topic: 'module_online',
176
+ address: addrHex(node.moduleAddr),
177
+ module: typeInfo.name,
178
+ typeId: addrHex(typeId),
179
+ outputType: typeInfo.outputType,
180
+ dimCurve: typeInfo.dimCurve,
181
+ channels: typeInfo.channels,
182
+ serial,
183
+ build,
184
+ memoryMapVersion: mapVer,
185
+ canFD,
186
+ ledMode: LEDPWM_TYPE_IDS.has(typeId) ? node.ledMode : null
187
+ }}, null]);
188
+
189
+ node.log('velbus-dimmer-20: ' + typeInfo.name + ' at ' + addrHex(node.moduleAddr) +
190
+ ' serial ' + (serial ? serial.toString(16).toUpperCase() : '?') +
191
+ ' build ' + build + ' map v' + mapVer +
192
+ ' (' + typeInfo.outputType + ', ' + typeInfo.dimCurve + ')');
193
+
194
+ requestName();
195
+ }
196
+
197
+ // ── Emit channel state message ────────────────────────────────────────
198
+
199
+ function emitChannel(ch) {
200
+ const s = _channelState[ch];
201
+ if (!s) return;
202
+ const state = channelStateStr(s);
203
+ const isActive = s.on && !s.forcedOff && !s.inhibited;
204
+ const isWarning = ['forced_off', 'inhibited', 'error'].includes(state);
205
+ const isForcedOn = state === 'forced_on';
206
+
207
+ setStatus(statusPrefix() + ' ch' + ch + ' ' + (s.on ? Math.round(s.percent) + '%' : 'off'),
208
+ isWarning ? 'yellow' : (isActive || isForcedOn ? 'green' : 'grey'));
209
+
210
+ const typeInfo = _moduleInfo || {};
211
+ const payload = {
212
+ topic: 'dimmer_status',
213
+ address: addrHex(node.moduleAddr),
214
+ module: displayName(),
215
+ outputType: typeInfo.outputType || null,
216
+ dimCurve: typeInfo.dimCurve || null,
217
+ channel: ch,
218
+ state,
219
+ on: s.on,
220
+ level: s.level,
221
+ percent: s.percent,
222
+ inhibited: s.inhibited,
223
+ forcedOn: s.forcedOn,
224
+ forcedOff: s.forcedOff,
225
+ programDisabled:s.programDisabled,
226
+ error: s.error,
227
+ alarmProgram: _alarmProgram,
228
+ timestamp: Date.now()
229
+ };
230
+
231
+ let warnMsg = null;
232
+ if (isWarning) {
233
+ warnMsg = {
234
+ topic: 'dimmer_state_warning',
235
+ address: addrHex(node.moduleAddr),
236
+ module: displayName(),
237
+ channel: ch,
238
+ state,
239
+ message: 'Channel ' + ch + ' is in ' + state + ' — commands may be ignored by module'
240
+ };
241
+ }
242
+
243
+ node.send([{ payload }, warnMsg ? { payload: warnMsg } : null]);
244
+ }
245
+
246
+ // ── Packet handler ────────────────────────────────────────────────────
247
+
248
+ function onPacket(raw) {
249
+ const p = parsePkt(raw);
250
+ if (!p) return;
251
+ const { body, cmd, rtr } = p;
252
+
253
+ // ── 0xFF Module type ──────────────────────────────────────────────
254
+ if (cmd === 0xFF && !rtr && body.length >= 2) {
255
+ handleModuleType(body);
256
+ return;
257
+ }
258
+
259
+ if (_blocked) return;
260
+
261
+ // ── 0xF0/0xF1/0xF2 Name parts ────────────────────────────────────
262
+ if ((cmd === 0xF0 || cmd === 0xF1 || cmd === 0xF2) && body.length >= 2) {
263
+ const part = cmd - 0xEF;
264
+ _nameParts[part] = body.slice(2);
265
+ if (part === 3) assembleName();
266
+ return;
267
+ }
268
+
269
+ // ── 0xEE Module status (V2 dimmer bitmask format) ─────────────────
270
+ // byte 1: cmd (0xEE)
271
+ // byte 2: ch 1-8 on/off bitmask
272
+ // byte 3: ch 1-8 inhibited bitmask
273
+ // byte 4: ch 1-8 forced_on bitmask
274
+ // byte 5: ch 1-8 forced_off bitmask
275
+ // byte 6: ch 1-8 program disabled bitmask
276
+ // byte 7: ch 1-8 error bitmask
277
+ // byte 8: alarm & program selection
278
+ if (cmd === 0xEE && body.length >= 8) {
279
+ const onBits = body[1];
280
+ const inhibBits = body[2];
281
+ const forcedOnBits = body[3];
282
+ const forcedOffBits = body[4];
283
+ const progDisBits = body[5];
284
+ const errorBits = body[6];
285
+ const alarmProg = body[7];
286
+
287
+ _alarmProgram = decodeAlarmProgram(alarmProg);
288
+
289
+ for (let i = 0; i < 8; i++) {
290
+ const ch = i + 1;
291
+ if (ch < node.startChannel || ch >= node.startChannel + node.channelCount) continue;
292
+ const b = 1 << i;
293
+
294
+ const prev = _channelState[ch] || {};
295
+ _channelState[ch] = {
296
+ on: !!(onBits & b),
297
+ level: prev.level !== undefined ? prev.level : 0,
298
+ percent: prev.percent !== undefined ? prev.percent : 0,
299
+ inhibited: !!(inhibBits & b),
300
+ forcedOn: !!(forcedOnBits & b),
301
+ forcedOff: !!(forcedOffBits & b),
302
+ programDisabled:!!(progDisBits & b),
303
+ error: !!(errorBits & b)
304
+ };
305
+ emitChannel(ch);
306
+ }
307
+ return;
308
+ }
309
+
310
+ // ── 0xE8 Device settings reply (settings API) ──────────────────────
311
+ // Reply to a 0xE7 device settings request. Used here to read the
312
+ // per-channel "Device Type" (settings index 25) that determines
313
+ // VMB4LEDPWM-20 grouping mode — see A.0.2 in the commissioning
314
+ // roadmap Appendix A and the doc comment in dimmer-types-20.js.
315
+ // byte 1: cmd (0xE8)
316
+ // byte 2: channel (1-4)
317
+ // byte 3: setting index
318
+ // byte 4: value
319
+ if (cmd === 0xE8 && body.length >= 4) {
320
+ const dtChannel = body[1];
321
+ const settingIndex = body[2];
322
+ const value = body[3];
323
+
324
+ if (settingIndex === 25) {
325
+ const deviceType = value;
326
+ const deviceTypeName = DEVICE_TYPE_NAMES[deviceType] ||
327
+ ('unknown (' + addrHex(deviceType) + ')');
328
+
329
+ let detectedGroupMode = null;
330
+ if (LEDPWM_TYPE_IDS.has(_moduleInfo && _moduleInfo.typeId)) {
331
+ if (deviceType === 0x08) detectedGroupMode = 'rgbw';
332
+ else if (deviceType === 0xF0) detectedGroupMode = 'rgb';
333
+ else detectedGroupMode = 'single';
334
+ }
335
+
336
+ node.log('velbus-dimmer-20: ' + statusPrefix() + ' ch' + dtChannel +
337
+ ' device type ' + addrHex(deviceType) + ' (' + deviceTypeName + ')' +
338
+ (detectedGroupMode ? ' — detected grouping mode: ' + detectedGroupMode : ''));
339
+
340
+ const payload = {
341
+ topic: 'device_type',
342
+ address: addrHex(node.moduleAddr),
343
+ module: displayName(),
344
+ channel: dtChannel,
345
+ settingIndex,
346
+ deviceType: addrHex(deviceType),
347
+ deviceTypeName,
348
+ detectedGroupMode
349
+ };
350
+
351
+ let warnMsg = null;
352
+ if (detectedGroupMode && dtChannel === 1 && detectedGroupMode !== node.ledMode) {
353
+ const msg = 'Node config ledMode ("' + node.ledMode + '") does not match ' +
354
+ statusPrefix() + '\u2019s actual Device Type setting ("' + detectedGroupMode +
355
+ '"). Update the node config to match, or re-check the physical wiring.';
356
+ warnMsg = {
357
+ topic: 'ledmode_mismatch',
358
+ address: addrHex(node.moduleAddr),
359
+ module: displayName(),
360
+ configuredLedMode: node.ledMode,
361
+ detectedGroupMode,
362
+ message: msg
363
+ };
364
+ node.warn('velbus-dimmer-20: ' + msg);
365
+ }
366
+
367
+ node.send([{ payload }, warnMsg ? { payload: warnMsg } : null]);
368
+ }
369
+ return;
370
+ }
371
+
372
+ // ── 0xA5 Dim level (up to 4 channels packed) ──────────────────────
373
+ // byte 1: cmd (0xA5)
374
+ // byte 2: channel number (1-based)
375
+ // byte 3: dim value (0-254)
376
+ // (repeated for up to 4 channels: bytes 4+5, 6+7, 8+9)
377
+ if (cmd === 0xA5 && body.length >= 3) {
378
+ let i = 1;
379
+ while (i + 1 < body.length) {
380
+ const ch = body[i];
381
+ const val = body[i + 1];
382
+ i += 2;
383
+
384
+ if (ch < node.startChannel || ch >= node.startChannel + node.channelCount) continue;
385
+ if (ch < 1 || ch > 8) continue;
386
+
387
+ const pct = rawToPercent(val);
388
+ if (_channelState[ch]) {
389
+ _channelState[ch].level = val;
390
+ _channelState[ch].percent = pct;
391
+ _channelState[ch].on = val > 0;
392
+ } else {
393
+ _channelState[ch] = {
394
+ on: val > 0, level: val, percent: pct,
395
+ inhibited: false, forcedOn: false, forcedOff: false,
396
+ programDisabled: false, error: false
397
+ };
398
+ }
399
+ emitChannel(ch);
400
+ }
401
+ return;
402
+ }
403
+
404
+ // ── 0x00 Dim level broadcast (spontaneous) ────────────────────────
405
+ // Sent when a dim level changes (button press, scene, timer etc.)
406
+ // Same format as 0xA5
407
+ if (cmd === 0x00 && body.length >= 3) {
408
+ let i = 1;
409
+ while (i + 1 < body.length) {
410
+ const ch = body[i];
411
+ const val = body[i + 1];
412
+ i += 2;
413
+
414
+ if (ch < node.startChannel || ch >= node.startChannel + node.channelCount) continue;
415
+ if (ch < 1 || ch > 8) continue;
416
+
417
+ const pct = rawToPercent(val);
418
+ if (_channelState[ch]) {
419
+ _channelState[ch].level = val;
420
+ _channelState[ch].percent = pct;
421
+ _channelState[ch].on = val > 0;
422
+ } else {
423
+ _channelState[ch] = {
424
+ on: val > 0, level: val, percent: pct,
425
+ inhibited: false, forcedOn: false, forcedOff: false,
426
+ programDisabled: false, error: false
427
+ };
428
+ }
429
+ emitChannel(ch);
430
+ }
431
+ }
432
+ }
433
+
434
+ // ── Input: command encoder ────────────────────────────────────────────
435
+
436
+ node.on('input', function(msg) {
437
+ if (_blocked) {
438
+ node.warn('velbus-dimmer-20: commands blocked — firmware incompatible or unknown module');
439
+ return;
440
+ }
441
+
442
+ const inp = msg.payload;
443
+ if (!inp || typeof inp !== 'object') return;
444
+
445
+ const addr = inp.address !== undefined
446
+ ? (typeof inp.address === 'string' ? parseInt(inp.address, 16) : inp.address)
447
+ : node.moduleAddr;
448
+
449
+ // Channel: 1-N, or 'all'/0/255 for all channels
450
+ let ch;
451
+ if (inp.channel === 'all' || inp.channel === 0 || inp.channel === 255) {
452
+ ch = 0xFF;
453
+ } else if (typeof inp.channel === 'number' && inp.channel >= 1 && inp.channel <= 8) {
454
+ ch = inp.channel;
455
+ } else {
456
+ node.warn('velbus-dimmer-20: missing or invalid channel');
457
+ return;
458
+ }
459
+
460
+ const cmd = inp.cmd || '';
461
+ const dur = inp.duration || 0;
462
+ const fadeMode = typeof inp.fadeMode === 'number' ? inp.fadeMode : 0; // 0=direct,1=rate,2=time
463
+
464
+ function timer24(sec) {
465
+ if (!sec || sec === 0) return [0, 0, 0];
466
+ if (sec < 0) return [0xFF, 0xFF, 0xFF];
467
+ return [(sec >> 16) & 0xFF, (sec >> 8) & 0xFF, sec & 0xFF];
468
+ }
469
+
470
+ // Clamp a level value to 0-254
471
+ function clampLevel(v) {
472
+ if (typeof v === 'number') return Math.max(0, Math.min(254, Math.round(v)));
473
+ return 0;
474
+ }
475
+
476
+ // Convert percentage 0-100 to raw 0-254
477
+ function pctToRaw(pct) {
478
+ return clampLevel(Math.round((pct / 100) * 254));
479
+ }
480
+
481
+ const t = timer24(dur);
482
+ let packet = null;
483
+
484
+ switch (cmd) {
485
+ case 'set': {
486
+ // Accept level (raw 0-254) or percent (0-100)
487
+ let raw;
488
+ if (typeof inp.level === 'number') {
489
+ raw = clampLevel(inp.level);
490
+ } else if (typeof inp.percent === 'number') {
491
+ raw = pctToRaw(inp.percent);
492
+ } else {
493
+ node.warn('velbus-dimmer-20: set command requires level (0-254) or percent (0-100)');
494
+ return;
495
+ }
496
+ packet = pkt(0xF8, addr, [0x07, ch, raw, fadeMode]);
497
+ break;
498
+ }
499
+ case 'on': {
500
+ // Restore to last non-zero level — send 0xFA level request (module restores)
501
+ // Or if a level/percent is provided, use that
502
+ if (inp.level !== undefined || inp.percent !== undefined) {
503
+ const raw = inp.level !== undefined ? clampLevel(inp.level) : pctToRaw(inp.percent);
504
+ packet = pkt(0xF8, addr, [0x07, ch, raw, fadeMode]);
505
+ } else {
506
+ packet = pkt(0xF8, addr, [0x11, ch]); // restore last level
507
+ }
508
+ break;
509
+ }
510
+ case 'off':
511
+ packet = pkt(0xF8, addr, [0x07, ch, 0x00, fadeMode]);
512
+ break;
513
+ case 'restore':
514
+ packet = pkt(0xF8, addr, [0x11, ch]);
515
+ break;
516
+ case 'timer':
517
+ packet = pkt(0xF8, addr, [0x08, ch, ...t]);
518
+ break;
519
+ case 'forced_on':
520
+ packet = pkt(0xF8, addr, [0x14, ch, ...t]);
521
+ break;
522
+ case 'forced_off':
523
+ packet = pkt(0xF8, addr, [0x12, ch, ...t]);
524
+ break;
525
+ case 'cancel_forced_on':
526
+ packet = pkt(0xF8, addr, [0x15, ch]);
527
+ break;
528
+ case 'cancel_forced_off':
529
+ packet = pkt(0xF8, addr, [0x13, ch]);
530
+ break;
531
+ case 'inhibit':
532
+ packet = pkt(0xF8, addr, [0x16, ch, ...t]);
533
+ break;
534
+ case 'cancel_inhibit':
535
+ packet = pkt(0xF8, addr, [0x17, ch]);
536
+ break;
537
+ case 'status':
538
+ packet = pkt(0xFB, node.moduleAddr, [0xFA, 0x00]);
539
+ break;
540
+ case 'get_device_type':
541
+ // Settings API read — reports actual grouping mode on output 1,
542
+ // warns on output 2 if it disagrees with configured ledMode.
543
+ // Read-only: never writes. See 0xE8 handler above.
544
+ packet = pkt(0xF8, addr, [0xE7, ch, 0x00, 25]);
545
+ break;
546
+ case 'scene': {
547
+ // Scenes 0-15
548
+ const scene = typeof inp.scene === 'number' ? Math.max(0, Math.min(15, inp.scene)) : 0;
549
+ packet = pkt(0xF8, addr, [0x1D, ch, scene]);
550
+ break;
551
+ }
552
+ case 'rgbw': {
553
+ // RGBW colour command (0x1E) — VMB4LEDPWM-20 in 'rgb' or 'rgbw' mode only
554
+ // Requires ledMode to be set correctly in node config.
555
+ if (!LEDPWM_TYPE_IDS.has(_moduleInfo && _moduleInfo.typeId)) {
556
+ node.warn('velbus-dimmer-20: rgbw command is only valid for VMB4LEDPWM-20');
557
+ return;
558
+ }
559
+ if (node.ledMode === 'single') {
560
+ node.warn('velbus-dimmer-20: rgbw command sent but ledMode is set to "single".' +
561
+ ' Update ledMode in node config to "rgb" or "rgbw" to enable colour commands.');
562
+ return;
563
+ }
564
+ // R, G, B, W: accept 0-254 (raw) or 0-100 (percent via inp.percent object)
565
+ function toRaw(v) { return Math.max(0, Math.min(254, Math.round(v))); }
566
+ const r = typeof inp.r === 'number' ? toRaw(inp.r) : 0;
567
+ const g = typeof inp.g === 'number' ? toRaw(inp.g) : 0;
568
+ const b = typeof inp.b === 'number' ? toRaw(inp.b) : 0;
569
+ const w = typeof inp.w === 'number' ? toRaw(inp.w) : 0;
570
+ const group = typeof inp.group === 'number' ? inp.group : 0xFF; // 0xFF = all groups
571
+ const fm = typeof inp.fadeMode === 'number' ? inp.fadeMode : 0;
572
+ packet = pkt(0xF8, addr, [0x1E, group, r, g, b, w, fm]);
573
+ break;
574
+ }
575
+ default:
576
+ node.warn('velbus-dimmer-20: unknown command: ' + cmd);
577
+ return;
578
+ }
579
+
580
+ if (packet) node.bridge.send(packet);
581
+ });
582
+
583
+ // ── Startup ───────────────────────────────────────────────────────────
584
+
585
+ node.bridge.register(node.moduleAddr, onPacket);
586
+
587
+ setTimeout(() => {
588
+ node.bridge.send(rtrPkt(node.moduleAddr), true);
589
+ }, 500);
590
+
591
+ setStatus('connecting…', 'grey');
592
+
593
+ // ── Cleanup ───────────────────────────────────────────────────────────
594
+
595
+ node.on('close', function() {
596
+ if (_nameTimer) clearTimeout(_nameTimer);
597
+ node.bridge.deregister(node.moduleAddr, onPacket);
598
+ });
599
+ }
600
+
601
+ RED.nodes.registerType('velbus-dimmer-20', VelbusDimmer20Node);
602
+ };