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,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>⚠ 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" — 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 — 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>
|