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,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>&#9888; 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" &mdash; 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>