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.
- package/CHANGELOG.md +58 -0
- package/CONTRIBUTING.md +70 -0
- package/DISCLAIMER.md +92 -0
- package/LICENSE +21 -0
- package/README.md +155 -166
- package/SECURITY.md +50 -0
- package/examples/basic-flow.json +130 -185
- package/nodes/modpackqt-config.html +106 -89
- package/nodes/modpackqt-config.js +345 -18
- package/nodes/modpackqt-master-read.html +16 -19
- package/nodes/modpackqt-master-read.js +27 -18
- package/nodes/modpackqt-master-write.html +12 -16
- package/nodes/modpackqt-master-write.js +49 -26
- package/nodes/modpackqt-slave-read.html +12 -85
- package/nodes/modpackqt-slave-read.js +27 -40
- package/nodes/modpackqt-slave-write.html +13 -94
- package/nodes/modpackqt-slave-write.js +24 -32
- package/nodes/modpackqt-traffic.html +118 -0
- package/nodes/modpackqt-traffic.js +68 -0
- package/package.json +24 -6
|
@@ -1,26 +1,353 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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: '#
|
|
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: '
|
|
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>
|
|
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
|
|
72
|
-
<
|
|
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">
|
|
81
|
-
<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>
|
|
82
|
+
<dd><code>modpackqt/read/{host}:{port}/fc{N}/{address}</code></dd>
|
|
84
83
|
</dl>
|
|
85
|
-
<h3>
|
|
86
|
-
<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> > 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
|
-
|
|
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)
|
|
9
|
-
node.unitId = parseInt(config.unitId)
|
|
10
|
-
node.functionCode = parseInt(config.functionCode) || 3;
|
|
11
|
-
node.address = parseInt(config.address)
|
|
12
|
-
node.quantity = parseInt(config.quantity)
|
|
13
|
-
node.pollInterval =
|
|
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
|
|
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
|
-
|
|
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
|
|
36
|
-
node.status({
|
|
37
|
-
|
|
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)
|
|
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: '#
|
|
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: '
|
|
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>
|
|
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
|
|
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>
|
|
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>
|