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,49 @@
1
+ 'use strict';
2
+
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // Sensor/input module type registry — original series
5
+ // Covered by velbus-sensor node.
6
+ //
7
+ // VMB7IN (0x22): 8 digital inputs + 4 pulse counters (channels 1-4)
8
+ // lockStyle: 'bitmask' — DB2 of lock/unlock/enable/disable is channel bitmask
9
+ // nameStyle: 'bitmask' — DB2 of 0xF0/F1/F2 is channel bitmask
10
+ // hasCounter: true — 0xBE pulse counter on channels 1-4
11
+ //
12
+ // VMB4AN (0x32): 16 logical channels across 3 functional groups
13
+ // GROUP 1: alarm outputs (channels 1-8) — 0x00 press/release
14
+ // GROUP 2: sensor inputs (channels 9-12) — analogue, 4 presets
15
+ // GROUP 3: analogue outputs (ch 13-16) — 0-10V, original dimmer protocol
16
+ // lockStyle: 'bitmask'
17
+ // nameStyle: 'bitmask'
18
+ // hasCounter: false
19
+ // hasAnalogue: true
20
+ // ─────────────────────────────────────────────────────────────────────────────
21
+
22
+ const SENSOR_TYPES = {
23
+ 0x22: {
24
+ name: 'VMB7IN',
25
+ channels: 8,
26
+ counterCh: [1, 2, 3, 4], // channels with pulse counter capability
27
+ hasCounter: true,
28
+ hasAnalogue: false,
29
+ lockStyle: 'bitmask',
30
+ nameStyle: 'bitmask',
31
+ minMapVer: null,
32
+ series: 'original',
33
+ },
34
+ 0x32: {
35
+ name: 'VMB4AN',
36
+ channels: 8, // alarm output channels (group 1, produce 0x00 events)
37
+ counterCh: [],
38
+ hasCounter: false,
39
+ hasAnalogue: true, // groups 2 and 3 — handled separately
40
+ lockStyle: 'bitmask',
41
+ nameStyle: 'bitmask',
42
+ minMapVer: null,
43
+ series: 'original',
44
+ },
45
+ };
46
+
47
+ const SENSOR_TYPE_IDS = new Set(Object.keys(SENSOR_TYPES).map(k => parseInt(k)));
48
+
49
+ module.exports = { SENSOR_TYPES, SENSOR_TYPE_IDS };
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * velbus-utils.js
5
+ * Shared Velbus packet framing, checksum, and parse/build utilities.
6
+ * Used by velbus-bridge (splitter) and velbus-relay (encoder/decoder).
7
+ */
8
+
9
+ /**
10
+ * Compute Velbus packet checksum.
11
+ * Sum all bytes mod 256, return two's complement.
12
+ */
13
+ function chk(bytes) {
14
+ let s = 0;
15
+ for (const x of bytes) s = (s + x) & 0xFF;
16
+ return ((~s + 1) & 0xFF);
17
+ }
18
+
19
+ /**
20
+ * Build a Velbus packet.
21
+ * @param {number} pri - Priority byte (0xF8 commands, 0xFB status/RTR)
22
+ * @param {number} addr - Module address (0x01–0xFE)
23
+ * @param {number[]} body - Body bytes array
24
+ * @returns {Buffer}
25
+ */
26
+ function pkt(pri, addr, body) {
27
+ const dlc = body.length & 0x0F;
28
+ const h = [0x0F, pri, addr, dlc, ...body];
29
+ return Buffer.from([...h, chk(h), 0x04]);
30
+ }
31
+
32
+ /**
33
+ * Build an RTR (request-to-respond / bus scan) packet for a given address.
34
+ * RTR packets have DLC=0x40 and no body.
35
+ */
36
+ function rtrPkt(addr) {
37
+ const h = [0x0F, 0xFB, addr, 0x40];
38
+ return Buffer.from([...h, chk(h), 0x04]);
39
+ }
40
+
41
+ /**
42
+ * Parse a raw Velbus packet buffer.
43
+ * @param {Buffer} raw
44
+ * @returns {{ pri, addr, rtr, body, cmd } | null}
45
+ */
46
+ function parsePkt(raw) {
47
+ if (!Buffer.isBuffer(raw) || raw.length < 6) return null;
48
+ if (raw[0] !== 0x0F || raw[raw.length - 1] !== 0x04) return null;
49
+ const pri = raw[1], addr = raw[2], dlc = raw[3];
50
+ const rtr = (dlc & 0x40) !== 0;
51
+ const bl = rtr ? 0 : (dlc & 0x0F);
52
+ if (raw.length < 4 + bl + 2) return null;
53
+ const body = Array.from(raw.slice(4, 4 + bl));
54
+ return { pri, addr, rtr, body, cmd: body.length > 0 ? body[0] : null };
55
+ }
56
+
57
+ /**
58
+ * Split a raw TCP stream buffer into complete Velbus packets.
59
+ * Returns { packets: Buffer[], remainder: Buffer }
60
+ * Caller is responsible for persisting remainder across TCP data events.
61
+ */
62
+ function splitPackets(buf) {
63
+ const packets = [];
64
+ let i = 0;
65
+ while (i < buf.length) {
66
+ if (buf[i] !== 0x0F) { i++; continue; }
67
+ if (i + 3 >= buf.length) break;
68
+ const dl = buf[i + 3];
69
+ const rtr = (dl & 0x40) !== 0;
70
+ const bl = rtr ? 0 : (dl & 0x0F);
71
+ const pl = 4 + bl + 2;
72
+ if (i + pl > buf.length) break;
73
+ const p = buf.slice(i, i + pl);
74
+ if (p[p.length - 1] === 0x04) packets.push(Buffer.from(p));
75
+ i += pl;
76
+ }
77
+ return { packets, remainder: buf.slice(i) };
78
+ }
79
+
80
+ module.exports = { chk, pkt, rtrPkt, parsePkt, splitPackets };
@@ -0,0 +1,165 @@
1
+ <script type="text/javascript">
2
+ (function() {
3
+
4
+ const BLIND_TYPES = {
5
+ 0x03: { name: 'VMB1BL', channels: 1 },
6
+ 0x09: { name: 'VMB2BL', channels: 2 },
7
+ };
8
+
9
+ RED.nodes.registerType('velbus-blind', {
10
+ category: 'Velbus (outputs)',
11
+ color: '#4A90D9',
12
+ defaults: {
13
+ name: { value: '' },
14
+ bridge: { value: '', type: 'velbus-bridge', required: true },
15
+ address: { value: '', required: true, validate: function(v) { return /^(0x)?[0-9a-fA-F]{1,2}$/.test(v); } },
16
+ channel: { value: 1, required: true },
17
+ moduleName: { value: '' },
18
+ typeId: { value: '' },
19
+ },
20
+ inputs: 1,
21
+ outputs: 1,
22
+ outputLabels: ['blind events / status'],
23
+ icon: 'font-awesome/fa-arrows-v',
24
+ paletteLabel: 'blind',
25
+
26
+ label: function() {
27
+ const t = this.typeId ? BLIND_TYPES[parseInt(this.typeId, 16)] : null;
28
+ const n = this.name || this.moduleName || (t ? t.name : 'blind');
29
+ return n + ' ch' + this.channel + ' 0x' +
30
+ (parseInt(this.address) || 0).toString(16).padStart(2,'0').toUpperCase();
31
+ },
32
+
33
+ oneditprepare: function() {
34
+ const savedAddr = this.address;
35
+ const savedCh = this.channel;
36
+ $('#node-input-typeId').prop('readonly', true).css('background', '#f0f0f0');
37
+
38
+ function populateDropdown(bridgeId) {
39
+ if (!bridgeId) return;
40
+ $.getJSON('velbus/scan-results?bridge=' + bridgeId, function(results) {
41
+ const modules = (results && results.modules) ? results.modules : [];
42
+ const $addr = $('#node-input-address');
43
+ $addr.empty().append($('<option>').val('').text('— select address —'));
44
+
45
+ const typeIds = Object.keys(BLIND_TYPES).map(k => parseInt(k));
46
+ const filtered = modules.filter(m => typeIds.includes(parseInt(m.typeId, 16)));
47
+
48
+ if (!filtered.length) {
49
+ $addr.append($('<option>').val('').text('No blind modules found on bus'));
50
+ return;
51
+ }
52
+ filtered.forEach(function(m) {
53
+ const tid = parseInt(m.typeId, 16);
54
+ const label = m.address + ' — ' + m.module;
55
+ const $opt = $('<option>').val(m.addressDec).text(label).data('typeid', tid);
56
+ if (m.addressDec === parseInt(savedAddr)) $opt.attr('selected', true);
57
+ $addr.append($opt);
58
+ });
59
+ });
60
+ }
61
+
62
+ function updateChannelSelect(typeId) {
63
+ const t = BLIND_TYPES[typeId];
64
+ const $ch = $('#node-input-channel');
65
+ $ch.empty();
66
+ if (t) {
67
+ for (let i = 1; i <= t.channels; i++) {
68
+ $ch.append($('<option>').val(i).text('Channel ' + i));
69
+ }
70
+ $ch.val(savedCh || 1);
71
+ $('#node-input-typeId').val('0x' + typeId.toString(16).padStart(2,'0').toUpperCase());
72
+ $('#blind-type-hint').text(t.name + ' · ' + t.channels + ' channel' + (t.channels > 1 ? 's' : '')).show();
73
+ }
74
+ }
75
+
76
+ $('#node-input-bridge').on('change', function() { populateDropdown($(this).val()); });
77
+ $('#node-input-address').on('change', function() {
78
+ const tid = $(this).find(':selected').data('typeid');
79
+ if (tid) updateChannelSelect(tid);
80
+ });
81
+ // Deferred load — TypedInput sets value async after oneditprepare
82
+
83
+ $('#node-input-bridge').trigger('change');
84
+
85
+ setTimeout(function() { $('#node-input-bridge').trigger('change'); }, 150);
86
+ },
87
+
88
+ oneditsave: function() {
89
+ this.address = parseInt($('#node-input-address').val()) || 0;
90
+ this.channel = parseInt($('#node-input-channel').val()) || 1;
91
+ const tid = $('#node-input-typeId').val();
92
+ this.typeId = tid || '';
93
+ if (tid) {
94
+ const t = BLIND_TYPES[parseInt(tid, 16)];
95
+ if (t) this.moduleName = t.name;
96
+ }
97
+ },
98
+ });
99
+
100
+ })();
101
+ </script>
102
+
103
+ <script type="text/html" data-template-name="velbus-blind">
104
+ <div class="form-row">
105
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
106
+ <input type="text" id="node-input-name" placeholder="Optional label">
107
+ </div>
108
+ <div class="form-row">
109
+ <label for="node-input-bridge"><i class="fa fa-plug"></i> Bridge</label>
110
+ <input type="text" id="node-input-bridge">
111
+ </div>
112
+ <div class="form-row">
113
+ <label for="node-input-address"><i class="fa fa-map-marker"></i> Address</label>
114
+ <select id="node-input-address" style="width:70%">
115
+ <option value="">— select bridge first —</option>
116
+ </select>
117
+ </div>
118
+ <div class="form-row">
119
+ <label for="node-input-channel"><i class="fa fa-arrows-v"></i> Channel</label>
120
+ <select id="node-input-channel" style="width:70%">
121
+ <option value="1">Channel 1</option>
122
+ </select>
123
+ </div>
124
+ <div class="form-row">
125
+ <label></label>
126
+ <span id="blind-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">
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>Blind/shutter motor controller for original series — VMB1BL (0x03), VMB2BL (0x09).</p>
143
+ <p>For VMB1BLS/VMB2BLE with position control use <strong>velbus-blind-s</strong>.</p>
144
+
145
+ <h3>Output — Blind events and status</h3>
146
+ <p>Relay/button event (0x00):</p>
147
+ <pre>{ "type": "blind_event", "channel": 1,
148
+ "events": [{ "channel": 1, "event": "relay_up_on" }] }</pre>
149
+ <p>Event types: relay_up_on, relay_up_off, relay_down_on, relay_down_off,
150
+ btn_up_pressed, btn_up_released, btn_up_long, btn_down_pressed, btn_down_released, btn_down_long.</p>
151
+
152
+ <p>Blind status (0xEC):</p>
153
+ <pre>{ "type": "blind_status", "channel": 1, "on": true, "status": "up",
154
+ "timeoutSetting": "1min", "delayRemaining": 42,
155
+ "led": { "upOn": true, "downOn": false, ... } }</pre>
156
+
157
+ <h3>Input commands</h3>
158
+ <dl>
159
+ <dt><code>{ "cmd": "stop" }</code></dt><dd>Stop blind movement.</dd>
160
+ <dt><code>{ "cmd": "up", "timeout": 0 }</code></dt><dd>Move up. timeout=0 uses dip switch setting. timeout=0xFFFFFF = permanent.</dd>
161
+ <dt><code>{ "cmd": "down", "timeout": 0 }</code></dt><dd>Move down.</dd>
162
+ <dt><code>{ "cmd": "get_status" }</code></dt><dd>Request blind status.</dd>
163
+ <dt><code>{ "cmd": "get_name" }</code></dt><dd>Request blind name from VelbusLink.</dd>
164
+ </dl>
165
+ </script>
@@ -0,0 +1,251 @@
1
+ 'use strict';
2
+
3
+ const { pkt, parsePkt } = require('../../lib/velbus-utils');
4
+ const { BLIND_TYPES, BLIND_TYPE_IDS } = require('../../lib/blind-types');
5
+
6
+ // ─────────────────────────────────────────────────────────────────────────────
7
+ // Helpers
8
+ // ─────────────────────────────────────────────────────────────────────────────
9
+
10
+ // VMB1BL: 0x00 bitmask uses bits 0-1 for relays, bits 4-5 for local buttons
11
+ // VMB2BL: 0x00 bitmask bits 0-1=ch1 relays, 2-3=ch2 relays,
12
+ // 4-5=ch1 local buttons, 6-7=ch2 local buttons
13
+ function decodeBlindEvent(pressedByte, releasedByte, longByte, typeDesc) {
14
+ const events = [];
15
+ const masks = typeDesc.channelMasks;
16
+
17
+ for (const [chStr, mask] of Object.entries(masks)) {
18
+ const ch = parseInt(chStr);
19
+ const upBit = mask & 0x01 ? 0x01 : (mask >> 2) & 0x01 ? 0x04 : 0;
20
+ const downBit = mask & 0x02 ? 0x02 : (mask >> 2) & 0x02 ? 0x08 : 0;
21
+
22
+ // Relay state changes
23
+ if (pressedByte & upBit) events.push({ channel: ch, event: 'relay_up_on' });
24
+ if (releasedByte & upBit) events.push({ channel: ch, event: 'relay_up_off' });
25
+ if (pressedByte & downBit) events.push({ channel: ch, event: 'relay_down_on' });
26
+ if (releasedByte & downBit) events.push({ channel: ch, event: 'relay_down_off' });
27
+ }
28
+
29
+ // Local button events (if module has them)
30
+ if (typeDesc.hasLocalButtons) {
31
+ // VMB1BL: up=bit4, down=bit5
32
+ // VMB2BL: ch1 up=bit4, ch1 down=bit5, ch2 up=bit6, ch2 down=bit7
33
+ const btnBases = typeDesc.channels === 1
34
+ ? [{ ch: 1, upBit: 0x10, downBit: 0x20 }]
35
+ : [{ ch: 1, upBit: 0x10, downBit: 0x20 }, { ch: 2, upBit: 0x40, downBit: 0x80 }];
36
+
37
+ for (const { ch, upBit: ub, downBit: db } of btnBases) {
38
+ if (pressedByte & ub) events.push({ channel: ch, event: 'btn_up_pressed' });
39
+ if (releasedByte & ub) events.push({ channel: ch, event: 'btn_up_released' });
40
+ if (longByte & ub) events.push({ channel: ch, event: 'btn_up_long' });
41
+ if (pressedByte & db) events.push({ channel: ch, event: 'btn_down_pressed' });
42
+ if (releasedByte & db) events.push({ channel: ch, event: 'btn_down_released' });
43
+ if (longByte & db) events.push({ channel: ch, event: 'btn_down_long' });
44
+ }
45
+ }
46
+
47
+ return events;
48
+ }
49
+
50
+ const TIMEOUT_LABELS = { 0: '15s', 1: '30s', 2: '1min', 3: '2min' };
51
+ const BLIND_STATUS = { 0: 'off', 1: 'up', 2: 'down' };
52
+
53
+ // ─────────────────────────────────────────────────────────────────────────────
54
+ // Node
55
+ // ─────────────────────────────────────────────────────────────────────────────
56
+
57
+ module.exports = function(RED) {
58
+
59
+ function VelbusBlindNode(config) {
60
+ RED.nodes.createNode(this, config);
61
+ const node = this;
62
+
63
+ node.bridge = RED.nodes.getNode(config.bridge);
64
+ node.address = typeof config.address === 'number'
65
+ ? config.address // legacy saves stored decimal numbers
66
+ : (parseInt(config.address, 16) || 0); // editor stores hex strings
67
+ node.moduleName = config.moduleName || '';
68
+ node.typeId = config.typeId ? parseInt(config.typeId) : null;
69
+ node.channel = parseInt(config.channel) || 1;
70
+
71
+ if (!node.bridge) {
72
+ node.status({ fill: 'red', shape: 'ring', text: 'no bridge' });
73
+ node.error('velbus-blind: no bridge configured');
74
+ return;
75
+ }
76
+ if (!node.address || node.address < 1 || node.address > 254) {
77
+ node.status({ fill: 'red', shape: 'ring', text: 'invalid address' });
78
+ node.error('velbus-blind: invalid address ' + node.address);
79
+ return;
80
+ }
81
+
82
+ const typeDesc = node.typeId !== null ? (BLIND_TYPES[node.typeId] || null) : null;
83
+ const chMask = typeDesc ? (typeDesc.channelMasks[node.channel] || 0x03) : 0x03;
84
+
85
+ const _addrHex = '0x' + node.address.toString(16).padStart(2, '0').toUpperCase();
86
+ function setStatus(text, fill, shape) {
87
+ const label = node.moduleName
88
+ ? node.moduleName + ' ch' + node.channel + ' (' + _addrHex + ')'
89
+ : _addrHex + ' ch' + node.channel;
90
+ node.status({ fill: fill || 'green', shape: shape || 'dot', text: label + ' ' + text });
91
+ }
92
+
93
+ // ── Packet handler ────────────────────────────────────────────────────
94
+
95
+ function onPacket(raw) {
96
+ const p = parsePkt(raw);
97
+ if (!p || p.rtr) return;
98
+ if (p.addr !== node.address) return;
99
+
100
+ const { cmd, body } = p;
101
+
102
+ switch (cmd) {
103
+
104
+ // ── 0x00 Relay events and local button events ────────────────────
105
+ case 0x00: {
106
+ if (body.length < 4) return;
107
+ // body[0]=cmd, body[1]=pressed, body[2]=released, body[3]=longPressed
108
+ if (!typeDesc) return;
109
+ const events = decodeBlindEvent(body[1], body[2], body[3], typeDesc);
110
+ if (!events.length) return;
111
+
112
+ // Filter to events relevant to this node's channel
113
+ const myEvents = events.filter(e => e.channel === node.channel);
114
+ if (!myEvents.length) return;
115
+
116
+ // Determine overall motion state from relay events
117
+ const relayOn = myEvents.filter(e => e.event === 'relay_up_on' || e.event === 'relay_down_on');
118
+ const relayOff = myEvents.filter(e => e.event === 'relay_up_off' || e.event === 'relay_down_off');
119
+ const moving = relayOn.some(e => e.event === 'relay_up_on') ? 'up'
120
+ : relayOn.some(e => e.event === 'relay_down_on') ? 'down'
121
+ : null;
122
+ if (moving) setStatus(moving);
123
+ else if (relayOff.length) setStatus('stopped');
124
+
125
+ const payload = {
126
+ type: 'blind_event',
127
+ channel: node.channel,
128
+ events: myEvents,
129
+ };
130
+ node.send([{ payload }]);
131
+ break;
132
+ }
133
+
134
+ // ── 0xEC Blind status ────────────────────────────────────────────
135
+ case 0xEC: {
136
+ if (body.length < 8) return;
137
+ // body[0]=0xEC, body[1]=channel mask, body[2]=timeout setting,
138
+ // body[3]=blind status, body[4]=LED status,
139
+ // body[5-7]=24-bit current delay time remaining
140
+ const statusMask = body[1];
141
+
142
+ // Only process if this packet is for our channel
143
+ if (statusMask !== chMask) return;
144
+
145
+ const timeoutSetting = TIMEOUT_LABELS[body[2]] || body[2] + 's';
146
+ const blindStatus = BLIND_STATUS[body[3]] || 'unknown';
147
+ const ledByte = body[4];
148
+ const delayRemaining = (body[5] << 16) | (body[6] << 8) | body[7];
149
+
150
+ const ledStatus = {
151
+ downOn: !!(ledByte & 0x80),
152
+ downSlowBlink: !!(ledByte & 0x40),
153
+ downFastBlink: !!(ledByte & 0x20),
154
+ downVFastBlink:!!(ledByte & 0x10),
155
+ upOn: !!(ledByte & 0x08),
156
+ upSlowBlink: !!(ledByte & 0x04),
157
+ upFastBlink: !!(ledByte & 0x02),
158
+ upVFastBlink: !!(ledByte & 0x01),
159
+ };
160
+
161
+ const on = blindStatus !== 'off';
162
+ setStatus(blindStatus);
163
+
164
+ const payload = {
165
+ type: 'blind_status',
166
+ channel: node.channel,
167
+ on,
168
+ status: blindStatus,
169
+ timeoutSetting,
170
+ delayRemaining,
171
+ led: ledStatus,
172
+ };
173
+ node.send([{ payload }]);
174
+ break;
175
+ }
176
+
177
+ // ── 0xF0/F1/F2 Blind name parts ─────────────────────────────────
178
+ case 0xF0:
179
+ case 0xF1:
180
+ case 0xF2: {
181
+ // body[0]=cmd, body[1]=channel mask, body[2..]=chars
182
+ if (body[1] !== chMask) return;
183
+ let text = '';
184
+ for (let i = 2; i < body.length; i++) {
185
+ if (body[i] === 0 || body[i] === 0xFF) break;
186
+ text += String.fromCharCode(body[i]);
187
+ }
188
+ if (text && cmd === 0xF0) node.moduleName = text;
189
+ break;
190
+ }
191
+
192
+ default:
193
+ break;
194
+ }
195
+ }
196
+
197
+ node.bridge.register(node.address, onPacket);
198
+
199
+ // ── Input commands ────────────────────────────────────────────────────
200
+
201
+ node.on('input', function(msg) {
202
+ const cmd = msg.payload && msg.payload.cmd;
203
+ if (!cmd) return;
204
+
205
+ if (cmd === 'get_status') {
206
+ node.bridge.send(pkt(0xFB, node.address, [0xFA, chMask]));
207
+ return;
208
+ }
209
+
210
+ if (cmd === 'stop') {
211
+ node.bridge.send(pkt(0xF8, node.address, [0x04, chMask]));
212
+ return;
213
+ }
214
+
215
+ if (cmd === 'up') {
216
+ const timeout = parseInt(msg.payload.timeout) || 0;
217
+ const hi = (timeout >> 16) & 0xFF;
218
+ const mid = (timeout >> 8) & 0xFF;
219
+ const lo = timeout & 0xFF;
220
+ node.bridge.send(pkt(0xF8, node.address, [0x05, chMask, hi, mid, lo]));
221
+ return;
222
+ }
223
+
224
+ if (cmd === 'down') {
225
+ const timeout = parseInt(msg.payload.timeout) || 0;
226
+ const hi = (timeout >> 16) & 0xFF;
227
+ const mid = (timeout >> 8) & 0xFF;
228
+ const lo = timeout & 0xFF;
229
+ node.bridge.send(pkt(0xF8, node.address, [0x06, chMask, hi, mid, lo]));
230
+ return;
231
+ }
232
+
233
+ if (cmd === 'get_name') {
234
+ node.bridge.send(pkt(0xF8, node.address, [0xEF, chMask]));
235
+ return;
236
+ }
237
+
238
+ node.warn('velbus-blind: unknown cmd: ' + cmd);
239
+ });
240
+
241
+ // ── Cleanup ───────────────────────────────────────────────────────────
242
+
243
+ node.on('close', function() {
244
+ node.bridge.deregister(node.address, onPacket);
245
+ });
246
+
247
+ setStatus('ready', 'grey', 'dot');
248
+ }
249
+
250
+ RED.nodes.registerType('velbus-blind', VelbusBlindNode);
251
+ };