node-red-contrib-modbus-modpackqt 3.3.28 → 3.3.30

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.
@@ -3,10 +3,16 @@
3
3
  category: 'ModPackQT',
4
4
  color: '#f97316',
5
5
  defaults: {
6
- name: { value: '' },
7
- bindHost: { value: '0.0.0.0' },
8
- bindPort: { value: 1502, validate: RED.validators.number() },
9
- unitId: { value: 1, validate: RED.validators.number() }
6
+ name: { value: '' },
7
+ protocol: { value: 'tcp' },
8
+ bindHost: { value: '0.0.0.0' },
9
+ bindPort: { value: 1502, validate: RED.validators.number() },
10
+ unitId: { value: 1, validate: RED.validators.number() },
11
+ serialPort: { value: '' },
12
+ baudRate: { value: 9600, validate: RED.validators.number() },
13
+ parity: { value: 'none' },
14
+ dataBits: { value: 8, validate: RED.validators.number() },
15
+ stopBits: { value: 1, validate: RED.validators.number() }
10
16
  },
11
17
  credentials: {
12
18
  apiKey: { type: 'password' }
@@ -17,6 +23,11 @@
17
23
  align: 'right',
18
24
  icon: 'modpackqt.png',
19
25
  label: function () {
26
+ if (this.protocol === 'rtu') {
27
+ const tag = `${this.serialPort || 'COM?'} #${this.unitId || 1}`;
28
+ return this.name ? `${this.name} — Modbus RTU Slave ${tag}`
29
+ : `Modbus RTU Slave Server ${tag}`;
30
+ }
20
31
  const tag = `:${this.bindPort || 1502} #${this.unitId || 1}`;
21
32
  return this.name ? `${this.name} — Modbus TCP Slave ${tag}`
22
33
  : `Modbus TCP Slave Server ${tag}`;
@@ -26,8 +37,19 @@
26
37
  oneditprepare: function () {
27
38
  const node = this;
28
39
 
40
+ // ── Protocol toggle
41
+ const $proto = $('#node-input-protocol');
42
+ const toggleProtocol = function () {
43
+ const isTcp = $proto.val() === 'tcp';
44
+ $('#modpackqt-slave-row-tcp').toggle(isTcp);
45
+ $('#modpackqt-slave-row-rtu').toggle(!isTcp);
46
+ reload();
47
+ buildLink();
48
+ };
49
+ $proto.on('change', toggleProtocol);
50
+ toggleProtocol();
51
+
29
52
  // ── My Slaves picker
30
- let selectedProtocol = 'tcp'; // track selected slave protocol for renderLink
31
53
  const $sel = $('#node-input-modpackqt-slave-picker');
32
54
  const reload = function () {
33
55
  $sel.empty().append('<option value="">— Loading… —</option>');
@@ -35,20 +57,24 @@
35
57
  $sel.empty().append('<option value="">— Save this node first, then reopen —</option>');
36
58
  return;
37
59
  }
60
+ const proto = $proto.val();
38
61
  $.getJSON('modpackqt/slaves?probe=' + encodeURIComponent(node.id))
39
62
  .done(function (rows) {
40
63
  $sel.empty().append('<option value="">— Manual entry —</option>');
41
- (rows || [])
42
- .filter(function (s) { return !!s; })
43
- .forEach(function (s) {
44
- const isRtu = s.protocol === 'rtu';
45
- const proto = isRtu ? ' [RTU]' : ' [TCP]';
46
- const conn = isRtu
64
+ const filtered = (rows || []).filter(function (s) {
65
+ return !!s && (proto === 'rtu' ? s.protocol === 'rtu' : s.protocol !== 'rtu');
66
+ });
67
+ if (filtered.length === 0) {
68
+ const msg = proto === 'rtu' ? ' No RTU slaves saved yet —' : ' No TCP slaves saved yet —';
69
+ $sel.empty().append($('<option>').val('').text(msg));
70
+ } else {
71
+ filtered.forEach(function (s) {
72
+ const conn = s.protocol === 'rtu'
47
73
  ? ' — unit #' + (s.unitId || 1)
48
74
  : ' — :' + (s.port || 1502) + ' #' + (s.unitId || 1);
49
- const label = (s.name || '(unnamed)') + conn + proto;
50
- $('<option>').val(s.id).text(label).data('row', s).appendTo($sel);
75
+ $('<option>').val(s.id).text((s.name || '(unnamed)') + conn).data('row', s).appendTo($sel);
51
76
  });
77
+ }
52
78
  })
53
79
  .fail(function (xhr) {
54
80
  const msg = (xhr.responseJSON && xhr.responseJSON.error) || ('HTTP ' + xhr.status);
@@ -60,10 +86,16 @@
60
86
  const row = $(this).find('option:selected').data('row');
61
87
  if (!row) return;
62
88
  if (!node.name) $('#node-input-name').val(row.name || '');
63
- // RTU slaves have no port — keep whatever TCP port is already set
64
- if (row.protocol !== 'rtu' && row.port) $('#node-input-bindPort').val(row.port);
89
+ if (row.protocol === 'rtu') {
90
+ if (row.serialPort) $('#node-input-serialPort').val(row.serialPort);
91
+ if (row.baudRate) $('#node-input-baudRate').val(row.baudRate);
92
+ if (row.parity) $('#node-input-parity').val(row.parity);
93
+ if (row.dataBits) $('#node-input-dataBits').val(row.dataBits);
94
+ if (row.stopBits) $('#node-input-stopBits').val(row.stopBits);
95
+ } else {
96
+ if (row.port) $('#node-input-bindPort').val(row.port);
97
+ }
65
98
  $('#node-input-unitId').val(row.unitId || 1);
66
- selectedProtocol = row.protocol || 'tcp';
67
99
  buildLink();
68
100
  });
69
101
  reload();
@@ -76,10 +108,10 @@
76
108
  const renderLink = function (info) {
77
109
  const probeHost = (info && info.host) || '127.0.0.1';
78
110
  const probePort = (info && info.port) || 8502;
111
+ const unitId = $('#node-input-unitId').val() || node.unitId || 1;
112
+ const name = $('#node-input-name').val() || node.name || '';
113
+ const isTcp = $proto.val() === 'tcp';
79
114
  const bindPort = $('#node-input-bindPort').val() || node.bindPort || 1502;
80
- const unitId = $('#node-input-unitId').val() || node.unitId || 1;
81
- const name = $('#node-input-name').val() || node.name || '';
82
- const isRtu = selectedProtocol === 'rtu';
83
115
  const url = 'https://modpackqt.com/slave'
84
116
  + '?probe=' + encodeURIComponent(node.id || '')
85
117
  + '&probeHost=' + encodeURIComponent(probeHost)
@@ -89,13 +121,15 @@
89
121
  + (name ? '&name=' + encodeURIComponent(name) : '');
90
122
  $('#modpackqt-slave-server-link').attr('href', url);
91
123
  $('#modpackqt-slave-server-runtime').text(probeHost + ':' + probePort);
92
- const targetText = isRtu
93
- ? 'RTU · unit ' + unitId
94
- : ':' + bindPort + ' · unit ' + unitId;
95
- $('#modpackqt-slave-server-target').text(targetText);
124
+ if (isTcp) {
125
+ $('#modpackqt-slave-server-target').text(':' + bindPort + ' · unit ' + unitId);
126
+ } else {
127
+ const sp = $('#node-input-serialPort').val() || node.serialPort || 'COM?';
128
+ $('#modpackqt-slave-server-target').text(sp + ' · unit ' + unitId);
129
+ }
96
130
  };
97
131
  buildLink();
98
- $('#node-input-bindPort, #node-input-unitId').on('change input', buildLink);
132
+ $('#node-input-bindPort, #node-input-unitId, #node-input-serialPort').on('change input', buildLink);
99
133
  }
100
134
  });
101
135
  </script>
@@ -107,12 +141,78 @@
107
141
  </div>
108
142
 
109
143
  <div class="form-row">
110
- <label for="node-input-apiKey"><i class="fa fa-key"></i> Cloud Gateway Key</label>
144
+ <label for="node-input-protocol"><i class="fa fa-plug"></i> Protocol</label>
145
+ <select id="node-input-protocol" style="width:70%">
146
+ <option value="tcp">Modbus TCP (network)</option>
147
+ <option value="rtu">Modbus RTU (serial port)</option>
148
+ </select>
149
+ </div>
150
+
151
+ <!-- TCP fields -->
152
+ <div id="modpackqt-slave-row-tcp">
153
+ <input type="hidden" id="node-input-bindHost">
154
+ <div class="form-row">
155
+ <label for="node-input-bindPort"><i class="fa fa-ethernet"></i> Bind Port</label>
156
+ <input type="number" id="node-input-bindPort" min="1" max="65535" style="width:120px" placeholder="1502">
157
+ </div>
158
+ </div>
159
+
160
+ <!-- RTU fields -->
161
+ <div id="modpackqt-slave-row-rtu" style="display:none">
162
+ <div class="form-row">
163
+ <label for="node-input-serialPort"><i class="fa fa-usb"></i> Serial Port</label>
164
+ <input type="text" id="node-input-serialPort" placeholder="e.g. COM3 or /dev/ttyUSB0" style="width:60%">
165
+ </div>
166
+ <div class="form-row">
167
+ <label for="node-input-baudRate"><i class="fa fa-tachometer"></i> Baud Rate</label>
168
+ <select id="node-input-baudRate" style="width:140px">
169
+ <option value="1200">1200</option>
170
+ <option value="2400">2400</option>
171
+ <option value="4800">4800</option>
172
+ <option value="9600" selected>9600</option>
173
+ <option value="19200">19200</option>
174
+ <option value="38400">38400</option>
175
+ <option value="57600">57600</option>
176
+ <option value="115200">115200</option>
177
+ </select>
178
+ </div>
179
+ <div class="form-row">
180
+ <label for="node-input-parity"><i class="fa fa-check-circle-o"></i> Parity</label>
181
+ <select id="node-input-parity" style="width:140px">
182
+ <option value="none">None</option>
183
+ <option value="even">Even</option>
184
+ <option value="odd">Odd</option>
185
+ </select>
186
+ </div>
187
+ <div class="form-row">
188
+ <label for="node-input-dataBits"><i class="fa fa-sliders"></i> Data Bits</label>
189
+ <select id="node-input-dataBits" style="width:100px">
190
+ <option value="7">7</option>
191
+ <option value="8" selected>8</option>
192
+ </select>
193
+ &nbsp;&nbsp;
194
+ <label for="node-input-stopBits" style="width:auto;margin:0 8px 0 0">Stop Bits</label>
195
+ <select id="node-input-stopBits" style="width:100px">
196
+ <option value="1" selected>1</option>
197
+ <option value="2">2</option>
198
+ </select>
199
+ </div>
200
+ </div>
201
+
202
+ <div class="form-row">
203
+ <label for="node-input-unitId"><i class="fa fa-hashtag"></i> Unit ID</label>
204
+ <input type="number" id="node-input-unitId" min="1" max="247" style="width:80px" placeholder="1">
205
+ </div>
206
+
207
+ <hr style="margin:14px 0;border-color:#e5e7eb;">
208
+
209
+ <div class="form-row">
210
+ <label for="node-input-apiKey"><i class="fa fa-key"></i> Gateway Key</label>
111
211
  <input type="password" id="node-input-apiKey" placeholder="Paste your ModPackQT tunnel key">
112
212
  </div>
113
213
  <div class="form-tips" style="font-size:11px;color:#6b7280;margin:-8px 0 12px 105px">
114
214
  Generate at <a href="https://modpackqt.com/settings" target="_blank" rel="noopener">modpackqt.com → Settings → Cloud Gateways</a>.
115
- Loads your saved slave configs in the picker below.
215
+ Loads matching saved slave configs in the picker below.
116
216
  </div>
117
217
 
118
218
  <div class="form-row">
@@ -121,14 +221,9 @@
121
221
  <button type="button" id="modpackqt-slave-server-picker-refresh" class="red-ui-button" title="Reload" style="margin-left:4px"><i class="fa fa-refresh"></i></button>
122
222
  </div>
123
223
  <div class="form-tips" style="font-size:11px;color:#6b7280;margin:-8px 0 12px 105px">
124
- Required — pick a saved slave to set the port and unit ID.
125
- Register layout and limits are managed from the web console.
224
+ Pick a saved slave to auto-fill settings. Register layout is managed from the web console.
126
225
  </div>
127
226
 
128
- <input type="hidden" id="node-input-bindHost">
129
- <input type="hidden" id="node-input-bindPort">
130
- <input type="hidden" id="node-input-unitId">
131
-
132
227
  <div class="form-row" style="margin-top:18px;padding-top:14px;border-top:1px solid #e5e7eb;text-align:center;">
133
228
  <a id="modpackqt-slave-server-link" href="https://modpackqt.com" target="_blank" rel="noopener noreferrer"
134
229
  style="display:inline-block;padding:10px 24px;background:#7c3aed;color:#fff;
@@ -142,7 +237,7 @@
142
237
  <div style="margin-top:10px;font-size:12px;">
143
238
  <a href="https://modpackqt.com/settings#slaves" target="_blank" rel="noopener noreferrer"
144
239
  style="color:#7c3aed;text-decoration:none;font-weight:500;">
145
- <i class="fa fa-pencil" style="margin-right:4px;"></i>Edit port / unit ID / register layout on modpackqt.com →
240
+ <i class="fa fa-pencil" style="margin-right:4px;"></i>Edit settings / register layout on modpackqt.com →
146
241
  </a>
147
242
  </div>
148
243
  </div>
@@ -150,20 +245,24 @@
150
245
 
151
246
  <script type="text/html" data-help-name="modpackqt-slave-server">
152
247
  <p>
153
- Runs a Modbus TCP slave server with its own full register store.
154
- Drop one on the canvas per fake device. <b>Slave-read</b> and
155
- <b>slave-write</b> nodes target it by node ID to read and write
156
- registers from the flow.
248
+ Runs a Modbus slave server — either TCP (network) or RTU (serial port) — with its own full register store.
249
+ Drop one on the canvas per fake device. <b>Slave-read</b> and <b>slave-write</b> nodes target it by node ID
250
+ to read and write registers from the flow.
157
251
  </p>
252
+ <h3>Protocol</h3>
253
+ <ul>
254
+ <li><b>Modbus TCP</b> — listens on a TCP port. Any Modbus master on the network can connect.</li>
255
+ <li><b>Modbus RTU</b> — listens on a serial port (COM3, /dev/ttyUSB0, etc.). The node acts as a serial slave device.</li>
256
+ </ul>
158
257
  <h3>Setup</h3>
159
258
  <ol>
160
- <li>Drop this node, set Port and Unit ID (or pick from <b>My Slaves</b>).</li>
259
+ <li>Choose protocol, set the port/serial settings and Unit ID (or pick from <b>My Slaves</b>).</li>
161
260
  <li>On your slave-read / slave-write nodes, select this node in the <b>Slave Server</b> dropdown.</li>
162
261
  <li>Click <b>Open in ModPackQT Console</b> to manage the register layout from the web.</li>
163
262
  </ol>
164
263
  <h3>Cloud Gateway Key</h3>
165
264
  <p>Generate at <a href="https://modpackqt.com/settings" target="_blank" rel="noopener">modpackqt.com → Settings → Cloud Gateways</a>.
166
265
  Lets the web console connect and loads your saved slave configs.</p>
167
- <h3>Port note</h3>
168
- <p>Use ports above 1024 (e.g. 1502, 1503) to avoid needing root privileges. Each server must use a unique port.</p>
266
+ <h3>TCP port note</h3>
267
+ <p>Use ports above 1024 (e.g. 1502, 1503) to avoid needing root privileges. Each TCP server must use a unique port.</p>
169
268
  </script>
@@ -1,8 +1,8 @@
1
1
  /**
2
- * ModPackQT Slave Server — Modbus TCP slave with a full register store.
2
+ * ModPackQT Slave Server — Modbus TCP or RTU slave with a full register store.
3
3
  *
4
4
  * Drop one on the canvas per fake device. Each server:
5
- * - Binds its own TCP port and listens for external Modbus masters
5
+ * - Binds either a TCP port (ServerTCP) or a serial port (ServerSerial)
6
6
  * - Owns 65 536 registers per type (coils, discrete, holding, input)
7
7
  * - Exposes slaveGet() / slaveSet() so slave-read / slave-write nodes
8
8
  * can push and pull values from the flow side
@@ -10,7 +10,7 @@
10
10
  * register editor for it
11
11
  *
12
12
  * Register limits and layout are managed from the modpackqt.com web
13
- * console — this node only needs port and unit ID to bind.
13
+ * console — this node only needs transport settings and unit ID to bind.
14
14
  */
15
15
  module.exports = function (RED) {
16
16
  const ModbusRTU = require('modbus-serial');
@@ -20,10 +20,16 @@ module.exports = function (RED) {
20
20
  RED.nodes.createNode(this, config);
21
21
  const node = this;
22
22
 
23
- node.name = config.name || '';
24
- node.bindHost = config.bindHost || '0.0.0.0';
25
- node.bindPort = parseInt(config.bindPort, 10) || 1502;
26
- node.unitId = parseInt(config.unitId, 10) || 1;
23
+ node.name = config.name || '';
24
+ node.protocol = config.protocol || 'tcp';
25
+ node.bindHost = config.bindHost || '0.0.0.0';
26
+ node.bindPort = parseInt(config.bindPort, 10) || 1502;
27
+ node.unitId = parseInt(config.unitId, 10) || 1;
28
+ node.serialPort = config.serialPort || '';
29
+ node.baudRate = parseInt(config.baudRate, 10) || 9600;
30
+ node.parity = config.parity || 'none';
31
+ node.dataBits = parseInt(config.dataBits, 10) || 8;
32
+ node.stopBits = parseInt(config.stopBits, 10) || 1;
27
33
 
28
34
  // ── Register store
29
35
  const store = {
@@ -50,39 +56,58 @@ module.exports = function (RED) {
50
56
  stored.forEach((v, i) => { arr[address + i] = v; });
51
57
  };
52
58
 
53
- // ── Modbus TCP server
59
+ // ── Modbus server (TCP or RTU)
54
60
  let server = null;
61
+ const emit = (direction, type, address, values) => {
62
+ try {
63
+ node.send({
64
+ payload: {
65
+ ts: new Date().toISOString(),
66
+ direction, type, address,
67
+ values: Array.isArray(values) ? values : [values],
68
+ protocol: node.protocol,
69
+ unitId: node.unitId
70
+ }
71
+ });
72
+ } catch (_) { /* never let listener errors break the slave */ }
73
+ };
74
+
75
+ const vector = {
76
+ getCoil: (addr, _u, cb) => { const v = store.coils[addr]; emit('read', 'coils', addr, v); cb(null, v); },
77
+ getDiscreteInput: (addr, _u, cb) => { const v = store.discrete[addr]; emit('read', 'discrete', addr, v); cb(null, v); },
78
+ getHoldingRegister: (addr, _u, cb) => { const v = store.holding[addr]; emit('read', 'holding', addr, v); cb(null, v); },
79
+ getInputRegister: (addr, _u, cb) => { const v = store.input[addr]; emit('read', 'input', addr, v); cb(null, v); },
80
+ setCoil: (addr, val, _u, cb) => { store.coils[addr] = !!val; emit('write', 'coils', addr, !!val); cb(null); },
81
+ setRegister: (addr, val, _u, cb) => { const v = val & 0xFFFF; store.holding[addr] = v; emit('write', 'holding', addr, v); cb(null); }
82
+ };
83
+
55
84
  try {
56
- const emit = (direction, type, address, values) => {
57
- try {
58
- node.send({
59
- payload: {
60
- ts: new Date().toISOString(),
61
- direction, type, address,
62
- values: Array.isArray(values) ? values : [values],
63
- port: node.bindPort, unitId: node.unitId
64
- }
65
- });
66
- } catch (_) { /* never let listener errors break the slave */ }
67
- };
68
- const vector = {
69
- getCoil: (addr, _u, cb) => { const v = store.coils[addr]; emit('read', 'coils', addr, v); cb(null, v); },
70
- getDiscreteInput: (addr, _u, cb) => { const v = store.discrete[addr]; emit('read', 'discrete', addr, v); cb(null, v); },
71
- getHoldingRegister: (addr, _u, cb) => { const v = store.holding[addr]; emit('read', 'holding', addr, v); cb(null, v); },
72
- getInputRegister: (addr, _u, cb) => { const v = store.input[addr]; emit('read', 'input', addr, v); cb(null, v); },
73
- setCoil: (addr, val, _u, cb) => { store.coils[addr] = !!val; emit('write', 'coils', addr, !!val); cb(null); },
74
- setRegister: (addr, val, _u, cb) => { const v = val & 0xFFFF; store.holding[addr] = v; emit('write', 'holding', addr, v); cb(null); }
75
- };
76
- server = new ModbusRTU.ServerTCP(vector, {
77
- host: node.bindHost,
78
- port: node.bindPort,
79
- debug: false,
80
- unitID: node.unitId
81
- });
82
- server.on('socketError', (err) => node.warn(`[slave-server] socket: ${err.message}`));
83
- server.on('serverError', (err) => node.error(`[slave-server] server: ${err.message}`));
84
- node.log(`[modpackqt] slave server listening on ${node.bindHost}:${node.bindPort} #${node.unitId}`);
85
- node.status({ fill: 'green', shape: 'dot', text: `listening :${node.bindPort} #${node.unitId}` });
85
+ if (node.protocol === 'rtu') {
86
+ if (!node.serialPort) throw new Error('Serial port path is required for RTU mode');
87
+ server = new ModbusRTU.ServerSerial(vector, {
88
+ port: node.serialPort,
89
+ baudRate: node.baudRate,
90
+ parity: node.parity,
91
+ dataBits: node.dataBits,
92
+ stopBits: node.stopBits,
93
+ unitID: node.unitId
94
+ });
95
+ server.on('socketError', (err) => node.warn(`[slave-server] serial: ${err.message}`));
96
+ server.on('serverError', (err) => node.error(`[slave-server] serial: ${err.message}`));
97
+ node.log(`[modpackqt] RTU slave listening on ${node.serialPort} #${node.unitId}`);
98
+ node.status({ fill: 'green', shape: 'dot', text: `RTU ${node.serialPort} #${node.unitId}` });
99
+ } else {
100
+ server = new ModbusRTU.ServerTCP(vector, {
101
+ host: node.bindHost,
102
+ port: node.bindPort,
103
+ debug: false,
104
+ unitID: node.unitId
105
+ });
106
+ server.on('socketError', (err) => node.warn(`[slave-server] socket: ${err.message}`));
107
+ server.on('serverError', (err) => node.error(`[slave-server] server: ${err.message}`));
108
+ node.log(`[modpackqt] TCP slave listening on ${node.bindHost}:${node.bindPort} #${node.unitId}`);
109
+ node.status({ fill: 'green', shape: 'dot', text: `TCP :${node.bindPort} #${node.unitId}` });
110
+ }
86
111
  } catch (err) {
87
112
  node.error(`Failed to start slave server: ${err.message}`);
88
113
  node.status({ fill: 'red', shape: 'ring', text: 'failed to start' });
@@ -93,16 +118,14 @@ module.exports = function (RED) {
93
118
  id: node.id,
94
119
  kind: 'slave',
95
120
  describe(_detailed = false) {
121
+ const target = node.protocol === 'rtu'
122
+ ? { mode: 'rtu', serialPort: node.serialPort, baudRate: node.baudRate, parity: node.parity, dataBits: node.dataBits, stopBits: node.stopBits, unitId: node.unitId }
123
+ : { mode: 'tcp', host: node.bindHost, port: node.bindPort, unitId: node.unitId };
96
124
  return {
97
125
  id: node.id,
98
126
  kind: 'slave',
99
- name: node.name || `Slave :${node.bindPort} #${node.unitId}`,
100
- target: {
101
- mode: 'tcp',
102
- host: node.bindHost,
103
- port: node.bindPort,
104
- unitId: node.unitId
105
- },
127
+ name: node.name || (node.protocol === 'rtu' ? `RTU Slave ${node.serialPort} #${node.unitId}` : `Slave :${node.bindPort} #${node.unitId}`),
128
+ target,
106
129
  status: server ? 'listening' : 'error'
107
130
  };
108
131
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-modbus-modpackqt",
3
- "version": "3.3.28",
3
+ "version": "3.3.30",
4
4
  "description": "Modbus commissioning, testing & analysis tools for Node-RED. Embedded Modbus TCP/RTU master + slave server, FC1/FC2/FC3/FC4 reads, FC5/FC6/FC15/FC16 writes, built-in slave register store, and a passive traffic monitor for debugging. 100% free, MIT, no usage limits. By ModPackQT — open the matching web console at modpackqt.com for register decoding, simulation and AI assistance.",
5
5
  "keywords": [
6
6
  "node-red",