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,314 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { pkt, rtrPkt, parsePkt } = require('../../lib/velbus-utils');
|
|
4
|
+
const { SENSOR_TYPES_20, SENSOR_TYPE_IDS_20 } = require('../../lib/sensor-types-20');
|
|
5
|
+
|
|
6
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
// Helpers
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
function activeFromBitmask(byte) {
|
|
11
|
+
const active = [];
|
|
12
|
+
for (let i = 0; i < 8; i++) {
|
|
13
|
+
if (byte & (1 << i)) active.push(i + 1);
|
|
14
|
+
}
|
|
15
|
+
return active;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
// Node
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
module.exports = function(RED) {
|
|
23
|
+
|
|
24
|
+
function VelbusSensor20Node(config) {
|
|
25
|
+
RED.nodes.createNode(this, config);
|
|
26
|
+
const node = this;
|
|
27
|
+
|
|
28
|
+
node.bridge = RED.nodes.getNode(config.bridge);
|
|
29
|
+
node.address = typeof config.address === 'number'
|
|
30
|
+
? config.address // legacy saves stored decimal numbers
|
|
31
|
+
: (parseInt(config.address, 16) || 0); // editor stores hex strings
|
|
32
|
+
node.moduleName = config.moduleName || '';
|
|
33
|
+
node.typeId = config.typeId ? parseInt(config.typeId) : null;
|
|
34
|
+
|
|
35
|
+
if (!node.bridge) {
|
|
36
|
+
node.status({ fill: 'red', shape: 'ring', text: 'no bridge' });
|
|
37
|
+
node.error('velbus-sensor-20: no bridge configured');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (!node.address || node.address < 1 || node.address > 254) {
|
|
41
|
+
node.status({ fill: 'red', shape: 'ring', text: 'invalid address' });
|
|
42
|
+
node.error('velbus-sensor-20: invalid address ' + node.address);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const typeDesc = node.typeId !== null ? (SENSOR_TYPES_20[node.typeId] || null) : null;
|
|
47
|
+
|
|
48
|
+
// Name retrieval state
|
|
49
|
+
let _nameParts = {};
|
|
50
|
+
let _nameTimer = null;
|
|
51
|
+
|
|
52
|
+
function assembleName() {
|
|
53
|
+
if (_nameTimer) { clearTimeout(_nameTimer); _nameTimer = null; }
|
|
54
|
+
const bytes = [
|
|
55
|
+
...(_nameParts[0] || []),
|
|
56
|
+
...(_nameParts[1] || []),
|
|
57
|
+
...(_nameParts[2] || []),
|
|
58
|
+
].filter(b => b !== 0 && b !== 0xFF);
|
|
59
|
+
const name = String.fromCharCode(...bytes).trim();
|
|
60
|
+
if (name) node.moduleName = name;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const _addrHex = '0x' + node.address.toString(16).padStart(2, '0').toUpperCase();
|
|
64
|
+
|
|
65
|
+
function setStatus(text, fill, shape) {
|
|
66
|
+
const label = node.moduleName
|
|
67
|
+
? node.moduleName + ' (' + _addrHex + ')'
|
|
68
|
+
: _addrHex;
|
|
69
|
+
node.status({ fill: fill || 'green', shape: shape || 'dot', text: label + ' ' + text });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Firmware check ────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
function handleModuleType(body) {
|
|
75
|
+
if (body.length < 8) return;
|
|
76
|
+
const typeId = body[1];
|
|
77
|
+
const mapVer = body[4];
|
|
78
|
+
const build = body[5] * 100 + body[6];
|
|
79
|
+
const canFD = !!(body[7] & 0x20);
|
|
80
|
+
|
|
81
|
+
const desc = SENSOR_TYPES_20[typeId];
|
|
82
|
+
if (!desc) {
|
|
83
|
+
node.status({ fill: 'red', shape: 'ring',
|
|
84
|
+
text: 'unknown type 0x' + typeId.toString(16) });
|
|
85
|
+
node.error('velbus-sensor-20: unrecognised module type 0x' + typeId.toString(16));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
node.moduleName = node.moduleName || desc.name;
|
|
90
|
+
setStatus('build ' + build + (canFD ? ' CAN FD' : ''), 'grey', 'dot');
|
|
91
|
+
|
|
92
|
+
// Request name for all channels
|
|
93
|
+
setTimeout(() => {
|
|
94
|
+
node.bridge.send(pkt(0xF8, node.address, [0xEF, 0xFF]));
|
|
95
|
+
_nameTimer = setTimeout(assembleName, 2000);
|
|
96
|
+
}, 100);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Packet handler ────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function onPacket(raw) {
|
|
102
|
+
const p = parsePkt(raw);
|
|
103
|
+
if (!p || p.rtr) return;
|
|
104
|
+
// Note: no address re-check here. The bridge routes frames to this
|
|
105
|
+
// listener, including subaddress frames mapped to the primary address
|
|
106
|
+
// (e.g. GPO-20 thermostat status from subaddress 0x34).
|
|
107
|
+
|
|
108
|
+
const { cmd, body } = p;
|
|
109
|
+
|
|
110
|
+
switch (cmd) {
|
|
111
|
+
|
|
112
|
+
// ── 0xFF Module type ─────────────────────────────────────────────
|
|
113
|
+
case 0xFF: {
|
|
114
|
+
handleModuleType(body);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── 0x00 Channel events ──────────────────────────────────────────
|
|
119
|
+
case 0x00: {
|
|
120
|
+
if (body.length < 4) return;
|
|
121
|
+
// body[0]=cmd, body[1]=pressed, body[2]=released, body[3]=longPressed
|
|
122
|
+
// Note: may arrive from primary address OR a subaddress (bridge routes both)
|
|
123
|
+
const pressed = activeFromBitmask(body[1]);
|
|
124
|
+
const released = activeFromBitmask(body[2]);
|
|
125
|
+
const longPressed = activeFromBitmask(body[3]);
|
|
126
|
+
const on = pressed.length > 0 || longPressed.length > 0;
|
|
127
|
+
|
|
128
|
+
// Determine channel offset from source address
|
|
129
|
+
// Primary=ch1-8, sub1=ch9-16, sub2=ch17-24, sub3=ch25-32
|
|
130
|
+
// Bridge delivers all under primary address via subaddress routing
|
|
131
|
+
// We receive the raw packet, so p.addr tells us the source
|
|
132
|
+
// For now emit raw bitmask channels 1-8 — subaddress offsetting
|
|
133
|
+
// requires knowing which subaddress fired (handled via p.addr)
|
|
134
|
+
const srcAddr = p.addr;
|
|
135
|
+
const payload = {
|
|
136
|
+
type: 'channel',
|
|
137
|
+
on,
|
|
138
|
+
pressed,
|
|
139
|
+
released,
|
|
140
|
+
longPressed,
|
|
141
|
+
sourceAddress: '0x' + srcAddr.toString(16).padStart(2,'0').toUpperCase(),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (pressed.length) setStatus('ch' + pressed.join(',') + ' on');
|
|
145
|
+
node.send([{ payload }, null]);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── 0xED Module status (primary — 8 bytes) ───────────────────────
|
|
150
|
+
case 0xED: {
|
|
151
|
+
if (body.length < 8) {
|
|
152
|
+
// Subaddress status — 5 bytes
|
|
153
|
+
if (body.length < 5) return;
|
|
154
|
+
// body[0]=0xED, body[1]=alarm channel status, body[2]=enabled,
|
|
155
|
+
// body[3]=locked, body[4]=prog-disabled
|
|
156
|
+
const payload = {
|
|
157
|
+
type: 'sub_status',
|
|
158
|
+
channels: activeFromBitmask(body[1]),
|
|
159
|
+
enabled: activeFromBitmask(body[2]),
|
|
160
|
+
locked: activeFromBitmask(body[3]),
|
|
161
|
+
progDisabled: activeFromBitmask(body[4]),
|
|
162
|
+
};
|
|
163
|
+
node.send([{ payload }, null]);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Primary address — 8 bytes
|
|
167
|
+
// body[0]=0xED, body[1]=channel status, body[2]=enabled/disabled,
|
|
168
|
+
// body[3]=normal/inverted, body[4]=locked, body[5]=prog-disabled,
|
|
169
|
+
// body[6]=alarm&program, body[7]=auto-send interval
|
|
170
|
+
const progByte = body[6];
|
|
171
|
+
const program = ['none', 'group1', 'group2', 'group3'][progByte & 0x03];
|
|
172
|
+
const alarm1Active = !!(progByte & 0x04);
|
|
173
|
+
const alarm2Active = !!(progByte & 0x10);
|
|
174
|
+
const sunriseEnabled = !!(progByte & 0x40);
|
|
175
|
+
const sunsetEnabled = !!(progByte & 0x80);
|
|
176
|
+
|
|
177
|
+
const on = body[1] !== 0;
|
|
178
|
+
const payload = {
|
|
179
|
+
type: 'status',
|
|
180
|
+
on,
|
|
181
|
+
channels: activeFromBitmask(body[1]),
|
|
182
|
+
enabled: activeFromBitmask(body[2]),
|
|
183
|
+
normal: activeFromBitmask(body[3]),
|
|
184
|
+
locked: activeFromBitmask(body[4]),
|
|
185
|
+
progDisabled: activeFromBitmask(body[5]),
|
|
186
|
+
program,
|
|
187
|
+
alarms: { alarm1Active, alarm2Active },
|
|
188
|
+
sunrise: sunriseEnabled,
|
|
189
|
+
sunset: sunsetEnabled,
|
|
190
|
+
autoSendInterval: body[7],
|
|
191
|
+
};
|
|
192
|
+
node.send([{ payload }, null]);
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── 0xA4 Energy counter value ────────────────────────────────────
|
|
197
|
+
case 0xA4: {
|
|
198
|
+
if (body.length < 8) return;
|
|
199
|
+
// body[0]=0xA4
|
|
200
|
+
// body[1]: bits4-7=channel(0-7), bits0-3=power high nibble
|
|
201
|
+
// body[2-3]: power low bytes → 20-bit total in Watts
|
|
202
|
+
// body[4-7]: 32-bit energy in Wh/litres/ml
|
|
203
|
+
const channel = ((body[1] >> 4) & 0x0F) + 1; // 1-8
|
|
204
|
+
const powerHi = body[1] & 0x0F; // bits 19-16
|
|
205
|
+
const powerW = (powerHi << 16) | (body[2] << 8) | body[3]; // 20-bit
|
|
206
|
+
const energyWh = ((body[4] << 24) | (body[5] << 16) |
|
|
207
|
+
(body[6] << 8) | body[7]) >>> 0;
|
|
208
|
+
|
|
209
|
+
const payload = {
|
|
210
|
+
type: 'energy',
|
|
211
|
+
channel,
|
|
212
|
+
powerW,
|
|
213
|
+
energyWh,
|
|
214
|
+
};
|
|
215
|
+
node.send([null, { payload }]);
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── 0xF0/F1/F2 Channel name parts ───────────────────────────────
|
|
220
|
+
case 0xF0:
|
|
221
|
+
case 0xF1:
|
|
222
|
+
case 0xF2: {
|
|
223
|
+
// body[0]=cmd, body[1]=channel number (1-32), body[2..]=chars
|
|
224
|
+
const ch = body[1];
|
|
225
|
+
const part = cmd - 0xF0;
|
|
226
|
+
if (!_nameParts[ch]) _nameParts[ch] = {};
|
|
227
|
+
_nameParts[ch][part] = Array.from(body).slice(2);
|
|
228
|
+
// Assemble only channel 1 name for module display name
|
|
229
|
+
if (ch === 1 && part === 2) assembleName();
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
default:
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
node.bridge.register(node.address, onPacket);
|
|
239
|
+
|
|
240
|
+
// Startup RTR
|
|
241
|
+
setTimeout(function() {
|
|
242
|
+
node.bridge.send(rtrPkt(node.address), true);
|
|
243
|
+
}, 500);
|
|
244
|
+
|
|
245
|
+
// ── Input commands ────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
node.on('input', function(msg) {
|
|
248
|
+
const cmd = msg.payload && msg.payload.cmd;
|
|
249
|
+
if (!cmd) return;
|
|
250
|
+
|
|
251
|
+
if (cmd === 'get_status') {
|
|
252
|
+
node.bridge.send(pkt(0xFB, node.address, [0xFA, 0x00]));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Request energy counter — DB2=channel bitmask (ch1-8), DB3=interval
|
|
257
|
+
if (cmd === 'get_counter') {
|
|
258
|
+
const chMask = parseInt(msg.payload.channels) || 0xFF;
|
|
259
|
+
const interval = parseInt(msg.payload.interval) || 0;
|
|
260
|
+
node.bridge.send(pkt(0xF8, node.address, [0xBD, chMask, interval]));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Load counter — DB2=channel number (0-7), DB3=don't care, DB4-7=32-bit value
|
|
265
|
+
if (cmd === 'load_counter') {
|
|
266
|
+
const ch = (parseInt(msg.payload.channel) || 1) - 1; // 0-indexed
|
|
267
|
+
const value = parseInt(msg.payload.value) || 0;
|
|
268
|
+
const b4 = (value >>> 24) & 0xFF;
|
|
269
|
+
const b5 = (value >>> 16) & 0xFF;
|
|
270
|
+
const b6 = (value >>> 8) & 0xFF;
|
|
271
|
+
const b7 = value & 0xFF;
|
|
272
|
+
node.bridge.send(pkt(0xF8, node.address, [0xAD, ch, 0x00, b4, b5, b6, b7]));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Lock channel — DB2=channel number (1-32, 0xFF=all), 24-bit duration
|
|
277
|
+
if (cmd === 'lock') {
|
|
278
|
+
const ch = parseInt(msg.payload.channel) || 0xFF;
|
|
279
|
+
const duration = parseInt(msg.payload.duration) || 0xFFFFFF;
|
|
280
|
+
const hi = (duration >> 16) & 0xFF;
|
|
281
|
+
const mid = (duration >> 8) & 0xFF;
|
|
282
|
+
const lo = duration & 0xFF;
|
|
283
|
+
node.bridge.send(pkt(0xF8, node.address, [0x12, ch, hi, mid, lo]));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (cmd === 'unlock') {
|
|
288
|
+
const ch = parseInt(msg.payload.channel) || 0xFF;
|
|
289
|
+
node.bridge.send(pkt(0xF8, node.address, [0x13, ch]));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Request channel name — DB2=channel number (1-32, 0xFF=all)
|
|
294
|
+
if (cmd === 'get_name') {
|
|
295
|
+
const ch = parseInt(msg.payload.channel) || 0xFF;
|
|
296
|
+
node.bridge.send(pkt(0xF8, node.address, [0xEF, ch]));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
node.warn('velbus-sensor-20: unknown cmd: ' + cmd);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ── Cleanup ───────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
node.on('close', function() {
|
|
306
|
+
if (_nameTimer) { clearTimeout(_nameTimer); _nameTimer = null; }
|
|
307
|
+
node.bridge.deregister(node.address, onPacket);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
setStatus('ready', 'grey', 'dot');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
RED.nodes.registerType('velbus-sensor-20', VelbusSensor20Node);
|
|
314
|
+
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
(function() {
|
|
3
|
+
|
|
4
|
+
RED.nodes.registerType('velbus-thermostat', {
|
|
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
|
+
moduleName: { value: '' },
|
|
12
|
+
},
|
|
13
|
+
inputs: 1,
|
|
14
|
+
outputs: 2,
|
|
15
|
+
outputLabels: ['thermostat / settings / programme', 'temperature'],
|
|
16
|
+
icon: 'font-awesome/fa-thermometer-half',
|
|
17
|
+
paletteLabel: 'thermostat',
|
|
18
|
+
|
|
19
|
+
label: function() {
|
|
20
|
+
return this.name || this.moduleName
|
|
21
|
+
? (this.name || this.moduleName) + ' thermostat'
|
|
22
|
+
: 'thermostat 0x' + (parseInt(this.address) || 0).toString(16).padStart(2, '0').toUpperCase();
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
oneditprepare: function() {
|
|
26
|
+
const self = this;
|
|
27
|
+
const savedAddr = this.address;
|
|
28
|
+
|
|
29
|
+
function populateDropdown(bridgeId) {
|
|
30
|
+
const $addr = $('#node-input-address');
|
|
31
|
+
if (!bridgeId) return;
|
|
32
|
+
|
|
33
|
+
$.getJSON('velbus/scan-results?bridge=' + bridgeId, function(results) {
|
|
34
|
+
const modules = (results && results.modules) ? results.modules : [];
|
|
35
|
+
if (!modules.length) return;
|
|
36
|
+
|
|
37
|
+
$addr.empty().append($('<option>').val('').text('— select address —'));
|
|
38
|
+
|
|
39
|
+
const GP_TYPE_PREFIXES = [
|
|
40
|
+
0x21, 0x2D, 0x34, 0x35, 0x36, 0x37, 0x38, 0x47,
|
|
41
|
+
0x3A, 0x3B, 0x3C, 0x3D,
|
|
42
|
+
0x4F, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56,
|
|
43
|
+
0x57, 0x5C, 0x5F,
|
|
44
|
+
0x32, // VMB4AN also has thermostat preset model
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const gpModules = modules.filter(m => {
|
|
48
|
+
const tid = parseInt(m.typeId, 16);
|
|
49
|
+
return GP_TYPE_PREFIXES.includes(tid);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (!gpModules.length) {
|
|
53
|
+
$addr.append($('<option>').val('').text('No thermostat modules found'));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
gpModules.forEach(function(m) {
|
|
58
|
+
const label = m.address + ' — ' + m.module;
|
|
59
|
+
const $opt = $('<option>').val(m.addressDec).text(label);
|
|
60
|
+
if (m.addressDec === parseInt(savedAddr)) $opt.attr('selected', true);
|
|
61
|
+
$addr.append($opt);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
$('#node-input-bridge').on('change', function() {
|
|
67
|
+
populateDropdown($(this).val());
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Deferred load — TypedInput sets value async after oneditprepare
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
$('#node-input-bridge').trigger('change');
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
setTimeout(function() { $('#node-input-bridge').trigger('change'); }, 150);
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
oneditsave: function() {
|
|
80
|
+
this.address = parseInt($('#node-input-address').val()) || 0;
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
})();
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<script type="text/html" data-template-name="velbus-thermostat">
|
|
88
|
+
<div class="form-row">
|
|
89
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
90
|
+
<input type="text" id="node-input-name" placeholder="Optional label">
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<div class="form-row">
|
|
94
|
+
<label for="node-input-bridge"><i class="fa fa-plug"></i> Bridge</label>
|
|
95
|
+
<input type="text" id="node-input-bridge">
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div class="form-row">
|
|
99
|
+
<label for="node-input-address"><i class="fa fa-map-marker"></i> Address</label>
|
|
100
|
+
<select id="node-input-address" style="width:70%">
|
|
101
|
+
<option value="">— select bridge first —</option>
|
|
102
|
+
</select>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div class="form-row" style="display:none">
|
|
106
|
+
<input type="text" id="node-input-moduleName">
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<p style="margin-top:12px; color:#888; font-size:0.9em; padding-left:110px;">
|
|
110
|
+
Target the primary module address — not the thermostat sub-address.<br>
|
|
111
|
+
Can share an address with a <strong>velbus-glass-panel</strong> node.
|
|
112
|
+
</p>
|
|
113
|
+
</script>
|
|
114
|
+
|
|
115
|
+
<script type="text/html" data-help-name="velbus-thermostat">
|
|
116
|
+
<p style="background:#fff3cd; border-left:4px solid #ffc107; padding:8px 12px; margin-bottom:12px; font-size:0.85em;">
|
|
117
|
+
<strong>⚠ Testing status:</strong> This node was generated with Claude.ai and is in
|
|
118
|
+
need of extensive field testing before being deployed into a commercial project.
|
|
119
|
+
It is presented "as is" — any use beyond testing is entirely at your own risk and liability.
|
|
120
|
+
Constructive feedback is welcomed, accompanied by as many examples and debug captures as possible.
|
|
121
|
+
<a href="https://github.com/MDAR/node-red-contrib-velbus-2026/issues" target="_blank">File an issue on GitHub</a>.
|
|
122
|
+
</p>
|
|
123
|
+
<p>
|
|
124
|
+
Dedicated thermostat control node for all Velbus glass panel modules — VMBEL, VMBGP,
|
|
125
|
+
VMBELO and VMBGPO families, original and V2 series.
|
|
126
|
+
For button press events from the same module use a <strong>velbus-glass-panel</strong> node
|
|
127
|
+
on the same address.
|
|
128
|
+
</p>
|
|
129
|
+
<p>
|
|
130
|
+
All commands go to the <strong>primary module address</strong>. The thermostat sub-address
|
|
131
|
+
is a source address for button events only and is handled transparently by the bridge.
|
|
132
|
+
</p>
|
|
133
|
+
|
|
134
|
+
<h3>Output 1 — Thermostat and settings</h3>
|
|
135
|
+
<p>Thermostat status (0xEA):</p>
|
|
136
|
+
<pre>{
|
|
137
|
+
"type": "thermostat",
|
|
138
|
+
"currentTemp": 21.5,
|
|
139
|
+
"targetTemp": 22.0,
|
|
140
|
+
"mode": "comfort", // comfort | day | night | safe
|
|
141
|
+
"heaterMode": true, // true=heat, false=cool
|
|
142
|
+
"heating": false, // actively heating
|
|
143
|
+
"cooling": false, // actively cooling
|
|
144
|
+
"boostMode": false,
|
|
145
|
+
"thermostatOn": true
|
|
146
|
+
}</pre>
|
|
147
|
+
<p>Temperature preset settings (0xE8) — on <code>get_settings</code> command:</p>
|
|
148
|
+
<pre>{
|
|
149
|
+
"type": "temp_settings",
|
|
150
|
+
"comfort": 22.0,
|
|
151
|
+
"day": 20.0,
|
|
152
|
+
"night": 18.0,
|
|
153
|
+
"safe": 12.0
|
|
154
|
+
}</pre>
|
|
155
|
+
<p>Programme status (0xED) — on <code>get_status</code> command:</p>
|
|
156
|
+
<pre>{
|
|
157
|
+
"type": "programme",
|
|
158
|
+
"thermostatProgram": "manual", // disabled | manual | timer | timer_override
|
|
159
|
+
"alarms": { "alarm1Active": false, "alarm2Active": false }
|
|
160
|
+
}</pre>
|
|
161
|
+
|
|
162
|
+
<h3>Output 2 — Temperature</h3>
|
|
163
|
+
<pre>{ "type": "temperature", "current": 21.5, "min": 15.0, "max": 30.0 }</pre>
|
|
164
|
+
|
|
165
|
+
<h3>Input commands</h3>
|
|
166
|
+
<dl>
|
|
167
|
+
<dt><code>{ "cmd": "comfort" }</code> / <code>"day"</code> / <code>"night"</code> / <code>"safe"</code></dt>
|
|
168
|
+
<dd>Switch thermostat mode. Optional <code>sleepTime</code> in minutes (0 = until next programme step).</dd>
|
|
169
|
+
|
|
170
|
+
<dt><code>{ "cmd": "set_temp", "pointer": 0, "temp": 21.5 }</code></dt>
|
|
171
|
+
<dd>Set a preset target temperature. pointer: 0=comfort, 1=day, 2=night, 3=safe.</dd>
|
|
172
|
+
|
|
173
|
+
<dt><code>{ "cmd": "get_thermostat" }</code></dt>
|
|
174
|
+
<dd>Request current thermostat status (triggers 0xEA response on output 1).</dd>
|
|
175
|
+
|
|
176
|
+
<dt><code>{ "cmd": "get_settings" }</code></dt>
|
|
177
|
+
<dd>Request all four preset temperatures (triggers 0xE8 response on output 1).</dd>
|
|
178
|
+
|
|
179
|
+
<dt><code>{ "cmd": "get_status" }</code></dt>
|
|
180
|
+
<dd>Request module status — returns programme mode and alarm state on output 1.</dd>
|
|
181
|
+
|
|
182
|
+
<dt><code>{ "cmd": "heat_mode" }</code> / <code>{ "cmd": "cool_mode" }</code></dt>
|
|
183
|
+
<dd>Switch between heating and cooling mode.</dd>
|
|
184
|
+
</dl>
|
|
185
|
+
|
|
186
|
+
<h3>Supported modules</h3>
|
|
187
|
+
<p>
|
|
188
|
+
All VMBEL, VMBGP, VMBELO and VMBGPO variants (original and V2 series).<br>
|
|
189
|
+
VMB4AN (shares the same 4-preset thermostat model).
|
|
190
|
+
</p>
|
|
191
|
+
</script>
|