node-red-contrib-modbus-tcp-full-avd 1.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/README.md +229 -0
- package/modbus-tcp.html +312 -0
- package/modbus-tcp.js +1102 -0
- package/package.json +40 -0
package/modbus-tcp.js
ADDED
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-RED MODBUS TCP Node
|
|
3
|
+
* Volledige ondersteuning voor MODBUS TCP communicatie
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
module.exports = function(RED) {
|
|
7
|
+
"use strict";
|
|
8
|
+
const ModbusRTU = require("modbus-serial");
|
|
9
|
+
const net = require("net");
|
|
10
|
+
const EventEmitter = require("events");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* MODBUS Protocol Handler
|
|
14
|
+
*/
|
|
15
|
+
class ModbusProtocol {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.transactionId = 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Genereer volgende transaction ID
|
|
22
|
+
*/
|
|
23
|
+
getNextTransactionId() {
|
|
24
|
+
this.transactionId = (this.transactionId + 1) % 65536;
|
|
25
|
+
return this.transactionId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Bouw MBAP header
|
|
30
|
+
*/
|
|
31
|
+
buildMBAP(transactionId, unitId, pduLength) {
|
|
32
|
+
const buffer = Buffer.allocUnsafe(7);
|
|
33
|
+
buffer.writeUInt16BE(transactionId, 0);
|
|
34
|
+
buffer.writeUInt16BE(0, 2); // Protocol ID (0 = MODBUS)
|
|
35
|
+
buffer.writeUInt16BE(pduLength, 4);
|
|
36
|
+
buffer.writeUInt8(unitId, 6);
|
|
37
|
+
return buffer;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse MBAP header
|
|
42
|
+
*/
|
|
43
|
+
parseMBAP(buffer) {
|
|
44
|
+
return {
|
|
45
|
+
transactionId: buffer.readUInt16BE(0),
|
|
46
|
+
protocolId: buffer.readUInt16BE(2),
|
|
47
|
+
length: buffer.readUInt16BE(4),
|
|
48
|
+
unitId: buffer.readUInt8(6)
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bouw PDU voor request
|
|
54
|
+
*/
|
|
55
|
+
buildPDU(functionCode, address, quantity, values) {
|
|
56
|
+
let buffer;
|
|
57
|
+
|
|
58
|
+
switch(functionCode) {
|
|
59
|
+
case 1: // Read Coils
|
|
60
|
+
case 2: // Read Discrete Inputs
|
|
61
|
+
case 3: // Read Holding Registers
|
|
62
|
+
case 4: // Read Input Registers
|
|
63
|
+
buffer = Buffer.allocUnsafe(5);
|
|
64
|
+
buffer.writeUInt8(functionCode, 0);
|
|
65
|
+
buffer.writeUInt16BE(address, 1);
|
|
66
|
+
buffer.writeUInt16BE(quantity, 3);
|
|
67
|
+
break;
|
|
68
|
+
|
|
69
|
+
case 5: // Write Single Coil
|
|
70
|
+
buffer = Buffer.allocUnsafe(5);
|
|
71
|
+
buffer.writeUInt8(functionCode, 0);
|
|
72
|
+
buffer.writeUInt16BE(address, 1);
|
|
73
|
+
buffer.writeUInt16BE(values ? 0xFF00 : 0x0000, 3);
|
|
74
|
+
break;
|
|
75
|
+
|
|
76
|
+
case 6: // Write Single Register
|
|
77
|
+
buffer = Buffer.allocUnsafe(5);
|
|
78
|
+
buffer.writeUInt8(functionCode, 0);
|
|
79
|
+
buffer.writeUInt16BE(address, 1);
|
|
80
|
+
buffer.writeUInt16BE(values, 3);
|
|
81
|
+
break;
|
|
82
|
+
|
|
83
|
+
case 15: // Write Multiple Coils
|
|
84
|
+
const coilBytes = Math.ceil(quantity / 8);
|
|
85
|
+
buffer = Buffer.allocUnsafe(6 + coilBytes);
|
|
86
|
+
buffer.writeUInt8(functionCode, 0);
|
|
87
|
+
buffer.writeUInt16BE(address, 1);
|
|
88
|
+
buffer.writeUInt16BE(quantity, 3);
|
|
89
|
+
buffer.writeUInt8(coilBytes, 5);
|
|
90
|
+
// Pack coils into bytes
|
|
91
|
+
for (let i = 0; i < quantity; i++) {
|
|
92
|
+
const byteIndex = 6 + Math.floor(i / 8);
|
|
93
|
+
const bitIndex = i % 8;
|
|
94
|
+
if (values[i]) {
|
|
95
|
+
buffer[byteIndex] |= (1 << bitIndex);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
break;
|
|
99
|
+
|
|
100
|
+
case 16: // Write Multiple Registers
|
|
101
|
+
buffer = Buffer.allocUnsafe(6 + (quantity * 2));
|
|
102
|
+
buffer.writeUInt8(functionCode, 0);
|
|
103
|
+
buffer.writeUInt16BE(address, 1);
|
|
104
|
+
buffer.writeUInt16BE(quantity, 3);
|
|
105
|
+
buffer.writeUInt8(quantity * 2, 5);
|
|
106
|
+
for (let i = 0; i < quantity; i++) {
|
|
107
|
+
buffer.writeUInt16BE(values[i], 6 + (i * 2));
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
|
|
111
|
+
default:
|
|
112
|
+
throw new Error(`Unsupported function code: ${functionCode}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return buffer;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Parse PDU response
|
|
120
|
+
*/
|
|
121
|
+
parsePDU(buffer) {
|
|
122
|
+
const functionCode = buffer[0];
|
|
123
|
+
|
|
124
|
+
// Check for exception
|
|
125
|
+
if (functionCode & 0x80) {
|
|
126
|
+
return {
|
|
127
|
+
exception: true,
|
|
128
|
+
functionCode: functionCode & 0x7F,
|
|
129
|
+
exceptionCode: buffer[1]
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let data = [];
|
|
134
|
+
|
|
135
|
+
switch(functionCode) {
|
|
136
|
+
case 1: // Read Coils
|
|
137
|
+
case 2: // Read Discrete Inputs
|
|
138
|
+
const byteCount = buffer[1];
|
|
139
|
+
for (let i = 0; i < byteCount; i++) {
|
|
140
|
+
const byte = buffer[2 + i];
|
|
141
|
+
for (let j = 0; j < 8; j++) {
|
|
142
|
+
data.push((byte & (1 << j)) !== 0);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
|
|
147
|
+
case 3: // Read Holding Registers
|
|
148
|
+
case 4: // Read Input Registers
|
|
149
|
+
const registerCount = buffer[1] / 2;
|
|
150
|
+
for (let i = 0; i < registerCount; i++) {
|
|
151
|
+
data.push(buffer.readUInt16BE(2 + (i * 2)));
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
|
|
155
|
+
case 5: // Write Single Coil
|
|
156
|
+
case 6: // Write Single Register
|
|
157
|
+
if (buffer.length < 5) {
|
|
158
|
+
throw new Error(`Invalid response length for function ${functionCode}: expected 5 bytes, got ${buffer.length}`);
|
|
159
|
+
}
|
|
160
|
+
data = {
|
|
161
|
+
address: buffer.readUInt16BE(1),
|
|
162
|
+
value: functionCode === 5 ?
|
|
163
|
+
(buffer.readUInt16BE(3) === 0xFF00) :
|
|
164
|
+
buffer.readUInt16BE(3)
|
|
165
|
+
};
|
|
166
|
+
break;
|
|
167
|
+
|
|
168
|
+
case 15: // Write Multiple Coils
|
|
169
|
+
case 16: // Write Multiple Registers
|
|
170
|
+
data = {
|
|
171
|
+
address: buffer.readUInt16BE(1),
|
|
172
|
+
quantity: buffer.readUInt16BE(3)
|
|
173
|
+
};
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
exception: false,
|
|
179
|
+
functionCode: functionCode,
|
|
180
|
+
data: data
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* MODBUS TCP Client
|
|
187
|
+
*/
|
|
188
|
+
class ModbusTCPClient {
|
|
189
|
+
constructor(config, node) {
|
|
190
|
+
this.config = config;
|
|
191
|
+
this.node = node;
|
|
192
|
+
this.protocol = new ModbusProtocol();
|
|
193
|
+
this.client = null;
|
|
194
|
+
this.connected = false;
|
|
195
|
+
this.pendingRequests = new Map();
|
|
196
|
+
this.reconnectTimer = null;
|
|
197
|
+
this.responseBuffer = Buffer.alloc(0);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
connect() {
|
|
201
|
+
if (this.client && this.connected) {
|
|
202
|
+
return Promise.resolve();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
this.client = new net.Socket();
|
|
207
|
+
|
|
208
|
+
this.client.on('connect', () => {
|
|
209
|
+
this.connected = true;
|
|
210
|
+
this.node.status({ fill: "green", shape: "dot", text: "connected" });
|
|
211
|
+
this.node.log("MODBUS TCP Client connected");
|
|
212
|
+
resolve();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
this.client.on('data', (data) => {
|
|
216
|
+
this.handleResponse(data);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
this.client.on('error', (err) => {
|
|
220
|
+
this.node.error(`MODBUS TCP Client error: ${err.message}`);
|
|
221
|
+
this.node.status({ fill: "red", shape: "ring", text: "error" });
|
|
222
|
+
this.connected = false;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
this.client.on('close', () => {
|
|
226
|
+
this.connected = false;
|
|
227
|
+
this.node.status({ fill: "yellow", shape: "ring", text: "disconnected" });
|
|
228
|
+
this.node.log("MODBUS TCP Client disconnected");
|
|
229
|
+
this.scheduleReconnect();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
this.client.setTimeout(this.config.connectionTimeout);
|
|
233
|
+
this.client.connect(this.config.port, this.config.host);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
scheduleReconnect() {
|
|
238
|
+
if (this.reconnectTimer) {
|
|
239
|
+
clearTimeout(this.reconnectTimer);
|
|
240
|
+
}
|
|
241
|
+
this.reconnectTimer = setTimeout(() => {
|
|
242
|
+
this.node.log("Attempting to reconnect...");
|
|
243
|
+
this.connect().catch(err => {
|
|
244
|
+
this.node.error(`Reconnect failed: ${err.message}`);
|
|
245
|
+
});
|
|
246
|
+
}, this.config.reconnectInterval);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
handleResponse(data) {
|
|
250
|
+
// Append new data to buffer
|
|
251
|
+
this.responseBuffer = Buffer.concat([this.responseBuffer, data]);
|
|
252
|
+
|
|
253
|
+
// Process complete responses from buffer
|
|
254
|
+
while (this.responseBuffer.length >= 7) {
|
|
255
|
+
const mbap = this.protocol.parseMBAP(this.responseBuffer);
|
|
256
|
+
// MBAP length includes PDU length + 1 (unitId byte)
|
|
257
|
+
const expectedLength = 7 + mbap.length - 1;
|
|
258
|
+
|
|
259
|
+
if (this.responseBuffer.length < expectedLength) {
|
|
260
|
+
// Wait for more data
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const pdu = this.responseBuffer.slice(7, expectedLength);
|
|
265
|
+
const response = this.protocol.parsePDU(pdu);
|
|
266
|
+
|
|
267
|
+
const requestId = mbap.transactionId;
|
|
268
|
+
const pendingRequest = this.pendingRequests.get(requestId);
|
|
269
|
+
|
|
270
|
+
if (pendingRequest) {
|
|
271
|
+
clearTimeout(pendingRequest.timeout);
|
|
272
|
+
this.pendingRequests.delete(requestId);
|
|
273
|
+
|
|
274
|
+
if (response.exception) {
|
|
275
|
+
pendingRequest.reject(new Error(`MODBUS Exception ${response.exceptionCode}`));
|
|
276
|
+
} else {
|
|
277
|
+
pendingRequest.resolve(response);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Remove processed response from buffer
|
|
282
|
+
this.responseBuffer = this.responseBuffer.slice(expectedLength);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async sendRequest(functionCode, address, quantity, values, unitId) {
|
|
287
|
+
if (!this.connected) {
|
|
288
|
+
await this.connect();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const transactionId = this.protocol.getNextTransactionId();
|
|
292
|
+
const pdu = this.protocol.buildPDU(functionCode, address, quantity, values);
|
|
293
|
+
const mbap = this.protocol.buildMBAP(transactionId, unitId || this.config.unitId, pdu.length + 1);
|
|
294
|
+
const request = Buffer.concat([mbap, pdu]);
|
|
295
|
+
|
|
296
|
+
return new Promise((resolve, reject) => {
|
|
297
|
+
const timeout = setTimeout(() => {
|
|
298
|
+
this.pendingRequests.delete(transactionId);
|
|
299
|
+
reject(new Error("Request timeout"));
|
|
300
|
+
}, this.config.requestTimeout);
|
|
301
|
+
|
|
302
|
+
this.pendingRequests.set(transactionId, {
|
|
303
|
+
resolve: (response) => {
|
|
304
|
+
clearTimeout(timeout);
|
|
305
|
+
resolve(response);
|
|
306
|
+
},
|
|
307
|
+
reject: (error) => {
|
|
308
|
+
clearTimeout(timeout);
|
|
309
|
+
reject(error);
|
|
310
|
+
},
|
|
311
|
+
timeout: timeout
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
this.client.write(request);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async requestWithRetry(functionCode, address, quantity, values, unitId) {
|
|
319
|
+
let lastError;
|
|
320
|
+
for (let i = 0; i <= this.config.retryCount; i++) {
|
|
321
|
+
try {
|
|
322
|
+
return await this.sendRequest(functionCode, address, quantity, values, unitId);
|
|
323
|
+
} catch (err) {
|
|
324
|
+
lastError = err;
|
|
325
|
+
if (i < this.config.retryCount) {
|
|
326
|
+
await new Promise(resolve => setTimeout(resolve, this.config.retryInterval));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
throw lastError;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
disconnect() {
|
|
334
|
+
if (this.reconnectTimer) {
|
|
335
|
+
clearTimeout(this.reconnectTimer);
|
|
336
|
+
}
|
|
337
|
+
if (this.client) {
|
|
338
|
+
this.client.destroy();
|
|
339
|
+
this.client = null;
|
|
340
|
+
}
|
|
341
|
+
this.connected = false;
|
|
342
|
+
this.responseBuffer = Buffer.alloc(0);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* MODBUS TCP Server
|
|
348
|
+
*/
|
|
349
|
+
class ModbusTCPServer {
|
|
350
|
+
constructor(config, node) {
|
|
351
|
+
this.config = config;
|
|
352
|
+
this.node = node;
|
|
353
|
+
this.protocol = new ModbusProtocol();
|
|
354
|
+
this.server = null;
|
|
355
|
+
this.clients = new Map();
|
|
356
|
+
this.dataModel = {
|
|
357
|
+
coils: new Array(65536).fill(false),
|
|
358
|
+
discreteInputs: new Array(65536).fill(false),
|
|
359
|
+
holdingRegisters: new Array(65536).fill(0),
|
|
360
|
+
inputRegisters: new Array(65536).fill(0)
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
start() {
|
|
365
|
+
this.server = net.createServer((socket) => {
|
|
366
|
+
const clientId = `${socket.remoteAddress}:${socket.remotePort}`;
|
|
367
|
+
this.node.log(`New client connected: ${clientId}`);
|
|
368
|
+
|
|
369
|
+
if (this.clients.size >= this.config.maxConnections) {
|
|
370
|
+
this.node.warn(`Max connections reached, rejecting ${clientId}`);
|
|
371
|
+
socket.destroy();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
this.clients.set(clientId, socket);
|
|
376
|
+
this.node.status({ fill: "green", shape: "dot", text: `${this.clients.size} clients` });
|
|
377
|
+
|
|
378
|
+
let buffer = Buffer.alloc(0);
|
|
379
|
+
|
|
380
|
+
socket.on('data', (data) => {
|
|
381
|
+
buffer = Buffer.concat([buffer, data]);
|
|
382
|
+
this.processRequest(socket, buffer, clientId);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
socket.on('error', (err) => {
|
|
386
|
+
this.node.error(`Client ${clientId} error: ${err.message}`);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
socket.on('close', () => {
|
|
390
|
+
this.clients.delete(clientId);
|
|
391
|
+
this.node.log(`Client disconnected: ${clientId}`);
|
|
392
|
+
this.node.status({ fill: "green", shape: "dot", text: `${this.clients.size} clients` });
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
this.server.listen(this.config.port, () => {
|
|
397
|
+
this.node.log(`MODBUS TCP Server listening on port ${this.config.port}`);
|
|
398
|
+
this.node.status({ fill: "green", shape: "dot", text: `listening on ${this.config.port}` });
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
this.server.on('error', (err) => {
|
|
402
|
+
this.node.error(`MODBUS TCP Server error: ${err.message}`);
|
|
403
|
+
this.node.status({ fill: "red", shape: "ring", text: "error" });
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
processRequest(socket, buffer, clientId) {
|
|
408
|
+
while (buffer.length >= 7) {
|
|
409
|
+
const mbap = this.protocol.parseMBAP(buffer);
|
|
410
|
+
const expectedLength = 7 + mbap.length - 1; // -1 because unitId is included in length
|
|
411
|
+
|
|
412
|
+
if (buffer.length < expectedLength) {
|
|
413
|
+
return; // Wait for more data
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const pdu = buffer.slice(7, expectedLength);
|
|
417
|
+
buffer = buffer.slice(expectedLength);
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
const response = this.handleModbusRequest(pdu, mbap.unitId);
|
|
421
|
+
const responseMBAP = this.protocol.buildMBAP(mbap.transactionId, mbap.unitId, response.length + 1);
|
|
422
|
+
socket.write(Buffer.concat([responseMBAP, response]));
|
|
423
|
+
} catch (err) {
|
|
424
|
+
this.node.error(`Error processing request: ${err.message}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
handleModbusRequest(pdu, unitId) {
|
|
430
|
+
if (unitId !== this.config.unitId && unitId !== 0) {
|
|
431
|
+
return this.buildExceptionResponse(pdu[0], 0x0A); // Gateway Target Device Failed
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const functionCode = pdu[0];
|
|
435
|
+
let response;
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
switch(functionCode) {
|
|
439
|
+
case 1: // Read Coils
|
|
440
|
+
response = this.readCoils(pdu);
|
|
441
|
+
break;
|
|
442
|
+
case 2: // Read Discrete Inputs
|
|
443
|
+
response = this.readDiscreteInputs(pdu);
|
|
444
|
+
break;
|
|
445
|
+
case 3: // Read Holding Registers
|
|
446
|
+
response = this.readHoldingRegisters(pdu);
|
|
447
|
+
break;
|
|
448
|
+
case 4: // Read Input Registers
|
|
449
|
+
response = this.readInputRegisters(pdu);
|
|
450
|
+
break;
|
|
451
|
+
case 5: // Write Single Coil
|
|
452
|
+
response = this.writeSingleCoil(pdu);
|
|
453
|
+
break;
|
|
454
|
+
case 6: // Write Single Register
|
|
455
|
+
response = this.writeSingleRegister(pdu);
|
|
456
|
+
break;
|
|
457
|
+
case 15: // Write Multiple Coils
|
|
458
|
+
response = this.writeMultipleCoils(pdu);
|
|
459
|
+
break;
|
|
460
|
+
case 16: // Write Multiple Registers
|
|
461
|
+
response = this.writeMultipleRegisters(pdu);
|
|
462
|
+
break;
|
|
463
|
+
default:
|
|
464
|
+
return this.buildExceptionResponse(functionCode, 0x01); // Illegal Function
|
|
465
|
+
}
|
|
466
|
+
} catch (err) {
|
|
467
|
+
if (err.code === 'ILLEGAL_ADDRESS') {
|
|
468
|
+
return this.buildExceptionResponse(functionCode, 0x02); // Illegal Data Address
|
|
469
|
+
} else if (err.code === 'ILLEGAL_VALUE') {
|
|
470
|
+
return this.buildExceptionResponse(functionCode, 0x03); // Illegal Data Value
|
|
471
|
+
} else {
|
|
472
|
+
return this.buildExceptionResponse(functionCode, 0x04); // Server Device Failure
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return response;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
readCoils(pdu) {
|
|
480
|
+
const address = pdu.readUInt16BE(1);
|
|
481
|
+
const quantity = pdu.readUInt16BE(3);
|
|
482
|
+
|
|
483
|
+
if (address + quantity > 65536) {
|
|
484
|
+
throw new Error('ILLEGAL_ADDRESS');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const byteCount = Math.ceil(quantity / 8);
|
|
488
|
+
const response = Buffer.allocUnsafe(2 + byteCount);
|
|
489
|
+
response.writeUInt8(1, 0);
|
|
490
|
+
response.writeUInt8(byteCount, 1);
|
|
491
|
+
|
|
492
|
+
for (let i = 0; i < quantity; i++) {
|
|
493
|
+
const byteIndex = 2 + Math.floor(i / 8);
|
|
494
|
+
const bitIndex = i % 8;
|
|
495
|
+
if (this.dataModel.coils[address + i]) {
|
|
496
|
+
response[byteIndex] |= (1 << bitIndex);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return response;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
readDiscreteInputs(pdu) {
|
|
504
|
+
const address = pdu.readUInt16BE(1);
|
|
505
|
+
const quantity = pdu.readUInt16BE(3);
|
|
506
|
+
|
|
507
|
+
if (address + quantity > 65536) {
|
|
508
|
+
throw new Error('ILLEGAL_ADDRESS');
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const byteCount = Math.ceil(quantity / 8);
|
|
512
|
+
const response = Buffer.allocUnsafe(2 + byteCount);
|
|
513
|
+
response.writeUInt8(2, 0);
|
|
514
|
+
response.writeUInt8(byteCount, 1);
|
|
515
|
+
|
|
516
|
+
for (let i = 0; i < quantity; i++) {
|
|
517
|
+
const byteIndex = 2 + Math.floor(i / 8);
|
|
518
|
+
const bitIndex = i % 8;
|
|
519
|
+
if (this.dataModel.discreteInputs[address + i]) {
|
|
520
|
+
response[byteIndex] |= (1 << bitIndex);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return response;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
readHoldingRegisters(pdu) {
|
|
528
|
+
const address = pdu.readUInt16BE(1);
|
|
529
|
+
const quantity = pdu.readUInt16BE(3);
|
|
530
|
+
|
|
531
|
+
if (address + quantity > 65536) {
|
|
532
|
+
throw new Error('ILLEGAL_ADDRESS');
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const response = Buffer.allocUnsafe(2 + (quantity * 2));
|
|
536
|
+
response.writeUInt8(3, 0);
|
|
537
|
+
response.writeUInt8(quantity * 2, 1);
|
|
538
|
+
|
|
539
|
+
for (let i = 0; i < quantity; i++) {
|
|
540
|
+
response.writeUInt16BE(this.dataModel.holdingRegisters[address + i], 2 + (i * 2));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return response;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
readInputRegisters(pdu) {
|
|
547
|
+
const address = pdu.readUInt16BE(1);
|
|
548
|
+
const quantity = pdu.readUInt16BE(3);
|
|
549
|
+
|
|
550
|
+
if (address + quantity > 65536) {
|
|
551
|
+
throw new Error('ILLEGAL_ADDRESS');
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const response = Buffer.allocUnsafe(2 + (quantity * 2));
|
|
555
|
+
response.writeUInt8(4, 0);
|
|
556
|
+
response.writeUInt8(quantity * 2, 1);
|
|
557
|
+
|
|
558
|
+
for (let i = 0; i < quantity; i++) {
|
|
559
|
+
response.writeUInt16BE(this.dataModel.inputRegisters[address + i], 2 + (i * 2));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return response;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
writeSingleCoil(pdu) {
|
|
566
|
+
const address = pdu.readUInt16BE(1);
|
|
567
|
+
const value = pdu.readUInt16BE(3);
|
|
568
|
+
|
|
569
|
+
if (address > 65535) {
|
|
570
|
+
throw new Error('ILLEGAL_ADDRESS');
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (value !== 0x0000 && value !== 0xFF00) {
|
|
574
|
+
throw new Error('ILLEGAL_VALUE');
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
this.dataModel.coils[address] = (value === 0xFF00);
|
|
578
|
+
|
|
579
|
+
// Echo response
|
|
580
|
+
return Buffer.from(pdu);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
writeSingleRegister(pdu) {
|
|
584
|
+
const address = pdu.readUInt16BE(1);
|
|
585
|
+
const value = pdu.readUInt16BE(3);
|
|
586
|
+
|
|
587
|
+
if (address > 65535) {
|
|
588
|
+
throw new Error('ILLEGAL_ADDRESS');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
this.dataModel.holdingRegisters[address] = value;
|
|
592
|
+
|
|
593
|
+
// Echo response
|
|
594
|
+
return Buffer.from(pdu);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
writeMultipleCoils(pdu) {
|
|
598
|
+
const address = pdu.readUInt16BE(1);
|
|
599
|
+
const quantity = pdu.readUInt16BE(3);
|
|
600
|
+
const byteCount = pdu[5];
|
|
601
|
+
|
|
602
|
+
if (address + quantity > 65536) {
|
|
603
|
+
throw new Error('ILLEGAL_ADDRESS');
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
for (let i = 0; i < quantity; i++) {
|
|
607
|
+
const byteIndex = 6 + Math.floor(i / 8);
|
|
608
|
+
const bitIndex = i % 8;
|
|
609
|
+
this.dataModel.coils[address + i] = (pdu[byteIndex] & (1 << bitIndex)) !== 0;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Echo response
|
|
613
|
+
const response = Buffer.allocUnsafe(5);
|
|
614
|
+
response.writeUInt8(15, 0);
|
|
615
|
+
response.writeUInt16BE(address, 1);
|
|
616
|
+
response.writeUInt16BE(quantity, 3);
|
|
617
|
+
return response;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
writeMultipleRegisters(pdu) {
|
|
621
|
+
const address = pdu.readUInt16BE(1);
|
|
622
|
+
const quantity = pdu.readUInt16BE(3);
|
|
623
|
+
|
|
624
|
+
if (address + quantity > 65536) {
|
|
625
|
+
throw new Error('ILLEGAL_ADDRESS');
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
for (let i = 0; i < quantity; i++) {
|
|
629
|
+
this.dataModel.holdingRegisters[address + i] = pdu.readUInt16BE(6 + (i * 2));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Echo response
|
|
633
|
+
const response = Buffer.allocUnsafe(5);
|
|
634
|
+
response.writeUInt8(16, 0);
|
|
635
|
+
response.writeUInt16BE(address, 1);
|
|
636
|
+
response.writeUInt16BE(quantity, 3);
|
|
637
|
+
return response;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
buildExceptionResponse(functionCode, exceptionCode) {
|
|
641
|
+
const response = Buffer.allocUnsafe(2);
|
|
642
|
+
response.writeUInt8(functionCode | 0x80, 0);
|
|
643
|
+
response.writeUInt8(exceptionCode, 1);
|
|
644
|
+
return response;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
updateData(type, address, value, values) {
|
|
648
|
+
if (values && Array.isArray(values)) {
|
|
649
|
+
for (let i = 0; i < values.length; i++) {
|
|
650
|
+
this.updateData(type, address + i, values[i]);
|
|
651
|
+
}
|
|
652
|
+
} else {
|
|
653
|
+
switch(type) {
|
|
654
|
+
case 'coils':
|
|
655
|
+
this.dataModel.coils[address] = value;
|
|
656
|
+
break;
|
|
657
|
+
case 'discreteInputs':
|
|
658
|
+
this.dataModel.discreteInputs[address] = value;
|
|
659
|
+
break;
|
|
660
|
+
case 'holdingRegisters':
|
|
661
|
+
this.dataModel.holdingRegisters[address] = value;
|
|
662
|
+
break;
|
|
663
|
+
case 'inputRegisters':
|
|
664
|
+
this.dataModel.inputRegisters[address] = value;
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
generateTestData(pattern, startAddress, quantity, dataType) {
|
|
671
|
+
startAddress = startAddress || 0;
|
|
672
|
+
quantity = quantity || 100;
|
|
673
|
+
dataType = dataType || 'holdingRegisters';
|
|
674
|
+
pattern = pattern || 'incrementing';
|
|
675
|
+
|
|
676
|
+
const dataArray = this.dataModel[dataType];
|
|
677
|
+
if (!dataArray) {
|
|
678
|
+
throw new Error(`Invalid data type: ${dataType}`);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
let values = [];
|
|
682
|
+
|
|
683
|
+
switch(pattern) {
|
|
684
|
+
case 'incrementing':
|
|
685
|
+
for (let i = 0; i < quantity; i++) {
|
|
686
|
+
values.push((startAddress + i) % 65536);
|
|
687
|
+
}
|
|
688
|
+
break;
|
|
689
|
+
|
|
690
|
+
case 'random':
|
|
691
|
+
for (let i = 0; i < quantity; i++) {
|
|
692
|
+
values.push(Math.floor(Math.random() * 65536));
|
|
693
|
+
}
|
|
694
|
+
break;
|
|
695
|
+
|
|
696
|
+
case 'sine':
|
|
697
|
+
for (let i = 0; i < quantity; i++) {
|
|
698
|
+
const sineValue = Math.sin((i / quantity) * Math.PI * 2);
|
|
699
|
+
values.push(Math.floor((sineValue + 1) * 32767.5));
|
|
700
|
+
}
|
|
701
|
+
break;
|
|
702
|
+
|
|
703
|
+
case 'square':
|
|
704
|
+
for (let i = 0; i < quantity; i++) {
|
|
705
|
+
values.push((i % 2 === 0) ? 0 : 65535);
|
|
706
|
+
}
|
|
707
|
+
break;
|
|
708
|
+
|
|
709
|
+
case 'alternating':
|
|
710
|
+
for (let i = 0; i < quantity; i++) {
|
|
711
|
+
values.push(i % 2 === 0 ? 0 : 1);
|
|
712
|
+
}
|
|
713
|
+
break;
|
|
714
|
+
|
|
715
|
+
default:
|
|
716
|
+
// Default to incrementing
|
|
717
|
+
for (let i = 0; i < quantity; i++) {
|
|
718
|
+
values.push((startAddress + i) % 65536);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Update the data model
|
|
723
|
+
if (dataType === 'coils' || dataType === 'discreteInputs') {
|
|
724
|
+
for (let i = 0; i < quantity; i++) {
|
|
725
|
+
dataArray[startAddress + i] = values[i] !== 0;
|
|
726
|
+
}
|
|
727
|
+
} else {
|
|
728
|
+
for (let i = 0; i < quantity; i++) {
|
|
729
|
+
dataArray[startAddress + i] = values[i];
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
this.node.log(`Generated ${quantity} test data values for ${dataType} starting at address ${startAddress} with pattern ${pattern}`);
|
|
734
|
+
|
|
735
|
+
return {
|
|
736
|
+
pattern: pattern,
|
|
737
|
+
dataType: dataType,
|
|
738
|
+
startAddress: startAddress,
|
|
739
|
+
quantity: quantity,
|
|
740
|
+
values: values.slice(0, 10) // Return first 10 as sample
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
stop() {
|
|
745
|
+
if (this.server) {
|
|
746
|
+
this.server.close();
|
|
747
|
+
this.server = null;
|
|
748
|
+
}
|
|
749
|
+
this.clients.forEach((socket) => socket.destroy());
|
|
750
|
+
this.clients.clear();
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Helper function to get data summary
|
|
756
|
+
*/
|
|
757
|
+
function getDataSummary(dataArray, type) {
|
|
758
|
+
const nonZeroIndices = [];
|
|
759
|
+
const sampleData = {};
|
|
760
|
+
|
|
761
|
+
// Find first 10 non-zero/non-false values
|
|
762
|
+
for (let i = 0; i < Math.min(dataArray.length, 100); i++) {
|
|
763
|
+
const value = dataArray[i];
|
|
764
|
+
if ((type === "coils" || type === "discreteInputs") ? value : value !== 0) {
|
|
765
|
+
nonZeroIndices.push(i);
|
|
766
|
+
if (nonZeroIndices.length <= 10) {
|
|
767
|
+
sampleData[i] = value;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return {
|
|
773
|
+
type: type,
|
|
774
|
+
totalRegisters: dataArray.length,
|
|
775
|
+
nonZeroCount: nonZeroIndices.length,
|
|
776
|
+
sampleData: sampleData,
|
|
777
|
+
firstNonZeroIndex: nonZeroIndices[0] !== undefined ? nonZeroIndices[0] : null,
|
|
778
|
+
lastNonZeroIndex: nonZeroIndices.length > 0 ? nonZeroIndices[nonZeroIndices.length - 1] : null
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Helper function to create data map with addresses
|
|
784
|
+
*/
|
|
785
|
+
function createDataMap(dataArray, startAddress) {
|
|
786
|
+
const map = {};
|
|
787
|
+
if (Array.isArray(dataArray)) {
|
|
788
|
+
dataArray.forEach((value, index) => {
|
|
789
|
+
map[startAddress + index] = value;
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
return map;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Node-RED Node Definition
|
|
797
|
+
*/
|
|
798
|
+
function ModbusTCPNode(config) {
|
|
799
|
+
RED.nodes.createNode(this, config);
|
|
800
|
+
|
|
801
|
+
this.config = {
|
|
802
|
+
tcpMode: config.tcpMode || "client",
|
|
803
|
+
host: config.host || "127.0.0.1",
|
|
804
|
+
port: config.port || 502,
|
|
805
|
+
reconnectInterval: config.reconnectInterval || 5000,
|
|
806
|
+
connectionTimeout: config.connectionTimeout || 10000,
|
|
807
|
+
maxConnections: config.maxConnections || 10,
|
|
808
|
+
modbusMode: config.modbusMode || "client",
|
|
809
|
+
unitId: config.unitId || 1,
|
|
810
|
+
byteOrder: config.byteOrder || "bigEndian",
|
|
811
|
+
addressOffset: config.addressOffset || 0,
|
|
812
|
+
requestTimeout: config.requestTimeout || 5000,
|
|
813
|
+
retryCount: config.retryCount || 3,
|
|
814
|
+
retryInterval: config.retryInterval || 1000,
|
|
815
|
+
showData: config.showData || false,
|
|
816
|
+
debugMode: config.debugMode || false,
|
|
817
|
+
generateTestData: config.generateTestData || false,
|
|
818
|
+
testDataPattern: config.testDataPattern || "incrementing"
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
this.client = null;
|
|
822
|
+
this.server = null;
|
|
823
|
+
|
|
824
|
+
// Initialize based on mode
|
|
825
|
+
if (this.config.tcpMode === "client" && this.config.modbusMode === "client") {
|
|
826
|
+
this.client = new ModbusTCPClient(this.config, this);
|
|
827
|
+
this.client.connect().catch(err => {
|
|
828
|
+
this.error(`Failed to connect: ${err.message}`);
|
|
829
|
+
});
|
|
830
|
+
} else if (this.config.tcpMode === "server" && this.config.modbusMode === "server") {
|
|
831
|
+
this.server = new ModbusTCPServer(this.config, this);
|
|
832
|
+
this.server.start();
|
|
833
|
+
|
|
834
|
+
// Generate test data if enabled
|
|
835
|
+
if (this.config.generateTestData) {
|
|
836
|
+
setTimeout(() => {
|
|
837
|
+
this.server.generateTestData(
|
|
838
|
+
this.config.testDataPattern,
|
|
839
|
+
0,
|
|
840
|
+
100,
|
|
841
|
+
'holdingRegisters'
|
|
842
|
+
);
|
|
843
|
+
this.server.generateTestData(
|
|
844
|
+
this.config.testDataPattern,
|
|
845
|
+
0,
|
|
846
|
+
100,
|
|
847
|
+
'inputRegisters'
|
|
848
|
+
);
|
|
849
|
+
this.log("Test data generated automatically");
|
|
850
|
+
}, 1000);
|
|
851
|
+
}
|
|
852
|
+
} else {
|
|
853
|
+
this.error(`Unsupported mode combination: TCP ${this.config.tcpMode} + MODBUS ${this.config.modbusMode}`);
|
|
854
|
+
this.status({ fill: "red", shape: "ring", text: "invalid mode" });
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Handle incoming messages
|
|
859
|
+
this.on('input', async (msg) => {
|
|
860
|
+
try {
|
|
861
|
+
// Check for special actions
|
|
862
|
+
const action = msg.action || msg.payload?.action;
|
|
863
|
+
|
|
864
|
+
// Client mode: read and show data
|
|
865
|
+
if (action === "readData" && this.config.modbusMode === "client" && this.client) {
|
|
866
|
+
const readType = msg.readType || msg.payload?.readType || "holdingRegisters";
|
|
867
|
+
const startAddress = (msg.address !== undefined ? msg.address : msg.payload?.address || 0) + this.config.addressOffset;
|
|
868
|
+
const quantity = msg.quantity || msg.payload?.quantity || 10;
|
|
869
|
+
|
|
870
|
+
let functionCode;
|
|
871
|
+
switch(readType) {
|
|
872
|
+
case "coils":
|
|
873
|
+
functionCode = 1;
|
|
874
|
+
break;
|
|
875
|
+
case "discreteInputs":
|
|
876
|
+
functionCode = 2;
|
|
877
|
+
break;
|
|
878
|
+
case "holdingRegisters":
|
|
879
|
+
functionCode = 3;
|
|
880
|
+
break;
|
|
881
|
+
case "inputRegisters":
|
|
882
|
+
functionCode = 4;
|
|
883
|
+
break;
|
|
884
|
+
default:
|
|
885
|
+
functionCode = 3;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
try {
|
|
889
|
+
const response = await this.client.requestWithRetry(
|
|
890
|
+
functionCode,
|
|
891
|
+
startAddress,
|
|
892
|
+
quantity,
|
|
893
|
+
null,
|
|
894
|
+
msg.unitId || msg.payload?.unitId
|
|
895
|
+
);
|
|
896
|
+
|
|
897
|
+
msg.payload = {
|
|
898
|
+
action: "readData",
|
|
899
|
+
type: readType,
|
|
900
|
+
address: startAddress,
|
|
901
|
+
quantity: quantity,
|
|
902
|
+
data: response.data,
|
|
903
|
+
dataMap: createDataMap(response.data, startAddress),
|
|
904
|
+
timestamp: new Date()
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
if (this.config.debugMode) {
|
|
908
|
+
this.log(`Read Data - Type: ${readType}, Address: ${startAddress}, Data: ${JSON.stringify(response.data)}`);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (this.config.showData) {
|
|
912
|
+
const dataPreview = Array.isArray(response.data)
|
|
913
|
+
? response.data.slice(0, 5).join(", ") + (response.data.length > 5 ? "..." : "")
|
|
914
|
+
: JSON.stringify(response.data);
|
|
915
|
+
this.status({
|
|
916
|
+
fill: "green",
|
|
917
|
+
shape: "dot",
|
|
918
|
+
text: `${readType}[${startAddress}]: ${dataPreview}`
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
this.send(msg);
|
|
923
|
+
return;
|
|
924
|
+
} catch (err) {
|
|
925
|
+
msg.error = {
|
|
926
|
+
message: err.message,
|
|
927
|
+
type: readType,
|
|
928
|
+
address: startAddress
|
|
929
|
+
};
|
|
930
|
+
this.error(`Failed to read data: ${err.message}`);
|
|
931
|
+
this.send(msg);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Server mode: generate test data
|
|
937
|
+
if (action === "generateTestData" && this.config.modbusMode === "server" && this.server) {
|
|
938
|
+
const pattern = msg.pattern || msg.payload?.pattern || this.config.testDataPattern;
|
|
939
|
+
const startAddress = (msg.address !== undefined ? msg.address : msg.payload?.address || 0) + this.config.addressOffset;
|
|
940
|
+
const quantity = msg.quantity || msg.payload?.quantity || 100;
|
|
941
|
+
const dataType = msg.dataType || msg.payload?.dataType || "holdingRegisters";
|
|
942
|
+
|
|
943
|
+
try {
|
|
944
|
+
const result = this.server.generateTestData(pattern, startAddress, quantity, dataType);
|
|
945
|
+
|
|
946
|
+
msg.payload = {
|
|
947
|
+
action: "generateTestData",
|
|
948
|
+
result: result,
|
|
949
|
+
timestamp: new Date()
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
if (this.config.debugMode) {
|
|
953
|
+
this.log(`Test data generated - Pattern: ${pattern}, Type: ${dataType}, Address: ${startAddress}, Quantity: ${quantity}`);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
this.status({
|
|
957
|
+
fill: "green",
|
|
958
|
+
shape: "dot",
|
|
959
|
+
text: `Test data: ${dataType}[${startAddress}-${startAddress + quantity - 1}]`
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
this.send(msg);
|
|
963
|
+
return;
|
|
964
|
+
} catch (err) {
|
|
965
|
+
msg.error = {
|
|
966
|
+
message: err.message,
|
|
967
|
+
pattern: pattern,
|
|
968
|
+
dataType: dataType
|
|
969
|
+
};
|
|
970
|
+
this.error(`Failed to generate test data: ${err.message}`);
|
|
971
|
+
this.send(msg);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Server mode: show all register data
|
|
977
|
+
if (action === "showData" && this.config.modbusMode === "server" && this.server) {
|
|
978
|
+
const dataSummary = {
|
|
979
|
+
coils: getDataSummary(this.server.dataModel.coils, "coils"),
|
|
980
|
+
discreteInputs: getDataSummary(this.server.dataModel.discreteInputs, "discreteInputs"),
|
|
981
|
+
holdingRegisters: getDataSummary(this.server.dataModel.holdingRegisters, "holdingRegisters"),
|
|
982
|
+
inputRegisters: getDataSummary(this.server.dataModel.inputRegisters, "inputRegisters")
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
msg.payload = {
|
|
986
|
+
action: "showData",
|
|
987
|
+
summary: dataSummary,
|
|
988
|
+
timestamp: new Date()
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
if (this.config.debugMode) {
|
|
992
|
+
this.log(JSON.stringify(dataSummary, null, 2));
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
this.send(msg);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (this.config.modbusMode === "client" && this.client) {
|
|
1000
|
+
// Client mode: send request
|
|
1001
|
+
const functionCode = msg.function || msg.payload?.function;
|
|
1002
|
+
const address = (msg.address !== undefined ? msg.address : msg.payload?.address) + this.config.addressOffset;
|
|
1003
|
+
const quantity = msg.quantity || msg.payload?.quantity;
|
|
1004
|
+
const values = msg.values || msg.payload?.values || msg.value || msg.payload?.value;
|
|
1005
|
+
const unitId = msg.unitId || msg.payload?.unitId;
|
|
1006
|
+
|
|
1007
|
+
if (!functionCode || address === undefined) {
|
|
1008
|
+
this.error("Missing required fields: function and address");
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const response = await this.client.requestWithRetry(
|
|
1013
|
+
functionCode,
|
|
1014
|
+
address,
|
|
1015
|
+
quantity,
|
|
1016
|
+
values,
|
|
1017
|
+
unitId
|
|
1018
|
+
);
|
|
1019
|
+
|
|
1020
|
+
msg.payload = {
|
|
1021
|
+
function: functionCode,
|
|
1022
|
+
address: address,
|
|
1023
|
+
quantity: quantity,
|
|
1024
|
+
data: response.data,
|
|
1025
|
+
timestamp: new Date(),
|
|
1026
|
+
responseTime: Date.now() - (msg.timestamp || Date.now())
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
// Show data in status if enabled
|
|
1030
|
+
if (this.config.showData && response.data) {
|
|
1031
|
+
const dataPreview = Array.isArray(response.data)
|
|
1032
|
+
? response.data.slice(0, 5).join(", ") + (response.data.length > 5 ? "..." : "")
|
|
1033
|
+
: JSON.stringify(response.data);
|
|
1034
|
+
this.status({
|
|
1035
|
+
fill: "green",
|
|
1036
|
+
shape: "dot",
|
|
1037
|
+
text: `Addr ${address}: ${dataPreview}`
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Debug mode: log data
|
|
1042
|
+
if (this.config.debugMode) {
|
|
1043
|
+
this.log(`MODBUS Response - Function: ${functionCode}, Address: ${address}, Data: ${JSON.stringify(response.data)}`);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
this.send(msg);
|
|
1047
|
+
} else if (this.config.modbusMode === "server" && this.server) {
|
|
1048
|
+
// Server mode: update data
|
|
1049
|
+
const type = msg.type || msg.payload?.type;
|
|
1050
|
+
const address = (msg.address !== undefined ? msg.address : msg.payload?.address) + this.config.addressOffset;
|
|
1051
|
+
const value = msg.value !== undefined ? msg.value : msg.payload?.value;
|
|
1052
|
+
const values = msg.values || msg.payload?.values;
|
|
1053
|
+
|
|
1054
|
+
if (type && address !== undefined) {
|
|
1055
|
+
this.server.updateData(type, address, value, values);
|
|
1056
|
+
|
|
1057
|
+
// Show data in status if enabled
|
|
1058
|
+
if (this.config.showData) {
|
|
1059
|
+
const displayValue = values ?
|
|
1060
|
+
`${values.slice(0, 3).join(",")}${values.length > 3 ? "..." : ""}` :
|
|
1061
|
+
value;
|
|
1062
|
+
this.status({
|
|
1063
|
+
fill: "green",
|
|
1064
|
+
shape: "dot",
|
|
1065
|
+
text: `${type}[${address}]: ${displayValue}`
|
|
1066
|
+
});
|
|
1067
|
+
} else {
|
|
1068
|
+
this.status({ fill: "green", shape: "dot", text: "data updated" });
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Debug mode: log data update
|
|
1072
|
+
if (this.config.debugMode) {
|
|
1073
|
+
this.log(`MODBUS Server Data Update - Type: ${type}, Address: ${address}, Value: ${JSON.stringify(values || value)}`);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
msg.error = {
|
|
1079
|
+
code: err.message.includes("Exception") ? parseInt(err.message.match(/\d+/)?.[0] || 0) : 0,
|
|
1080
|
+
message: err.message,
|
|
1081
|
+
function: msg.function || msg.payload?.function,
|
|
1082
|
+
address: msg.address || msg.payload?.address
|
|
1083
|
+
};
|
|
1084
|
+
msg.payload = null;
|
|
1085
|
+
this.error(err.message);
|
|
1086
|
+
this.send(msg);
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
// Cleanup on close
|
|
1091
|
+
this.on('close', () => {
|
|
1092
|
+
if (this.client) {
|
|
1093
|
+
this.client.disconnect();
|
|
1094
|
+
}
|
|
1095
|
+
if (this.server) {
|
|
1096
|
+
this.server.stop();
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
RED.nodes.registerType("modbus-tcp-avd", ModbusTCPNode);
|
|
1102
|
+
};
|