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,179 @@
1
+ <script type="text/javascript">
2
+ (function() {
3
+
4
+ const BLIND_TYPES_20 = {
5
+ 0x61: { name: 'VMB2BLE-20', channels: 2 },
6
+ };
7
+
8
+ RED.nodes.registerType('velbus-blind-20', {
9
+ category: 'Velbus (outputs)',
10
+ color: '#4A90D9',
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
+ channel: { value: 1, required: true },
16
+ moduleName: { value: '' },
17
+ typeId: { value: '' },
18
+ },
19
+ inputs: 1,
20
+ outputs: 1,
21
+ outputLabels: ['blind status'],
22
+ icon: 'font-awesome/fa-arrows-v',
23
+ paletteLabel: 'blind-20',
24
+
25
+ label: function() {
26
+ const t = this.typeId ? BLIND_TYPES_20[parseInt(this.typeId, 16)] : null;
27
+ const n = this.name || this.moduleName || (t ? t.name : 'blind-20');
28
+ return n + ' ch' + this.channel + ' 0x' +
29
+ (parseInt(this.address) || 0).toString(16).padStart(2,'0').toUpperCase();
30
+ },
31
+
32
+ oneditprepare: function() {
33
+ const savedAddr = this.address;
34
+ const savedCh = this.channel;
35
+ $('#node-input-typeId').prop('readonly', true).css('background', '#f0f0f0');
36
+
37
+ function populateDropdown(bridgeId) {
38
+ if (!bridgeId) return;
39
+ $.getJSON('velbus/scan-results?bridge=' + bridgeId, function(results) {
40
+ const modules = (results && results.modules) ? results.modules : [];
41
+ const $addr = $('#node-input-address');
42
+ $addr.empty().append($('<option>').val('').text('— select address —'));
43
+
44
+ const typeIds = Object.keys(BLIND_TYPES_20).map(k => parseInt(k));
45
+ const filtered = modules.filter(m => typeIds.includes(parseInt(m.typeId, 16)));
46
+
47
+ if (!filtered.length) {
48
+ $addr.append($('<option>').val('').text('No VMB2BLE-20 modules found on bus'));
49
+ return;
50
+ }
51
+ filtered.forEach(function(m) {
52
+ const tid = parseInt(m.typeId, 16);
53
+ const label = m.address + ' — ' + m.module;
54
+ const $opt = $('<option>').val(m.addressDec).text(label).data('typeid', tid);
55
+ if (m.addressDec === parseInt(savedAddr)) $opt.attr('selected', true);
56
+ $addr.append($opt);
57
+ });
58
+ });
59
+ }
60
+
61
+ function updateChannelSelect(typeId) {
62
+ const t = BLIND_TYPES_20[typeId];
63
+ const $ch = $('#node-input-channel');
64
+ $ch.empty();
65
+ if (t) {
66
+ for (let i = 1; i <= t.channels; i++) {
67
+ $ch.append($('<option>').val(i).text('Channel ' + i));
68
+ }
69
+ $ch.val(savedCh || 1);
70
+ $('#node-input-typeId').val('0x' + typeId.toString(16).padStart(2,'0').toUpperCase());
71
+ $('#blind20-type-hint').text(t.name + ' · V2 · 2ch · position + CAN FD').show();
72
+ }
73
+ }
74
+
75
+ $('#node-input-bridge').on('change', function() { populateDropdown($(this).val()); });
76
+ $('#node-input-address').on('change', function() {
77
+ const tid = $(this).find(':selected').data('typeid');
78
+ if (tid) updateChannelSelect(tid);
79
+ });
80
+ // Deferred load — TypedInput sets value async after oneditprepare
81
+
82
+ $('#node-input-bridge').trigger('change');
83
+
84
+ setTimeout(function() { $('#node-input-bridge').trigger('change'); }, 150);
85
+ },
86
+
87
+ oneditsave: function() {
88
+ this.address = parseInt($('#node-input-address').val()) || 0;
89
+ this.channel = parseInt($('#node-input-channel').val()) || 1;
90
+ const tid = $('#node-input-typeId').val();
91
+ this.typeId = tid || '';
92
+ if (tid) {
93
+ const t = BLIND_TYPES_20[parseInt(tid, 16)];
94
+ if (t) this.moduleName = t.name;
95
+ }
96
+ },
97
+ });
98
+
99
+ })();
100
+ </script>
101
+
102
+ <script type="text/html" data-template-name="velbus-blind-20">
103
+ <div class="form-row">
104
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
105
+ <input type="text" id="node-input-name" placeholder="Optional label">
106
+ </div>
107
+ <div class="form-row">
108
+ <label for="node-input-bridge"><i class="fa fa-plug"></i> Bridge</label>
109
+ <input type="text" id="node-input-bridge">
110
+ </div>
111
+ <div class="form-row">
112
+ <label for="node-input-address"><i class="fa fa-map-marker"></i> Address</label>
113
+ <select id="node-input-address" style="width:70%">
114
+ <option value="">— select bridge first —</option>
115
+ </select>
116
+ </div>
117
+ <div class="form-row">
118
+ <label for="node-input-channel"><i class="fa fa-arrows-v"></i> Channel</label>
119
+ <select id="node-input-channel" style="width:70%">
120
+ <option value="1">Channel 1</option>
121
+ <option value="2">Channel 2</option>
122
+ </select>
123
+ </div>
124
+ <div class="form-row">
125
+ <label></label>
126
+ <span id="blind20-type-hint" style="color:#888; font-size:0.9em; display:none;"></span>
127
+ </div>
128
+ <div class="form-row" style="display:none">
129
+ <input type="text" id="node-input-typeId">
130
+ <input type="text" id="node-input-moduleName">
131
+ </div>
132
+ </script>
133
+
134
+ <script type="text/html" data-help-name="velbus-blind-20">
135
+ <p style="background:#fff3cd; border-left:4px solid #ffc107; padding:8px 12px; margin-bottom:12px; font-size:0.85em;">
136
+ <strong>&#9888; Testing status:</strong> This node was generated with Claude.ai and is in
137
+ need of extensive field testing before being deployed into a commercial project.
138
+ It is presented "as is" &mdash; any use beyond testing is entirely at your own risk and liability.
139
+ Constructive feedback is welcomed, accompanied by as many examples and debug captures as possible.
140
+ <a href="https://github.com/MDAR/node-red-contrib-velbus-2026/issues" target="_blank">File an issue on GitHub</a>.
141
+ </p>
142
+ <p>V2 blind/shutter motor controller — VMB2BLE-20 (0x61).</p>
143
+ <p>Full V2 redesign with CAN FD support, firmware check on startup, and name
144
+ auto-retrieval. Status packet covers both channels simultaneously.
145
+ For older BLE/BLS modules use <strong>velbus-blind-s</strong>.</p>
146
+
147
+ <h3>Output — Blind status (0xEC)</h3>
148
+ <pre>{ "type": "blind_status", "channel": 1, "on": true,
149
+ "status": "up", "position": 25, "lockState": "normal",
150
+ "autoMode": 0, "progDisabled": false,
151
+ "program": "none", "alarms": { "alarm1Active": false, "alarm2Active": false } }</pre>
152
+ <p>position: 0=fully up, 100=fully down.</p>
153
+ <p>lockState: normal, inhibited, inhibit_preset_down, inhibit_preset_up,
154
+ forced_down, forced_up, locked.</p>
155
+ <p>Note: 0xFFFFFF (permanent) is NOT allowed for up/down timeout on this module.</p>
156
+
157
+ <h3>Input commands</h3>
158
+ <dl>
159
+ <dt><code>{ "cmd": "stop" }</code></dt><dd>Stop movement.</dd>
160
+ <dt><code>{ "cmd": "up", "timeout": 0 }</code></dt><dd>Move up. 0=default timeout. Max 0xFFFFFE.</dd>
161
+ <dt><code>{ "cmd": "down", "timeout": 0 }</code></dt><dd>Move down.</dd>
162
+ <dt><code>{ "cmd": "position", "position": 50 }</code></dt><dd>Set position 0-100%.</dd>
163
+ <dt><code>{ "cmd": "lock", "duration": 3600 }</code></dt><dd>Lock. 0xFFFFFF=permanent.</dd>
164
+ <dt><code>{ "cmd": "unlock" }</code></dt><dd>Unlock.</dd>
165
+ <dt><code>{ "cmd": "forced_up", "duration": 3600 }</code></dt><dd>Force up.</dd>
166
+ <dt><code>{ "cmd": "cancel_forced_up" }</code></dt><dd>Cancel forced up.</dd>
167
+ <dt><code>{ "cmd": "forced_down", "duration": 3600 }</code></dt><dd>Force down.</dd>
168
+ <dt><code>{ "cmd": "cancel_forced_down" }</code></dt><dd>Cancel forced down.</dd>
169
+ <dt><code>{ "cmd": "inhibit", "duration": 3600 }</code></dt><dd>Inhibit all movement.</dd>
170
+ <dt><code>{ "cmd": "cancel_inhibit" }</code></dt><dd>Cancel inhibit.</dd>
171
+ <dt><code>{ "cmd": "inhibit_preset_up", "duration": 3600 }</code></dt><dd>Inhibit with preset up.</dd>
172
+ <dt><code>{ "cmd": "inhibit_preset_down", "duration": 3600 }</code></dt><dd>Inhibit with preset down.</dd>
173
+ <dt><code>{ "cmd": "auto_mode", "mode": 1 }</code></dt><dd>Select auto mode (0=disabled, 1-3).</dd>
174
+ <dt><code>{ "cmd": "enable_program" }</code></dt><dd>Enable channel program.</dd>
175
+ <dt><code>{ "cmd": "disable_program", "duration": 3600 }</code></dt><dd>Disable channel program.</dd>
176
+ <dt><code>{ "cmd": "get_status" }</code></dt><dd>Request module status.</dd>
177
+ <dt><code>{ "cmd": "get_name" }</code></dt><dd>Request channel name from VelbusLink.</dd>
178
+ </dl>
179
+ </script>
@@ -0,0 +1,300 @@
1
+ 'use strict';
2
+
3
+ const { pkt, rtrPkt, parsePkt } = require('../../lib/velbus-utils');
4
+ const { BLIND_TYPES_20, BLIND_TYPE_IDS_20 } = require('../../lib/blind-types-20');
5
+
6
+ // ─────────────────────────────────────────────────────────────────────────────
7
+ // Helpers
8
+ // ─────────────────────────────────────────────────────────────────────────────
9
+
10
+ const BLIND_STATUS = { 0: 'off', 1: 'up', 2: 'down' };
11
+
12
+ const LOCK_STATUS = {
13
+ 0: 'normal', 1: 'inhibited', 2: 'inhibit_preset_down',
14
+ 3: 'inhibit_preset_up', 4: 'forced_down', 5: 'forced_up', 6: 'locked',
15
+ };
16
+
17
+ function make24(duration) {
18
+ return [(duration >> 16) & 0xFF, (duration >> 8) & 0xFF, duration & 0xFF];
19
+ }
20
+
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+ // Node
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+
25
+ module.exports = function(RED) {
26
+
27
+ function VelbusBlind20Node(config) {
28
+ RED.nodes.createNode(this, config);
29
+ const node = this;
30
+
31
+ node.bridge = RED.nodes.getNode(config.bridge);
32
+ node.address = typeof config.address === 'number'
33
+ ? config.address // legacy saves stored decimal numbers
34
+ : (parseInt(config.address, 16) || 0); // editor stores hex strings
35
+ node.moduleName = config.moduleName || '';
36
+ node.typeId = config.typeId ? parseInt(config.typeId) : null;
37
+ node.channel = parseInt(config.channel) || 1;
38
+
39
+ if (!node.bridge) {
40
+ node.status({ fill: 'red', shape: 'ring', text: 'no bridge' });
41
+ node.error('velbus-blind-20: no bridge configured');
42
+ return;
43
+ }
44
+ if (!node.address || node.address < 1 || node.address > 254) {
45
+ node.status({ fill: 'red', shape: 'ring', text: 'invalid address' });
46
+ node.error('velbus-blind-20: invalid address ' + node.address);
47
+ return;
48
+ }
49
+
50
+ const _addrHex = '0x' + node.address.toString(16).padStart(2, '0').toUpperCase();
51
+
52
+ // Name retrieval
53
+ let _nameParts = {};
54
+ let _nameTimer = null;
55
+
56
+ function assembleName() {
57
+ if (_nameTimer) { clearTimeout(_nameTimer); _nameTimer = null; }
58
+ const bytes = [
59
+ ...(_nameParts[0] || []),
60
+ ...(_nameParts[1] || []),
61
+ ...(_nameParts[2] || []),
62
+ ].filter(b => b !== 0 && b !== 0xFF);
63
+ const name = String.fromCharCode(...bytes).trim();
64
+ if (name) node.moduleName = name;
65
+ }
66
+
67
+ function setStatus(text, fill, shape) {
68
+ const label = node.moduleName
69
+ ? node.moduleName + ' ch' + node.channel + ' (' + _addrHex + ')'
70
+ : _addrHex + ' ch' + node.channel;
71
+ node.status({ fill: fill || 'green', shape: shape || 'dot', text: label + ' ' + text });
72
+ }
73
+
74
+ // ── Firmware check ────────────────────────────────────────────────────
75
+
76
+ function handleModuleType(body) {
77
+ if (body.length < 8) return;
78
+ const typeId = body[1];
79
+ const build = body[5] * 100 + body[6];
80
+ const canFD = !!(body[7] & 0x20);
81
+
82
+ const desc = BLIND_TYPES_20[typeId];
83
+ if (!desc) {
84
+ node.status({ fill: 'red', shape: 'ring',
85
+ text: 'unknown type 0x' + typeId.toString(16) });
86
+ return;
87
+ }
88
+ node.moduleName = node.moduleName || desc.name;
89
+ setStatus('build ' + build + (canFD ? ' CAN FD' : ''), 'grey', 'dot');
90
+
91
+ // Request name for this channel
92
+ setTimeout(() => {
93
+ node.bridge.send(pkt(0xF8, node.address, [0xEF, node.channel]));
94
+ _nameTimer = setTimeout(assembleName, 2000);
95
+ }, 100);
96
+ }
97
+
98
+ // ── Packet handler ────────────────────────────────────────────────────
99
+
100
+ function onPacket(raw) {
101
+ const p = parsePkt(raw);
102
+ if (!p || p.rtr) return;
103
+ if (p.addr !== node.address) return;
104
+
105
+ const { cmd, body } = p;
106
+
107
+ switch (cmd) {
108
+
109
+ // ── 0xFF Module type ─────────────────────────────────────────────
110
+ case 0xFF: {
111
+ handleModuleType(body);
112
+ break;
113
+ }
114
+
115
+ // ── 0xEC Module status — both channels in one packet ─────────────
116
+ case 0xEC: {
117
+ if (body.length < 8) return;
118
+ // body[0]=0xEC
119
+ // body[1]=DB2: bits0-3=ch1 status, bits4-7=ch2 status
120
+ // body[2]=DB3: ch1 position (0-100%)
121
+ // body[3]=DB4: ch2 position (0-100%)
122
+ // body[4]=DB5: lock state bits0-3=ch1, bits4-7=ch2
123
+ // body[5]=DB6: auto mode bits0-3=ch1, bits4-7=ch2
124
+ // body[6]=DB7: program disabled bit0=ch1, bit4=ch2
125
+ // body[7]=DB8: alarm & program selection (global)
126
+
127
+ const ch = node.channel;
128
+ const statusNibble = ch === 1 ? (body[1] & 0x0F) : ((body[1] >> 4) & 0x0F);
129
+ const position = ch === 1 ? body[2] : body[3];
130
+ const lockNibble = ch === 1 ? (body[4] & 0x0F) : ((body[4] >> 4) & 0x0F);
131
+ const autoMode = ch === 1 ? (body[5] & 0x0F) : ((body[5] >> 4) & 0x0F);
132
+ const progDisabled = ch === 1 ? !!(body[6] & 0x01) : !!(body[6] & 0x10);
133
+
134
+ const blindStatus = BLIND_STATUS[statusNibble] || 'unknown';
135
+ const lockState = LOCK_STATUS[lockNibble] || 'unknown';
136
+
137
+ const alarmByte = body[7];
138
+ const program = ['none','summer','winter','holiday'][alarmByte & 0x03];
139
+ const alarm1Active = !!(alarmByte & 0x04);
140
+ const alarm2Active = !!(alarmByte & 0x10);
141
+ const sunriseEnabled = !!(alarmByte & 0x40);
142
+ const sunsetEnabled = !!(alarmByte & 0x80);
143
+
144
+ const on = blindStatus !== 'off';
145
+ setStatus(blindStatus + ' ' + position + '%');
146
+
147
+ const payload = {
148
+ type: 'blind_status',
149
+ channel: ch,
150
+ on,
151
+ status: blindStatus,
152
+ position,
153
+ lockState,
154
+ autoMode,
155
+ progDisabled,
156
+ program,
157
+ alarms: { alarm1Active, alarm2Active },
158
+ sunrise: sunriseEnabled,
159
+ sunset: sunsetEnabled,
160
+ };
161
+ node.send([{ payload }]);
162
+ break;
163
+ }
164
+
165
+ // ── 0xF0/F1/F2 Channel name parts ───────────────────────────────
166
+ case 0xF0:
167
+ case 0xF1:
168
+ case 0xF2: {
169
+ // body[0]=cmd, body[1]=channel number (1 or 2), body[2..]=chars
170
+ if (body[1] !== node.channel) return;
171
+ const part = cmd - 0xF0;
172
+ _nameParts[part] = Array.from(body).slice(2);
173
+ if (part === 2) assembleName();
174
+ break;
175
+ }
176
+
177
+ default:
178
+ break;
179
+ }
180
+ }
181
+
182
+ node.bridge.register(node.address, onPacket);
183
+
184
+ // Startup RTR
185
+ setTimeout(function() {
186
+ node.bridge.send(rtrPkt(node.address), true);
187
+ }, 500);
188
+
189
+ // ── Input commands ────────────────────────────────────────────────────
190
+
191
+ node.on('input', function(msg) {
192
+ const cmd = msg.payload && msg.payload.cmd;
193
+ if (!cmd) return;
194
+
195
+ const ch = node.channel; // 1, 2, or caller can override via msg.payload.channel
196
+
197
+ if (cmd === 'get_status') {
198
+ node.bridge.send(pkt(0xFB, node.address, [0xFA, 0x00]));
199
+ return;
200
+ }
201
+ if (cmd === 'stop') {
202
+ node.bridge.send(pkt(0xF8, node.address, [0x04, ch]));
203
+ return;
204
+ }
205
+ if (cmd === 'up') {
206
+ // Note: 0xFFFFFF NOT allowed for timeout on VMB2BLE-20
207
+ const timeout = Math.min(parseInt(msg.payload.timeout) || 0, 0xFFFFFE);
208
+ node.bridge.send(pkt(0xF8, node.address, [0x05, ch, ...make24(timeout)]));
209
+ return;
210
+ }
211
+ if (cmd === 'down') {
212
+ const timeout = Math.min(parseInt(msg.payload.timeout) || 0, 0xFFFFFE);
213
+ node.bridge.send(pkt(0xF8, node.address, [0x06, ch, ...make24(timeout)]));
214
+ return;
215
+ }
216
+ if (cmd === 'position') {
217
+ const pos = Math.max(0, Math.min(100, parseInt(msg.payload.position) || 0));
218
+ node.bridge.send(pkt(0xF8, node.address, [0x1C, ch, pos]));
219
+ return;
220
+ }
221
+ if (cmd === 'lock') {
222
+ const duration = parseInt(msg.payload.duration) || 0xFFFFFF;
223
+ node.bridge.send(pkt(0xF8, node.address, [0x1A, ch, ...make24(duration)]));
224
+ return;
225
+ }
226
+ if (cmd === 'unlock') {
227
+ node.bridge.send(pkt(0xF8, node.address, [0x1B, ch]));
228
+ return;
229
+ }
230
+ if (cmd === 'forced_up') {
231
+ const duration = parseInt(msg.payload.duration) || 0xFFFFFF;
232
+ node.bridge.send(pkt(0xF8, node.address, [0x12, ch, ...make24(duration)]));
233
+ return;
234
+ }
235
+ if (cmd === 'cancel_forced_up') {
236
+ node.bridge.send(pkt(0xF8, node.address, [0x13, ch]));
237
+ return;
238
+ }
239
+ if (cmd === 'forced_down') {
240
+ const duration = parseInt(msg.payload.duration) || 0xFFFFFF;
241
+ node.bridge.send(pkt(0xF8, node.address, [0x14, ch, ...make24(duration)]));
242
+ return;
243
+ }
244
+ if (cmd === 'cancel_forced_down') {
245
+ node.bridge.send(pkt(0xF8, node.address, [0x15, ch]));
246
+ return;
247
+ }
248
+ if (cmd === 'inhibit') {
249
+ const duration = parseInt(msg.payload.duration) || 0xFFFFFF;
250
+ node.bridge.send(pkt(0xF8, node.address, [0x16, ch, ...make24(duration)]));
251
+ return;
252
+ }
253
+ if (cmd === 'cancel_inhibit') {
254
+ node.bridge.send(pkt(0xF8, node.address, [0x17, ch]));
255
+ return;
256
+ }
257
+ if (cmd === 'inhibit_preset_up') {
258
+ const duration = parseInt(msg.payload.duration) || 0xFFFFFF;
259
+ node.bridge.send(pkt(0xF8, node.address, [0x18, ch, ...make24(duration)]));
260
+ return;
261
+ }
262
+ if (cmd === 'inhibit_preset_down') {
263
+ const duration = parseInt(msg.payload.duration) || 0xFFFFFF;
264
+ node.bridge.send(pkt(0xF8, node.address, [0x19, ch, ...make24(duration)]));
265
+ return;
266
+ }
267
+ if (cmd === 'auto_mode') {
268
+ const mode = parseInt(msg.payload.mode) || 0;
269
+ node.bridge.send(pkt(0xF8, node.address, [0xB3, ch, mode]));
270
+ return;
271
+ }
272
+ if (cmd === 'enable_program') {
273
+ node.bridge.send(pkt(0xF8, node.address, [0xB2, ch]));
274
+ return;
275
+ }
276
+ if (cmd === 'disable_program') {
277
+ const duration = parseInt(msg.payload.duration) || 0xFFFFFF;
278
+ node.bridge.send(pkt(0xF8, node.address, [0xB1, ch, ...make24(duration)]));
279
+ return;
280
+ }
281
+ if (cmd === 'get_name') {
282
+ node.bridge.send(pkt(0xF8, node.address, [0xEF, ch]));
283
+ return;
284
+ }
285
+
286
+ node.warn('velbus-blind-20: unknown cmd: ' + cmd);
287
+ });
288
+
289
+ // ── Cleanup ───────────────────────────────────────────────────────────
290
+
291
+ node.on('close', function() {
292
+ if (_nameTimer) { clearTimeout(_nameTimer); _nameTimer = null; }
293
+ node.bridge.deregister(node.address, onPacket);
294
+ });
295
+
296
+ setStatus('ready', 'grey', 'dot');
297
+ }
298
+
299
+ RED.nodes.registerType('velbus-blind-20', VelbusBlind20Node);
300
+ };
@@ -0,0 +1,181 @@
1
+ <script type="text/javascript">
2
+ (function() {
3
+
4
+ const BLIND_TYPES_S = {
5
+ 0x2E: { name: 'VMB1BLS', channels: 1 },
6
+ 0x1D: { name: 'VMB2BLE', channels: 2 },
7
+ 0x4A: { name: 'VMB2BLE-10', channels: 2 },
8
+ };
9
+
10
+ RED.nodes.registerType('velbus-blind-s', {
11
+ category: 'Velbus (outputs)',
12
+ color: '#4A90D9',
13
+ defaults: {
14
+ name: { value: '' },
15
+ bridge: { value: '', type: 'velbus-bridge', required: true },
16
+ address: { value: '', required: true, validate: function(v) { return /^(0x)?[0-9a-fA-F]{1,2}$/.test(v); } },
17
+ channel: { value: 1, required: true },
18
+ moduleName: { value: '' },
19
+ typeId: { value: '' },
20
+ },
21
+ inputs: 1,
22
+ outputs: 2,
23
+ outputLabels: ['relay events', 'blind status'],
24
+ icon: 'font-awesome/fa-arrows-v',
25
+ paletteLabel: 'blind-s',
26
+
27
+ label: function() {
28
+ const t = this.typeId ? BLIND_TYPES_S[parseInt(this.typeId, 16)] : null;
29
+ const n = this.name || this.moduleName || (t ? t.name : 'blind-s');
30
+ return n + ' ch' + this.channel + ' 0x' +
31
+ (parseInt(this.address) || 0).toString(16).padStart(2,'0').toUpperCase();
32
+ },
33
+
34
+ oneditprepare: function() {
35
+ const savedAddr = this.address;
36
+ const savedCh = this.channel;
37
+ $('#node-input-typeId').prop('readonly', true).css('background', '#f0f0f0');
38
+
39
+ function populateDropdown(bridgeId) {
40
+ if (!bridgeId) return;
41
+ $.getJSON('velbus/scan-results?bridge=' + bridgeId, function(results) {
42
+ const modules = (results && results.modules) ? results.modules : [];
43
+ const $addr = $('#node-input-address');
44
+ $addr.empty().append($('<option>').val('').text('— select address —'));
45
+
46
+ const typeIds = Object.keys(BLIND_TYPES_S).map(k => parseInt(k));
47
+ const filtered = modules.filter(m => typeIds.includes(parseInt(m.typeId, 16)));
48
+
49
+ if (!filtered.length) {
50
+ $addr.append($('<option>').val('').text('No BLS/BLE blind modules found on bus'));
51
+ return;
52
+ }
53
+ filtered.forEach(function(m) {
54
+ const tid = parseInt(m.typeId, 16);
55
+ const label = m.address + ' — ' + m.module;
56
+ const $opt = $('<option>').val(m.addressDec).text(label).data('typeid', tid);
57
+ if (m.addressDec === parseInt(savedAddr)) $opt.attr('selected', true);
58
+ $addr.append($opt);
59
+ });
60
+ });
61
+ }
62
+
63
+ function updateChannelSelect(typeId) {
64
+ const t = BLIND_TYPES_S[typeId];
65
+ const $ch = $('#node-input-channel');
66
+ $ch.empty();
67
+ if (t) {
68
+ for (let i = 1; i <= t.channels; i++) {
69
+ $ch.append($('<option>').val(i).text('Channel ' + i));
70
+ }
71
+ $ch.val(savedCh || 1);
72
+ $('#node-input-typeId').val('0x' + typeId.toString(16).padStart(2,'0').toUpperCase());
73
+ $('#blinds-type-hint').text(t.name + ' · ' + t.channels + 'ch · position + lock/inhibit').show();
74
+ }
75
+ }
76
+
77
+ $('#node-input-bridge').on('change', function() { populateDropdown($(this).val()); });
78
+ $('#node-input-address').on('change', function() {
79
+ const tid = $(this).find(':selected').data('typeid');
80
+ if (tid) updateChannelSelect(tid);
81
+ });
82
+ // Deferred load — TypedInput sets value async after oneditprepare
83
+
84
+ $('#node-input-bridge').trigger('change');
85
+
86
+ setTimeout(function() { $('#node-input-bridge').trigger('change'); }, 150);
87
+ },
88
+
89
+ oneditsave: function() {
90
+ this.address = parseInt($('#node-input-address').val()) || 0;
91
+ this.channel = parseInt($('#node-input-channel').val()) || 1;
92
+ const tid = $('#node-input-typeId').val();
93
+ this.typeId = tid || '';
94
+ if (tid) {
95
+ const t = BLIND_TYPES_S[parseInt(tid, 16)];
96
+ if (t) this.moduleName = t.name;
97
+ }
98
+ },
99
+ });
100
+
101
+ })();
102
+ </script>
103
+
104
+ <script type="text/html" data-template-name="velbus-blind-s">
105
+ <div class="form-row">
106
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
107
+ <input type="text" id="node-input-name" placeholder="Optional label">
108
+ </div>
109
+ <div class="form-row">
110
+ <label for="node-input-bridge"><i class="fa fa-plug"></i> Bridge</label>
111
+ <input type="text" id="node-input-bridge">
112
+ </div>
113
+ <div class="form-row">
114
+ <label for="node-input-address"><i class="fa fa-map-marker"></i> Address</label>
115
+ <select id="node-input-address" style="width:70%">
116
+ <option value="">— select bridge first —</option>
117
+ </select>
118
+ </div>
119
+ <div class="form-row">
120
+ <label for="node-input-channel"><i class="fa fa-arrows-v"></i> Channel</label>
121
+ <select id="node-input-channel" style="width:70%">
122
+ <option value="1">Channel 1</option>
123
+ </select>
124
+ </div>
125
+ <div class="form-row">
126
+ <label></label>
127
+ <span id="blinds-type-hint" style="color:#888; font-size:0.9em; display:none;"></span>
128
+ </div>
129
+ <div class="form-row" style="display:none">
130
+ <input type="text" id="node-input-typeId">
131
+ <input type="text" id="node-input-moduleName">
132
+ </div>
133
+ </script>
134
+
135
+ <script type="text/html" data-help-name="velbus-blind-s">
136
+ <p style="background:#fff3cd; border-left:4px solid #ffc107; padding:8px 12px; margin-bottom:12px; font-size:0.85em;">
137
+ <strong>&#9888; Testing status:</strong> This node was generated with Claude.ai and is in
138
+ need of extensive field testing before being deployed into a commercial project.
139
+ It is presented "as is" &mdash; any use beyond testing is entirely at your own risk and liability.
140
+ Constructive feedback is welcomed, accompanied by as many examples and debug captures as possible.
141
+ <a href="https://github.com/MDAR/node-red-contrib-velbus-2026/issues" target="_blank">File an issue on GitHub</a>.
142
+ </p>
143
+ <p>Full-featured blind/shutter motor controller — VMB1BLS (0x2E), VMB2BLE (0x1D).</p>
144
+ <p>Supports position (0-100%), lock, forced up/down, inhibit variants, auto modes,
145
+ sunrise/sunset. For older VMB1BL/VMB2BL use <strong>velbus-blind</strong>.</p>
146
+
147
+ <h3>Output 1 — Relay events (0x00)</h3>
148
+ <pre>{ "type": "blind_event", "channel": 1,
149
+ "upOn": true, "upOff": false, "downOn": false, "downOff": false,
150
+ "moving": "up" }</pre>
151
+
152
+ <h3>Output 2 — Blind status (0xEC)</h3>
153
+ <pre>{ "type": "blind_status", "channel": 1, "on": true,
154
+ "status": "up", "position": 25, "lockState": "normal",
155
+ "autoMode": 0, "alarms": { "alarm1Active": false, "alarm2Active": false },
156
+ "led": { "upOn": true, "downOn": false, ... } }</pre>
157
+ <p>position: 0=fully up, 100=fully down.</p>
158
+ <p>lockState: normal, inhibited, inhibit_preset_down, inhibit_preset_up,
159
+ forced_down, forced_up, locked.</p>
160
+
161
+ <h3>Input commands</h3>
162
+ <dl>
163
+ <dt><code>{ "cmd": "stop" }</code></dt><dd>Stop movement.</dd>
164
+ <dt><code>{ "cmd": "up", "timeout": 0 }</code></dt><dd>Move up. 0=default, 0xFFFFFF=permanent.</dd>
165
+ <dt><code>{ "cmd": "down", "timeout": 0 }</code></dt><dd>Move down.</dd>
166
+ <dt><code>{ "cmd": "position", "position": 50 }</code></dt><dd>Set position 0-100%.</dd>
167
+ <dt><code>{ "cmd": "lock", "duration": 3600 }</code></dt><dd>Lock channel. 0xFFFFFF=permanent.</dd>
168
+ <dt><code>{ "cmd": "unlock" }</code></dt><dd>Unlock channel.</dd>
169
+ <dt><code>{ "cmd": "forced_up", "duration": 3600 }</code></dt><dd>Force up.</dd>
170
+ <dt><code>{ "cmd": "cancel_forced_up" }</code></dt><dd>Cancel forced up.</dd>
171
+ <dt><code>{ "cmd": "forced_down", "duration": 3600 }</code></dt><dd>Force down.</dd>
172
+ <dt><code>{ "cmd": "cancel_forced_down" }</code></dt><dd>Cancel forced down.</dd>
173
+ <dt><code>{ "cmd": "inhibit", "duration": 3600 }</code></dt><dd>Inhibit all movement.</dd>
174
+ <dt><code>{ "cmd": "cancel_inhibit" }</code></dt><dd>Cancel inhibit.</dd>
175
+ <dt><code>{ "cmd": "inhibit_preset_up", "duration": 3600 }</code></dt><dd>Inhibit with preset up position.</dd>
176
+ <dt><code>{ "cmd": "inhibit_preset_down", "duration": 3600 }</code></dt><dd>Inhibit with preset down position.</dd>
177
+ <dt><code>{ "cmd": "auto_mode", "mode": 1 }</code></dt><dd>Select auto mode (0=disabled, 1-3).</dd>
178
+ <dt><code>{ "cmd": "get_status" }</code></dt><dd>Request blind status.</dd>
179
+ <dt><code>{ "cmd": "get_name" }</code></dt><dd>Request blind name from VelbusLink.</dd>
180
+ </dl>
181
+ </script>