node-red-contrib-modbus-modpackqt 1.1.85 → 2.0.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.
@@ -1,26 +1,353 @@
1
- module.exports = function(RED) {
1
+ /**
2
+ * ModPackQT Config Node — embedded Modbus runtime for Node-RED.
3
+ *
4
+ * Owns:
5
+ * - A connection pool of Modbus TCP/RTU master clients (one per target).
6
+ * - An optional embedded Modbus TCP slave server with a local register store.
7
+ * - A local rate-limit counter (anonymous tier: 1,000 ops/day per Node-RED instance).
8
+ *
9
+ * Phase 1: limits are enforced locally only. Phase 2 will validate the API key
10
+ * against the ModPackQT cloud lease endpoint for unlimited usage.
11
+ */
12
+ module.exports = function (RED) {
13
+ const ModbusRTU = require('modbus-serial');
14
+ const { EventEmitter } = require('events');
15
+
16
+ const FREE_DAILY_LIMIT = 1000;
17
+ const UPGRADE_URL = 'https://modpackqt.com/nodered';
18
+ const DEBUG_LOG_INTERVAL = 100; // log "powered by ModPackQT" every N ops
19
+
2
20
  function ModPackQTConfigNode(config) {
3
21
  RED.nodes.createNode(this, config);
4
- this.host = config.host || 'localhost';
5
- this.port = config.port || 8502;
6
- this.apiKey = this.credentials.apiKey || '';
7
-
8
- const axios = require('axios');
9
- this.baseUrl = `http://${this.host}:${this.port}`;
10
-
11
- this.request = async function(method, path, data) {
12
- const headers = {};
13
- if (this.apiKey) headers['x-api-key'] = this.apiKey;
14
- const response = await axios({
15
- method,
16
- url: `${this.baseUrl}${path}`,
17
- data,
18
- headers,
19
- timeout: 10000
22
+ const node = this;
23
+
24
+ // Traffic event bus — modpackqt-traffic nodes subscribe to this.
25
+ node.traffic = new EventEmitter();
26
+ node.traffic.setMaxListeners(0);
27
+ function emitTraffic(evt) {
28
+ try { node.traffic.emit('op', evt); } catch (_) { /* listener errors must not affect ops */ }
29
+ }
30
+
31
+ // ---- Master settings ----
32
+ node.masterMode = config.masterMode || 'tcp'; // 'tcp' | 'rtu'
33
+ node.serialPort = config.serialPort || '';
34
+ node.baudRate = parseInt(config.baudRate, 10) || 9600;
35
+ node.parity = config.parity || 'none';
36
+ node.dataBits = parseInt(config.dataBits, 10) || 8;
37
+ node.stopBits = parseInt(config.stopBits, 10) || 1;
38
+ node.timeoutMs = parseInt(config.timeoutMs, 10) || 3000;
39
+
40
+ // ---- Slave server settings ----
41
+ node.slaveEnabled = config.slaveEnabled === true || config.slaveEnabled === 'true';
42
+ node.slavePort = parseInt(config.slavePort, 10) || 1502;
43
+ node.slaveHost = config.slaveHost || '0.0.0.0';
44
+
45
+ // ---- Auth (Phase 2) ----
46
+ node.apiKey = (node.credentials && node.credentials.apiKey) || '';
47
+
48
+ // ====================================================================
49
+ // RATE LIMITING (local, in-memory)
50
+ // ====================================================================
51
+ const today = () => new Date().toISOString().slice(0, 10);
52
+ node._opsDay = today();
53
+ node._opsCount = 0;
54
+
55
+ node.checkLimit = function () {
56
+ // Reset counter at midnight
57
+ const d = today();
58
+ if (d !== node._opsDay) { node._opsDay = d; node._opsCount = 0; }
59
+
60
+ // Phase 1: anonymous tier only — API key currently ignored for billing
61
+ if (node._opsCount >= FREE_DAILY_LIMIT) {
62
+ const err = new Error(
63
+ `ModPackQT free tier limit reached (${FREE_DAILY_LIMIT} ops/day). ` +
64
+ `Get a free trial API key at ${UPGRADE_URL}`
65
+ );
66
+ err.code = 'RATE_LIMIT';
67
+ throw err;
68
+ }
69
+ node._opsCount += 1;
70
+
71
+ if (node._opsCount % DEBUG_LOG_INTERVAL === 0) {
72
+ node.log(
73
+ `[modpackqt] ${node._opsCount} ops served today — ` +
74
+ `unlock unlimited at ${UPGRADE_URL}`
75
+ );
76
+ }
77
+ return node._opsCount;
78
+ };
79
+
80
+ node.brandStatus = function (text) {
81
+ // Phase 1: branding always visible (anonymous tier).
82
+ // Phase 2: paid users get clean text without the "modpackqt ·" prefix.
83
+ return `modpackqt · ${text}`;
84
+ };
85
+
86
+ node.opsToday = function () { return node._opsCount; };
87
+
88
+ // ====================================================================
89
+ // MASTER CLIENT POOL — one client per target (host:port:unit or serial:unit)
90
+ // ====================================================================
91
+ node._masterPool = new Map();
92
+ node._masterQueue = Promise.resolve(); // serialize all master ops
93
+
94
+ function targetKey(opts) {
95
+ if (node.masterMode === 'rtu') return `rtu:${node.serialPort}:${opts.unitId}`;
96
+ return `tcp:${opts.host}:${opts.port}:${opts.unitId}`;
97
+ }
98
+
99
+ async function getClient(opts) {
100
+ const key = targetKey(opts);
101
+ let client = node._masterPool.get(key);
102
+ if (client && client.isOpen) return client;
103
+
104
+ client = new ModbusRTU();
105
+ client.setTimeout(node.timeoutMs);
106
+
107
+ if (node.masterMode === 'rtu') {
108
+ if (!node.serialPort) throw new Error('Serial port not configured for RTU mode');
109
+ await client.connectRTUBuffered(node.serialPort, {
110
+ baudRate: node.baudRate,
111
+ parity: node.parity,
112
+ dataBits: node.dataBits,
113
+ stopBits: node.stopBits
114
+ });
115
+ } else {
116
+ await client.connectTCP(opts.host, { port: opts.port });
117
+ }
118
+ client.setID(opts.unitId);
119
+ node._masterPool.set(key, client);
120
+ return client;
121
+ }
122
+
123
+ /**
124
+ * Read raw register values from a remote Modbus device.
125
+ * Returns plain array — no decoding, no objects. Use the
126
+ * node-red-contrib-bytes-modpackqt palette to decode.
127
+ */
128
+ // Modbus spec maximums (per IEC 61158-6-7) — enforced before hitting the wire
129
+ // so users get a clear error instead of a cryptic device exception.
130
+ const MAX_READ_BITS = 2000; // FC1, FC2
131
+ const MAX_READ_REGS = 125; // FC3, FC4
132
+ const MAX_WRITE_BITS = 1968; // FC15
133
+ const MAX_WRITE_REGS = 123; // FC16
134
+
135
+ node.read = function (opts) {
136
+ // Queue this op behind any in-flight master ops to avoid serial-port collision
137
+ node._masterQueue = node._masterQueue.then(async () => {
138
+ node.checkLimit();
139
+ const fc = parseInt(opts.functionCode, 10);
140
+ const addr = parseInt(opts.address, 10);
141
+ const qty = parseInt(opts.quantity, 10);
142
+ // Pre-validate quantity against Modbus spec
143
+ if (qty < 1) throw new Error(`Quantity must be >= 1 (got ${qty})`);
144
+ if ((fc === 1 || fc === 2) && qty > MAX_READ_BITS) {
145
+ throw new Error(`FC${fc} read quantity ${qty} exceeds Modbus spec max of ${MAX_READ_BITS} bits`);
146
+ }
147
+ if ((fc === 3 || fc === 4) && qty > MAX_READ_REGS) {
148
+ throw new Error(`FC${fc} read quantity ${qty} exceeds Modbus spec max of ${MAX_READ_REGS} registers`);
149
+ }
150
+ const target = node.masterMode === 'rtu'
151
+ ? `rtu:${node.serialPort}` : `${opts.host}:${opts.port}`;
152
+ const start = Date.now();
153
+ let res, err = null;
154
+ try {
155
+ const client = await getClient(opts);
156
+ client.setID(opts.unitId);
157
+ switch (fc) {
158
+ case 1: res = await client.readCoils(addr, qty); break;
159
+ case 2: res = await client.readDiscreteInputs(addr, qty); break;
160
+ case 3: res = await client.readHoldingRegisters(addr, qty); break;
161
+ case 4: res = await client.readInputRegisters(addr, qty); break;
162
+ default: throw new Error(`Unsupported read function code: ${fc}`);
163
+ }
164
+ return res.data;
165
+ } catch (e) { err = e; throw e; }
166
+ finally {
167
+ emitTraffic({
168
+ ts: new Date().toISOString(),
169
+ direction: 'read', kind: 'master',
170
+ target, unitId: opts.unitId, fc, address: addr, quantity: qty,
171
+ values: res ? res.data : null,
172
+ durationMs: Date.now() - start,
173
+ ok: !err, error: err ? err.message : null
174
+ });
175
+ }
176
+ });
177
+ return node._masterQueue;
178
+ };
179
+
180
+ /**
181
+ * Write raw register values to a remote Modbus device.
182
+ * Caller must pre-encode multi-register values (use the bytes palette).
183
+ */
184
+ node.write = function (opts) {
185
+ node._masterQueue = node._masterQueue.then(async () => {
186
+ node.checkLimit();
187
+ const fc = parseInt(opts.functionCode, 10);
188
+ const addr = parseInt(opts.address, 10);
189
+ const values = opts.values;
190
+ // Pre-validate write count against Modbus spec
191
+ const count = Array.isArray(values) ? values.length : 1;
192
+ if (fc === 15 && count > MAX_WRITE_BITS) {
193
+ throw new Error(`FC15 write count ${count} exceeds Modbus spec max of ${MAX_WRITE_BITS} bits`);
194
+ }
195
+ if (fc === 16 && count > MAX_WRITE_REGS) {
196
+ throw new Error(`FC16 write count ${count} exceeds Modbus spec max of ${MAX_WRITE_REGS} registers`);
197
+ }
198
+ const target = node.masterMode === 'rtu'
199
+ ? `rtu:${node.serialPort}` : `${opts.host}:${opts.port}`;
200
+ const start = Date.now();
201
+ let err = null;
202
+ try {
203
+ const client = await getClient(opts);
204
+ client.setID(opts.unitId);
205
+ switch (fc) {
206
+ case 5: return await client.writeCoil(addr, !!values[0]);
207
+ case 6: return await client.writeRegister(addr, values[0]);
208
+ case 15: return await client.writeCoils(addr, values.map(Boolean));
209
+ case 16: return await client.writeRegisters(addr, values);
210
+ default: throw new Error(`Unsupported write function code: ${fc}`);
211
+ }
212
+ } catch (e) { err = e; throw e; }
213
+ finally {
214
+ emitTraffic({
215
+ ts: new Date().toISOString(),
216
+ direction: 'write', kind: 'master',
217
+ target, unitId: opts.unitId, fc, address: addr,
218
+ quantity: values.length, values,
219
+ durationMs: Date.now() - start,
220
+ ok: !err, error: err ? err.message : null
221
+ });
222
+ }
223
+ });
224
+ return node._masterQueue;
225
+ };
226
+
227
+ // ====================================================================
228
+ // EMBEDDED SLAVE SERVER (Modbus TCP)
229
+ // ====================================================================
230
+ // Local register store — single unit, multiple register types
231
+ node._slaveStore = {
232
+ coils: new Array(65536).fill(false),
233
+ discrete: new Array(65536).fill(false),
234
+ holding: new Array(65536).fill(0),
235
+ input: new Array(65536).fill(0)
236
+ };
237
+
238
+ node.slaveGet = function (registerType, address, quantity) {
239
+ const arr = node._slaveStore[registerType];
240
+ if (!arr) throw new Error(`Unknown register type: ${registerType}`);
241
+ const values = arr.slice(address, address + quantity);
242
+ emitTraffic({
243
+ ts: new Date().toISOString(),
244
+ direction: 'read', kind: 'slave', via: 'flow',
245
+ target: `local:${node.slavePort}`, unitId: 1, fc: null,
246
+ registerType, address, quantity: values.length, values,
247
+ durationMs: 0, ok: true, error: null
20
248
  });
21
- return response.data;
249
+ return values;
22
250
  };
251
+
252
+ node.slaveSet = function (registerType, address, values) {
253
+ const arr = node._slaveStore[registerType];
254
+ if (!arr) throw new Error(`Unknown register type: ${registerType}`);
255
+ const isBool = (registerType === 'coils' || registerType === 'discrete');
256
+ const stored = values.map((v) => isBool ? Boolean(v) : (parseInt(v, 10) & 0xFFFF));
257
+ stored.forEach((v, i) => { arr[address + i] = v; });
258
+ emitTraffic({
259
+ ts: new Date().toISOString(),
260
+ direction: 'write', kind: 'slave', via: 'flow',
261
+ target: `local:${node.slavePort}`, unitId: 1, fc: null,
262
+ registerType, address, quantity: stored.length, values: stored,
263
+ durationMs: 0, ok: true, error: null
264
+ });
265
+ };
266
+
267
+ // Helper: emit a slave traffic event for ops coming from EXTERNAL Modbus masters
268
+ // (i.e. via the embedded TCP server's vector callbacks). Internal slaveGet/slaveSet
269
+ // already emit their own events tagged via='flow'.
270
+ function emitExternalSlave(direction, registerType, address, values) {
271
+ emitTraffic({
272
+ ts: new Date().toISOString(),
273
+ direction, kind: 'slave', via: 'external',
274
+ target: `local:${node.slavePort}`, unitId: 1, fc: null,
275
+ registerType, address, quantity: Array.isArray(values) ? values.length : 1,
276
+ values: Array.isArray(values) ? values : [values],
277
+ durationMs: 0, ok: true, error: null
278
+ });
279
+ }
280
+
281
+ node._slaveServer = null;
282
+ if (node.slaveEnabled) {
283
+ const vector = {
284
+ getCoil: (addr, _unit, cb) => {
285
+ const v = node._slaveStore.coils[addr];
286
+ emitExternalSlave('read', 'coils', addr, v);
287
+ cb(null, v);
288
+ },
289
+ getDiscreteInput: (addr, _unit, cb) => {
290
+ const v = node._slaveStore.discrete[addr];
291
+ emitExternalSlave('read', 'discrete', addr, v);
292
+ cb(null, v);
293
+ },
294
+ getHoldingRegister: (addr, _unit, cb) => {
295
+ const v = node._slaveStore.holding[addr];
296
+ emitExternalSlave('read', 'holding', addr, v);
297
+ cb(null, v);
298
+ },
299
+ getInputRegister: (addr, _unit, cb) => {
300
+ const v = node._slaveStore.input[addr];
301
+ emitExternalSlave('read', 'input', addr, v);
302
+ cb(null, v);
303
+ },
304
+ setCoil: (addr, value, _unit, cb) => {
305
+ node._slaveStore.coils[addr] = !!value;
306
+ emitExternalSlave('write', 'coils', addr, !!value);
307
+ cb(null);
308
+ },
309
+ setRegister: (addr, value, _unit, cb) => {
310
+ const v = value & 0xFFFF;
311
+ node._slaveStore.holding[addr] = v;
312
+ emitExternalSlave('write', 'holding', addr, v);
313
+ cb(null);
314
+ }
315
+ };
316
+ try {
317
+ node._slaveServer = new ModbusRTU.ServerTCP(vector, {
318
+ host: node.slaveHost,
319
+ port: node.slavePort,
320
+ debug: false,
321
+ unitID: 1
322
+ });
323
+ node._slaveServer.on('socketError', (err) => node.warn(`[slave] socket error: ${err.message}`));
324
+ node._slaveServer.on('serverError', (err) => node.error(`[slave] server error: ${err.message}`));
325
+ node.log(
326
+ `[modpackqt] embedded Modbus slave listening on ${node.slaveHost}:${node.slavePort} — ` +
327
+ `powered by ModPackQT (${UPGRADE_URL})`
328
+ );
329
+ } catch (err) {
330
+ node.error(`Failed to start embedded slave server: ${err.message}`);
331
+ }
332
+ }
333
+
334
+ // ====================================================================
335
+ // CLEANUP
336
+ // ====================================================================
337
+ node.on('close', function (done) {
338
+ const tasks = [];
339
+ for (const client of node._masterPool.values()) {
340
+ try { if (client.isOpen) tasks.push(new Promise((r) => client.close(r))); } catch (_) {}
341
+ }
342
+ node._masterPool.clear();
343
+ if (node._slaveServer) {
344
+ tasks.push(new Promise((r) => node._slaveServer.close(r)));
345
+ node._slaveServer = null;
346
+ }
347
+ Promise.all(tasks).then(() => done()).catch(() => done());
348
+ });
23
349
  }
350
+
24
351
  RED.nodes.registerType('modpackqt-config', ModPackQTConfigNode, {
25
352
  credentials: { apiKey: { type: 'password' } }
26
353
  });
@@ -1,7 +1,7 @@
1
1
  <script type="text/javascript">
2
2
  RED.nodes.registerType('modpackqt-master-read', {
3
3
  category: 'ModPackQT',
4
- color: '#2563eb',
4
+ color: '#16a34a',
5
5
  defaults: {
6
6
  name: { value: '' },
7
7
  server: { value: '', type: 'modpackqt-config', required: true },
@@ -16,10 +16,10 @@
16
16
  inputs: 1,
17
17
  outputs: 1,
18
18
  icon: 'font-awesome/fa-download',
19
- label: function() {
19
+ label: function () {
20
20
  return this.name || `Master Read FC${this.functionCode} @${this.address}`;
21
21
  },
22
- paletteLabel: 'modpackqt master read'
22
+ paletteLabel: 'modbus master read'
23
23
  });
24
24
  </script>
25
25
 
@@ -29,12 +29,12 @@
29
29
  <input type="text" id="node-input-name" placeholder="Name">
30
30
  </div>
31
31
  <div class="form-row">
32
- <label for="node-input-server"><i class="fa fa-cog"></i> Gateway</label>
32
+ <label for="node-input-server"><i class="fa fa-cog"></i> Runtime</label>
33
33
  <input type="text" id="node-input-server">
34
34
  </div>
35
35
  <div class="form-row">
36
36
  <label for="node-input-targetHost"><i class="fa fa-plug"></i> Target Host</label>
37
- <input type="text" id="node-input-targetHost" placeholder="Modbus device IP/hostname">
37
+ <input type="text" id="node-input-targetHost" placeholder="Modbus device IP/hostname (TCP only)">
38
38
  </div>
39
39
  <div class="form-row">
40
40
  <label for="node-input-targetPort"><i class="fa fa-hashtag"></i> Target Port</label>
@@ -65,25 +65,22 @@
65
65
  <label for="node-input-pollInterval"><i class="fa fa-clock-o"></i> Poll Interval (ms)</label>
66
66
  <input type="number" id="node-input-pollInterval" min="0" placeholder="0 = disabled">
67
67
  </div>
68
+ <div class="form-tips">
69
+ <b>Output:</b> <code>msg.payload</code> = raw register array. To decode int / float / string,
70
+ pair with <a href="https://www.npmjs.com/package/node-red-contrib-bytes-modpackqt" target="_blank">node-red-contrib-bytes-modpackqt</a>.
71
+ </div>
68
72
  </script>
69
73
 
70
74
  <script type="text/html" data-help-name="modpackqt-master-read">
71
- <p>Reads Modbus registers from a remote device via the ModPackQT Gateway app.</p>
72
- <p>Calls <code>POST /api/modbus/tcp/read</code> on the gateway, which opens a Modbus TCP connection to the target device.</p>
73
- <h3>Inputs</h3>
74
- <dl class="message-properties">
75
- <dt>payload <span class="property-type">any</span></dt>
76
- <dd>Any incoming message triggers a read (when Poll Interval is 0).</dd>
77
- </dl>
75
+ <p>Reads Modbus registers from a remote device using the embedded Modbus runtime in the
76
+ <code>modpackqt-config</code> node. No external gateway app required.</p>
78
77
  <h3>Outputs</h3>
79
78
  <dl class="message-properties">
80
- <dt>payload <span class="property-type">object</span></dt>
81
- <dd>Contains <code>values</code> (array) and <code>success</code>.</dd>
79
+ <dt>payload <span class="property-type">array</span></dt>
80
+ <dd>Raw register values — integers (FC3/FC4) or booleans (FC1/FC2). Use the bytes palette to decode int32, float32, string, etc.</dd>
82
81
  <dt>topic <span class="property-type">string</span></dt>
83
- <dd><code>modbus/read/{targetHost}:{targetPort}</code></dd>
82
+ <dd><code>modpackqt/read/{host}:{port}/fc{N}/{address}</code></dd>
84
83
  </dl>
85
- <h3>Details</h3>
86
- <p><b>Gateway</b> host and port of the running ModPackQT Gateway app (default: <code>localhost:8502</code>).</p>
87
- <p><b>Target Host / Target Port</b> — the actual Modbus TCP slave device to read from.</p>
88
- <p>Set <b>Poll Interval</b> &gt; 0 to continuously poll on a timer.</p>
84
+ <h3>Free tier</h3>
85
+ <p>1,000 ops/day per Node-RED instance. Add an API key in the runtime config for unlimited use.</p>
89
86
  </script>
@@ -1,18 +1,21 @@
1
- module.exports = function(RED) {
1
+ /**
2
+ * ModPackQT Master Read — embedded Modbus TCP/RTU read.
3
+ * Outputs raw register values (no decoding). Pair with
4
+ * node-red-contrib-bytes-modpackqt to decode int/float/string/bitmask.
5
+ */
6
+ module.exports = function (RED) {
2
7
  function ModPackQTMasterReadNode(config) {
3
8
  RED.nodes.createNode(this, config);
4
9
  const node = this;
5
10
  const server = RED.nodes.getNode(config.server);
6
11
 
7
12
  node.targetHost = config.targetHost || 'localhost';
8
- node.targetPort = parseInt(config.targetPort) || 502;
9
- node.unitId = parseInt(config.unitId) || 1;
10
- node.functionCode = parseInt(config.functionCode) || 3;
11
- node.address = parseInt(config.address) || 0;
12
- node.quantity = parseInt(config.quantity) || 1;
13
- node.pollInterval = config.pollInterval ? parseInt(config.pollInterval, 10) : 0;
14
-
15
- const FC_TO_REGISTER_TYPE = { 1: 'coil', 2: 'discrete', 3: 'holding', 4: 'input' };
13
+ node.targetPort = parseInt(config.targetPort, 10) || 502;
14
+ node.unitId = parseInt(config.unitId, 10) || 1;
15
+ node.functionCode = parseInt(config.functionCode, 10) || 3;
16
+ node.address = parseInt(config.address, 10) || 0;
17
+ node.quantity = parseInt(config.quantity, 10) || 1;
18
+ node.pollInterval = parseInt(config.pollInterval, 10) || 0;
16
19
 
17
20
  let timer = null;
18
21
 
@@ -22,21 +25,27 @@ module.exports = function(RED) {
22
25
  return;
23
26
  }
24
27
  try {
25
- const registerType = FC_TO_REGISTER_TYPE[node.functionCode] || 'holding';
26
- const result = await server.request('POST', '/api/modbus/tcp/read', {
28
+ const values = await server.read({
27
29
  host: node.targetHost,
28
30
  port: node.targetPort,
29
31
  unitId: node.unitId,
30
- registerType,
32
+ functionCode: node.functionCode,
31
33
  address: node.address,
32
34
  quantity: node.quantity
33
35
  });
34
36
  const ts = new Date().toLocaleTimeString();
35
- const pollLabel = node.pollInterval > 0 ? ` (poll ${node.pollInterval}ms)` : '';
36
- node.status({ fill: 'green', shape: 'dot', text: `FC${node.functionCode} @${node.address} · ${ts}${pollLabel}` });
37
- node.send({ payload: result, topic: `modbus/read/${node.targetHost}:${node.targetPort}` });
37
+ const ops = server.opsToday();
38
+ node.status({
39
+ fill: 'green', shape: 'dot',
40
+ text: server.brandStatus(`FC${node.functionCode} @${node.address} · ${ts} · ${ops} ops`)
41
+ });
42
+ node.send({
43
+ payload: values,
44
+ topic: `modpackqt/read/${node.targetHost}:${node.targetPort}/fc${node.functionCode}/${node.address}`,
45
+ modpackqt: { fc: node.functionCode, address: node.address, quantity: node.quantity }
46
+ });
38
47
  } catch (err) {
39
- node.status({ fill: 'red', shape: 'dot', text: err.message });
48
+ node.status({ fill: 'red', shape: 'dot', text: err.message.slice(0, 60) });
40
49
  node.error(err.message);
41
50
  }
42
51
  }
@@ -46,8 +55,8 @@ module.exports = function(RED) {
46
55
  timer = setInterval(doRead, node.pollInterval);
47
56
  }
48
57
 
49
- node.on('input', async function() { await doRead(); });
50
- node.on('close', function() { if (timer) { clearInterval(timer); timer = null; } });
58
+ node.on('input', async function () { await doRead(); });
59
+ node.on('close', function () { if (timer) clearInterval(timer); });
51
60
  }
52
61
  RED.nodes.registerType('modpackqt-master-read', ModPackQTMasterReadNode);
53
62
  };
@@ -1,7 +1,7 @@
1
1
  <script type="text/javascript">
2
2
  RED.nodes.registerType('modpackqt-master-write', {
3
3
  category: 'ModPackQT',
4
- color: '#2563eb',
4
+ color: '#16a34a',
5
5
  defaults: {
6
6
  name: { value: '' },
7
7
  server: { value: '', type: 'modpackqt-config', required: true },
@@ -14,10 +14,10 @@
14
14
  inputs: 1,
15
15
  outputs: 1,
16
16
  icon: 'font-awesome/fa-upload',
17
- label: function() {
17
+ label: function () {
18
18
  return this.name || `Master Write FC${this.functionCode} @${this.address}`;
19
19
  },
20
- paletteLabel: 'modpackqt master write'
20
+ paletteLabel: 'modbus master write'
21
21
  });
22
22
  </script>
23
23
 
@@ -27,12 +27,12 @@
27
27
  <input type="text" id="node-input-name" placeholder="Name">
28
28
  </div>
29
29
  <div class="form-row">
30
- <label for="node-input-server"><i class="fa fa-cog"></i> Gateway</label>
30
+ <label for="node-input-server"><i class="fa fa-cog"></i> Runtime</label>
31
31
  <input type="text" id="node-input-server">
32
32
  </div>
33
33
  <div class="form-row">
34
34
  <label for="node-input-targetHost"><i class="fa fa-plug"></i> Target Host</label>
35
- <input type="text" id="node-input-targetHost" placeholder="Modbus device IP/hostname">
35
+ <input type="text" id="node-input-targetHost" placeholder="Modbus device IP/hostname (TCP only)">
36
36
  </div>
37
37
  <div class="form-row">
38
38
  <label for="node-input-targetPort"><i class="fa fa-hashtag"></i> Target Port</label>
@@ -55,22 +55,18 @@
55
55
  <label for="node-input-address"><i class="fa fa-map-marker"></i> Start Address</label>
56
56
  <input type="number" id="node-input-address" min="0" max="65535" placeholder="0">
57
57
  </div>
58
+ <div class="form-tips">
59
+ <b>Input:</b> <code>msg.payload</code> = number (FC5/FC6) or array of numbers (FC15/FC16).
60
+ Encode multi-register values upstream with
61
+ <a href="https://www.npmjs.com/package/node-red-contrib-bytes-modpackqt" target="_blank">node-red-contrib-bytes-modpackqt</a>.
62
+ </div>
58
63
  </script>
59
64
 
60
65
  <script type="text/html" data-help-name="modpackqt-master-write">
61
- <p>Writes Modbus registers to a remote device via the ModPackQT Gateway app.</p>
62
- <p>Calls <code>POST /api/modbus/tcp/write</code> on the gateway.</p>
66
+ <p>Writes Modbus registers to a remote device using the embedded Modbus runtime.</p>
63
67
  <h3>Inputs</h3>
64
68
  <dl class="message-properties">
65
69
  <dt>payload <span class="property-type">number | array</span></dt>
66
- <dd>The value(s) to write. A single number for FC5/FC6, or an array for FC15/FC16.</dd>
67
- </dl>
68
- <h3>Outputs</h3>
69
- <dl class="message-properties">
70
- <dt>payload <span class="property-type">any</span></dt>
71
- <dd>Original message is passed through with <code>msg.success = true</code> on success.</dd>
70
+ <dd>Raw value(s). For float/int32/string, pre-encode upstream with the bytes palette.</dd>
72
71
  </dl>
73
- <h3>Details</h3>
74
- <p><b>Gateway</b> — host and port of the running ModPackQT Gateway app (default: <code>localhost:8502</code>).</p>
75
- <p><b>Target Host / Target Port</b> — the actual Modbus TCP slave device to write to.</p>
76
72
  </script>