node-red-contrib-velbus-2026 0.8.1 → 0.9.0
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 +42 -0
- package/README.md +1 -0
- package/lib/energy-types-20.js +29 -0
- package/nodes/velbus-energy/velbus-energy.html +167 -0
- package/nodes/velbus-energy/velbus-energy.js +365 -0
- package/package.json +3 -2
- package/velbus-2026-repo.bundle +0 -0
package/CHANGELOG_FORUM.md
CHANGED
|
@@ -14,6 +14,48 @@ All feedback via GitHub issues, with examples and debug captures where possible.
|
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
17
|
+
## v0.9.0 — 08/07/2026
|
|
18
|
+
|
|
19
|
+
### velbus-energy — new node (19th node), VMBPSUMNGR-20
|
|
20
|
+
|
|
21
|
+
- **Closes the last "not yet built" item on the module registry** —
|
|
22
|
+
VMBPSUMNGR-20 (0x04), the power-supply-manager module, following the same
|
|
23
|
+
V2 architectural pattern as `velbus-sensor-20` (firmware/type check, name
|
|
24
|
+
auto-retrieval, standard startup RTR).
|
|
25
|
+
- Handles: `0xED` module status (PSU load %ages, alarm bitmasks, shared
|
|
26
|
+
program/clock-alarm/sunrise/sunset byte), `0x00` real-time alarm events
|
|
27
|
+
(arrives via primary address as one bitmask, via sub-address1 as a second
|
|
28
|
+
bitmask — same command, discriminated by source address, merged into
|
|
29
|
+
running alarm state so a partial update never discards the other half),
|
|
30
|
+
`0xA1` warranty counter (31-bit hours-in-operation + expired flag, packed
|
|
31
|
+
across 4 bytes), `0xA2` PSU load status (mode + 3 load percentages), `0xA3`
|
|
32
|
+
PSU values (wattage/voltage/amperage per rail, one message per PSU/PSUOut).
|
|
33
|
+
- **Does not reimplement the shared V2 "system" commands** (real-time clock,
|
|
34
|
+
date, DST, sunrise/sunset, clock alarm) present throughout this PDF —
|
|
35
|
+
`velbus-clock` already owns broadcasting those; duplicating them here
|
|
36
|
+
would just be dead weight.
|
|
37
|
+
- **Two real errors found in Velbus's own protocol PDF while implementing
|
|
38
|
+
this, both documented in the node's source comments:**
|
|
39
|
+
- The document header reads "VMB8IN-20 PROTOCOL" throughout — an
|
|
40
|
+
un-retitled template artifact from a different module's document.
|
|
41
|
+
- The `0xA3` (PSU values status) section labels three different byte
|
|
42
|
+
positions all as "DATABYTE6" (voltage high, amperage high, amperage
|
|
43
|
+
low) — internally inconsistent with its own stated 8-byte length.
|
|
44
|
+
Reconstructed from the byte count as DATABYTE5/6 = voltage hi/lo,
|
|
45
|
+
DATABYTE7/8 = amperage hi/lo. Same category as the temperature-divisor
|
|
46
|
+
and build-number-mislabelling issues already known elsewhere in this
|
|
47
|
+
project — worth remembering if re-checking against the source PDF.
|
|
48
|
+
- Verified with the mock-RED harness: every packet type (module type
|
|
49
|
+
identification, both alarm bitmask sources, module status, warranty
|
|
50
|
+
counter, PSU load, PSU values, name assembly across all three name-part
|
|
51
|
+
commands) exercised with hand-checked checksums; outgoing commands
|
|
52
|
+
(`get_status`, `get_warranty`, `get_name`) checksum-verified by hand
|
|
53
|
+
against the actual bytes produced. **Not yet sent to a real bus** — no
|
|
54
|
+
VMBPSUMNGR-20 has been confirmed present on a scanned bus yet (see the
|
|
55
|
+
handover's outstanding-verification notes).
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
17
59
|
## v0.8.1 — 07/07/2026
|
|
18
60
|
|
|
19
61
|
### velbus-clock — set_alarm command (global + local, one command)
|
package/README.md
CHANGED
|
@@ -85,6 +85,7 @@ npm install /path/to/node-red-contrib-velbus-2026_v0.8.1.tar.gz
|
|
|
85
85
|
| `velbus-blind-s` | VMB1BLS, VMB2BLE, VMB2BLE-10 — full position, lock, force, inhibit |
|
|
86
86
|
| `velbus-blind-20` | VMB2BLE-20 — V2 series with CAN FD support |
|
|
87
87
|
| `velbus-clock` | No fixed module — broadcasts time/date/DST to bus address `0x00`, plus global/local clock alarm (V2 series only) |
|
|
88
|
+
| `velbus-energy` | VMBPSUMNGR-20 — power supply manager: PSU load, live wattage/voltage/amperage, warranty counter, PSU alarms (V2 series only) |
|
|
88
89
|
|
|
89
90
|
---
|
|
90
91
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
// Energy / power-supply-manager module type registry — V2 series
|
|
5
|
+
// Covered by velbus-energy node.
|
|
6
|
+
//
|
|
7
|
+
// VMBPSUMNGR-20 (0x04): manages up to two power supplies (PSU1, PSU2) feeding
|
|
8
|
+
// a combined output (PSUOut) — load balancing / boost / back-up modes.
|
|
9
|
+
// Reports per-rail load percentage, live wattage/voltage/amperage, a
|
|
10
|
+
// warranty counter (hours in operation, 10-year/87660h limit + expired
|
|
11
|
+
// flag), and PSU/warranty alarm status. Single-purpose monitoring module —
|
|
12
|
+
// no per-channel I/O config in the usual sense.
|
|
13
|
+
//
|
|
14
|
+
// Source: protocol_vmbpsumngr_20.pdf (ed.1, 14/03/2025). Note: the PDF's own
|
|
15
|
+
// header still reads "VMB8IN-20 PROTOCOL" throughout — an un-retitled
|
|
16
|
+
// template artifact, not a sign this registry entry is wrong.
|
|
17
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const ENERGY_TYPES_20 = {
|
|
20
|
+
0x04: {
|
|
21
|
+
name: 'VMBPSUMNGR-20',
|
|
22
|
+
minMapVer: null, // no minimum stated in the protocol PDF
|
|
23
|
+
series: 'v2',
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const ENERGY_TYPE_IDS_20 = new Set(Object.keys(ENERGY_TYPES_20).map(k => parseInt(k)));
|
|
28
|
+
|
|
29
|
+
module.exports = { ENERGY_TYPES_20, ENERGY_TYPE_IDS_20 };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
(function() {
|
|
3
|
+
|
|
4
|
+
const ENERGY_TYPES_20 = {
|
|
5
|
+
0x04: { name: 'VMBPSUMNGR-20', series: 'v2' },
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
RED.nodes.registerType('velbus-energy', {
|
|
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: ['status / alarm / warranty / psu load', 'psu values (per rail)'],
|
|
21
|
+
icon: 'font-awesome/fa-plug',
|
|
22
|
+
paletteLabel: 'energy',
|
|
23
|
+
|
|
24
|
+
label: function() {
|
|
25
|
+
const t = this.typeId ? ENERGY_TYPES_20[parseInt(this.typeId, 16)] : null;
|
|
26
|
+
const n = this.name || this.moduleName || (t ? t.name : 'energy');
|
|
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(ENERGY_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 power supply manager 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
|
+
$('#energy-type-hint').text(ENERGY_TYPES_20[tid].name + ' · PSU1/PSU2/PSUOut monitoring').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 && ENERGY_TYPES_20[tid]) {
|
|
70
|
+
$('#energy-type-hint').text(ENERGY_TYPES_20[tid].name + ' · PSU1/PSU2/PSUOut monitoring').show();
|
|
71
|
+
$('#node-input-typeId').val('0x' + tid.toString(16).padStart(2,'0').toUpperCase());
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
$('#node-input-bridge').trigger('change');
|
|
76
|
+
setTimeout(function() { $('#node-input-bridge').trigger('change'); }, 150);
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
oneditsave: function() {
|
|
80
|
+
this.address = parseInt($('#node-input-address').val()) || 0;
|
|
81
|
+
const tid = $('#node-input-typeId').val();
|
|
82
|
+
this.typeId = tid || '';
|
|
83
|
+
if (tid) {
|
|
84
|
+
const t = ENERGY_TYPES_20[parseInt(tid, 16)];
|
|
85
|
+
if (t) this.moduleName = t.name;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
})();
|
|
91
|
+
</script>
|
|
92
|
+
|
|
93
|
+
<script type="text/html" data-template-name="velbus-energy">
|
|
94
|
+
<div class="form-row">
|
|
95
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
96
|
+
<input type="text" id="node-input-name" placeholder="Optional label">
|
|
97
|
+
</div>
|
|
98
|
+
<div class="form-row">
|
|
99
|
+
<label for="node-input-bridge"><i class="fa fa-plug"></i> Bridge</label>
|
|
100
|
+
<input type="text" id="node-input-bridge">
|
|
101
|
+
</div>
|
|
102
|
+
<div class="form-row">
|
|
103
|
+
<label for="node-input-address"><i class="fa fa-map-marker"></i> Address</label>
|
|
104
|
+
<select id="node-input-address" style="width:70%">
|
|
105
|
+
<option value="">— select bridge first —</option>
|
|
106
|
+
</select>
|
|
107
|
+
</div>
|
|
108
|
+
<div class="form-row">
|
|
109
|
+
<label></label>
|
|
110
|
+
<span id="energy-type-hint" style="color:#888; font-size:0.9em; display:none;"></span>
|
|
111
|
+
</div>
|
|
112
|
+
<div class="form-row" style="display:none">
|
|
113
|
+
<input type="text" id="node-input-typeId">
|
|
114
|
+
<input type="text" id="node-input-moduleName">
|
|
115
|
+
</div>
|
|
116
|
+
</script>
|
|
117
|
+
|
|
118
|
+
<script type="text/html" data-help-name="velbus-energy">
|
|
119
|
+
<p style="background:#fff3cd; border-left:4px solid #ffc107; padding:8px 12px; margin-bottom:12px; font-size:0.85em;">
|
|
120
|
+
<strong>⚠ Testing status:</strong> This node was generated with Claude.ai and is in
|
|
121
|
+
need of extensive field testing before being deployed into a commercial project.
|
|
122
|
+
It is presented "as is" — any use beyond testing is entirely at your own risk and liability.
|
|
123
|
+
Constructive feedback is welcomed, accompanied by as many examples and debug captures as possible.
|
|
124
|
+
<a href="https://github.com/MDAR/node-red-contrib-velbus-2026/issues" target="_blank">File an issue on GitHub</a>.
|
|
125
|
+
</p>
|
|
126
|
+
<p><strong>Verified only against a mock test harness, not yet against real hardware</strong> — no
|
|
127
|
+
VMBPSUMNGR-20 has been confirmed present on a scanned bus yet. Packet checksums, DLC, and field
|
|
128
|
+
layout have been hand-verified against the protocol PDF; the actual real-hardware byte layout has
|
|
129
|
+
not been independently confirmed. See this project's <code>HANDOVER.md</code> for detail.</p>
|
|
130
|
+
|
|
131
|
+
<p>V2 power supply manager node for VMBPSUMNGR-20 (0x04). Monitors up to two power supplies
|
|
132
|
+
(PSU1, PSU2) feeding a combined output (PSUOut) — load percentages, live wattage/voltage/
|
|
133
|
+
amperage per rail, a warranty counter (hours in operation), and PSU/warranty alarm status.</p>
|
|
134
|
+
<p>This is a monitoring-only module — there's no per-channel I/O configuration the way
|
|
135
|
+
most other nodes in this palette have.</p>
|
|
136
|
+
|
|
137
|
+
<h3>Output 1 — Status / alarm / warranty / PSU load</h3>
|
|
138
|
+
<p>Alarm event (0x00 — fires immediately on change):</p>
|
|
139
|
+
<pre>{ "type": "alarm", "on": true, "alarms": {
|
|
140
|
+
"psu1Offline": false, "psu2Offline": true, "warrantyExpired": false,
|
|
141
|
+
"psu1Alarm1": false, "psu2Alarm1": false, "psuOutAlarm1": false,
|
|
142
|
+
"psu1Alarm2": false, "psu1Alarm3": false, "psu1Alarm4": false,
|
|
143
|
+
"psu2Alarm2": false, "psu2Alarm3": false, "psu2Alarm4": false,
|
|
144
|
+
"psuOutAlarm2": false } }</pre>
|
|
145
|
+
<p>Module status (0xED — the same alarm bitmasks as above, plus load and program info in one packet):</p>
|
|
146
|
+
<pre>{ "type": "status", "on": false, "alarms": { ... },
|
|
147
|
+
"psu1Load": 45, "psu2Load": 50, "psuOutLoad": 48, "program": "group1",
|
|
148
|
+
"clockAlarms": { "alarm1": { "on": true, "global": false },
|
|
149
|
+
"alarm2": { "on": false, "global": false } },
|
|
150
|
+
"sunrise": false, "sunset": false, "autoSendInterval": 60 }</pre>
|
|
151
|
+
<p>Warranty counter (0xA1):</p>
|
|
152
|
+
<pre>{ "type": "warranty", "hours": 5000, "expired": false, "limitHours": 87660, "on": false }</pre>
|
|
153
|
+
<p>PSU load status (0xA2):</p>
|
|
154
|
+
<pre>{ "type": "psu_load", "mode": "boost", "psu1Load": 60, "psu2Load": 70, "psuOutLoad": 65 }</pre>
|
|
155
|
+
|
|
156
|
+
<h3>Output 2 — PSU values (0xA3, one message per rail)</h3>
|
|
157
|
+
<pre>{ "type": "psu_values", "channel": 2, "channelName": "PSU2",
|
|
158
|
+
"wattageMW": 50000, "voltageMV": 24000, "amperageMA": 2100,
|
|
159
|
+
"watts": 50, "volts": 24, "amps": 2.1 }</pre>
|
|
160
|
+
|
|
161
|
+
<h3>Input commands</h3>
|
|
162
|
+
<dl>
|
|
163
|
+
<dt><code>{ "cmd": "get_status" }</code></dt><dd>Request module status.</dd>
|
|
164
|
+
<dt><code>{ "cmd": "get_warranty" }</code></dt><dd>Request the warranty/hours-in-operation counter.</dd>
|
|
165
|
+
<dt><code>{ "cmd": "get_name", "channel": 1 }</code></dt><dd>Request channel name (1-10, 255=all).</dd>
|
|
166
|
+
</dl>
|
|
167
|
+
</script>
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { pkt, rtrPkt, parsePkt } = require('../../lib/velbus-utils');
|
|
4
|
+
const { ENERGY_TYPES_20, ENERGY_TYPE_IDS_20 } = require('../../lib/energy-types-20');
|
|
5
|
+
|
|
6
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
// velbus-energy — VMBPSUMNGR-20 (power supply manager), type 0x04
|
|
8
|
+
//
|
|
9
|
+
// Source: protocol_vmbpsumngr_20.pdf (ed.1, 14/03/2025). This PDF is largely
|
|
10
|
+
// the same shared V2 "system" command block already implemented in
|
|
11
|
+
// velbus-clock (real-time clock, date, DST, sunrise/sunset, clock alarm) —
|
|
12
|
+
// none of that is reimplemented here; velbus-clock remains the single owner
|
|
13
|
+
// of broadcasting those. Only the genuinely energy/PSU-specific packets are
|
|
14
|
+
// handled below.
|
|
15
|
+
//
|
|
16
|
+
// Known PDF documentation issues, both worth remembering if re-checking
|
|
17
|
+
// against the source document directly:
|
|
18
|
+
// - The document header reads "VMB8IN-20 PROTOCOL" throughout — an
|
|
19
|
+
// un-retitled template artifact from a different module's document, not
|
|
20
|
+
// a sign this file targets the wrong module.
|
|
21
|
+
// - The 0xA3 (PSU values status) section labels three different byte
|
|
22
|
+
// positions all as "DATABYTE6" (voltage high, amperage high, amperage
|
|
23
|
+
// low) — internally inconsistent with its own stated 8-byte length.
|
|
24
|
+
// Reconstructed below from the byte count and field descriptions as
|
|
25
|
+
// DATABYTE5/6 = voltage hi/lo, DATABYTE7/8 = amperage hi/lo.
|
|
26
|
+
// - 0xA0 (Warranty Counter Request)'s internal constant name is given as
|
|
27
|
+
// "COMMAND_POWER_UP" — clearly a copy-paste artifact from the 0xAB
|
|
28
|
+
// section just above it, not a sign this is actually the power-up
|
|
29
|
+
// command. Treated here as a simple no-parameter trigger, per its
|
|
30
|
+
// stated 1-byte length.
|
|
31
|
+
//
|
|
32
|
+
// NOT YET VERIFIED AGAINST REAL HARDWARE — this node has not been sent to a
|
|
33
|
+
// real VMBPSUMNGR-20. See the mock-harness verification note in this
|
|
34
|
+
// project's HANDOVER.md, section 11, for the testing method used instead.
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
module.exports = function(RED) {
|
|
38
|
+
|
|
39
|
+
function VelbusEnergyNode(config) {
|
|
40
|
+
RED.nodes.createNode(this, config);
|
|
41
|
+
const node = this;
|
|
42
|
+
|
|
43
|
+
node.bridge = RED.nodes.getNode(config.bridge);
|
|
44
|
+
node.address = typeof config.address === 'number'
|
|
45
|
+
? config.address // legacy saves stored decimal numbers
|
|
46
|
+
: (parseInt(config.address, 16) || 0); // editor stores hex strings
|
|
47
|
+
node.moduleName = config.moduleName || '';
|
|
48
|
+
node.typeId = config.typeId ? parseInt(config.typeId) : null;
|
|
49
|
+
|
|
50
|
+
if (!node.bridge) {
|
|
51
|
+
node.status({ fill: 'red', shape: 'ring', text: 'no bridge' });
|
|
52
|
+
node.error('velbus-energy: no bridge configured');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!node.address || node.address < 1 || node.address > 254) {
|
|
56
|
+
node.status({ fill: 'red', shape: 'ring', text: 'invalid address' });
|
|
57
|
+
node.error('velbus-energy: invalid address ' + node.address);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Running alarm state — 0x00 arrives as two separate partial bitmasks
|
|
62
|
+
// (see the case 0x00 handler below), and 0xED conveniently repeats both
|
|
63
|
+
// in one packet. Kept as node state so a partial 0x00 update doesn't
|
|
64
|
+
// discard the other half of the picture.
|
|
65
|
+
let _alarmState = {
|
|
66
|
+
psu1Offline: false, psu2Offline: false, warrantyExpired: false,
|
|
67
|
+
psu1Alarm1: false, psu2Alarm1: false, psuOutAlarm1: false,
|
|
68
|
+
psu1Alarm2: false, psu1Alarm3: false, psu1Alarm4: false,
|
|
69
|
+
psu2Alarm2: false, psu2Alarm3: false, psu2Alarm4: false,
|
|
70
|
+
psuOutAlarm2: false,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function anyAlarmActive() {
|
|
74
|
+
return Object.values(_alarmState).some(v => v);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Name retrieval state — channel-keyed per the protocol (channels 1-10
|
|
78
|
+
// declared, though only 1-3 are meaningful here: PSU1/PSU2/PSUOut).
|
|
79
|
+
// Channel 1's name is used as the module's own display name, matching
|
|
80
|
+
// the convention in velbus-sensor-20.
|
|
81
|
+
let _nameParts = {};
|
|
82
|
+
let _nameTimer = null;
|
|
83
|
+
|
|
84
|
+
function assembleName(ch) {
|
|
85
|
+
const bytes = [
|
|
86
|
+
...(_nameParts[ch] && _nameParts[ch][0] || []),
|
|
87
|
+
...(_nameParts[ch] && _nameParts[ch][1] || []),
|
|
88
|
+
...(_nameParts[ch] && _nameParts[ch][2] || []),
|
|
89
|
+
].filter(b => b !== 0 && b !== 0xFF);
|
|
90
|
+
const name = String.fromCharCode(...bytes).trim();
|
|
91
|
+
if (name && ch === 1) node.moduleName = name;
|
|
92
|
+
return name;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const _addrHex = '0x' + node.address.toString(16).padStart(2, '0').toUpperCase();
|
|
96
|
+
|
|
97
|
+
function setStatus(text, fill, shape) {
|
|
98
|
+
const label = node.moduleName
|
|
99
|
+
? node.moduleName + ' (' + _addrHex + ')'
|
|
100
|
+
: _addrHex;
|
|
101
|
+
node.status({ fill: fill || 'green', shape: shape || 'dot', text: label + ' ' + text });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Firmware/type check ───────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
function handleModuleType(body) {
|
|
107
|
+
if (body.length < 8) return;
|
|
108
|
+
const typeId = body[1];
|
|
109
|
+
const build = body[5] * 100 + body[6];
|
|
110
|
+
const canFD = !!(body[7] & 0x20);
|
|
111
|
+
|
|
112
|
+
const desc = ENERGY_TYPES_20[typeId];
|
|
113
|
+
if (!desc) {
|
|
114
|
+
node.status({ fill: 'red', shape: 'ring',
|
|
115
|
+
text: 'unknown type 0x' + typeId.toString(16) });
|
|
116
|
+
node.error('velbus-energy: unrecognised module type 0x' + typeId.toString(16));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
node.moduleName = node.moduleName || desc.name;
|
|
121
|
+
setStatus('build ' + build + (canFD ? ' CAN FD' : ''), 'grey', 'dot');
|
|
122
|
+
|
|
123
|
+
setTimeout(() => {
|
|
124
|
+
node.bridge.send(pkt(0xF8, node.address, [0xEF, 0xFF]));
|
|
125
|
+
_nameTimer = setTimeout(() => assembleName(1), 2000);
|
|
126
|
+
}, 100);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Packet handler ────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
function onPacket(raw) {
|
|
132
|
+
const p = parsePkt(raw);
|
|
133
|
+
if (!p || p.rtr) return;
|
|
134
|
+
|
|
135
|
+
const { cmd, body } = p;
|
|
136
|
+
|
|
137
|
+
switch (cmd) {
|
|
138
|
+
|
|
139
|
+
// ── 0xFF Module type ─────────────────────────────────────────────
|
|
140
|
+
case 0xFF: {
|
|
141
|
+
handleModuleType(body);
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── 0x00 Alarm status (primary address = bitmask 1, sub-address1 =
|
|
146
|
+
// bitmask 2 — same command, discriminated by source address) ──
|
|
147
|
+
// The PDF's own DATABYTE2/3/4 labels ("Channel just pressed" etc.)
|
|
148
|
+
// are template leftovers from a different module and don't apply —
|
|
149
|
+
// the actual meaning comes from the bitmask tables underneath them.
|
|
150
|
+
case 0x00: {
|
|
151
|
+
if (body.length < 4) return;
|
|
152
|
+
const bits = body[1];
|
|
153
|
+
|
|
154
|
+
if (p.addr === node.address) {
|
|
155
|
+
// Primary address — alarm bitmask 1
|
|
156
|
+
_alarmState.psu1Offline = !!(bits & 0x01);
|
|
157
|
+
_alarmState.psu2Offline = !!(bits & 0x02);
|
|
158
|
+
_alarmState.warrantyExpired = !!(bits & 0x04);
|
|
159
|
+
_alarmState.psu1Alarm1 = !!(bits & 0x08);
|
|
160
|
+
// bit 0x10 = PSU1 peak load >xx% — documented "not implemented"
|
|
161
|
+
_alarmState.psu2Alarm1 = !!(bits & 0x20);
|
|
162
|
+
// bit 0x40 = PSU2 peak load >xx% — documented "not implemented"
|
|
163
|
+
_alarmState.psuOutAlarm1 = !!(bits & 0x80);
|
|
164
|
+
} else {
|
|
165
|
+
// Arrived via sub-address1 — alarm bitmask 2
|
|
166
|
+
_alarmState.psu1Alarm2 = !!(bits & 0x01);
|
|
167
|
+
_alarmState.psu1Alarm3 = !!(bits & 0x02);
|
|
168
|
+
_alarmState.psu1Alarm4 = !!(bits & 0x04);
|
|
169
|
+
_alarmState.psu2Alarm2 = !!(bits & 0x08);
|
|
170
|
+
_alarmState.psu2Alarm3 = !!(bits & 0x10);
|
|
171
|
+
_alarmState.psu2Alarm4 = !!(bits & 0x20);
|
|
172
|
+
_alarmState.psuOutAlarm2 = !!(bits & 0x40);
|
|
173
|
+
// bit 0x80 = PSUOut peak load >xx% — documented "not implemented"
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const on = anyAlarmActive();
|
|
177
|
+
const payload = { type: 'alarm', on, alarms: { ..._alarmState } };
|
|
178
|
+
if (on) setStatus('alarm active', 'red');
|
|
179
|
+
node.send([{ payload }, null]);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── 0xED Module status — repeats both alarm bitmasks from 0x00 in
|
|
184
|
+
// one packet, plus load percentages and the shared
|
|
185
|
+
// program/clock-alarm/sunrise/sunset byte ─────────────────
|
|
186
|
+
case 0xED: {
|
|
187
|
+
if (body.length < 8) return;
|
|
188
|
+
const bits1 = body[1];
|
|
189
|
+
const bits2 = body[2];
|
|
190
|
+
|
|
191
|
+
_alarmState.psu1Offline = !!(bits1 & 0x01);
|
|
192
|
+
_alarmState.psu2Offline = !!(bits1 & 0x02);
|
|
193
|
+
_alarmState.warrantyExpired = !!(bits1 & 0x04);
|
|
194
|
+
_alarmState.psu1Alarm1 = !!(bits1 & 0x08);
|
|
195
|
+
_alarmState.psu2Alarm1 = !!(bits1 & 0x20);
|
|
196
|
+
_alarmState.psuOutAlarm1 = !!(bits1 & 0x80);
|
|
197
|
+
_alarmState.psu1Alarm2 = !!(bits2 & 0x01);
|
|
198
|
+
_alarmState.psu1Alarm3 = !!(bits2 & 0x02);
|
|
199
|
+
_alarmState.psu1Alarm4 = !!(bits2 & 0x04);
|
|
200
|
+
_alarmState.psu2Alarm2 = !!(bits2 & 0x08);
|
|
201
|
+
_alarmState.psu2Alarm3 = !!(bits2 & 0x10);
|
|
202
|
+
_alarmState.psu2Alarm4 = !!(bits2 & 0x20);
|
|
203
|
+
_alarmState.psuOutAlarm2 = !!(bits2 & 0x40);
|
|
204
|
+
|
|
205
|
+
const psu1Load = body[3];
|
|
206
|
+
const psu2Load = body[4];
|
|
207
|
+
const psuOutLoad = body[5];
|
|
208
|
+
|
|
209
|
+
const progByte = body[6];
|
|
210
|
+
const program = ['none', 'group1', 'group2', 'group3'][progByte & 0x03];
|
|
211
|
+
const clockAlarm1On = !!(progByte & 0x04);
|
|
212
|
+
const clockAlarm1Global = !!(progByte & 0x08);
|
|
213
|
+
const clockAlarm2On = !!(progByte & 0x10);
|
|
214
|
+
const clockAlarm2Global = !!(progByte & 0x20);
|
|
215
|
+
const sunriseEnabled = !!(progByte & 0x40);
|
|
216
|
+
const sunsetEnabled = !!(progByte & 0x80);
|
|
217
|
+
|
|
218
|
+
const autoSendInterval = body[7];
|
|
219
|
+
const on = anyAlarmActive();
|
|
220
|
+
|
|
221
|
+
const payload = {
|
|
222
|
+
type: 'status',
|
|
223
|
+
on,
|
|
224
|
+
alarms: { ..._alarmState },
|
|
225
|
+
psu1Load, psu2Load, psuOutLoad,
|
|
226
|
+
program,
|
|
227
|
+
clockAlarms: {
|
|
228
|
+
alarm1: { on: clockAlarm1On, global: clockAlarm1Global },
|
|
229
|
+
alarm2: { on: clockAlarm2On, global: clockAlarm2Global },
|
|
230
|
+
},
|
|
231
|
+
sunrise: sunriseEnabled,
|
|
232
|
+
sunset: sunsetEnabled,
|
|
233
|
+
autoSendInterval,
|
|
234
|
+
};
|
|
235
|
+
setStatus('PSU1:' + psu1Load + '% PSU2:' + psu2Load + '% Out:' + psuOutLoad + '%',
|
|
236
|
+
on ? 'red' : 'green');
|
|
237
|
+
node.send([{ payload }, null]);
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── 0xA1 Warranty counter status ─────────────────────────────────
|
|
242
|
+
// 31-bit hours-in-operation value spans body[1]-body[3] plus the low
|
|
243
|
+
// 7 bits of body[4]; bit 7 of body[4] is the expired flag. Byte
|
|
244
|
+
// ordering (MSB-first) reasoned from the field description order in
|
|
245
|
+
// the PDF ("1st/2nd/3rd byte of value" then "+7 bits") — not
|
|
246
|
+
// independently confirmed against a captured real packet.
|
|
247
|
+
case 0xA1: {
|
|
248
|
+
if (body.length < 5) return;
|
|
249
|
+
const hours = ((body[1] << 23) | (body[2] << 15) | (body[3] << 7) |
|
|
250
|
+
(body[4] & 0x7F)) >>> 0;
|
|
251
|
+
const expired = !!(body[4] & 0x80);
|
|
252
|
+
|
|
253
|
+
const payload = { type: 'warranty', hours, expired,
|
|
254
|
+
limitHours: 87660, on: expired };
|
|
255
|
+
if (expired) setStatus('WARRANTY EXPIRED', 'red');
|
|
256
|
+
node.send([{ payload }, null]);
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── 0xA2 PSU load status ──────────────────────────────────────────
|
|
261
|
+
case 0xA2: {
|
|
262
|
+
if (body.length < 5) return;
|
|
263
|
+
const modeNames = { 1: 'balance', 2: 'boost', 3: 'backup' };
|
|
264
|
+
const mode = modeNames[body[1]] || 'unknown';
|
|
265
|
+
|
|
266
|
+
const payload = {
|
|
267
|
+
type: 'psu_load',
|
|
268
|
+
mode,
|
|
269
|
+
psu1Load: body[2],
|
|
270
|
+
psu2Load: body[3],
|
|
271
|
+
psuOutLoad: body[4],
|
|
272
|
+
};
|
|
273
|
+
node.send([{ payload }, null]);
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── 0xA3 PSU values status — one packet per rail (PSU1/PSU2/PSUOut) ─
|
|
278
|
+
// See the file-level comment re: the PDF's own DATABYTE6 labelling
|
|
279
|
+
// error — this reconstructs the intended 8-byte layout from the
|
|
280
|
+
// stated field count and DLC, not from the PDF's literal (broken)
|
|
281
|
+
// byte labels.
|
|
282
|
+
case 0xA3: {
|
|
283
|
+
if (body.length < 8) return;
|
|
284
|
+
const channel = (body[1] >> 4) & 0x0F; // 1=PSU1, 2=PSU2, 3=PSUOut
|
|
285
|
+
const wattageMSB = body[1] & 0x0F;
|
|
286
|
+
const wattageMW = (wattageMSB << 16) | (body[2] << 8) | body[3];
|
|
287
|
+
const voltageMV = (body[4] << 8) | body[5];
|
|
288
|
+
const amperageMA = (body[6] << 8) | body[7];
|
|
289
|
+
const channelName = { 1: 'PSU1', 2: 'PSU2', 3: 'PSUOut' }[channel] || 'unknown';
|
|
290
|
+
|
|
291
|
+
const payload = {
|
|
292
|
+
type: 'psu_values',
|
|
293
|
+
channel, channelName,
|
|
294
|
+
wattageMW, voltageMV, amperageMA,
|
|
295
|
+
watts: wattageMW / 1000,
|
|
296
|
+
volts: voltageMV / 1000,
|
|
297
|
+
amps: amperageMA / 1000,
|
|
298
|
+
};
|
|
299
|
+
node.send([{ payload }, null]);
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── 0xF0/F1/F2 Channel name parts ─────────────────────────────────
|
|
304
|
+
case 0xF0:
|
|
305
|
+
case 0xF1:
|
|
306
|
+
case 0xF2: {
|
|
307
|
+
const ch = body[1];
|
|
308
|
+
const part = cmd - 0xF0;
|
|
309
|
+
if (!_nameParts[ch]) _nameParts[ch] = {};
|
|
310
|
+
_nameParts[ch][part] = Array.from(body).slice(2);
|
|
311
|
+
if (part === 2) assembleName(ch);
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
default:
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
node.bridge.register(node.address, onPacket);
|
|
321
|
+
|
|
322
|
+
// Startup RTR
|
|
323
|
+
setTimeout(function() {
|
|
324
|
+
node.bridge.send(rtrPkt(node.address), true);
|
|
325
|
+
}, 500);
|
|
326
|
+
|
|
327
|
+
// ── Input commands ────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
node.on('input', function(msg) {
|
|
330
|
+
const cmd = msg.payload && msg.payload.cmd;
|
|
331
|
+
if (!cmd) return;
|
|
332
|
+
|
|
333
|
+
if (cmd === 'get_status') {
|
|
334
|
+
node.bridge.send(pkt(0xFB, node.address, [0xFA, 0x00]));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Warranty counter request — single byte, no parameters.
|
|
339
|
+
if (cmd === 'get_warranty') {
|
|
340
|
+
node.bridge.send(pkt(0xF8, node.address, [0xA0]));
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Request channel name — DB2=channel number (1-10, 0xFF=all)
|
|
345
|
+
if (cmd === 'get_name') {
|
|
346
|
+
const ch = parseInt(msg.payload.channel) || 0xFF;
|
|
347
|
+
node.bridge.send(pkt(0xF8, node.address, [0xEF, ch]));
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
node.warn('velbus-energy: unknown cmd: ' + cmd);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ── Cleanup ───────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
node.on('close', function() {
|
|
357
|
+
if (_nameTimer) { clearTimeout(_nameTimer); _nameTimer = null; }
|
|
358
|
+
node.bridge.deregister(node.address, onPacket);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
setStatus('ready', 'grey', 'dot');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
RED.nodes.registerType('velbus-energy', VelbusEnergyNode);
|
|
365
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-velbus-2026",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Node-RED palette for Velbus building automation \u2014 relay, dimmer, glass panel, PIR, blind, sensor, meteo, clock nodes. Original and V2 (-20) series.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node-red",
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"velbus-blind": "nodes/velbus-blind/velbus-blind.js",
|
|
32
32
|
"velbus-blind-s": "nodes/velbus-blind-s/velbus-blind-s.js",
|
|
33
33
|
"velbus-blind-20": "nodes/velbus-blind-20/velbus-blind-20.js",
|
|
34
|
-
"velbus-clock": "nodes/velbus-clock/velbus-clock.js"
|
|
34
|
+
"velbus-clock": "nodes/velbus-clock/velbus-clock.js",
|
|
35
|
+
"velbus-energy": "nodes/velbus-energy/velbus-energy.js"
|
|
35
36
|
},
|
|
36
37
|
"examples": "examples"
|
|
37
38
|
},
|
package/velbus-2026-repo.bundle
CHANGED
|
Binary file
|