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.
@@ -0,0 +1,400 @@
1
+ const jsmodbus = require('jsmodbus')
2
+ const ModbusTCPRequest = require('jsmodbus/dist/tcp-request').default
3
+ const { respondWithPayload } = require('./modbus-response-context')
4
+
5
+ module.exports = function (RED) {
6
+ function ModbusDynamicProxyNode (config) {
7
+ RED.nodes.createNode(this, config)
8
+ const node = this
9
+
10
+ node.name = config.name || ''
11
+ node.proxyTarget = RED.nodes.getNode(config.proxyTarget)
12
+
13
+ if (!node.proxyTarget) {
14
+ node.status({ fill: 'red', shape: 'ring', text: 'no proxy target' })
15
+ return
16
+ }
17
+
18
+ node._lastStatusText = 'ready'
19
+
20
+ function getConnectionStyle () {
21
+ if (!node.proxyTarget) {
22
+ return { fill: 'red', shape: 'ring' }
23
+ }
24
+
25
+ if (node.proxyTarget.targetType === 'tcp') {
26
+ if (node.proxyTarget._connected) {
27
+ return { fill: 'green', shape: 'dot' }
28
+ }
29
+
30
+ if (node.proxyTarget._connecting) {
31
+ return { fill: 'yellow', shape: 'ring' }
32
+ }
33
+
34
+ return { fill: 'red', shape: 'ring' }
35
+ }
36
+
37
+ // Serial target exists but current proxy-target serial path is stubbed.
38
+ return { fill: 'yellow', shape: 'ring' }
39
+ }
40
+
41
+ function updateConnectionStatus (statusText) {
42
+ if (typeof statusText === 'string' && statusText.length > 0) {
43
+ node._lastStatusText = statusText
44
+ }
45
+
46
+ const style = getConnectionStyle()
47
+ node.status({ fill: style.fill, shape: style.shape, text: node._lastStatusText })
48
+ }
49
+
50
+ updateConnectionStatus()
51
+ node._statusTimer = setInterval(function () {
52
+ updateConnectionStatus()
53
+ }, 1000)
54
+
55
+ node.on('input', function (msg, send, done) {
56
+ const context = msg._modbus || {}
57
+ const requestId = context.requestId
58
+ const eventName = context.eventName
59
+ const address = context.address
60
+ const quantity = context.quantity
61
+ const request = msg.payload || {}
62
+
63
+ if (!requestId || !eventName) {
64
+ node.status({ fill: 'red', shape: 'ring', text: 'missing context' })
65
+ node.error('Missing Modbus request context', msg)
66
+ done()
67
+ return
68
+ }
69
+
70
+ // Extract request buffer from msg or construct from Modbus request
71
+ const requestBuffer = msg.requestBuffer || constructModbusRequest(request)
72
+ if (!requestBuffer) {
73
+ done(new Error('modbus-dynamic-proxy: unable to construct request buffer'))
74
+ return
75
+ }
76
+
77
+
78
+ // Send to proxy target queue
79
+ node.proxyTarget.enqueueRequest(requestBuffer, function (proxyResult) {
80
+ const debugEnabled = msg.modbusProxyDebug === true
81
+ if (!proxyResult.ok) {
82
+ // Send Modbus exception back to server
83
+ const exceptionCode = proxyResult.exception || 4
84
+ const responseResult = respondWithPayload(RED, node, msg, { exception: exceptionCode })
85
+ const ok = responseResult.ok
86
+
87
+ if (!responseResult.context) {
88
+ node.status({ fill: 'red', shape: 'ring', text: 'missing context' })
89
+ done()
90
+ return
91
+ }
92
+
93
+ if (!ok) {
94
+ node.status({ fill: 'red', shape: 'ring', text: 'unknown request' })
95
+ done(new Error(`modbus-dynamic-proxy: request ${requestId} could not be responded to`))
96
+ return
97
+ }
98
+
99
+ updateConnectionStatus(`exception ${exceptionCode}`)
100
+
101
+ const outputMsg = {
102
+ payload: null,
103
+ modbusProxy: {
104
+ requestId,
105
+ eventName,
106
+ fc: request.fc,
107
+ address: address !== undefined ? address : request.address,
108
+ quantity: quantity !== undefined ? quantity : request.quantity,
109
+ proxied: false,
110
+ proxyError: proxyResult.error,
111
+ exception: exceptionCode,
112
+ targetType: node.proxyTarget.targetType
113
+ }
114
+ }
115
+
116
+ send(outputMsg)
117
+ done()
118
+ return
119
+ }
120
+
121
+ // Parse response and send back to server
122
+ const parsedResponse = parseModbusResponse(proxyResult.responseBuffer, eventName, quantity !== undefined ? quantity : request.quantity)
123
+ const responseSummary = summarizeTcpResponse(proxyResult.responseBuffer)
124
+ if (debugEnabled) {
125
+ node.warn(`modbus-dynamic-proxy raw response requestId=${requestId} summary=${JSON.stringify(responseSummary)}`)
126
+ }
127
+ if (!parsedResponse) {
128
+ const responseResult = respondWithPayload(RED, node, msg, { exception: 4 })
129
+ const ok = responseResult.ok
130
+
131
+ if (!responseResult.context) {
132
+ node.status({ fill: 'red', shape: 'ring', text: 'missing context' })
133
+ done()
134
+ return
135
+ }
136
+
137
+ if (!ok) {
138
+ done(new Error(`modbus-dynamic-proxy: request ${requestId} could not be responded to`))
139
+ return
140
+ }
141
+
142
+ updateConnectionStatus('parse error')
143
+
144
+ const outputMsg = {
145
+ payload: null,
146
+ modbusProxy: {
147
+ requestId,
148
+ eventName,
149
+ fc: request.fc,
150
+ address: address !== undefined ? address : request.address,
151
+ quantity: quantity !== undefined ? quantity : request.quantity,
152
+ proxied: false,
153
+ parseError: true,
154
+ exception: 4,
155
+ targetType: node.proxyTarget.targetType
156
+ }
157
+ }
158
+
159
+ send(outputMsg)
160
+ done()
161
+ return
162
+ }
163
+
164
+ // Send response back to server
165
+ const responseResult = respondWithPayload(RED, node, msg, parsedResponse)
166
+ const ok = responseResult.ok
167
+
168
+ if (!responseResult.context) {
169
+ node.status({ fill: 'red', shape: 'ring', text: 'missing context' })
170
+ done()
171
+ return
172
+ }
173
+
174
+ if (!ok) {
175
+ node.status({ fill: 'red', shape: 'ring', text: 'unknown request' })
176
+ done(new Error(`modbus-dynamic-proxy: request ${requestId} could not be responded to`))
177
+ return
178
+ }
179
+
180
+ updateConnectionStatus(`proxied ${eventName}`)
181
+
182
+ // Emit the proxied response for caching/inspection
183
+ const outputMsg = {
184
+ payload: proxyResult.responseBuffer,
185
+ modbusProxy: {
186
+ requestId,
187
+ eventName,
188
+ fc: request.fc,
189
+ address: address !== undefined ? address : request.address,
190
+ quantity: quantity !== undefined ? quantity : request.quantity,
191
+ proxied: true,
192
+ targetType: node.proxyTarget.targetType,
193
+ responseSummary
194
+ }
195
+ }
196
+
197
+ if (debugEnabled) {
198
+ outputMsg.modbusProxy.parsedResponsePreview = previewParsedResponse(parsedResponse)
199
+ node.warn(`modbus-dynamic-proxy parsed response requestId=${requestId} parsed=${JSON.stringify(outputMsg.modbusProxy.parsedResponsePreview)}`)
200
+ }
201
+
202
+ send(outputMsg)
203
+ done()
204
+ })
205
+ })
206
+
207
+ node.on('close', function (done) {
208
+ if (node._statusTimer) {
209
+ clearInterval(node._statusTimer)
210
+ node._statusTimer = null
211
+ }
212
+ // nothing to clean up; queue is owned by proxy target config node
213
+ done()
214
+ })
215
+ }
216
+
217
+ function constructModbusRequest (request) {
218
+ if (!request || typeof request !== 'object') {
219
+ return null
220
+ }
221
+
222
+ const fc = Number(request.fc)
223
+ const address = Number(request.address)
224
+ const quantity = Number(request.quantity)
225
+ const unitId = Number(request.unitId) || 0
226
+
227
+ if (!Number.isFinite(fc) || !Number.isFinite(address) || !Number.isFinite(quantity)) {
228
+ return null
229
+ }
230
+
231
+ // Validate FC and address/quantity ranges
232
+ if (fc < 1 || fc > 16 || [7, 8, 9, 10, 11, 12, 13, 14].includes(fc)) {
233
+ return null
234
+ }
235
+
236
+ if (address < 0 || address > 65535 || quantity < 1 || quantity > 65535) {
237
+ return null
238
+ }
239
+
240
+ try {
241
+ let requestBody
242
+
243
+ switch (fc) {
244
+ case 1:
245
+ requestBody = new jsmodbus.requests.ReadCoilsRequestBody(address, quantity)
246
+ break
247
+ case 2:
248
+ requestBody = new jsmodbus.requests.ReadDiscreteInputsRequestBody(address, quantity)
249
+ break
250
+ case 3:
251
+ requestBody = new jsmodbus.requests.ReadHoldingRegistersRequestBody(address, quantity)
252
+ break
253
+ case 4:
254
+ requestBody = new jsmodbus.requests.ReadInputRegistersRequestBody(address, quantity)
255
+ break
256
+ case 5:
257
+ case 6:
258
+ case 15:
259
+ case 16:
260
+ // Write requests need values which aren't in this basic payload
261
+ // Return null to indicate request buffer should be passed via msg.requestBuffer
262
+ return null
263
+ default:
264
+ return null
265
+ }
266
+
267
+ if (!requestBody) {
268
+ return null
269
+ }
270
+
271
+ // Wrap in Modbus TCP request
272
+ const tcpRequest = new ModbusTCPRequest(requestBody)
273
+ // Constructor puts requestBody in _id, move it to _body
274
+ tcpRequest._body = tcpRequest._id
275
+ tcpRequest._id = 1
276
+ tcpRequest._protocol = 0
277
+ tcpRequest._length = 0
278
+ tcpRequest._unitId = unitId
279
+
280
+ return tcpRequest.createPayload()
281
+ } catch (err) {
282
+ return null
283
+ }
284
+ }
285
+
286
+ function parseModbusResponse (responseBuffer, eventName, quantity) {
287
+ if (!Buffer.isBuffer(responseBuffer) || responseBuffer.length < 9) {
288
+ return null
289
+ }
290
+
291
+ // Modbus TCP MBAP header (7 bytes) + PDU
292
+ const pduOffset = 7
293
+ const fc = responseBuffer[pduOffset]
294
+
295
+ // Exception response: FC | 0x80, exception byte follows
296
+ if ((fc & 0x80) === 0x80) {
297
+ if (responseBuffer.length < pduOffset + 2) {
298
+ return null
299
+ }
300
+ return { exception: responseBuffer[pduOffset + 1] }
301
+ }
302
+
303
+ if (eventName === 'readHoldingRegisters' || eventName === 'readInputRegisters') {
304
+ const byteCount = responseBuffer[pduOffset + 1]
305
+ const start = pduOffset + 2
306
+ const end = start + byteCount
307
+ if (responseBuffer.length < end) {
308
+ return null
309
+ }
310
+
311
+ // Decode into register word array so server config builds an unambiguous response.
312
+ const data = responseBuffer.slice(start, end)
313
+ const words = []
314
+ for (let i = 0; i + 1 < data.length; i += 2) {
315
+ words.push(data.readUInt16BE(i))
316
+ }
317
+ return words
318
+ }
319
+
320
+ if (eventName === 'readCoils' || eventName === 'readDiscreteInputs') {
321
+ const byteCount = responseBuffer[pduOffset + 1]
322
+ const start = pduOffset + 2
323
+ const end = start + byteCount
324
+ if (responseBuffer.length < end) {
325
+ return null
326
+ }
327
+
328
+ // Decode packed bits to explicit coil/discrete array.
329
+ const count = Number(quantity)
330
+ if (!Number.isFinite(count) || count <= 0) {
331
+ return []
332
+ }
333
+
334
+ const out = new Array(count).fill(0)
335
+ const data = responseBuffer.slice(start, end)
336
+ for (let i = 0; i < count; i++) {
337
+ const byteIndex = Math.floor(i / 8)
338
+ const bitIndex = i % 8
339
+ if (byteIndex < data.length) {
340
+ out[i] = (data[byteIndex] >> bitIndex) & 1
341
+ }
342
+ }
343
+ return out
344
+ }
345
+
346
+ // For write responses, return undefined (server builds auto-response)
347
+ if (eventName.startsWith('write')) {
348
+ return undefined
349
+ }
350
+
351
+ return null
352
+ }
353
+
354
+ function summarizeTcpResponse (responseBuffer) {
355
+ if (!Buffer.isBuffer(responseBuffer) || responseBuffer.length < 9) {
356
+ return { valid: false }
357
+ }
358
+
359
+ const transactionId = responseBuffer.readUInt16BE(0)
360
+ const protocolId = responseBuffer.readUInt16BE(2)
361
+ const mbapLength = responseBuffer.readUInt16BE(4)
362
+ const unitId = responseBuffer[6]
363
+ const fc = responseBuffer[7]
364
+ const byteCount = responseBuffer.length > 8 ? responseBuffer[8] : null
365
+
366
+ let dataWords = []
367
+ if ((fc & 0x80) === 0 && Number.isInteger(byteCount) && byteCount >= 2 && responseBuffer.length >= 9 + byteCount) {
368
+ const data = responseBuffer.slice(9, 9 + byteCount)
369
+ for (let i = 0; i + 1 < data.length; i += 2) {
370
+ dataWords.push(data.readUInt16BE(i))
371
+ }
372
+ }
373
+
374
+ return {
375
+ valid: true,
376
+ transactionId,
377
+ protocolId,
378
+ mbapLength,
379
+ unitId,
380
+ fc,
381
+ byteCount,
382
+ dataWords,
383
+ rawHex: responseBuffer.toString('hex')
384
+ }
385
+ }
386
+
387
+ function previewParsedResponse (parsedResponse) {
388
+ if (parsedResponse === undefined) return { type: 'undefined' }
389
+ if (Buffer.isBuffer(parsedResponse)) {
390
+ return { type: 'buffer', hex: parsedResponse.toString('hex') }
391
+ }
392
+ if (Array.isArray(parsedResponse)) {
393
+ return { type: 'array', values: parsedResponse }
394
+ }
395
+ return { type: typeof parsedResponse, value: parsedResponse }
396
+ }
397
+
398
+ RED.nodes.registerType('modbus-dynamic-proxy', ModbusDynamicProxyNode)
399
+ }
400
+
@@ -0,0 +1,85 @@
1
+ <script type="text/html" data-template-name="modbus-dynamic-server-config">
2
+
3
+ <div class="form-row">
4
+ <label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
5
+ <input type="text" id="node-config-input-name" placeholder="Optional name">
6
+ </div>
7
+
8
+ <div class="form-row">
9
+ <label for="node-config-input-host"><i class="fa fa-globe"></i> Host</label>
10
+ <input type="text" id="node-config-input-host" placeholder="0.0.0.0">
11
+ </div>
12
+
13
+ <div class="form-row">
14
+ <label for="node-config-input-port"><i class="fa fa-random"></i> Port</label>
15
+ <input type="number" id="node-config-input-port" placeholder="502">
16
+ </div>
17
+
18
+ <div class="form-row">
19
+ <label for="node-config-input-enforceResponseOrder">
20
+ <i class="fa fa-sort-numeric-asc"></i> Enforce Response Order
21
+ </label>
22
+ <input type="checkbox" id="node-config-input-enforceResponseOrder" style="display:inline-block; width:auto;">
23
+ </div>
24
+
25
+ <div class="form-row">
26
+ <label for="node-config-input-pendingResponseTimeout">
27
+ <i class="fa fa-clock-o"></i> Pending Response Timeout (ms)
28
+ </label>
29
+ <input type="number" id="node-config-input-pendingResponseTimeout" placeholder="300000">
30
+ </div>
31
+
32
+ </script>
33
+
34
+ <script type="text/html" data-help-name="modbus-dynamic-server-config">
35
+ <p>Configures and starts a Modbus TCP server. All incoming function-code requests are held as
36
+ <em>pending requests</em> and dispatched to connected <code>modbus-dynamic-server</code> nodes
37
+ for processing.</p>
38
+
39
+ <h3>Configuration</h3>
40
+ <dl class="message-properties">
41
+ <dt>Name <span class="property-type">string</span></dt>
42
+ <dd>Optional label shown in the editor.</dd>
43
+
44
+ <dt>Host <span class="property-type">string</span></dt>
45
+ <dd>IP address or hostname the TCP server will bind to. Use <code>0.0.0.0</code> to listen
46
+ on all interfaces. Default: <code>0.0.0.0</code>.</dd>
47
+
48
+ <dt>Port <span class="property-type">number</span></dt>
49
+ <dd>TCP port to listen on. Default: <code>502</code>.</dd>
50
+
51
+ <dt>Enforce Response Order <span class="property-type">boolean</span></dt>
52
+ <dd>When enabled, responses for a given TCP connection are written back in the same order
53
+ the requests were received, regardless of which response is completed first. Default: enabled.</dd>
54
+
55
+ <dt>Pending Response Timeout (ms) <span class="property-type">number</span></dt>
56
+ <dd>Maximum time in milliseconds a request may remain unanswered before the server
57
+ automatically replies with a Modbus exception code&nbsp;4 (Server Failure).
58
+ Default: <code>300000</code> (5 minutes).</dd>
59
+ </dl>
60
+
61
+ <h3>Details</h3>
62
+ <p>One config node represents one Modbus TCP server instance. Multiple
63
+ <code>modbus-dynamic-server</code> nodes can reference the same config node; each
64
+ will receive a copy of every incoming request message.</p>
65
+ <p>Requests are held internally by a <em>requestId</em> UUID until a response node calls
66
+ <code>respondToRequest</code>. If no response arrives within the timeout window the server
67
+ sends a Gateway Path Unavailable exception to the client automatically.</p>
68
+ </script>
69
+
70
+ <script type="text/javascript">
71
+ RED.nodes.registerType('modbus-dynamic-server-config', {
72
+ category: 'config',
73
+ defaults: {
74
+ name: { value: '' },
75
+ host: { value: '0.0.0.0', required: true },
76
+ port: { value: 502, required: true, validate: RED.validators.number() },
77
+ enforceResponseOrder: { value: true },
78
+ pendingResponseTimeout: { value: 300000, required: true, validate: RED.validators.number() }
79
+
80
+ },
81
+ label: function () {
82
+ return this.name || this.host + ':' + this.port
83
+ }
84
+ })
85
+ </script>