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.
- package/CHANGELOG_FORUM.md +784 -0
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/examples/velbus-basic-relay-dimmer.json +328 -0
- package/index.js +3 -0
- package/lib/blind-types-20.js +26 -0
- package/lib/blind-types-s.js +46 -0
- package/lib/blind-types.js +38 -0
- package/lib/dimmer-types-20.js +120 -0
- package/lib/dimmer-types.js +34 -0
- package/lib/glass-panel-types.js +296 -0
- package/lib/pir-types-20.js +51 -0
- package/lib/pir-types.js +61 -0
- package/lib/relay-types-20.js +43 -0
- package/lib/relay-types.js +39 -0
- package/lib/sensor-types-20.js +29 -0
- package/lib/sensor-types.js +49 -0
- package/lib/velbus-utils.js +80 -0
- package/nodes/velbus-blind/velbus-blind.html +165 -0
- package/nodes/velbus-blind/velbus-blind.js +251 -0
- package/nodes/velbus-blind-20/velbus-blind-20.html +179 -0
- package/nodes/velbus-blind-20/velbus-blind-20.js +300 -0
- package/nodes/velbus-blind-s/velbus-blind-s.html +181 -0
- package/nodes/velbus-blind-s/velbus-blind-s.js +282 -0
- package/nodes/velbus-bridge/velbus-bridge.html +72 -0
- package/nodes/velbus-bridge/velbus-bridge.js +304 -0
- package/nodes/velbus-button/velbus-button.html +172 -0
- package/nodes/velbus-button/velbus-button.js +117 -0
- package/nodes/velbus-clock/velbus-clock.html +198 -0
- package/nodes/velbus-clock/velbus-clock.js +273 -0
- package/nodes/velbus-dimmer/velbus-dimmer.html +174 -0
- package/nodes/velbus-dimmer/velbus-dimmer.js +457 -0
- package/nodes/velbus-dimmer-20/velbus-dimmer-20.html +271 -0
- package/nodes/velbus-dimmer-20/velbus-dimmer-20.js +602 -0
- package/nodes/velbus-glass-panel/velbus-glass-panel.html +337 -0
- package/nodes/velbus-glass-panel/velbus-glass-panel.js +492 -0
- package/nodes/velbus-meteo/velbus-meteo.html +120 -0
- package/nodes/velbus-meteo/velbus-meteo.js +279 -0
- package/nodes/velbus-pir/velbus-pir.html +187 -0
- package/nodes/velbus-pir/velbus-pir.js +269 -0
- package/nodes/velbus-pir-20/velbus-pir-20.html +186 -0
- package/nodes/velbus-pir-20/velbus-pir-20.js +352 -0
- package/nodes/velbus-relay/velbus-relay.html +215 -0
- package/nodes/velbus-relay/velbus-relay.js +404 -0
- package/nodes/velbus-relay-20/velbus-relay-20.html +123 -0
- package/nodes/velbus-relay-20/velbus-relay-20.js +434 -0
- package/nodes/velbus-scan/velbus-scan.html +87 -0
- package/nodes/velbus-scan/velbus-scan.js +313 -0
- package/nodes/velbus-sensor/velbus-sensor.html +154 -0
- package/nodes/velbus-sensor/velbus-sensor.js +259 -0
- package/nodes/velbus-sensor-20/velbus-sensor-20.html +158 -0
- package/nodes/velbus-sensor-20/velbus-sensor-20.js +314 -0
- package/nodes/velbus-thermostat/velbus-thermostat.html +191 -0
- package/nodes/velbus-thermostat/velbus-thermostat.js +322 -0
- package/package.json +55 -0
- 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
|
+
};
|