node-red-modbus-dynamic-server 0.1.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 +41 -0
- package/LICENSE +19 -0
- package/README.md +180 -0
- package/package.json +75 -0
- package/src/modbus-dynamic-proxy.html +108 -0
- package/src/modbus-dynamic-proxy.js +400 -0
- package/src/modbus-dynamic-server-config.html +85 -0
- package/src/modbus-dynamic-server-config.js +391 -0
- package/src/modbus-dynamic-server-response.html +88 -0
- package/src/modbus-dynamic-server-response.js +160 -0
- package/src/modbus-dynamic-server.html +90 -0
- package/src/modbus-dynamic-server.js +45 -0
- package/src/modbus-fc-filter.html +205 -0
- package/src/modbus-fc-filter.js +85 -0
- package/src/modbus-proxy-target.html +175 -0
- package/src/modbus-proxy-target.js +302 -0
- package/src/modbus-registers-config.html +109 -0
- package/src/modbus-registers-config.js +216 -0
- package/src/modbus-registers-read.html +133 -0
- package/src/modbus-registers-read.js +204 -0
- package/src/modbus-registers-respond.html +116 -0
- package/src/modbus-registers-respond.js +286 -0
- package/src/modbus-registers-write.html +157 -0
- package/src/modbus-registers-write.js +156 -0
- package/src/modbus-response-context.js +52 -0
- package/src/modbus-server-exception.html +82 -0
- package/src/modbus-server-exception.js +44 -0
- package/src/modbus-source-router.html +296 -0
- package/src/modbus-source-router.js +120 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
const net = require('net')
|
|
2
|
+
const modbus = require('jsmodbus')
|
|
3
|
+
const EventEmitter = require('events')
|
|
4
|
+
const { randomUUID } = require('crypto')
|
|
5
|
+
const resp = require('jsmodbus/dist/response')
|
|
6
|
+
const TcpResponse = require('jsmodbus/dist/tcp-response')
|
|
7
|
+
|
|
8
|
+
const DEFAULT_PENDING_RESPONSE_TIMEOUT_MS = 5 * 60 * 1000
|
|
9
|
+
const DEFAULT_TIMEOUT_EXCEPTION_CODE = 4
|
|
10
|
+
|
|
11
|
+
// FC event names jsmodbus emits when no buffer is provided
|
|
12
|
+
const FC_EVENTS = [
|
|
13
|
+
'readCoils',
|
|
14
|
+
'readDiscreteInputs',
|
|
15
|
+
'readHoldingRegisters',
|
|
16
|
+
'readInputRegisters',
|
|
17
|
+
'writeSingleCoil',
|
|
18
|
+
'writeSingleRegister',
|
|
19
|
+
'writeMultipleCoils',
|
|
20
|
+
'writeMultipleRegisters'
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
module.exports = function (RED) {
|
|
25
|
+
'use strict'
|
|
26
|
+
|
|
27
|
+
function ModbusFlexServerConfigNode (config) {
|
|
28
|
+
RED.nodes.createNode(this, config)
|
|
29
|
+
const node = this
|
|
30
|
+
|
|
31
|
+
node.name = config.name || ''
|
|
32
|
+
node.host = config.host || '0.0.0.0'
|
|
33
|
+
node.port = Number(config.port || 502)
|
|
34
|
+
node.enforceResponseOrder = config.enforceResponseOrder !== false
|
|
35
|
+
node.pendingResponseTimeout = normalizePendingResponseTimeout(config.pendingResponseTimeout)
|
|
36
|
+
|
|
37
|
+
// Emitter used by modbus-dynamic-server nodes to receive incoming requests
|
|
38
|
+
node.emitter = new EventEmitter()
|
|
39
|
+
|
|
40
|
+
// requestId → { request, cb, eventName }
|
|
41
|
+
node.pendingRequests = new Map()
|
|
42
|
+
node._requestContexts = new WeakMap()
|
|
43
|
+
|
|
44
|
+
// connectionId → { nextSeqOut: Number, queue: Map<seqNum, { buf, writeCb }> }
|
|
45
|
+
node._connectionStates = new Map()
|
|
46
|
+
|
|
47
|
+
// connectionId → nextSeqIn counter
|
|
48
|
+
node._connectionSeqCounters = new Map()
|
|
49
|
+
|
|
50
|
+
node.netServer = new net.Server()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
// Pass null for all buffers so jsmodbus emits FC events for every request
|
|
54
|
+
// instead of serving them automatically from an internal buffer.
|
|
55
|
+
node.modbusServer = new modbus.server.TCP(node.netServer, {
|
|
56
|
+
coils: null,
|
|
57
|
+
discrete: null,
|
|
58
|
+
holding: null,
|
|
59
|
+
input: null
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
node.modbusServer.on('connection', function (client) {
|
|
64
|
+
const socket = client.socket
|
|
65
|
+
const connectionId = randomUUID()
|
|
66
|
+
|
|
67
|
+
const remoteAddress = socket.remoteAddress || ''
|
|
68
|
+
const remotePort = Number(socket.remotePort) || 0
|
|
69
|
+
const localAddress = socket.localAddress || node.host
|
|
70
|
+
const localPort = Number(socket.localPort) || node.port
|
|
71
|
+
const meta = {
|
|
72
|
+
remoteAddress,
|
|
73
|
+
remotePort,
|
|
74
|
+
localAddress,
|
|
75
|
+
localPort,
|
|
76
|
+
remoteEndpoint: formatEndpoint(remoteAddress, remotePort),
|
|
77
|
+
localEndpoint: formatEndpoint(localAddress, localPort)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
node._connectionStates.set(connectionId, { nextSeqOut: 0, queue: new Map(), meta })
|
|
81
|
+
node._connectionSeqCounters.set(connectionId, 0)
|
|
82
|
+
|
|
83
|
+
// Wrap the response handler so we can inject sequence numbers for
|
|
84
|
+
// in-order delivery per connection.
|
|
85
|
+
const origHandle = client._responseHandler.handle.bind(client._responseHandler)
|
|
86
|
+
client._responseHandler.handle = function (request, writeCb) {
|
|
87
|
+
const seqNum = node._connectionSeqCounters.get(connectionId)
|
|
88
|
+
node._connectionSeqCounters.set(connectionId, seqNum + 1)
|
|
89
|
+
node._requestContexts.set(request, { connectionId, seqNum })
|
|
90
|
+
|
|
91
|
+
const wrappedCb = function (responseBuffer) {
|
|
92
|
+
if (!node.enforceResponseOrder) {
|
|
93
|
+
writeCb(responseBuffer)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
const connState = node._connectionStates.get(connectionId)
|
|
97
|
+
if (!connState) return
|
|
98
|
+
|
|
99
|
+
if (seqNum === connState.nextSeqOut) {
|
|
100
|
+
writeCb(responseBuffer)
|
|
101
|
+
connState.nextSeqOut++
|
|
102
|
+
// Drain any already-ready queued responses
|
|
103
|
+
while (connState.queue.has(connState.nextSeqOut)) {
|
|
104
|
+
const queued = connState.queue.get(connState.nextSeqOut)
|
|
105
|
+
connState.queue.delete(connState.nextSeqOut)
|
|
106
|
+
queued.writeCb(queued.buf)
|
|
107
|
+
connState.nextSeqOut++
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
connState.queue.set(seqNum, { buf: responseBuffer, writeCb })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return origHandle(request, wrappedCb)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
socket.on('close', function () {
|
|
118
|
+
node._connectionStates.delete(connectionId)
|
|
119
|
+
node._connectionSeqCounters.delete(connectionId)
|
|
120
|
+
const cleanedRequests = node.cleanupPendingRequestsForConnection(connectionId)
|
|
121
|
+
|
|
122
|
+
node.log(`Client disconnected: ${connectionId}${cleanedRequests ? ` (cleared ${cleanedRequests} pending requests)` : ''}`)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
node.log(`Client connected from ${socket.remoteAddress}:${socket.remotePort} (${connectionId})`)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// For this new jsmodbus connectin, register a handler for every possible FC event.
|
|
129
|
+
// The cb received here is already the wrappedCb from the connection handler above.
|
|
130
|
+
// These events will just be emitted to any 'dynamic server' nodes attached to
|
|
131
|
+
// this config object (under all but very odd circumstances there will only
|
|
132
|
+
// be one).
|
|
133
|
+
FC_EVENTS.forEach(function (eventName) {
|
|
134
|
+
node.modbusServer.on(eventName, function (request, cb) {
|
|
135
|
+
const body = request.body
|
|
136
|
+
const requestId = node.registerPendingRequest(request, cb, eventName)
|
|
137
|
+
const requestContext = node._requestContexts.get(request) || {}
|
|
138
|
+
const connectionState = requestContext.connectionId ? node._connectionStates.get(requestContext.connectionId) : null
|
|
139
|
+
const connectionMeta = connectionState ? connectionState.meta : null
|
|
140
|
+
|
|
141
|
+
node.emitter.emit('request', {
|
|
142
|
+
requestId,
|
|
143
|
+
eventName,
|
|
144
|
+
connection: {
|
|
145
|
+
id: requestContext.connectionId || null,
|
|
146
|
+
source: connectionMeta
|
|
147
|
+
? {
|
|
148
|
+
address: connectionMeta.remoteAddress,
|
|
149
|
+
port: connectionMeta.remotePort,
|
|
150
|
+
endpoint: connectionMeta.remoteEndpoint
|
|
151
|
+
}
|
|
152
|
+
: null,
|
|
153
|
+
destination: connectionMeta
|
|
154
|
+
? {
|
|
155
|
+
address: connectionMeta.localAddress,
|
|
156
|
+
port: connectionMeta.localPort,
|
|
157
|
+
endpoint: connectionMeta.localEndpoint
|
|
158
|
+
}
|
|
159
|
+
: null
|
|
160
|
+
},
|
|
161
|
+
request: {
|
|
162
|
+
fc: body.fc,
|
|
163
|
+
// Reads use .start/.count; writes use .address/.quantity
|
|
164
|
+
address: body.address !== undefined ? body.address : body.start,
|
|
165
|
+
quantity: body.quantity !== undefined ? body.quantity : (body.count !== undefined ? body.count : 1),
|
|
166
|
+
unitId: request.unitId,
|
|
167
|
+
transactionId: request.id,
|
|
168
|
+
body
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
node.takePendingRequest = function (requestId) {
|
|
175
|
+
const pending = node.pendingRequests.get(requestId)
|
|
176
|
+
if (!pending) {
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (pending.timeoutHandle) {
|
|
181
|
+
clearTimeout(pending.timeoutHandle)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
node.pendingRequests.delete(requestId)
|
|
185
|
+
return pending
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
node.cleanupPendingRequestsForConnection = function (connectionId) {
|
|
189
|
+
let cleanedRequests = 0
|
|
190
|
+
|
|
191
|
+
for (const [requestId, pending] of node.pendingRequests) {
|
|
192
|
+
if (pending.connectionId === connectionId) {
|
|
193
|
+
node.takePendingRequest(requestId)
|
|
194
|
+
cleanedRequests++
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return cleanedRequests
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
node.registerPendingRequest = function (request, cb, eventName) {
|
|
202
|
+
const requestId = randomUUID()
|
|
203
|
+
const requestContext = node._requestContexts.get(request) || {}
|
|
204
|
+
const pending = {
|
|
205
|
+
request,
|
|
206
|
+
cb,
|
|
207
|
+
eventName,
|
|
208
|
+
connectionId: requestContext.connectionId,
|
|
209
|
+
seqNum: requestContext.seqNum
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
pending.timeoutHandle = setTimeout(function () {
|
|
213
|
+
const expiredPending = node.takePendingRequest(requestId)
|
|
214
|
+
if (!expiredPending) {
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const connectionIsOpen = !expiredPending.connectionId || node._connectionStates.has(expiredPending.connectionId)
|
|
219
|
+
if (!connectionIsOpen) {
|
|
220
|
+
node.warn(`Pending Modbus response expired after ${node.pendingResponseTimeout}ms for requestId ${requestId}; connection already closed`)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
node.warn(`Pending Modbus response expired after ${node.pendingResponseTimeout}ms for requestId ${requestId}; replying with exception ${DEFAULT_TIMEOUT_EXCEPTION_CODE}`)
|
|
225
|
+
try {
|
|
226
|
+
sendPendingResponse(expiredPending, { exception: DEFAULT_TIMEOUT_EXCEPTION_CODE })
|
|
227
|
+
} catch (err) {
|
|
228
|
+
node.error(`respondToRequest timeout error for ${expiredPending.eventName}: ${err.message}`)
|
|
229
|
+
}
|
|
230
|
+
}, node.pendingResponseTimeout)
|
|
231
|
+
|
|
232
|
+
node.pendingRequests.set(requestId, pending)
|
|
233
|
+
return requestId
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
node.netServer.on('error', function (err) {
|
|
237
|
+
node.error(`Server error: ${err.message}`)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
node.netServer.listen(node.port, node.host, function () {
|
|
241
|
+
node.log(`Listening on ${node.host}:${node.port}`)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Called by modbus-dynamic-server-response to complete a pending request.
|
|
246
|
+
*
|
|
247
|
+
* @param {string} requestId - The requestId from msg.modbus.requestId
|
|
248
|
+
* @param {Array|Buffer|{exception: number}|{values: Array}} payload
|
|
249
|
+
* - Read FCs: array of values or Buffer, or { values: [...] }
|
|
250
|
+
* - Write FCs: ignored (response is auto-built from the original request)
|
|
251
|
+
* - Exception: { exception: <modbus exception code 1–11> }
|
|
252
|
+
* @returns {boolean} true on success, false if requestId not found
|
|
253
|
+
*/
|
|
254
|
+
node.respondToRequest = function (requestId, payload) {
|
|
255
|
+
const pending = node.takePendingRequest(requestId)
|
|
256
|
+
if (!pending) {
|
|
257
|
+
node.warn(`respondToRequest: unknown requestId ${requestId}`)
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const connectionIsOpen = !pending.connectionId || node._connectionStates.has(pending.connectionId)
|
|
263
|
+
if (!connectionIsOpen) {
|
|
264
|
+
node.warn(`respondToRequest: requestId ${requestId} connection already closed`)
|
|
265
|
+
return false
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
sendPendingResponse(pending, payload)
|
|
269
|
+
return true
|
|
270
|
+
} catch (err) {
|
|
271
|
+
node.error(`respondToRequest error for ${pending.eventName}: ${err.message}`)
|
|
272
|
+
return false
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function sendPendingResponse (pending, payload) {
|
|
277
|
+
const { request, cb, eventName } = pending
|
|
278
|
+
let responseBody
|
|
279
|
+
|
|
280
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload) && !Buffer.isBuffer(payload) && payload.exception) {
|
|
281
|
+
responseBody = new resp.ExceptionResponseBody(request.body.fc, payload.exception)
|
|
282
|
+
} else {
|
|
283
|
+
responseBody = buildResponseBody(eventName, request, payload)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const response = TcpResponse.default.fromRequest(request, responseBody)
|
|
287
|
+
cb(response.createPayload())
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function buildResponseBody (eventName, request, payload) {
|
|
291
|
+
const values = Array.isArray(payload)
|
|
292
|
+
? payload
|
|
293
|
+
: (payload && payload.values)
|
|
294
|
+
? payload.values
|
|
295
|
+
: payload
|
|
296
|
+
|
|
297
|
+
switch (eventName) {
|
|
298
|
+
case 'readCoils': {
|
|
299
|
+
const count = request.body.count
|
|
300
|
+
const coilArr = normalizeCoilValues(values, count)
|
|
301
|
+
return new resp.ReadCoilsResponseBody(coilArr, Math.ceil(count / 8))
|
|
302
|
+
}
|
|
303
|
+
case 'readDiscreteInputs': {
|
|
304
|
+
const count = request.body.count
|
|
305
|
+
const discArr = normalizeCoilValues(values, count)
|
|
306
|
+
return new resp.ReadDiscreteInputsResponseBody(discArr, Math.ceil(count / 8))
|
|
307
|
+
}
|
|
308
|
+
case 'readHoldingRegisters': {
|
|
309
|
+
const count = request.body.count
|
|
310
|
+
const regArr = normalizeRegisterValues(values, count)
|
|
311
|
+
return new resp.ReadHoldingRegistersResponseBody(count * 2, regArr)
|
|
312
|
+
}
|
|
313
|
+
case 'readInputRegisters': {
|
|
314
|
+
const count = request.body.count
|
|
315
|
+
const regArr = normalizeRegisterValues(values, count)
|
|
316
|
+
return new resp.ReadInputRegistersResponseBody(count * 2, regArr)
|
|
317
|
+
}
|
|
318
|
+
case 'writeSingleCoil':
|
|
319
|
+
return resp.WriteSingleCoilResponseBody.fromRequest(request.body)
|
|
320
|
+
case 'writeSingleRegister':
|
|
321
|
+
return resp.WriteSingleRegisterResponseBody.fromRequest(request.body)
|
|
322
|
+
case 'writeMultipleCoils':
|
|
323
|
+
return resp.WriteMultipleCoilsResponseBody.fromRequest(request.body)
|
|
324
|
+
case 'writeMultipleRegisters':
|
|
325
|
+
return resp.WriteMultipleRegistersResponseBody.fromRequest(request.body)
|
|
326
|
+
default:
|
|
327
|
+
throw new Error(`Unknown FC event: ${eventName}`)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function normalizeRegisterValues (values, count) {
|
|
332
|
+
if (Buffer.isBuffer(values)) {
|
|
333
|
+
const expected = count * 2
|
|
334
|
+
if (values.length === expected) return values
|
|
335
|
+
// wrong size — reallocate and copy what we can
|
|
336
|
+
const buf = Buffer.alloc(expected, 0)
|
|
337
|
+
values.copy(buf, 0, 0, Math.min(values.length, expected))
|
|
338
|
+
return buf
|
|
339
|
+
}
|
|
340
|
+
if (Array.isArray(values)) {
|
|
341
|
+
const padded = new Array(count).fill(0)
|
|
342
|
+
values.slice(0, count).forEach((v, i) => { padded[i] = v })
|
|
343
|
+
return padded
|
|
344
|
+
}
|
|
345
|
+
return new Array(count).fill(0)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function normalizeCoilValues (values, count) {
|
|
349
|
+
if (Buffer.isBuffer(values)) {
|
|
350
|
+
const expected = Math.ceil(count / 8)
|
|
351
|
+
if (values.length === expected) return values
|
|
352
|
+
const buf = Buffer.alloc(expected, 0)
|
|
353
|
+
values.copy(buf, 0, 0, Math.min(values.length, expected))
|
|
354
|
+
return buf
|
|
355
|
+
}
|
|
356
|
+
if (Array.isArray(values)) {
|
|
357
|
+
const padded = new Array(count).fill(0)
|
|
358
|
+
values.slice(0, count).forEach((v, i) => { padded[i] = v ? 1 : 0 })
|
|
359
|
+
return padded
|
|
360
|
+
}
|
|
361
|
+
return new Array(count).fill(0)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
node.on('close', function (done) {
|
|
365
|
+
for (const requestId of node.pendingRequests.keys()) {
|
|
366
|
+
node.takePendingRequest(requestId)
|
|
367
|
+
}
|
|
368
|
+
node._connectionStates.clear()
|
|
369
|
+
node._connectionSeqCounters.clear()
|
|
370
|
+
if (node.netServer) {
|
|
371
|
+
node.netServer.close(function (err) {
|
|
372
|
+
done(err || undefined)
|
|
373
|
+
})
|
|
374
|
+
} else {
|
|
375
|
+
done()
|
|
376
|
+
}
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function normalizePendingResponseTimeout (value) {
|
|
381
|
+
const timeout = Number(value)
|
|
382
|
+
return Number.isFinite(timeout) && timeout > 0 ? timeout : DEFAULT_PENDING_RESPONSE_TIMEOUT_MS
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function formatEndpoint (address, port) {
|
|
386
|
+
if (!address && !port) return ''
|
|
387
|
+
return `${address || ''}:${port || 0}`
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
RED.nodes.registerType('modbus-dynamic-server-config', ModbusFlexServerConfigNode)
|
|
391
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<script type="text/html" data-template-name="modbus-dynamic-server-response">
|
|
2
|
+
<div class="form-row">
|
|
3
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
4
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<script type="text/html" data-help-name="modbus-dynamic-server-response">
|
|
10
|
+
<p>Completes a pending Modbus request by sending a response back to the connected client.
|
|
11
|
+
The input message must carry the <code>requestId</code> originally emitted by a
|
|
12
|
+
<code>modbus-dynamic-server</code> node.</p>
|
|
13
|
+
|
|
14
|
+
<h3>Inputs</h3>
|
|
15
|
+
<dl class="message-properties">
|
|
16
|
+
<dt>_modbus.requestId <span class="property-type">string</span></dt>
|
|
17
|
+
<dd>Required internal request context populated by <code>modbus-dynamic-server</code>.</dd>
|
|
18
|
+
|
|
19
|
+
<dt>_modbus.configNodeId <span class="property-type">string</span></dt>
|
|
20
|
+
<dd>Required internal config-node identifier used to complete the pending request.</dd>
|
|
21
|
+
|
|
22
|
+
<dt>payload <span class="property-type">array | Buffer | object</span></dt>
|
|
23
|
+
<dd>
|
|
24
|
+
The response value(s) to send back to the Modbus client:
|
|
25
|
+
<ul>
|
|
26
|
+
<li><b>Read requests (FC1–FC4):</b> an array of values, a <code>Buffer</code>,
|
|
27
|
+
or an object <code>{ values: [...] }</code>.</li>
|
|
28
|
+
<li><b>Write requests (FC5, FC6, FC15, FC16):</b> ignored — the response is
|
|
29
|
+
built automatically from the original request body.</li>
|
|
30
|
+
<li><b>Exception:</b> an object <code>{ exception: <code> }</code> where
|
|
31
|
+
code is a Modbus exception code (1–11).</li>
|
|
32
|
+
</ul>
|
|
33
|
+
</dd>
|
|
34
|
+
</dl>
|
|
35
|
+
|
|
36
|
+
<h3>Outputs</h3>
|
|
37
|
+
<ol class="node-ports">
|
|
38
|
+
<li>Response report message
|
|
39
|
+
<dl class="message-properties">
|
|
40
|
+
<dt>modbusResponse <span class="property-type">object</span></dt>
|
|
41
|
+
<dd>Details of what this node submitted to <code>respondToRequest</code>, including:
|
|
42
|
+
<ul>
|
|
43
|
+
<li><code>requestId</code>, <code>eventName</code>, <code>address</code>, <code>quantity</code></li>
|
|
44
|
+
<li><code>payloadSubmitted</code> — the exact payload passed to server config</li>
|
|
45
|
+
<li><code>payloadType</code> — payload classification (<code>array</code>, <code>buffer</code>, <code>object</code>, ...)</li>
|
|
46
|
+
<li><code>actualDataSent</code> — normalized values actually encoded for read FC responses</li>
|
|
47
|
+
<li><code>actualDataType</code> — type of <code>actualDataSent</code></li>
|
|
48
|
+
<li><code>ok</code> — whether the response was accepted for the requestId</li>
|
|
49
|
+
<li><code>timestamp</code> — ISO timestamp when this node submitted it</li>
|
|
50
|
+
<li><code>note</code> — present for write FCs to indicate auto-built response behavior</li>
|
|
51
|
+
</ul>
|
|
52
|
+
</dd>
|
|
53
|
+
</dl>
|
|
54
|
+
</li>
|
|
55
|
+
</ol>
|
|
56
|
+
|
|
57
|
+
<h3>Details</h3>
|
|
58
|
+
<p>Once <code>respondToRequest</code> is called, the pending request is consumed and cannot be
|
|
59
|
+
answered again. This node now emits one message so flows can inspect what was sent.</p>
|
|
60
|
+
<p>If <code>msg._modbus</code> is missing, this node cannot respond and logs
|
|
61
|
+
<em>Missing Modbus request context</em>.</p>
|
|
62
|
+
<p>Flows must preserve <code>msg._modbus</code>:</p>
|
|
63
|
+
<pre><code>// CORRECT
|
|
64
|
+
msg.payload = newValue;
|
|
65
|
+
return msg;
|
|
66
|
+
|
|
67
|
+
// INCORRECT: request context is lost
|
|
68
|
+
msg = { payload: newValue };
|
|
69
|
+
return msg;</code></pre>
|
|
70
|
+
<p>For a simpler alternative that automatically reads/writes values from a shared register map
|
|
71
|
+
without manual payload construction, use the <code>modbus-registers-respond</code> node instead.</p>
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<script type="text/javascript">
|
|
75
|
+
RED.nodes.registerType('modbus-dynamic-server-response', {
|
|
76
|
+
category: 'modbus',
|
|
77
|
+
color: '#E9967A',
|
|
78
|
+
defaults: {
|
|
79
|
+
name: { value: '' }
|
|
80
|
+
},
|
|
81
|
+
inputs: 1,
|
|
82
|
+
outputs: 1,
|
|
83
|
+
icon: 'arrow-out.svg',
|
|
84
|
+
label: function () {
|
|
85
|
+
return this.name || 'Modbus Dynamic Response'
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
</script>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const { respondWithPayload } = require('./modbus-response-context')
|
|
2
|
+
|
|
3
|
+
module.exports = function (RED) {
|
|
4
|
+
function ModbusFlexServerResponseNode (config) {
|
|
5
|
+
RED.nodes.createNode(this, config)
|
|
6
|
+
const node = this
|
|
7
|
+
|
|
8
|
+
node.status({ fill: 'grey', shape: 'ring', text: 'ready' })
|
|
9
|
+
|
|
10
|
+
node.on('input', function (msg, send, done) {
|
|
11
|
+
const responseResult = respondWithPayload(RED, node, msg, msg.payload)
|
|
12
|
+
if (!responseResult.context) {
|
|
13
|
+
node.status({ fill: 'red', shape: 'ring', text: 'missing context' })
|
|
14
|
+
done()
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const context = responseResult.context
|
|
19
|
+
const eventName = context.eventName || (msg.modbus && msg.modbus.eventName)
|
|
20
|
+
const address = context.address !== undefined ? context.address : (msg.modbus && msg.modbus.address)
|
|
21
|
+
const quantity = context.quantity !== undefined ? context.quantity : (msg.modbus && msg.modbus.quantity)
|
|
22
|
+
const requestId = context.requestId
|
|
23
|
+
const ok = responseResult.ok
|
|
24
|
+
const actualDataSent = deriveActualDataSent(eventName, quantity, msg.payload)
|
|
25
|
+
|
|
26
|
+
msg.modbusResponse = {
|
|
27
|
+
requestId,
|
|
28
|
+
eventName,
|
|
29
|
+
address,
|
|
30
|
+
quantity,
|
|
31
|
+
payloadSubmitted: clonePayload(msg.payload),
|
|
32
|
+
payloadType: describePayload(msg.payload),
|
|
33
|
+
actualDataSent,
|
|
34
|
+
actualDataType: describePayload(actualDataSent),
|
|
35
|
+
ok,
|
|
36
|
+
timestamp: new Date().toISOString()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Write FC responses are generated from the original request body by jsmodbus.
|
|
40
|
+
if (typeof eventName === 'string' && eventName.startsWith('write')) {
|
|
41
|
+
msg.modbusResponse.note = 'Write FC response is auto-built from request body by server config'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (ok) {
|
|
45
|
+
node.status({ fill: 'green', shape: 'dot', text: 'responded' })
|
|
46
|
+
send(msg)
|
|
47
|
+
done()
|
|
48
|
+
} else {
|
|
49
|
+
node.status({ fill: 'red', shape: 'ring', text: 'unknown requestId' })
|
|
50
|
+
done(new Error(`modbus-dynamic-server-response: unknown requestId ${requestId}`))
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
node.on('close', function () {
|
|
55
|
+
// nothing to clean up; pending requests are owned by the config node
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clonePayload (payload) {
|
|
60
|
+
if (Buffer.isBuffer(payload)) {
|
|
61
|
+
return Buffer.from(payload)
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(payload)) {
|
|
64
|
+
return payload.slice()
|
|
65
|
+
}
|
|
66
|
+
if (payload && typeof payload === 'object') {
|
|
67
|
+
return { ...payload }
|
|
68
|
+
}
|
|
69
|
+
return payload
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function describePayload (payload) {
|
|
73
|
+
if (Buffer.isBuffer(payload)) return 'buffer'
|
|
74
|
+
if (Array.isArray(payload)) return 'array'
|
|
75
|
+
return typeof payload
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function deriveActualDataSent (eventName, quantity, payload) {
|
|
79
|
+
const count = Number(quantity)
|
|
80
|
+
if (!Number.isFinite(count) || count <= 0) {
|
|
81
|
+
return payload
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (eventName === 'readHoldingRegisters' || eventName === 'readInputRegisters') {
|
|
85
|
+
return normalizeWordPayload(payload, count)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (eventName === 'readCoils' || eventName === 'readDiscreteInputs') {
|
|
89
|
+
return normalizeBitPayload(payload, count)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// write responses are auto-built from request body by server config
|
|
93
|
+
return payload
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeWordPayload (payload, count) {
|
|
97
|
+
const values = resolveValues(payload)
|
|
98
|
+
|
|
99
|
+
if (Buffer.isBuffer(values)) {
|
|
100
|
+
const expectedBytes = count * 2
|
|
101
|
+
const buf = Buffer.alloc(expectedBytes, 0)
|
|
102
|
+
values.copy(buf, 0, 0, Math.min(values.length, expectedBytes))
|
|
103
|
+
const words = []
|
|
104
|
+
for (let i = 0; i < expectedBytes; i += 2) {
|
|
105
|
+
words.push(buf.readUInt16BE(i))
|
|
106
|
+
}
|
|
107
|
+
return words
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (Array.isArray(values)) {
|
|
111
|
+
const out = new Array(count).fill(0)
|
|
112
|
+
values.slice(0, count).forEach(function (v, i) {
|
|
113
|
+
out[i] = Number(v) & 0xffff
|
|
114
|
+
})
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return new Array(count).fill(0)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeBitPayload (payload, count) {
|
|
122
|
+
const values = resolveValues(payload)
|
|
123
|
+
|
|
124
|
+
if (Buffer.isBuffer(values)) {
|
|
125
|
+
const out = new Array(count).fill(0)
|
|
126
|
+
for (let i = 0; i < count; i++) {
|
|
127
|
+
const byteIndex = Math.floor(i / 8)
|
|
128
|
+
const bitIndex = i % 8
|
|
129
|
+
if (byteIndex < values.length) {
|
|
130
|
+
out[i] = (values[byteIndex] >> bitIndex) & 1
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return out
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (Array.isArray(values)) {
|
|
137
|
+
const out = new Array(count).fill(0)
|
|
138
|
+
values.slice(0, count).forEach(function (v, i) {
|
|
139
|
+
out[i] = v ? 1 : 0
|
|
140
|
+
})
|
|
141
|
+
return out
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return new Array(count).fill(0)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function resolveValues (payload) {
|
|
148
|
+
if (Array.isArray(payload) || Buffer.isBuffer(payload)) {
|
|
149
|
+
return payload
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (payload && typeof payload === 'object' && Array.isArray(payload.values)) {
|
|
153
|
+
return payload.values
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return payload
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
RED.nodes.registerType('modbus-dynamic-server-response', ModbusFlexServerResponseNode)
|
|
160
|
+
}
|