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,259 @@
1
+ 'use strict';
2
+
3
+ const { pkt, parsePkt } = require('../../lib/velbus-utils');
4
+ const { SENSOR_TYPES, SENSOR_TYPE_IDS } = require('../../lib/sensor-types');
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
+ function decodeNameBitmask(byte) {
19
+ // Returns the channel number (1-8) for a single-bit bitmask
20
+ for (let i = 0; i < 8; i++) {
21
+ if (byte === (1 << i)) return i + 1;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+ // Node
28
+ // ─────────────────────────────────────────────────────────────────────────────
29
+
30
+ module.exports = function(RED) {
31
+
32
+ function VelbusSensorNode(config) {
33
+ RED.nodes.createNode(this, config);
34
+ const node = this;
35
+
36
+ node.bridge = RED.nodes.getNode(config.bridge);
37
+ node.address = typeof config.address === 'number'
38
+ ? config.address // legacy saves stored decimal numbers
39
+ : (parseInt(config.address, 16) || 0); // editor stores hex strings
40
+ node.moduleName = config.moduleName || '';
41
+ node.typeId = config.typeId ? parseInt(config.typeId) : null;
42
+
43
+ if (!node.bridge) {
44
+ node.status({ fill: 'red', shape: 'ring', text: 'no bridge' });
45
+ node.error('velbus-sensor: no bridge configured');
46
+ return;
47
+ }
48
+ if (!node.address || node.address < 1 || node.address > 254) {
49
+ node.status({ fill: 'red', shape: 'ring', text: 'invalid address' });
50
+ node.error('velbus-sensor: invalid address ' + node.address);
51
+ return;
52
+ }
53
+
54
+ const typeDesc = node.typeId !== null ? (SENSOR_TYPES[node.typeId] || null) : null;
55
+ const hasCounter = typeDesc ? typeDesc.hasCounter : false;
56
+ const isVMB4AN = node.typeId === 0x32;
57
+
58
+ const _addrHex = '0x' + node.address.toString(16).padStart(2, '0').toUpperCase();
59
+ const _label = () => node.moduleName
60
+ ? node.moduleName + ' (' + _addrHex + ')'
61
+ : (typeDesc ? typeDesc.name : 'sensor') + ' ' + _addrHex;
62
+
63
+ function setStatus(text, fill, shape) {
64
+ node.status({ fill: fill || 'green', shape: shape || 'dot',
65
+ text: _label() + ' ' + text });
66
+ }
67
+
68
+ // Channel name store — keyed by channel number (1-8)
69
+ const _channelNames = {};
70
+ const _nameParts = {}; // keyed by channel number, then part 0/1/2
71
+
72
+ function assembleName(ch) {
73
+ const parts = _nameParts[ch];
74
+ if (!parts) return;
75
+ const bytes = [
76
+ ...(parts[0] || []),
77
+ ...(parts[1] || []),
78
+ ...(parts[2] || []),
79
+ ].filter(b => b !== 0 && b !== 0xFF);
80
+ const name = String.fromCharCode(...bytes).trim();
81
+ if (name) _channelNames[ch] = name;
82
+ }
83
+
84
+ // ── Packet handler ────────────────────────────────────────────────────
85
+
86
+ function onPacket(raw) {
87
+ const p = parsePkt(raw);
88
+ if (!p || p.rtr) return;
89
+ // Note: no address re-check here. The bridge routes frames to this
90
+ // listener, including subaddress frames mapped to the primary address
91
+ // (e.g. GPO-20 thermostat status from subaddress 0x34).
92
+
93
+ const { cmd, body } = p;
94
+
95
+ switch (cmd) {
96
+
97
+ // ── 0x00 Channel events ──────────────────────────────────────────
98
+ case 0x00: {
99
+ if (body.length < 4) return;
100
+ // body[0]=cmd, body[1]=pressed, body[2]=released, body[3]=longPressed
101
+ const pressed = activeFromBitmask(body[1]);
102
+ const released = activeFromBitmask(body[2]);
103
+ const longPressed = activeFromBitmask(body[3]);
104
+ const on = pressed.length > 0 || longPressed.length > 0;
105
+
106
+ const payload = { type: 'channel', on, pressed, released, longPressed };
107
+ if (pressed.length) setStatus('ch' + pressed.join(',') + ' on');
108
+ node.send([{ payload }, null]);
109
+ break;
110
+ }
111
+
112
+ // ── 0xED Module status ───────────────────────────────────────────
113
+ case 0xED: {
114
+ // VMB7IN: PDF says DLC=5 but lists 7 fields — treat as 7 data bytes
115
+ if (body.length < 7) return;
116
+ // body[0]=0xED, body[1]=channel status, body[2]=enabled/disabled,
117
+ // body[3]=normal/inverted, body[4]=locked, body[5]=prog-disabled,
118
+ // body[6]=alarm&program
119
+ const on = body[1] !== 0;
120
+ const progByte = body[6];
121
+ const program = ['none', 'summer', 'winter', 'holiday'][progByte & 0x03];
122
+ const alarm1Active = !!(progByte & 0x04);
123
+ const alarm2Active = !!(progByte & 0x10);
124
+ const sunriseEnabled = !!(progByte & 0x40);
125
+ const sunsetEnabled = !!(progByte & 0x80);
126
+
127
+ const payload = {
128
+ type: 'status',
129
+ on,
130
+ channels: activeFromBitmask(body[1]),
131
+ enabled: activeFromBitmask(body[2]),
132
+ normal: activeFromBitmask(body[3]),
133
+ locked: activeFromBitmask(body[4]),
134
+ progDisabled: activeFromBitmask(body[5]),
135
+ program,
136
+ alarms: { alarm1Active, alarm2Active },
137
+ sunrise: sunriseEnabled,
138
+ sunset: sunsetEnabled,
139
+ };
140
+ node.send([{ payload }, null]);
141
+ break;
142
+ }
143
+
144
+ // ── 0xBE Pulse counter status (VMB7IN channels 1-4) ─────────────
145
+ case 0xBE: {
146
+ if (!hasCounter) return;
147
+ if (body.length < 8) return;
148
+ // body[0]=0xBE
149
+ // body[1]: bits0-1=channel(0-3), bits2-7=pulses-per-unit scaling
150
+ // body[2-5]: 32-bit pulse count (MSB first)
151
+ // body[6-7]: 16-bit period in ms between last 2 pulses (0xFFFF=overflow)
152
+ const channelIndex = body[1] & 0x03; // 0-3
153
+ const channel = channelIndex + 1; // 1-4
154
+ const scalingBits = (body[1] >> 2) & 0x3F; // bits 2-7
155
+ // scaling encodes pulses per unit * 100 (e.g. 0x01=100, 0x0A=1000)
156
+ const pulsesPerUnit = scalingBits * 100;
157
+
158
+ const count = ((body[2] << 24) | (body[3] << 16) | (body[4] << 8) | body[5]) >>> 0;
159
+ const periodMs = (body[6] << 8) | body[7];
160
+ const overflow = periodMs === 0xFFFF;
161
+
162
+ const payload = {
163
+ type: 'counter',
164
+ channel,
165
+ count,
166
+ pulsesPerUnit,
167
+ periodMs: overflow ? null : periodMs,
168
+ overflow,
169
+ };
170
+
171
+ node.send([null, { payload }]);
172
+ break;
173
+ }
174
+
175
+ // ── 0xF0/F1/F2 Channel name parts ───────────────────────────────
176
+ case 0xF0:
177
+ case 0xF1:
178
+ case 0xF2: {
179
+ // body[0]=cmd, body[1]=channel bitmask, body[2..]=chars
180
+ const mask = body[1];
181
+ const ch = decodeNameBitmask(mask);
182
+ if (!ch) break;
183
+ const part = cmd - 0xF0;
184
+ if (!_nameParts[ch]) _nameParts[ch] = {};
185
+ _nameParts[ch][part] = Array.from(body).slice(2);
186
+ if (part === 2) assembleName(ch);
187
+ break;
188
+ }
189
+
190
+ default:
191
+ break;
192
+ }
193
+ }
194
+
195
+ node.bridge.register(node.address, onPacket);
196
+
197
+ // ── Input commands ────────────────────────────────────────────────────
198
+
199
+ node.on('input', function(msg) {
200
+ const cmd = msg.payload && msg.payload.cmd;
201
+ if (!cmd) return;
202
+
203
+ if (cmd === 'get_status') {
204
+ node.bridge.send(pkt(0xFB, node.address, [0xFA, 0x00]));
205
+ return;
206
+ }
207
+
208
+ // Request counter status — DB2=channel bitmask (bits 0-3 = ch 1-4), DB3=interval
209
+ if (cmd === 'get_counter') {
210
+ if (!hasCounter) { node.warn('velbus-sensor: this module has no pulse counter'); return; }
211
+ const chMask = parseInt(msg.payload.channels) || 0x0F; // default all 4
212
+ const interval = parseInt(msg.payload.interval) || 0;
213
+ node.bridge.send(pkt(0xF8, node.address, [0xBD, chMask, interval]));
214
+ return;
215
+ }
216
+
217
+ // Reset counter — DB2=channel number (0-3)
218
+ if (cmd === 'reset_counter') {
219
+ if (!hasCounter) { node.warn('velbus-sensor: this module has no pulse counter'); return; }
220
+ const ch = (parseInt(msg.payload.channel) || 1) - 1; // 0-indexed
221
+ node.bridge.send(pkt(0xF8, node.address, [0xAD, ch]));
222
+ return;
223
+ }
224
+
225
+ // Load counter — DB2=channel number (0-3), DB3=don't care, DB4-7=32-bit value
226
+ if (cmd === 'load_counter') {
227
+ if (!hasCounter) { node.warn('velbus-sensor: this module has no pulse counter'); return; }
228
+ const ch = (parseInt(msg.payload.channel) || 1) - 1;
229
+ const value = parseInt(msg.payload.value) || 0;
230
+ const b4 = (value >>> 24) & 0xFF;
231
+ const b5 = (value >>> 16) & 0xFF;
232
+ const b6 = (value >>> 8) & 0xFF;
233
+ const b7 = value & 0xFF;
234
+ node.bridge.send(pkt(0xF8, node.address, [0xAD, ch, 0x00, b4, b5, b6, b7]));
235
+ return;
236
+ }
237
+
238
+ // Request channel name — DB2=channel bitmask
239
+ if (cmd === 'get_name') {
240
+ const ch = parseInt(msg.payload.channel) || 0;
241
+ const mask = ch >= 1 && ch <= 8 ? (1 << (ch - 1)) : 0xFF;
242
+ node.bridge.send(pkt(0xF8, node.address, [0xEF, mask]));
243
+ return;
244
+ }
245
+
246
+ node.warn('velbus-sensor: unknown cmd: ' + cmd);
247
+ });
248
+
249
+ // ── Cleanup ───────────────────────────────────────────────────────────
250
+
251
+ node.on('close', function() {
252
+ node.bridge.deregister(node.address, onPacket);
253
+ });
254
+
255
+ setStatus('ready', 'grey', 'dot');
256
+ }
257
+
258
+ RED.nodes.registerType('velbus-sensor', VelbusSensorNode);
259
+ };
@@ -0,0 +1,158 @@
1
+ <script type="text/javascript">
2
+ (function() {
3
+
4
+ const SENSOR_TYPES_20 = {
5
+ 0x4E: { name: 'VMB8IN-20', hasCounter: true, series: 'v2' },
6
+ };
7
+
8
+ RED.nodes.registerType('velbus-sensor-20', {
9
+ category: 'Velbus (inputs)',
10
+ color: '#3A8C8C',
11
+ defaults: {
12
+ name: { value: '' },
13
+ bridge: { value: '', type: 'velbus-bridge', required: true },
14
+ address: { value: '', required: true, validate: function(v) { return /^(0x)?[0-9a-fA-F]{1,2}$/.test(v); } },
15
+ moduleName: { value: '' },
16
+ typeId: { value: '' },
17
+ },
18
+ inputs: 1,
19
+ outputs: 2,
20
+ outputLabels: ['channel events / status', 'energy counter'],
21
+ icon: 'font-awesome/fa-tachometer',
22
+ paletteLabel: 'sensor-20',
23
+
24
+ label: function() {
25
+ const t = this.typeId ? SENSOR_TYPES_20[parseInt(this.typeId, 16)] : null;
26
+ const n = this.name || this.moduleName || (t ? t.name : 'sensor-20');
27
+ return n + ' 0x' + (parseInt(this.address) || 0).toString(16).padStart(2,'0').toUpperCase();
28
+ },
29
+
30
+ oneditprepare: function() {
31
+ const savedAddr = this.address;
32
+ $('#node-input-typeId').prop('readonly', true).css('background', '#f0f0f0');
33
+
34
+ function populateDropdown(bridgeId) {
35
+ if (!bridgeId) return;
36
+ $.getJSON('velbus/scan-results?bridge=' + bridgeId, function(results) {
37
+ const modules = (results && results.modules) ? results.modules : [];
38
+ const $addr = $('#node-input-address');
39
+ $addr.empty().append($('<option>').val('').text('— select address —'));
40
+
41
+ const typeIds = Object.keys(SENSOR_TYPES_20).map(k => parseInt(k));
42
+ const filtered = modules.filter(m => typeIds.includes(parseInt(m.typeId, 16)));
43
+
44
+ if (!filtered.length) {
45
+ $addr.append($('<option>').val('').text('No V2 sensor modules found on bus'));
46
+ return;
47
+ }
48
+ filtered.forEach(function(m) {
49
+ const tid = parseInt(m.typeId, 16);
50
+ const label = m.address + ' — ' + m.module;
51
+ const $opt = $('<option>').val(m.addressDec).text(label).data('typeid', tid);
52
+ if (m.addressDec === parseInt(savedAddr)) $opt.attr('selected', true);
53
+ $addr.append($opt);
54
+ });
55
+ if (savedAddr) {
56
+ const $opt = $('#node-input-address option[value="' + parseInt(savedAddr) + '"]');
57
+ const tid = $opt.data('typeid');
58
+ if (tid) {
59
+ $('#sensor20-type-hint').text(SENSOR_TYPES_20[tid].name + ' · up to 32ch · energy counters').show();
60
+ $('#node-input-typeId').val('0x' + tid.toString(16).padStart(2,'0').toUpperCase());
61
+ }
62
+ }
63
+ });
64
+ }
65
+
66
+ $('#node-input-bridge').on('change', function() { populateDropdown($(this).val()); });
67
+ $('#node-input-address').on('change', function() {
68
+ const tid = $(this).find(':selected').data('typeid');
69
+ if (tid && SENSOR_TYPES_20[tid]) {
70
+ $('#sensor20-type-hint').text(SENSOR_TYPES_20[tid].name + ' · up to 32ch · energy counters').show();
71
+ $('#node-input-typeId').val('0x' + tid.toString(16).padStart(2,'0').toUpperCase());
72
+ }
73
+ });
74
+ // Deferred load — TypedInput sets value async after oneditprepare
75
+
76
+ $('#node-input-bridge').trigger('change');
77
+
78
+ setTimeout(function() { $('#node-input-bridge').trigger('change'); }, 150);
79
+ },
80
+
81
+ oneditsave: function() {
82
+ this.address = parseInt($('#node-input-address').val()) || 0;
83
+ const tid = $('#node-input-typeId').val();
84
+ this.typeId = tid || '';
85
+ if (tid) {
86
+ const t = SENSOR_TYPES_20[parseInt(tid, 16)];
87
+ if (t) this.moduleName = t.name;
88
+ }
89
+ },
90
+ });
91
+
92
+ })();
93
+ </script>
94
+
95
+ <script type="text/html" data-template-name="velbus-sensor-20">
96
+ <div class="form-row">
97
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
98
+ <input type="text" id="node-input-name" placeholder="Optional label">
99
+ </div>
100
+ <div class="form-row">
101
+ <label for="node-input-bridge"><i class="fa fa-plug"></i> Bridge</label>
102
+ <input type="text" id="node-input-bridge">
103
+ </div>
104
+ <div class="form-row">
105
+ <label for="node-input-address"><i class="fa fa-map-marker"></i> Address</label>
106
+ <select id="node-input-address" style="width:70%">
107
+ <option value="">— select bridge first —</option>
108
+ </select>
109
+ </div>
110
+ <div class="form-row">
111
+ <label></label>
112
+ <span id="sensor20-type-hint" style="color:#888; font-size:0.9em; display:none;"></span>
113
+ </div>
114
+ <div class="form-row" style="display:none">
115
+ <input type="text" id="node-input-typeId">
116
+ <input type="text" id="node-input-moduleName">
117
+ </div>
118
+ </script>
119
+
120
+ <script type="text/html" data-help-name="velbus-sensor-20">
121
+ <p style="background:#fff3cd; border-left:4px solid #ffc107; padding:8px 12px; margin-bottom:12px; font-size:0.85em;">
122
+ <strong>&#9888; Testing status:</strong> This node was generated with Claude.ai and is in
123
+ need of extensive field testing before being deployed into a commercial project.
124
+ It is presented "as is" &mdash; any use beyond testing is entirely at your own risk and liability.
125
+ Constructive feedback is welcomed, accompanied by as many examples and debug captures as possible.
126
+ <a href="https://github.com/MDAR/node-red-contrib-velbus-2026/issues" target="_blank">File an issue on GitHub</a>.
127
+ </p>
128
+ <p>V2 universal input node for VMB8IN-20 (0x4E).</p>
129
+ <p>Supports up to 32 digital input channels (8 primary + 24 via subaddresses),
130
+ 8 energy counters, firmware check on startup, and name auto-retrieval.</p>
131
+ <p>For original series use <strong>velbus-sensor</strong>.</p>
132
+
133
+ <h3>Output 1 — Channel events / status</h3>
134
+ <p>Channel event (0x00) — includes sourceAddress to identify primary vs subaddress:</p>
135
+ <pre>{ "type": "channel", "on": true, "pressed": [1,3], "released": [],
136
+ "longPressed": [], "sourceAddress": "0x0A" }</pre>
137
+ <p>Primary module status (0xED — 8 bytes):</p>
138
+ <pre>{ "type": "status", "on": true, "channels": [1,2],
139
+ "enabled": [1,2,3,4,5,6,7,8], "normal": [1,2,3,4,5,6,7,8],
140
+ "locked": [], "progDisabled": [], "program": "group1",
141
+ "autoSendInterval": 30 }</pre>
142
+ <p>Subaddress status (0xED — 5 bytes):</p>
143
+ <pre>{ "type": "sub_status", "channels": [1,3], "enabled": [...], "locked": [], "progDisabled": [] }</pre>
144
+
145
+ <h3>Output 2 — Energy counter (0xA4)</h3>
146
+ <pre>{ "type": "energy", "channel": 1, "powerW": 1250, "energyWh": 45678 }</pre>
147
+
148
+ <h3>Input commands</h3>
149
+ <dl>
150
+ <dt><code>{ "cmd": "get_status" }</code></dt><dd>Request module status.</dd>
151
+ <dt><code>{ "cmd": "get_counter", "channels": 255, "interval": 30 }</code></dt>
152
+ <dd>Request energy counters. channels=bitmask of ch1-8 (255=all). interval=0 no change.</dd>
153
+ <dt><code>{ "cmd": "load_counter", "channel": 1, "value": 0 }</code></dt><dd>Load counter with preset value.</dd>
154
+ <dt><code>{ "cmd": "lock", "channel": 1, "duration": 3600 }</code></dt><dd>Lock channel by number (1-32, 255=all).</dd>
155
+ <dt><code>{ "cmd": "unlock", "channel": 1 }</code></dt><dd>Unlock channel.</dd>
156
+ <dt><code>{ "cmd": "get_name", "channel": 1 }</code></dt><dd>Request channel name (1-32, 255=all).</dd>
157
+ </dl>
158
+ </script>