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,492 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { pkt, parsePkt } = require('../../lib/velbus-utils');
|
|
4
|
+
const { GLASS_PANEL_TYPES, GLASS_PANEL_TYPE_IDS } = require('../../lib/glass-panel-types');
|
|
5
|
+
|
|
6
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
// Thermostat mode constants
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
const THERM_MODE_CMD = {
|
|
10
|
+
comfort: 0xDB,
|
|
11
|
+
day: 0xDC,
|
|
12
|
+
night: 0xDD,
|
|
13
|
+
safe: 0xDE,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const THERM_MODE_LABEL = {
|
|
17
|
+
0: 'comfort',
|
|
18
|
+
1: 'day',
|
|
19
|
+
2: 'night',
|
|
20
|
+
3: 'safe',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const THERM_STATUS_LABEL = {
|
|
24
|
+
0: 'disabled',
|
|
25
|
+
1: 'manual',
|
|
26
|
+
2: 'timer',
|
|
27
|
+
3: 'timer_override',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
// LED command bytes
|
|
32
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
const LED_CMD = {
|
|
34
|
+
led_clear: 0xF5,
|
|
35
|
+
led_set: 0xF6,
|
|
36
|
+
led_slow: 0xF7,
|
|
37
|
+
led_fast: 0xF8,
|
|
38
|
+
led_vfast: 0xF9,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
// Channel bitmask helpers
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
function bitsToChannels(byte1, byte2, byte3, byte4) {
|
|
45
|
+
// Returns array of 1-indexed channel numbers where bit is set
|
|
46
|
+
const channels = [];
|
|
47
|
+
const bytes = [byte1 || 0, byte2 || 0, byte3 || 0, byte4 || 0];
|
|
48
|
+
for (let b = 0; b < 4; b++) {
|
|
49
|
+
for (let bit = 0; bit < 8; bit++) {
|
|
50
|
+
if (bytes[b] & (1 << bit)) channels.push(b * 8 + bit + 1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return channels;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function channelToBit(ch) {
|
|
57
|
+
// Returns { byteIndex, mask } for a 1-indexed channel number
|
|
58
|
+
const zero = ch - 1;
|
|
59
|
+
return { byteIndex: Math.floor(zero / 8), mask: 1 << (zero % 8) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
// Node
|
|
64
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
module.exports = function(RED) {
|
|
66
|
+
|
|
67
|
+
function VelbusGlassPanelNode(config) {
|
|
68
|
+
RED.nodes.createNode(this, config);
|
|
69
|
+
const node = this;
|
|
70
|
+
|
|
71
|
+
node.bridge = RED.nodes.getNode(config.bridge);
|
|
72
|
+
node.address = typeof config.address === 'number'
|
|
73
|
+
? config.address // legacy saves stored decimal numbers
|
|
74
|
+
: (parseInt(config.address, 16) || 0); // editor stores hex strings
|
|
75
|
+
node.channelCount = parseInt(config.channelCount) || 4;
|
|
76
|
+
node.moduleName = config.moduleName || '';
|
|
77
|
+
node.typeId = config.typeId ? parseInt(config.typeId) : null;
|
|
78
|
+
|
|
79
|
+
if (!node.bridge) {
|
|
80
|
+
node.status({ fill: 'red', shape: 'ring', text: 'no bridge' });
|
|
81
|
+
node.error('velbus-glass-panel: no bridge configured');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!node.address || node.address < 1 || node.address > 254) {
|
|
85
|
+
node.status({ fill: 'red', shape: 'ring', text: 'invalid address' });
|
|
86
|
+
node.error('velbus-glass-panel: invalid address ' + node.address);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Resolved type descriptor — may be null if typeId unknown or not set
|
|
91
|
+
const typeDesc = (node.typeId !== null) ? (GLASS_PANEL_TYPES[node.typeId] || null) : null;
|
|
92
|
+
const hasOled = typeDesc ? typeDesc.hasOled : false;
|
|
93
|
+
const hasPir = typeDesc ? typeDesc.hasPir : false;
|
|
94
|
+
const hasOc = typeDesc ? !!typeDesc.hasOc : false;
|
|
95
|
+
const pirCh = typeDesc ? (typeDesc.pirChannels || {}) : {};
|
|
96
|
+
|
|
97
|
+
// Subaddresses populated from scan data (thermostat sub-address etc.)
|
|
98
|
+
// Key: subaddress decimal, value: role string
|
|
99
|
+
const _subAddresses = {};
|
|
100
|
+
|
|
101
|
+
// Name retrieval state
|
|
102
|
+
let _nameParts = {};
|
|
103
|
+
let _nameTimer = null;
|
|
104
|
+
|
|
105
|
+
function assembleName() {
|
|
106
|
+
if (_nameTimer) { clearTimeout(_nameTimer); _nameTimer = null; }
|
|
107
|
+
const bytes = [
|
|
108
|
+
...(_nameParts[0] || []),
|
|
109
|
+
...(_nameParts[1] || []),
|
|
110
|
+
...(_nameParts[2] || []),
|
|
111
|
+
].filter(b => b !== 0 && b !== 0xFF);
|
|
112
|
+
const name = String.fromCharCode(...bytes).trim();
|
|
113
|
+
if (name) node.moduleName = name;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Friendly label for status bar
|
|
117
|
+
const _label = node.moduleName
|
|
118
|
+
? node.moduleName + ' (0x' + node.address.toString(16).padStart(2, '0').toUpperCase() + ')'
|
|
119
|
+
: '0x' + node.address.toString(16).padStart(2, '0').toUpperCase();
|
|
120
|
+
|
|
121
|
+
function setStatus(text, fill, shape) {
|
|
122
|
+
node.status({ fill: fill || 'green', shape: shape || 'dot', text: _label + ' ' + text });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Packet receive handler ───────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
function onPacket(raw) {
|
|
128
|
+
const p = parsePkt(raw);
|
|
129
|
+
if (!p || p.rtr) return;
|
|
130
|
+
|
|
131
|
+
const { addr, cmd, body } = p;
|
|
132
|
+
|
|
133
|
+
// Accept packets from primary address and any registered sub-addresses
|
|
134
|
+
const isMe = (addr === node.address) ||
|
|
135
|
+
(addr in _subAddresses);
|
|
136
|
+
if (!isMe) return;
|
|
137
|
+
|
|
138
|
+
switch (cmd) {
|
|
139
|
+
|
|
140
|
+
// ── 0x00 Button press / release ──────────────────────────────────
|
|
141
|
+
case 0x00: {
|
|
142
|
+
if (body.length < 3) return;
|
|
143
|
+
// body[0]=0x00 cmd, body[1]=DB2 pressed, body[2]=DB3 released, body[3]=DB4 long
|
|
144
|
+
const pressed = bitsToChannels(body[1]);
|
|
145
|
+
const released = bitsToChannels(body[2]);
|
|
146
|
+
const longPressed = body.length >= 4 ? bitsToChannels(body[3]) : [];
|
|
147
|
+
|
|
148
|
+
// Map PIR channel numbers to semantic labels when known
|
|
149
|
+
function mapPir(chs) {
|
|
150
|
+
if (!hasPir || !Object.keys(pirCh).length) return chs;
|
|
151
|
+
return chs.map(ch => pirCh[ch] !== undefined
|
|
152
|
+
? { channel: ch, label: pirCh[ch] }
|
|
153
|
+
: { channel: ch });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const on = pressed.length > 0 || longPressed.length > 0;
|
|
157
|
+
|
|
158
|
+
const payload = {
|
|
159
|
+
type: 'button',
|
|
160
|
+
on,
|
|
161
|
+
pressed: hasPir ? mapPir(pressed) : pressed,
|
|
162
|
+
released: hasPir ? mapPir(released) : released,
|
|
163
|
+
longPressed: hasPir ? mapPir(longPressed) : longPressed,
|
|
164
|
+
};
|
|
165
|
+
setStatus('button');
|
|
166
|
+
node.send([{ payload }, null, null]);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── 0xED Module status ────────────────────────────────────────────
|
|
171
|
+
case 0xED: {
|
|
172
|
+
if (body.length < 8) return;
|
|
173
|
+
|
|
174
|
+
// body[0] = 0xED (command byte — skip)
|
|
175
|
+
// body[1] = DB2: channel 1-8 status (pressed/released) — not used here
|
|
176
|
+
// body[2] = DB3: enabled/disabled channel status — not used here
|
|
177
|
+
// body[3] = DB4: OC output + sensor flags
|
|
178
|
+
// body[4] = DB5: locked channel bitmask
|
|
179
|
+
// body[5] = DB6: disabled channel program bitmask
|
|
180
|
+
// body[6] = DB7: alarm & program selection
|
|
181
|
+
// body[7] = DB8: OLED page number (OLED types only)
|
|
182
|
+
|
|
183
|
+
const ocByte = body[3];
|
|
184
|
+
const ocOn = !!(ocByte & 0x80);
|
|
185
|
+
const ocLocked = !!(ocByte & 0x40);
|
|
186
|
+
const ocProgramDisabled = !!(ocByte & 0x20);
|
|
187
|
+
|
|
188
|
+
const lockedChannels = bitsToChannels(body[4]);
|
|
189
|
+
const progDisabledChannels = bitsToChannels(body[5]);
|
|
190
|
+
|
|
191
|
+
const progByte = body[6];
|
|
192
|
+
const thermostatProgram = THERM_STATUS_LABEL[progByte & 0x03] || 'unknown';
|
|
193
|
+
const alarm1Active = !!(progByte & 0x04);
|
|
194
|
+
const alarm2Active = !!(progByte & 0x08);
|
|
195
|
+
|
|
196
|
+
const payload = {
|
|
197
|
+
type: 'status',
|
|
198
|
+
locked: lockedChannels,
|
|
199
|
+
thermostatProgram,
|
|
200
|
+
alarms: {
|
|
201
|
+
alarm1Active,
|
|
202
|
+
alarm2Active,
|
|
203
|
+
selected: progByte >> 6,
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Open collector state — only emit if module is known to have OC
|
|
208
|
+
if (hasOc) {
|
|
209
|
+
payload.output = {
|
|
210
|
+
on: ocOn,
|
|
211
|
+
locked: ocLocked,
|
|
212
|
+
programDisabled: ocProgramDisabled,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// OLED types: body[7] = DB8 = display page number
|
|
217
|
+
if (hasOled && body.length >= 8) {
|
|
218
|
+
payload.oledPage = body[7];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
setStatus('status');
|
|
222
|
+
node.send([null, { payload }, null]);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── 0xEA Thermostat status ────────────────────────────────────────
|
|
227
|
+
case 0xEA: {
|
|
228
|
+
if (body.length < 8) return;
|
|
229
|
+
// body[0]=0xEA cmd
|
|
230
|
+
// body[1]=DB2 operating mode byte
|
|
231
|
+
// body[2]=DB3 program step mode byte
|
|
232
|
+
// body[3]=DB4 output status byte
|
|
233
|
+
// body[4]=DB5 current sensor temp (signed, resolution 0.5°)
|
|
234
|
+
// body[5]=DB6 current temperature set (signed, resolution 0.5°)
|
|
235
|
+
// body[6]=DB7 sleep timer high byte
|
|
236
|
+
// body[7]=DB8 sleep timer low byte
|
|
237
|
+
|
|
238
|
+
const opMode = body[1];
|
|
239
|
+
// Mode bits 4-6: comfort=0x40, day=0x20, night=0x10, safe=0x00
|
|
240
|
+
const modeBits = (opMode >> 4) & 0x07;
|
|
241
|
+
const mode = modeBits === 4 ? 'comfort'
|
|
242
|
+
: modeBits === 2 ? 'day'
|
|
243
|
+
: modeBits === 1 ? 'night'
|
|
244
|
+
: 'safe';
|
|
245
|
+
const heaterMode = !(opMode & 0x80); // bit7: 0=heater, 1=cooler
|
|
246
|
+
const runModeBits = opMode & 0x06;
|
|
247
|
+
const thermostatOn = runModeBits !== 0x06; // 0x06 = disabled
|
|
248
|
+
|
|
249
|
+
const outByte = body[3]; // DB4 output status
|
|
250
|
+
const heating = !!(outByte & 0x01); // heater on
|
|
251
|
+
const boostMode = !!(outByte & 0x02); // boost on
|
|
252
|
+
const cooling = !!(outByte & 0x08); // cooler on
|
|
253
|
+
|
|
254
|
+
// Signed 0.5° resolution: raw byte is two's complement
|
|
255
|
+
const rawCurrent = body[4] > 127 ? body[4] - 256 : body[4];
|
|
256
|
+
const rawTarget = body[5] > 127 ? body[5] - 256 : body[5];
|
|
257
|
+
|
|
258
|
+
const payload = {
|
|
259
|
+
type: 'thermostat',
|
|
260
|
+
currentTemp: rawCurrent * 0.5,
|
|
261
|
+
targetTemp: rawTarget * 0.5,
|
|
262
|
+
mode,
|
|
263
|
+
heaterMode,
|
|
264
|
+
heating,
|
|
265
|
+
cooling,
|
|
266
|
+
boostMode,
|
|
267
|
+
thermostatOn,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
setStatus('therm ' + currentTemp.toFixed(1) + '°C → ' + targetTemp.toFixed(1) + '°C');
|
|
271
|
+
node.send([null, { payload }, null]);
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── 0xE6 Current temperature ─────────────────────────────────────
|
|
276
|
+
case 0xE6: {
|
|
277
|
+
if (body.length < 3) return;
|
|
278
|
+
// body[0]=0xE6 cmd, body[1-2]=DB2-3 current temp hi/lo (0.0625° resolution)
|
|
279
|
+
// body[3-4]=DB4-5 min temp, body[5-6]=DB6-7 max temp (optional)
|
|
280
|
+
// 16-bit signed, °C × 512 (0.0625° resolution, low 5 bits unused)
|
|
281
|
+
const t16 = (hi, lo) => { const r = (hi << 8) | lo; return (r > 32767 ? r - 65536 : r) / 512; };
|
|
282
|
+
const current = t16(body[1], body[2]);
|
|
283
|
+
const min = body.length >= 5 ? t16(body[3], body[4]) : null;
|
|
284
|
+
const max = body.length >= 7 ? t16(body[5], body[6]) : null;
|
|
285
|
+
|
|
286
|
+
const payload = { type: 'temperature', current };
|
|
287
|
+
if (min !== null) payload.min = min;
|
|
288
|
+
if (max !== null) payload.max = max;
|
|
289
|
+
|
|
290
|
+
node.send([null, { payload }, null]);
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── 0xA9 Raw light sensor value (PIR types only) ─────────────────
|
|
295
|
+
case 0xA9: {
|
|
296
|
+
if (!hasPir) return;
|
|
297
|
+
if (body.length < 3) return;
|
|
298
|
+
// body[0]=0xA9 cmd, body[1-2]=DB2-3 16-bit light value
|
|
299
|
+
const raw = (body[1] << 8) | body[2];
|
|
300
|
+
const payload = { type: 'light', raw };
|
|
301
|
+
node.send([null, { payload }, null]);
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── 0xAC Memo text (OLED types only) ────────────────────────────
|
|
306
|
+
case 0xAC: {
|
|
307
|
+
if (!hasOled) return;
|
|
308
|
+
// body[0]=0xAC cmd, body[1]=DB2 page, body[2..]=DB3.. ASCII null-terminated
|
|
309
|
+
const page = body[1];
|
|
310
|
+
let text = '';
|
|
311
|
+
for (let i = 2; i < body.length; i++) {
|
|
312
|
+
if (body[i] === 0) break;
|
|
313
|
+
text += String.fromCharCode(body[i]);
|
|
314
|
+
}
|
|
315
|
+
const payload = { type: 'memo', page, text };
|
|
316
|
+
node.send([null, { payload }, null]);
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── 0xBE Counter status (OLED types only) ───────────────────────
|
|
321
|
+
case 0xBE: {
|
|
322
|
+
if (!hasOled) return;
|
|
323
|
+
if (body.length < 6) return;
|
|
324
|
+
// body[0]=0xBE cmd, body[1]=DB2 counter, body[2-5]=DB3-6 32-bit value
|
|
325
|
+
const counter = body[1];
|
|
326
|
+
const value = (body[2] << 24) | (body[3] << 16) | (body[4] << 8) | body[5];
|
|
327
|
+
const payload = { type: 'counter', counter, value };
|
|
328
|
+
node.send([null, { payload }, null]);
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── 0xF0/F1/F2 Name part response ───────────────────────────────
|
|
333
|
+
case 0xF0:
|
|
334
|
+
case 0xF1:
|
|
335
|
+
case 0xF2: {
|
|
336
|
+
// body[0]=cmd (0xF0/F1/F2), body[1]=DB2 channel number, body[2..]=DB3.. chars
|
|
337
|
+
const part = cmd - 0xF0; // 0, 1, 2
|
|
338
|
+
_nameParts[part] = Array.from(body).slice(2); // strip cmd + channel bytes
|
|
339
|
+
let text = '';
|
|
340
|
+
for (let i = 2; i < body.length; i++) {
|
|
341
|
+
if (body[i] === 0) break;
|
|
342
|
+
text += String.fromCharCode(body[i]);
|
|
343
|
+
}
|
|
344
|
+
if (part === 2) assembleName();
|
|
345
|
+
node.send([null, null, { payload: { type: 'name_part', part, text } }]);
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
default:
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
node.bridge.register(node.address, onPacket);
|
|
355
|
+
|
|
356
|
+
// Auto-retrieve module name on startup (2s timeout)
|
|
357
|
+
setTimeout(function() {
|
|
358
|
+
node.bridge.send(pkt(0xF8, node.address, [0xEF]));
|
|
359
|
+
_nameTimer = setTimeout(assembleName, 2000);
|
|
360
|
+
}, 500);
|
|
361
|
+
|
|
362
|
+
// Register sub-address listener if scan data provides them
|
|
363
|
+
// (bridge passive 0xB0 handler already captures these; we read from
|
|
364
|
+
// scan results stored on bridge if available)
|
|
365
|
+
if (node.bridge.getScanResults) {
|
|
366
|
+
const results = node.bridge.getScanResults();
|
|
367
|
+
if (results) {
|
|
368
|
+
const me = results.find(m => m.addressDec === node.address);
|
|
369
|
+
if (me && me.subaddresses) {
|
|
370
|
+
me.subaddresses.forEach(sa => {
|
|
371
|
+
const dec = parseInt(sa, 16);
|
|
372
|
+
if (dec && dec !== node.address) {
|
|
373
|
+
_subAddresses[dec] = 'sub';
|
|
374
|
+
node.bridge.register(dec, onPacket);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ── Input command handler ────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
node.on('input', function(msg) {
|
|
384
|
+
const cmd = msg.payload && msg.payload.cmd;
|
|
385
|
+
if (!cmd) return;
|
|
386
|
+
|
|
387
|
+
// ── Thermostat mode switch ─────────────────────────────────────────
|
|
388
|
+
if (THERM_MODE_CMD[cmd] !== undefined) {
|
|
389
|
+
// sleepTime: 0 = until next programme step, >0 = minutes
|
|
390
|
+
const sleep = (msg.payload.sleepTime !== undefined)
|
|
391
|
+
? parseInt(msg.payload.sleepTime) : 0;
|
|
392
|
+
const sleepHi = (sleep >> 8) & 0xFF;
|
|
393
|
+
const sleepLo = sleep & 0xFF;
|
|
394
|
+
const out = pkt(0xF8, node.address, [THERM_MODE_CMD[cmd], sleepHi, sleepLo]);
|
|
395
|
+
node.bridge.send(out);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── Set target temperature ─────────────────────────────────────────
|
|
400
|
+
if (cmd === 'set_temp') {
|
|
401
|
+
const pointer = (msg.payload.pointer !== undefined)
|
|
402
|
+
? parseInt(msg.payload.pointer) : 0; // 0=comfort,1=day,2=night,3=safe
|
|
403
|
+
const tempRaw = Math.round((parseFloat(msg.payload.temp) || 0) * 100);
|
|
404
|
+
const tempHi = (tempRaw >> 8) & 0xFF;
|
|
405
|
+
const tempLo = tempRaw & 0xFF;
|
|
406
|
+
const out = pkt(0xF8, node.address, [0xE4, pointer, tempHi, tempLo]);
|
|
407
|
+
node.bridge.send(out);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── LED control ────────────────────────────────────────────────────
|
|
412
|
+
if (LED_CMD[cmd] !== undefined) {
|
|
413
|
+
const ch = parseInt(msg.payload.channel) || 1;
|
|
414
|
+
const { byteIndex, mask } = channelToBit(ch);
|
|
415
|
+
const bytes = [0, 0, 0, 0];
|
|
416
|
+
bytes[byteIndex] = mask;
|
|
417
|
+
const out = pkt(0xF8, node.address, [LED_CMD[cmd], ...bytes]);
|
|
418
|
+
node.bridge.send(out);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ── Open collector output ──────────────────────────────────────────
|
|
423
|
+
if (cmd === 'output_on') {
|
|
424
|
+
// COMMAND_SWITCH_RELAY_ON (0x02), channel byte = don't care (0xFF)
|
|
425
|
+
node.bridge.send(pkt(0xF8, node.address, [0x02, 0xFF]));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (cmd === 'output_off') {
|
|
429
|
+
// COMMAND_SWITCH_RELAY_OFF (0x01), channel byte = don't care (0xFF)
|
|
430
|
+
node.bridge.send(pkt(0xF8, node.address, [0x01, 0xFF]));
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (cmd === 'output_timer') {
|
|
434
|
+
// COMMAND_START_RELAY_TIMER (0x03)
|
|
435
|
+
// 24-bit duration in seconds: [channel, hiB, midB, loB]
|
|
436
|
+
const duration = parseInt(msg.payload.duration) || 0;
|
|
437
|
+
const hiB = (duration >> 16) & 0xFF;
|
|
438
|
+
const midB = (duration >> 8) & 0xFF;
|
|
439
|
+
const loB = duration & 0xFF;
|
|
440
|
+
node.bridge.send(pkt(0xF8, node.address, [0x03, 0xFF, hiB, midB, loB]));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── Request module status ──────────────────────────────────────────
|
|
445
|
+
if (cmd === 'get_status') {
|
|
446
|
+
const out = pkt(0xFB, node.address, [0xFA]);
|
|
447
|
+
node.bridge.send(out);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── Request thermostat status ──────────────────────────────────────
|
|
452
|
+
if (cmd === 'get_thermostat') {
|
|
453
|
+
const out = pkt(0xF8, node.address, [0xE7]);
|
|
454
|
+
node.bridge.send(out);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── Request module name ────────────────────────────────────────────
|
|
459
|
+
if (cmd === 'get_name') {
|
|
460
|
+
const out = pkt(0xF8, node.address, [0xEF]);
|
|
461
|
+
node.bridge.send(out);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── Heat / cool mode ───────────────────────────────────────────────
|
|
466
|
+
if (cmd === 'heat_mode') {
|
|
467
|
+
node.bridge.send(pkt(0xF8, node.address, [0xE0]));
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (cmd === 'cool_mode') {
|
|
471
|
+
node.bridge.send(pkt(0xF8, node.address, [0xDF]));
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
node.warn('velbus-glass-panel: unknown cmd: ' + cmd);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// ── Cleanup ──────────────────────────────────────────────────────────
|
|
479
|
+
|
|
480
|
+
node.on('close', function() {
|
|
481
|
+
if (_nameTimer) { clearTimeout(_nameTimer); _nameTimer = null; }
|
|
482
|
+
node.bridge.deregister(node.address, onPacket);
|
|
483
|
+
Object.keys(_subAddresses).forEach(sa => {
|
|
484
|
+
node.bridge.deregister(parseInt(sa), onPacket);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
setStatus('ready', 'grey', 'dot');
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
RED.nodes.registerType('velbus-glass-panel', VelbusGlassPanelNode);
|
|
492
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
(function() {
|
|
3
|
+
|
|
4
|
+
RED.nodes.registerType('velbus-meteo', {
|
|
5
|
+
category: 'Velbus (inputs)',
|
|
6
|
+
color: '#3A8C8C',
|
|
7
|
+
defaults: {
|
|
8
|
+
name: { value: '' },
|
|
9
|
+
bridge: { value: '', type: 'velbus-bridge', required: true },
|
|
10
|
+
address: { value: '', required: true, validate: function(v) { return /^(0x)?[0-9a-fA-F]{1,2}$/.test(v); } },
|
|
11
|
+
},
|
|
12
|
+
inputs: 1,
|
|
13
|
+
outputs: 2,
|
|
14
|
+
outputLabels: ['alarms / status', 'temperature / rain / light / wind'],
|
|
15
|
+
icon: 'font-awesome/fa-cloud',
|
|
16
|
+
paletteLabel: 'meteo',
|
|
17
|
+
|
|
18
|
+
label: function() {
|
|
19
|
+
const addr = parseInt(this.address) || 0;
|
|
20
|
+
return (this.name || 'VMBMETEO') + ' 0x' +
|
|
21
|
+
addr.toString(16).padStart(2,'0').toUpperCase();
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
oneditprepare: function() {
|
|
25
|
+
const savedAddr = this.address;
|
|
26
|
+
|
|
27
|
+
function populateDropdown(bridgeId) {
|
|
28
|
+
if (!bridgeId) return;
|
|
29
|
+
$.getJSON('velbus/scan-results?bridge=' + bridgeId, function(results) {
|
|
30
|
+
const modules = (results && results.modules) ? results.modules : [];
|
|
31
|
+
const $addr = $('#node-input-address');
|
|
32
|
+
$addr.empty().append($('<option>').val('').text('— select address —'));
|
|
33
|
+
|
|
34
|
+
const meteoModules = modules.filter(m => parseInt(m.typeId, 16) === 0x31);
|
|
35
|
+
if (!meteoModules.length) {
|
|
36
|
+
$addr.append($('<option>').val('').text('No VMBMETEO found on bus'));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
meteoModules.forEach(function(m) {
|
|
40
|
+
const label = m.address + ' — ' + m.module;
|
|
41
|
+
const $opt = $('<option>').val(m.addressDec).text(label);
|
|
42
|
+
if (m.addressDec === parseInt(savedAddr)) $opt.attr('selected', true);
|
|
43
|
+
$addr.append($opt);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
$('#node-input-bridge').on('change', function() { populateDropdown($(this).val()); });
|
|
49
|
+
// Deferred load — TypedInput sets value async after oneditprepare
|
|
50
|
+
|
|
51
|
+
$('#node-input-bridge').trigger('change');
|
|
52
|
+
|
|
53
|
+
setTimeout(function() { $('#node-input-bridge').trigger('change'); }, 150);
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
oneditsave: function() {
|
|
57
|
+
this.address = parseInt($('#node-input-address').val()) || 0;
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
})();
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<script type="text/html" data-template-name="velbus-meteo">
|
|
65
|
+
<div class="form-row">
|
|
66
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
67
|
+
<input type="text" id="node-input-name" placeholder="Optional label">
|
|
68
|
+
</div>
|
|
69
|
+
<div class="form-row">
|
|
70
|
+
<label for="node-input-bridge"><i class="fa fa-plug"></i> Bridge</label>
|
|
71
|
+
<input type="text" id="node-input-bridge">
|
|
72
|
+
</div>
|
|
73
|
+
<div class="form-row">
|
|
74
|
+
<label for="node-input-address"><i class="fa fa-map-marker"></i> Address</label>
|
|
75
|
+
<select id="node-input-address" style="width:70%">
|
|
76
|
+
<option value="">— select bridge first —</option>
|
|
77
|
+
</select>
|
|
78
|
+
</div>
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
<script type="text/html" data-help-name="velbus-meteo">
|
|
82
|
+
<p style="background:#fff3cd; border-left:4px solid #ffc107; padding:8px 12px; margin-bottom:12px; font-size:0.85em;">
|
|
83
|
+
<strong>⚠ Testing status:</strong> This node was generated with Claude.ai and is in
|
|
84
|
+
need of extensive field testing before being deployed into a commercial project.
|
|
85
|
+
It is presented "as is" — any use beyond testing is entirely at your own risk and liability.
|
|
86
|
+
Constructive feedback is welcomed, accompanied by as many examples and debug captures as possible.
|
|
87
|
+
<a href="https://github.com/MDAR/node-red-contrib-velbus-2026/issues" target="_blank">File an issue on GitHub</a>.
|
|
88
|
+
</p>
|
|
89
|
+
<p>Weather station node for the VMBMETEO module (0x31).</p>
|
|
90
|
+
|
|
91
|
+
<h3>Output 1 — Alarms / status</h3>
|
|
92
|
+
<p>Alarm event (0x00) — fires when a configured threshold alarm changes:</p>
|
|
93
|
+
<pre>{ "type": "alarm", "on": true,
|
|
94
|
+
"pressed": ["alarm1","alarm3"], "released": [], "longPressed": [] }</pre>
|
|
95
|
+
<p>Module status (0xED):</p>
|
|
96
|
+
<pre>{ "type": "status", "on": false,
|
|
97
|
+
"alarms": { "alarm1": false, ... "alarm8": false },
|
|
98
|
+
"locked": { "alarm1": false, ... },
|
|
99
|
+
"program": "group1", "autoSendInterval": 30, "testMode": false }</pre>
|
|
100
|
+
|
|
101
|
+
<h3>Output 2 — Sensor values</h3>
|
|
102
|
+
<p>Temperature (0xE6):</p>
|
|
103
|
+
<pre>{ "type": "temperature", "current": 21.5, "min": 5.0, "max": 35.0 }</pre>
|
|
104
|
+
<p>Rain / light / wind (0xA9):</p>
|
|
105
|
+
<pre>{ "type": "meteo", "rain": 0.5, "light": 12500, "wind": 14.3,
|
|
106
|
+
"raw": { "rain": 5, "light": 12500, "wind": 143 } }</pre>
|
|
107
|
+
<p>Units: rain mm/h, light lux, wind km/h.</p>
|
|
108
|
+
<p>Sensor text (0xAC):</p>
|
|
109
|
+
<pre>{ "type": "meteo_text", "sensor": "wind", "startPos": 0, "text": "14.3 km/h" }</pre>
|
|
110
|
+
|
|
111
|
+
<h3>Input commands</h3>
|
|
112
|
+
<dl>
|
|
113
|
+
<dt><code>{ "cmd": "get_status" }</code></dt><dd>Request module status.</dd>
|
|
114
|
+
<dt><code>{ "cmd": "get_temp", "interval": 30 }</code></dt><dd>Request temperature. interval=0 no change.</dd>
|
|
115
|
+
<dt><code>{ "cmd": "get_meteo", "sensor": "all", "interval": 30 }</code></dt>
|
|
116
|
+
<dd>Request sensor values. sensor: "rain", "light", "wind", or "all".</dd>
|
|
117
|
+
<dt><code>{ "cmd": "get_alarm_name", "channel": 1 }</code></dt><dd>Request alarm channel name from VelbusLink.</dd>
|
|
118
|
+
<dt><code>{ "cmd": "test_on" }</code> / <code>{ "cmd": "test_off" }</code></dt><dd>Test mode (30 minute timeout).</dd>
|
|
119
|
+
</dl>
|
|
120
|
+
</script>
|