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,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>⚠ 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" — 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>⚠ 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" — 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>
|