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,279 @@
1
+ 'use strict';
2
+
3
+ const { pkt, rtrPkt, parsePkt } = require('../../lib/velbus-utils');
4
+
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+ // Helpers
7
+ // ─────────────────────────────────────────────────────────────────────────────
8
+
9
+ function activeFromBitmask(byte, count) {
10
+ const active = [];
11
+ for (let i = 0; i < count; i++) {
12
+ if (byte & (1 << i)) active.push('alarm' + (i + 1));
13
+ }
14
+ return active;
15
+ }
16
+
17
+ function decodeBitmask(byte, count) {
18
+ const result = {};
19
+ for (let i = 0; i < count; i++) {
20
+ result['alarm' + (i + 1)] = !!(byte & (1 << i));
21
+ }
22
+ return result;
23
+ }
24
+
25
+ function tempFrom16(hi, lo) {
26
+ const raw = (hi << 8) | lo;
27
+ return (raw > 32767 ? raw - 65536 : raw) / 512; // °C × 512, 0.0625° res
28
+ }
29
+
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+ // Node
32
+ // ─────────────────────────────────────────────────────────────────────────────
33
+
34
+ module.exports = function(RED) {
35
+
36
+ function VelbusMeteoNode(config) {
37
+ RED.nodes.createNode(this, config);
38
+ const node = this;
39
+
40
+ node.bridge = RED.nodes.getNode(config.bridge);
41
+ node.address = typeof config.address === 'number'
42
+ ? config.address // legacy saves stored decimal numbers
43
+ : (parseInt(config.address, 16) || 0); // editor stores hex strings
44
+
45
+ if (!node.bridge) {
46
+ node.status({ fill: 'red', shape: 'ring', text: 'no bridge' });
47
+ node.error('velbus-meteo: no bridge configured');
48
+ return;
49
+ }
50
+ if (!node.address || node.address < 1 || node.address > 254) {
51
+ node.status({ fill: 'red', shape: 'ring', text: 'invalid address' });
52
+ node.error('velbus-meteo: invalid address ' + node.address);
53
+ return;
54
+ }
55
+
56
+ const _addrHex = '0x' + node.address.toString(16).padStart(2, '0').toUpperCase();
57
+
58
+ function setStatus(text, fill, shape) {
59
+ node.status({ fill: fill || 'green', shape: shape || 'dot',
60
+ text: _addrHex + ' ' + text });
61
+ }
62
+
63
+ // Alarm channel names — keyed by bitmask position (0-7)
64
+ const _alarmNames = {};
65
+ const _nameParts = {}; // keyed by alarmBit then part (0/1/2)
66
+
67
+ function assembleNames() {
68
+ for (let bit = 0; bit < 8; bit++) {
69
+ const mask = 1 << bit;
70
+ const parts = _nameParts[mask];
71
+ if (!parts) continue;
72
+ const bytes = [
73
+ ...(parts[0] || []),
74
+ ...(parts[1] || []),
75
+ ...(parts[2] || []),
76
+ ].filter(b => b !== 0 && b !== 0xFF);
77
+ const name = String.fromCharCode(...bytes).trim();
78
+ if (name) _alarmNames[bit] = name;
79
+ }
80
+ }
81
+
82
+ // ── Packet handler ────────────────────────────────────────────────────
83
+
84
+ function onPacket(raw) {
85
+ const p = parsePkt(raw);
86
+ if (!p || p.rtr) return;
87
+ if (p.addr !== node.address) return;
88
+
89
+ const { cmd, body } = p;
90
+
91
+ switch (cmd) {
92
+
93
+ // ── 0x00 Alarm output events ─────────────────────────────────────
94
+ case 0x00: {
95
+ if (body.length < 4) return;
96
+ // body[0]=cmd, body[1]=pressed, body[2]=released, body[3]=longPressed
97
+ const pressed = activeFromBitmask(body[1], 8);
98
+ const released = activeFromBitmask(body[2], 8);
99
+ const longPressed = activeFromBitmask(body[3], 8);
100
+ const on = pressed.length > 0 || longPressed.length > 0;
101
+
102
+ const payload = { type: 'alarm', on, pressed, released, longPressed };
103
+ if (pressed.length) setStatus('alarm: ' + pressed.join(','));
104
+ node.send([{ payload }, null]);
105
+ break;
106
+ }
107
+
108
+ // ── 0xED Module status ───────────────────────────────────────────
109
+ case 0xED: {
110
+ if (body.length < 7) return;
111
+ // body[0]=0xED, body[1]=alarm bitmask, body[2]=locked, body[3]=prog-disabled
112
+ // body[4]=clock alarm & program, body[5]=auto-send interval, body[6]=test mode
113
+ const alarms = decodeBitmask(body[1], 8);
114
+ const locked = decodeBitmask(body[2], 8);
115
+ const progDisabled = decodeBitmask(body[3], 8);
116
+
117
+ const progByte = body[4];
118
+ const program = ['none', 'group1', 'group2', 'group3'][progByte & 0x03];
119
+ const alarm1Active = !!(progByte & 0x04);
120
+ const alarm2Active = !!(progByte & 0x10);
121
+ const sunriseEnabled = !!(progByte & 0x40);
122
+ const sunsetEnabled = !!(progByte & 0x80);
123
+
124
+ const autoSendInterval = body[5];
125
+ const testMode = !!(body[6] & 0x80);
126
+
127
+ const on = Object.values(alarms).some(v => v);
128
+ const payload = {
129
+ type: 'status',
130
+ on,
131
+ alarms,
132
+ locked,
133
+ progDisabled,
134
+ program,
135
+ clockAlarms: { alarm1Active, alarm2Active },
136
+ sunrise: sunriseEnabled,
137
+ sunset: sunsetEnabled,
138
+ autoSendInterval,
139
+ testMode,
140
+ };
141
+ node.send([{ payload }, null]);
142
+ break;
143
+ }
144
+
145
+ // ── 0xE6 Temperature ─────────────────────────────────────────────
146
+ case 0xE6: {
147
+ if (body.length < 3) return;
148
+ // body[0]=0xE6, body[1-2]=current, body[3-4]=min, body[5-6]=max
149
+ const current = tempFrom16(body[1], body[2]);
150
+ const min = body.length >= 5 ? tempFrom16(body[3], body[4]) : null;
151
+ const max = body.length >= 7 ? tempFrom16(body[5], body[6]) : null;
152
+
153
+ const payload = { type: 'temperature', current };
154
+ if (min !== null) payload.min = min;
155
+ if (max !== null) payload.max = max;
156
+ node.send([null, { payload }]);
157
+ break;
158
+ }
159
+
160
+ // ── 0xA9 Rain / light / wind raw values ─────────────────────────
161
+ case 0xA9: {
162
+ if (body.length < 7) return;
163
+ // body[0]=0xA9, body[1-2]=rain, body[3-4]=light, body[5-6]=wind
164
+ const rainRaw = (body[1] << 8) | body[2];
165
+ const lightRaw = (body[3] << 8) | body[4];
166
+ const windRaw = (body[5] << 8) | body[6];
167
+
168
+ const payload = {
169
+ type: 'meteo',
170
+ rain: rainRaw / 10, // mm/h
171
+ light: lightRaw, // lux
172
+ wind: windRaw / 10, // km/h
173
+ raw: { rain: rainRaw, light: lightRaw, wind: windRaw },
174
+ };
175
+ setStatus('wind:' + payload.wind.toFixed(1) + ' rain:' + payload.rain.toFixed(1));
176
+ node.send([null, { payload }]);
177
+ break;
178
+ }
179
+
180
+ // ── 0xAC Sensor text string ──────────────────────────────────────
181
+ case 0xAC: {
182
+ if (body.length < 4) return;
183
+ // body[0]=0xAC, body[1]=sensor bitmask, body[2]=start position, body[3..]=chars
184
+ const sensorByte = body[1];
185
+ const sensor = sensorByte === 0x02 ? 'rain'
186
+ : sensorByte === 0x04 ? 'light'
187
+ : sensorByte === 0x08 ? 'wind'
188
+ : 'unknown';
189
+ const startPos = body[2];
190
+ let text = '';
191
+ for (let i = 3; i < body.length; i++) {
192
+ if (body[i] === 0) break;
193
+ text += String.fromCharCode(body[i]);
194
+ }
195
+ const payload = { type: 'meteo_text', sensor, startPos, text };
196
+ node.send([null, { payload }]);
197
+ break;
198
+ }
199
+
200
+ // ── 0xF0/F1/F2 Alarm channel name parts ─────────────────────────
201
+ case 0xF0:
202
+ case 0xF1:
203
+ case 0xF2: {
204
+ // body[0]=cmd, body[1]=channel bitmask, body[2..]=chars
205
+ const mask = body[1];
206
+ const part = cmd - 0xF0;
207
+ if (!_nameParts[mask]) _nameParts[mask] = {};
208
+ _nameParts[mask][part] = Array.from(body).slice(2);
209
+ if (part === 2) assembleNames();
210
+ break;
211
+ }
212
+
213
+ default:
214
+ break;
215
+ }
216
+ }
217
+
218
+ node.bridge.register(node.address, onPacket);
219
+
220
+ // ── Input commands ────────────────────────────────────────────────────
221
+
222
+ node.on('input', function(msg) {
223
+ const cmd = msg.payload && msg.payload.cmd;
224
+ if (!cmd) return;
225
+
226
+ if (cmd === 'get_status') {
227
+ node.bridge.send(pkt(0xFB, node.address, [0xFA, 0x00]));
228
+ return;
229
+ }
230
+
231
+ // Request temperature — DB2=0x00 means temp, interval in seconds
232
+ if (cmd === 'get_temp') {
233
+ const interval = parseInt(msg.payload.interval) || 0;
234
+ node.bridge.send(pkt(0xF8, node.address, [0xE5, 0x00, interval]));
235
+ return;
236
+ }
237
+
238
+ // Request sensor (rain/light/wind) — DB2=sensor bitmask, DB3=interval
239
+ if (cmd === 'get_meteo') {
240
+ const sensorMap = { rain: 0x02, light: 0x04, wind: 0x08 };
241
+ const sensor = msg.payload.sensor || 'all';
242
+ let mask = 0;
243
+ if (sensor === 'all') {
244
+ mask = 0x02 | 0x04 | 0x08;
245
+ } else if (typeof sensor === 'string' && sensorMap[sensor]) {
246
+ mask = sensorMap[sensor];
247
+ } else {
248
+ mask = 0x0E; // all three
249
+ }
250
+ const interval = parseInt(msg.payload.interval) || 0;
251
+ node.bridge.send(pkt(0xF8, node.address, [0xE5, mask, interval]));
252
+ return;
253
+ }
254
+
255
+ // Request alarm channel name — DB2=channel bitmask
256
+ if (cmd === 'get_alarm_name') {
257
+ const ch = parseInt(msg.payload.channel) || 0;
258
+ const mask = ch >= 1 && ch <= 8 ? (1 << (ch - 1)) : 0xFF;
259
+ node.bridge.send(pkt(0xF8, node.address, [0xEF, mask]));
260
+ return;
261
+ }
262
+
263
+ if (cmd === 'test_on') { node.bridge.send(pkt(0xF8, node.address, [0xB5, 0x01])); return; }
264
+ if (cmd === 'test_off') { node.bridge.send(pkt(0xF8, node.address, [0xB5, 0x00])); return; }
265
+
266
+ node.warn('velbus-meteo: unknown cmd: ' + cmd);
267
+ });
268
+
269
+ // ── Cleanup ───────────────────────────────────────────────────────────
270
+
271
+ node.on('close', function() {
272
+ node.bridge.deregister(node.address, onPacket);
273
+ });
274
+
275
+ setStatus('ready', 'grey', 'dot');
276
+ }
277
+
278
+ RED.nodes.registerType('velbus-meteo', VelbusMeteoNode);
279
+ };
@@ -0,0 +1,187 @@
1
+ <script type="text/javascript">
2
+ (function() {
3
+
4
+ const PIR_TYPES = {
5
+ 0x2A: { name: 'VMBPIRM', hasTempSensor: false, series: 'original' },
6
+ 0x2B: { name: 'VMBPIRC', hasTempSensor: false, series: 'original' },
7
+ 0x2C: { name: 'VMBPIRO', hasTempSensor: true, series: 'original' },
8
+ 0x23: { name: 'VMBPIRO-10', hasTempSensor: true, series: '-10' },
9
+ };
10
+
11
+ RED.nodes.registerType('velbus-pir', {
12
+ category: 'Velbus (inputs)',
13
+ color: '#3A8C8C',
14
+ defaults: {
15
+ name: { value: '' },
16
+ bridge: { value: '', type: 'velbus-bridge', required: true },
17
+ address: { value: '', required: true, validate: function(v) { return /^(0x)?[0-9a-fA-F]{1,2}$/.test(v); } },
18
+ moduleName: { value: '' },
19
+ typeId: { value: '' },
20
+ },
21
+ inputs: 1,
22
+ outputs: 2,
23
+ outputLabels: ['channel / status / light', 'temperature'],
24
+ icon: 'font-awesome/fa-eye',
25
+ paletteLabel: 'pir',
26
+
27
+ label: function() {
28
+ const t = this.typeId ? PIR_TYPES[parseInt(this.typeId, 16)] : null;
29
+ const n = this.name || this.moduleName || (t ? t.name : 'PIR');
30
+ return n + ' 0x' + (parseInt(this.address) || 0).toString(16).padStart(2,'0').toUpperCase();
31
+ },
32
+
33
+ oneditprepare: function() {
34
+ const savedAddr = this.address;
35
+ const savedTypeId = this.typeId ? parseInt(this.typeId, 16) : null;
36
+
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
+ if (!modules.length) return;
44
+
45
+ const $addr = $('#node-input-address');
46
+ $addr.empty().append($('<option>').val('').text('— select address —'));
47
+
48
+ const pirTypeIds = Object.keys(PIR_TYPES).map(k => parseInt(k));
49
+ const pirModules = modules.filter(m => pirTypeIds.includes(parseInt(m.typeId, 16)));
50
+
51
+ if (!pirModules.length) {
52
+ $addr.append($('<option>').val('').text('No PIR modules found on bus'));
53
+ return;
54
+ }
55
+
56
+ pirModules.forEach(function(m) {
57
+ const tid = parseInt(m.typeId, 16);
58
+ const label = m.address + ' — ' + m.module;
59
+ const $opt = $('<option>').val(m.addressDec).text(label).data('typeid', tid);
60
+ if (m.addressDec === parseInt(savedAddr)) $opt.attr('selected', true);
61
+ $addr.append($opt);
62
+ });
63
+
64
+ if (savedAddr) updateHint(parseInt(savedAddr));
65
+ });
66
+ }
67
+
68
+ function updateHint(addrDec) {
69
+ const $opt = $('#node-input-address option[value="' + addrDec + '"]');
70
+ const tid = $opt.data('typeid');
71
+ if (tid && PIR_TYPES[tid]) {
72
+ const t = PIR_TYPES[tid];
73
+ $('#pir-type-hint').text(
74
+ t.name + ' · ' + t.series +
75
+ (t.hasTempSensor ? ' · temperature sensor' : '')
76
+ ).show();
77
+ $('#node-input-typeId').val('0x' + tid.toString(16).padStart(2,'0').toUpperCase());
78
+ } else {
79
+ $('#pir-type-hint').hide();
80
+ }
81
+ }
82
+
83
+ $('#node-input-bridge').on('change', function() { populateDropdown($(this).val()); });
84
+ $('#node-input-address').on('change', function() { updateHint(parseInt($(this).val())); });
85
+
86
+ // Deferred load — TypedInput sets value async after oneditprepare
87
+
88
+
89
+ $('#node-input-bridge').trigger('change');
90
+
91
+
92
+ setTimeout(function() { $('#node-input-bridge').trigger('change'); }, 150);
93
+ },
94
+
95
+ oneditsave: function() {
96
+ this.address = parseInt($('#node-input-address').val()) || 0;
97
+ const tid = $('#node-input-typeId').val();
98
+ this.typeId = tid || '';
99
+ if (tid) {
100
+ const t = PIR_TYPES[parseInt(tid, 16)];
101
+ if (t) this.moduleName = t.name;
102
+ }
103
+ },
104
+ });
105
+
106
+ })();
107
+ </script>
108
+
109
+ <script type="text/html" data-template-name="velbus-pir">
110
+ <div class="form-row">
111
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
112
+ <input type="text" id="node-input-name" placeholder="Optional label">
113
+ </div>
114
+ <div class="form-row">
115
+ <label for="node-input-bridge"><i class="fa fa-plug"></i> Bridge</label>
116
+ <input type="text" id="node-input-bridge">
117
+ </div>
118
+ <div class="form-row">
119
+ <label for="node-input-address"><i class="fa fa-map-marker"></i> Address</label>
120
+ <select id="node-input-address" style="width:70%">
121
+ <option value="">— select bridge first —</option>
122
+ </select>
123
+ </div>
124
+ <div class="form-row">
125
+ <label></label>
126
+ <span id="pir-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-pir">
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>
143
+ PIR motion sensor node for Velbus original and -10 series modules.
144
+ For V2 (-20) series use <strong>velbus-pir-20</strong>.
145
+ </p>
146
+
147
+ <h3>Supported modules</h3>
148
+ <p>VMBPIRM (0x2A), VMBPIRC (0x2B), VMBPIRO (0x2C), VMBPIRO-10 (0x23).</p>
149
+ <p>VMBPIRO and VMBPIRO-10 include a temperature sensor — output 2 is active on these types.</p>
150
+
151
+ <h3>Output 1 — Channel / status / light</h3>
152
+ <p>Channel event (0x00):</p>
153
+ <pre>{
154
+ "type": "channel",
155
+ "on": true,
156
+ "pressed": ["motion1", "dark"],
157
+ "released": [],
158
+ "longPressed": []
159
+ }</pre>
160
+ <p>Module status (0xED):</p>
161
+ <pre>{
162
+ "type": "status",
163
+ "on": true,
164
+ "channels": { "dark": false, "light": true, "motion1": true, ... },
165
+ "lightRaw": 1024,
166
+ "testMode": false,
167
+ "locked": { "dark": false, ... },
168
+ "program": "summer",
169
+ "alarms": { "alarm1Active": false, "alarm2Active": false }
170
+ }</pre>
171
+ <p>Light value (0xA9):</p>
172
+ <pre>{ "type": "light", "on": true, "raw": 1024 }</pre>
173
+
174
+ <h3>Output 2 — Temperature (VMBPIRO / VMBPIRO-10 only)</h3>
175
+ <pre>{ "type": "temperature", "current": 21.5, "min": 18.0, "max": 25.0 }</pre>
176
+ <p>Settings (0xE8):</p>
177
+ <pre>{ "type": "temp_settings", "calOffset": 0, "calGain": 128, "lowAlarm": 5.0, "highAlarm": 35.0, "zone": 0 }</pre>
178
+
179
+ <h3>Input commands</h3>
180
+ <dl>
181
+ <dt><code>{ "cmd": "get_status" }</code></dt><dd>Request module status (0xFA).</dd>
182
+ <dt><code>{ "cmd": "get_light", "interval": 30 }</code></dt><dd>Request light value. interval=0 means no change to auto-send setting.</dd>
183
+ <dt><code>{ "cmd": "get_temp" }</code></dt><dd>Request temperature (VMBPIRO/VMBPIRO-10 only).</dd>
184
+ <dt><code>{ "cmd": "get_temp_settings" }</code></dt><dd>Request temperature calibration and alarm settings.</dd>
185
+ <dt><code>{ "cmd": "test_on" }</code> / <code>{ "cmd": "test_off" }</code></dt><dd>Enable/disable test mode (30 minute timeout).</dd>
186
+ </dl>
187
+ </script>