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,302 @@
|
|
|
1
|
+
const net = require('net')
|
|
2
|
+
|
|
3
|
+
module.exports = function (RED) {
|
|
4
|
+
function ModbusProxyTargetNode (config) {
|
|
5
|
+
RED.nodes.createNode(this, config)
|
|
6
|
+
const node = this
|
|
7
|
+
|
|
8
|
+
node.name = config.name || ''
|
|
9
|
+
node.targetType = config.targetType || 'tcp'
|
|
10
|
+
node.timeout = normalizeTimeout(config.timeout, 5000)
|
|
11
|
+
|
|
12
|
+
// TCP settings
|
|
13
|
+
node.tcpHost = config.tcpHost || 'localhost'
|
|
14
|
+
node.tcpPort = normalizePort(config.tcpPort, 502)
|
|
15
|
+
|
|
16
|
+
// Serial settings
|
|
17
|
+
node.serialPort = config.serialPort || '/dev/ttyUSB0'
|
|
18
|
+
node.serialBaud = normalizeBaud(config.serialBaud, 9600)
|
|
19
|
+
node.serialParity = config.serialParity || 'none'
|
|
20
|
+
node.serialWordLength = normalizeWordLength(config.serialWordLength, 8)
|
|
21
|
+
node.serialStopBits = normalizeStopBits(config.serialStopBits, 1)
|
|
22
|
+
|
|
23
|
+
// Request queue and connection state
|
|
24
|
+
node._requestQueue = []
|
|
25
|
+
node._processing = false
|
|
26
|
+
node._connection = null
|
|
27
|
+
node._lastError = null
|
|
28
|
+
node._activeRequest = null
|
|
29
|
+
node._activeTimeout = null
|
|
30
|
+
node._incomingBuffer = Buffer.alloc(0)
|
|
31
|
+
node._connecting = false
|
|
32
|
+
node._connected = false
|
|
33
|
+
node._closing = false
|
|
34
|
+
node._reconnectTimer = null
|
|
35
|
+
node._reconnectDelay = 1000
|
|
36
|
+
|
|
37
|
+
node.enqueueRequest = function (requestBuffer, callback) {
|
|
38
|
+
node._requestQueue.push({
|
|
39
|
+
buffer: requestBuffer,
|
|
40
|
+
callback,
|
|
41
|
+
timestamp: Date.now(),
|
|
42
|
+
retries: 0
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
node._processQueue()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
node._processQueue = function () {
|
|
49
|
+
if (node._activeRequest || node._closing) {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (node._requestQueue.length === 0) {
|
|
54
|
+
node._processing = false
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (node.targetType === 'tcp') {
|
|
59
|
+
node._ensureTcpConnection()
|
|
60
|
+
if (!node._connected || !node._connection) {
|
|
61
|
+
node._processing = true
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
node._processing = true
|
|
66
|
+
const request = node._requestQueue.shift()
|
|
67
|
+
node._sendViaTcp(request)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Serial path remains single-request and currently unimplemented.
|
|
72
|
+
node._processing = true
|
|
73
|
+
const request = node._requestQueue.shift()
|
|
74
|
+
node._sendRequest(request)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
node._sendRequest = function (request) {
|
|
78
|
+
const requestTimeout = setTimeout(function () {
|
|
79
|
+
node._lastError = 'Timeout'
|
|
80
|
+
request.callback({
|
|
81
|
+
ok: false,
|
|
82
|
+
error: 'timeout',
|
|
83
|
+
exception: 4 // Gateway Path Unavailable
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// Close connection on timeout and continue with next
|
|
87
|
+
if (node._connection) {
|
|
88
|
+
if (node.targetType === 'tcp') {
|
|
89
|
+
node._connection.destroy()
|
|
90
|
+
}
|
|
91
|
+
node._connection = null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
node._processQueue()
|
|
95
|
+
}, node.timeout)
|
|
96
|
+
|
|
97
|
+
if (node.targetType === 'tcp') {
|
|
98
|
+
// TCP uses persistent connection flow and per-request timeout managed in _sendViaTcp.
|
|
99
|
+
clearTimeout(requestTimeout)
|
|
100
|
+
node._requestQueue.unshift(request)
|
|
101
|
+
node._processQueue()
|
|
102
|
+
} else if (node.targetType === 'serial') {
|
|
103
|
+
node._sendViaSerial(request, requestTimeout)
|
|
104
|
+
} else {
|
|
105
|
+
clearTimeout(requestTimeout)
|
|
106
|
+
request.callback({
|
|
107
|
+
ok: false,
|
|
108
|
+
error: 'unsupported target type',
|
|
109
|
+
exception: 4
|
|
110
|
+
})
|
|
111
|
+
node._processQueue()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
node._sendViaTcp = function (request) {
|
|
116
|
+
if (!node._connected || !node._connection) {
|
|
117
|
+
node._requestQueue.unshift(request)
|
|
118
|
+
node._ensureTcpConnection()
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
node._activeRequest = request
|
|
123
|
+
node._activeTimeout = setTimeout(function () {
|
|
124
|
+
node._failActiveRequest('timeout')
|
|
125
|
+
if (node._connection) {
|
|
126
|
+
node._connection.destroy()
|
|
127
|
+
}
|
|
128
|
+
}, node.timeout)
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
node._connection.write(request.buffer)
|
|
132
|
+
} catch (err) {
|
|
133
|
+
node._failActiveRequest(err.message || 'write failed')
|
|
134
|
+
if (node._connection) {
|
|
135
|
+
node._connection.destroy()
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
node._ensureTcpConnection = function () {
|
|
141
|
+
if (node._closing || node.targetType !== 'tcp') return
|
|
142
|
+
if (node._connected || node._connecting) return
|
|
143
|
+
|
|
144
|
+
node._connecting = true
|
|
145
|
+
node.status({ fill: 'yellow', shape: 'ring', text: 'connecting' })
|
|
146
|
+
|
|
147
|
+
const socket = net.createConnection({ host: node.tcpHost, port: node.tcpPort })
|
|
148
|
+
node._connection = socket
|
|
149
|
+
|
|
150
|
+
socket.on('connect', function () {
|
|
151
|
+
node._connecting = false
|
|
152
|
+
node._connected = true
|
|
153
|
+
node._lastError = null
|
|
154
|
+
node._incomingBuffer = Buffer.alloc(0)
|
|
155
|
+
node.status({ fill: 'green', shape: 'dot', text: 'connected' })
|
|
156
|
+
node._processQueue()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
socket.on('data', function (chunk) {
|
|
160
|
+
node._incomingBuffer = Buffer.concat([node._incomingBuffer, chunk])
|
|
161
|
+
node._drainIncomingFrames()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
socket.on('error', function (err) {
|
|
165
|
+
node._lastError = err.message
|
|
166
|
+
node._failActiveRequest(err.message)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
socket.on('close', function () {
|
|
170
|
+
node._connected = false
|
|
171
|
+
node._connecting = false
|
|
172
|
+
node._connection = null
|
|
173
|
+
node._incomingBuffer = Buffer.alloc(0)
|
|
174
|
+
node._failActiveRequest(node._lastError || 'connection closed')
|
|
175
|
+
if (!node._closing) {
|
|
176
|
+
node.status({ fill: 'red', shape: 'ring', text: 'disconnected' })
|
|
177
|
+
node._scheduleReconnect()
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
node._drainIncomingFrames = function () {
|
|
183
|
+
while (node._incomingBuffer.length >= 6) {
|
|
184
|
+
const mbapLength = node._incomingBuffer.readUInt16BE(4)
|
|
185
|
+
const expectedFrameLength = 6 + mbapLength
|
|
186
|
+
if (node._incomingBuffer.length < expectedFrameLength) {
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const frame = Buffer.from(node._incomingBuffer.slice(0, expectedFrameLength))
|
|
191
|
+
node._incomingBuffer = Buffer.from(node._incomingBuffer.slice(expectedFrameLength))
|
|
192
|
+
|
|
193
|
+
if (node._activeRequest) {
|
|
194
|
+
const active = node._activeRequest
|
|
195
|
+
node._activeRequest = null
|
|
196
|
+
clearTimeout(node._activeTimeout)
|
|
197
|
+
node._activeTimeout = null
|
|
198
|
+
active.callback({ ok: true, responseBuffer: frame, error: null })
|
|
199
|
+
node._processing = false
|
|
200
|
+
node._processQueue()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
node._failActiveRequest = function (error) {
|
|
206
|
+
if (!node._activeRequest) return
|
|
207
|
+
const active = node._activeRequest
|
|
208
|
+
node._activeRequest = null
|
|
209
|
+
clearTimeout(node._activeTimeout)
|
|
210
|
+
node._activeTimeout = null
|
|
211
|
+
active.callback({ ok: false, error, exception: 4 })
|
|
212
|
+
node._processing = false
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
node._scheduleReconnect = function () {
|
|
216
|
+
if (node._reconnectTimer || node._closing || node.targetType !== 'tcp') return
|
|
217
|
+
node._reconnectTimer = setTimeout(function () {
|
|
218
|
+
node._reconnectTimer = null
|
|
219
|
+
node._ensureTcpConnection()
|
|
220
|
+
}, node._reconnectDelay)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
node._sendViaSerial = function (request, requestTimeout) {
|
|
224
|
+
// Serial support stub - would require 'serialport' package
|
|
225
|
+
clearTimeout(requestTimeout)
|
|
226
|
+
node._lastError = 'Serial not yet implemented'
|
|
227
|
+
request.callback({
|
|
228
|
+
ok: false,
|
|
229
|
+
error: 'serial not implemented',
|
|
230
|
+
exception: 4
|
|
231
|
+
})
|
|
232
|
+
node._processQueue()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
node.on('close', function (done) {
|
|
236
|
+
node._closing = true
|
|
237
|
+
if (node._reconnectTimer) {
|
|
238
|
+
clearTimeout(node._reconnectTimer)
|
|
239
|
+
node._reconnectTimer = null
|
|
240
|
+
}
|
|
241
|
+
if (node._activeTimeout) {
|
|
242
|
+
clearTimeout(node._activeTimeout)
|
|
243
|
+
node._activeTimeout = null
|
|
244
|
+
}
|
|
245
|
+
node._activeRequest = null
|
|
246
|
+
if (node._connection) {
|
|
247
|
+
node._connection.destroy()
|
|
248
|
+
node._connection = null
|
|
249
|
+
}
|
|
250
|
+
node._requestQueue = []
|
|
251
|
+
done()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
if (node.targetType === 'tcp') {
|
|
255
|
+
node._ensureTcpConnection()
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function normalizeTimeout (value, fallback) {
|
|
260
|
+
const parsed = Number(value)
|
|
261
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
262
|
+
return fallback
|
|
263
|
+
}
|
|
264
|
+
return Math.floor(parsed)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function normalizePort (value, fallback) {
|
|
268
|
+
const parsed = Number(value)
|
|
269
|
+
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
|
|
270
|
+
return fallback
|
|
271
|
+
}
|
|
272
|
+
return Math.floor(parsed)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function normalizeBaud (value, fallback) {
|
|
276
|
+
const validBauds = [300, 600, 1200, 2400, 4800, 9600, 14400, 19200, 28800, 38400, 57600, 115200]
|
|
277
|
+
const parsed = Number(value)
|
|
278
|
+
if (validBauds.includes(parsed)) {
|
|
279
|
+
return parsed
|
|
280
|
+
}
|
|
281
|
+
return fallback
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function normalizeWordLength (value, fallback) {
|
|
285
|
+
const parsed = Number(value)
|
|
286
|
+
if (parsed === 5 || parsed === 6 || parsed === 7 || parsed === 8) {
|
|
287
|
+
return parsed
|
|
288
|
+
}
|
|
289
|
+
return fallback
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function normalizeStopBits (value, fallback) {
|
|
293
|
+
const parsed = Number(value)
|
|
294
|
+
if (parsed === 1 || parsed === 2) {
|
|
295
|
+
return parsed
|
|
296
|
+
}
|
|
297
|
+
return fallback
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
RED.nodes.registerType('modbus-proxy-target', ModbusProxyTargetNode)
|
|
301
|
+
}
|
|
302
|
+
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<script type="text/html" data-template-name="modbus-registers-config">
|
|
2
|
+
<div class="form-row">
|
|
3
|
+
<label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
4
|
+
<input type="text" id="node-config-input-name" placeholder="Optional name">
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<div class="form-row">
|
|
8
|
+
<label for="node-config-input-holdingRegisters"><i class="fa fa-list-ol"></i> Holding Registers</label>
|
|
9
|
+
<input type="number" id="node-config-input-holdingRegisters" min="0" placeholder="100">
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<div class="form-row">
|
|
13
|
+
<label for="node-config-input-inputRegisters"><i class="fa fa-list-ol"></i> Input Registers</label>
|
|
14
|
+
<input type="number" id="node-config-input-inputRegisters" min="0" placeholder="100">
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<div class="form-row">
|
|
18
|
+
<label for="node-config-input-coilRegisters"><i class="fa fa-dot-circle-o"></i> Coil Registers</label>
|
|
19
|
+
<input type="number" id="node-config-input-coilRegisters" min="0" placeholder="100">
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<div class="form-row">
|
|
23
|
+
<label for="node-config-input-discreteRegisters"><i class="fa fa-dot-circle-o"></i> Discrete Inputs</label>
|
|
24
|
+
<input type="number" id="node-config-input-discreteRegisters" min="0" placeholder="100">
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div class="form-row">
|
|
28
|
+
<label for="node-config-input-wordOrderMode"><i class="fa fa-exchange"></i> 32-bit Word Order</label>
|
|
29
|
+
<select id="node-config-input-wordOrderMode">
|
|
30
|
+
<option value="ABCD">ABCD (Big-endian word, big-endian byte)</option>
|
|
31
|
+
<option value="CDAB">CDAB (Little-endian word, big-endian byte)</option>
|
|
32
|
+
<option value="BADC">BADC (Big-endian word, little-endian byte)</option>
|
|
33
|
+
<option value="DCBA">DCBA (Little-endian word, little-endian byte)</option>
|
|
34
|
+
</select>
|
|
35
|
+
</div>
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<script type="text/html" data-help-name="modbus-registers-config">
|
|
39
|
+
<p>Defines a shared in-memory Modbus register map that is referenced by
|
|
40
|
+
<code>modbus-registers-write</code> and <code>modbus-registers-respond</code> nodes.
|
|
41
|
+
All nodes sharing the same config instance read and write the same register arrays.</p>
|
|
42
|
+
|
|
43
|
+
<h3>Configuration</h3>
|
|
44
|
+
<dl class="message-properties">
|
|
45
|
+
<dt>Name <span class="property-type">string</span></dt>
|
|
46
|
+
<dd>Optional label shown in the editor.</dd>
|
|
47
|
+
|
|
48
|
+
<dt>Holding Registers <span class="property-type">number</span></dt>
|
|
49
|
+
<dd>Number of holding registers (FC3/FC6/FC16) to allocate. Set to <code>0</code> to
|
|
50
|
+
disable this register type entirely — any client request targeting holding registers will
|
|
51
|
+
receive a Modbus exception 2 (Illegal Data Address). Default: <code>100</code>.</dd>
|
|
52
|
+
|
|
53
|
+
<dt>Input Registers <span class="property-type">number</span></dt>
|
|
54
|
+
<dd>Number of input registers (FC4) to allocate. Set to <code>0</code> to disable.
|
|
55
|
+
Default: <code>100</code>.</dd>
|
|
56
|
+
|
|
57
|
+
<dt>Coil Registers <span class="property-type">number</span></dt>
|
|
58
|
+
<dd>Number of coil registers (FC1/FC5/FC15) to allocate. Set to <code>0</code> to disable.
|
|
59
|
+
Default: <code>100</code>.</dd>
|
|
60
|
+
|
|
61
|
+
<dt>Discrete Inputs <span class="property-type">number</span></dt>
|
|
62
|
+
<dd>Number of discrete input bits (FC2) to allocate. Set to <code>0</code> to disable.
|
|
63
|
+
Default: <code>100</code>.</dd>
|
|
64
|
+
|
|
65
|
+
<dt>32-bit Word Order <span class="property-type">select</span></dt>
|
|
66
|
+
<dd>Controls the byte and word ordering used when encoding or decoding 32-bit values
|
|
67
|
+
(<code>int32</code>, <code>uint32</code>, <code>float32</code>) across two consecutive
|
|
68
|
+
16-bit registers. Uses PLC-style byte-sequence notation where A is the most-significant byte:
|
|
69
|
+
<ul>
|
|
70
|
+
<li><b>ABCD</b> — Big-endian word, big-endian byte. Most significant word first, most
|
|
71
|
+
significant byte first within each word. Most common; matches network byte order.</li>
|
|
72
|
+
<li><b>CDAB</b> — Little-endian word, big-endian byte (word swap). Least significant
|
|
73
|
+
word first, bytes within each word remain big-endian.</li>
|
|
74
|
+
<li><b>BADC</b> — Big-endian word, little-endian byte. Most significant word first,
|
|
75
|
+
bytes within each word are swapped.</li>
|
|
76
|
+
<li><b>DCBA</b> — Little-endian word, little-endian byte (full swap). Both word order
|
|
77
|
+
and byte order are reversed.</li>
|
|
78
|
+
</ul>
|
|
79
|
+
Default: <b>ABCD</b>.</dd>
|
|
80
|
+
</dl>
|
|
81
|
+
|
|
82
|
+
<h3>Details</h3>
|
|
83
|
+
<p>A single config node represents one independent register space. Multiple
|
|
84
|
+
<code>modbus-registers-write</code> and <code>modbus-registers-respond</code> nodes linked to
|
|
85
|
+
the same config node share the same register data — writes from one node are immediately
|
|
86
|
+
visible to all others.</p>
|
|
87
|
+
<p>All register arrays are initialised to zero when Node-RED deploys the flow. Values persist
|
|
88
|
+
in memory for the lifetime of the runtime; they are not saved to disk.</p>
|
|
89
|
+
<p>The 32-bit word order setting applies globally to all write nodes that use this register map.
|
|
90
|
+
Ensure all writes and your Modbus client agree on the same ordering.</p>
|
|
91
|
+
</script>
|
|
92
|
+
|
|
93
|
+
<script type="text/javascript">
|
|
94
|
+
RED.nodes.registerType('modbus-registers-config', {
|
|
95
|
+
category: 'config',
|
|
96
|
+
defaults: {
|
|
97
|
+
name: { value: '' },
|
|
98
|
+
holdingRegisters: { value: 100, required: true, validate: RED.validators.number() },
|
|
99
|
+
inputRegisters: { value: 100, required: true, validate: RED.validators.number() },
|
|
100
|
+
coilRegisters: { value: 100, required: true, validate: RED.validators.number() },
|
|
101
|
+
discreteRegisters: { value: 100, required: true, validate: RED.validators.number() },
|
|
102
|
+
wordOrderMode: { value: 'ABCD', required: true }
|
|
103
|
+
},
|
|
104
|
+
label: function () {
|
|
105
|
+
return this.name || 'Registers Map'
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
</script>
|
|
109
|
+
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
module.exports = function (RED) {
|
|
2
|
+
function ModbusRegistersConfigNode (config) {
|
|
3
|
+
RED.nodes.createNode(this, config)
|
|
4
|
+
const node = this
|
|
5
|
+
|
|
6
|
+
node.name = config.name || ''
|
|
7
|
+
node.holdingRegisters = normalizeCount(config.holdingRegisters, 100)
|
|
8
|
+
node.inputRegisters = normalizeCount(config.inputRegisters, 100)
|
|
9
|
+
node.coilRegisters = normalizeCount(config.coilRegisters, 100)
|
|
10
|
+
node.discreteRegisters = normalizeCount(config.discreteRegisters, 100)
|
|
11
|
+
node.wordOrderMode = normalizeWordOrderMode(config.wordOrderMode)
|
|
12
|
+
|
|
13
|
+
node._maps = {
|
|
14
|
+
holding: new Uint16Array(node.holdingRegisters),
|
|
15
|
+
input: new Uint16Array(node.inputRegisters),
|
|
16
|
+
coil: new Array(node.coilRegisters).fill(0),
|
|
17
|
+
discrete: new Array(node.discreteRegisters).fill(0)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
node.readWords = function (registerType, start, quantity) {
|
|
21
|
+
const map = getWordMap(registerType, node._maps)
|
|
22
|
+
if (!map || !isNonNegativeInteger(start) || !isPositiveInteger(quantity) || start + quantity > map.length) {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
return Array.from(map.slice(start, start + quantity))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
node.readBits = function (registerType, start, quantity) {
|
|
29
|
+
const map = getBitMap(registerType, node._maps)
|
|
30
|
+
if (!map || !isNonNegativeInteger(start) || !isPositiveInteger(quantity) || start + quantity > map.length) {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
return map.slice(start, start + quantity).map(Boolean)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
node.writeValue = function (registerType, address, format, value) {
|
|
37
|
+
if (!isNonNegativeInteger(address)) {
|
|
38
|
+
return { ok: false, error: 'address must be a non-negative integer' }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (registerType === 'coil' || registerType === 'discrete') {
|
|
42
|
+
const bitMap = getBitMap(registerType, node._maps)
|
|
43
|
+
if (!bitMap || address >= bitMap.length) {
|
|
44
|
+
return { ok: false, error: 'address out of range' }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
bitMap[address] = toBitValue(value)
|
|
48
|
+
return { ok: true, wordsWritten: 1 }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const map = getWordMap(registerType, node._maps)
|
|
52
|
+
if (!map) {
|
|
53
|
+
return { ok: false, error: `unsupported registerType ${registerType}` }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const words = encodeValueAsWords(format, value, node.wordOrderMode)
|
|
57
|
+
if (!words) {
|
|
58
|
+
return { ok: false, error: `unsupported format ${format}` }
|
|
59
|
+
}
|
|
60
|
+
if (address + words.length > map.length) {
|
|
61
|
+
return { ok: false, error: 'address out of range' }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
words.forEach(function (word, idx) {
|
|
65
|
+
map[address + idx] = word
|
|
66
|
+
})
|
|
67
|
+
return { ok: true, wordsWritten: words.length }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
node.writeWords = function (registerType, address, values) {
|
|
71
|
+
const map = getWordMap(registerType, node._maps)
|
|
72
|
+
if (!map || !isNonNegativeInteger(address) || !Array.isArray(values) || address + values.length > map.length) {
|
|
73
|
+
return { ok: false, error: 'invalid write range' }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
values.forEach(function (value, idx) {
|
|
77
|
+
map[address + idx] = Number(value) & 0xffff
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
return { ok: true, wordsWritten: values.length }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
node.writeBits = function (registerType, address, values) {
|
|
84
|
+
const map = getBitMap(registerType, node._maps)
|
|
85
|
+
if (!map || !isNonNegativeInteger(address) || !Array.isArray(values) || address + values.length > map.length) {
|
|
86
|
+
return { ok: false, error: 'invalid write range' }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
values.forEach(function (value, idx) {
|
|
90
|
+
map[address + idx] = toBitValue(value)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
return { ok: true, bitsWritten: values.length }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeCount (value, fallback) {
|
|
98
|
+
if (value === '' || value === null || value === undefined) {
|
|
99
|
+
return fallback
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const n = Number(value)
|
|
103
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
104
|
+
return fallback
|
|
105
|
+
}
|
|
106
|
+
return Math.floor(n)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getWordMap (registerType, maps) {
|
|
110
|
+
if (registerType === 'holding') return maps.holding
|
|
111
|
+
if (registerType === 'input') return maps.input
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getBitMap (registerType, maps) {
|
|
116
|
+
if (registerType === 'coil') return maps.coil
|
|
117
|
+
if (registerType === 'discrete') return maps.discrete
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeWordOrderMode (value) {
|
|
122
|
+
const mode = String(value || '').trim().toLowerCase()
|
|
123
|
+
if (mode === 'abcd' || mode === 'be-be') return 'ABCD'
|
|
124
|
+
if (mode === 'cdab' || mode === 'le-be') return 'CDAB'
|
|
125
|
+
if (mode === 'badc' || mode === 'be-le') return 'BADC'
|
|
126
|
+
if (mode === 'dcba' || mode === 'le-le') return 'DCBA'
|
|
127
|
+
return 'ABCD'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isNonNegativeInteger (value) {
|
|
131
|
+
return Number.isInteger(value) && value >= 0
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isPositiveInteger (value) {
|
|
135
|
+
return Number.isInteger(value) && value > 0
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function toBitValue (value) {
|
|
139
|
+
if (typeof value === 'string') {
|
|
140
|
+
const normalized = value.trim().toLowerCase()
|
|
141
|
+
if (normalized === 'true' || normalized === '1' || normalized === 'on') {
|
|
142
|
+
return 1
|
|
143
|
+
}
|
|
144
|
+
if (normalized === 'false' || normalized === '0' || normalized === 'off') {
|
|
145
|
+
return 0
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return value ? 1 : 0
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function encodeValueAsWords (format, value, wordOrderMode) {
|
|
153
|
+
switch (format) {
|
|
154
|
+
case 'int16':
|
|
155
|
+
case 'uint16': {
|
|
156
|
+
const n = Number(value)
|
|
157
|
+
if (!Number.isFinite(n)) return null
|
|
158
|
+
return [n & 0xffff]
|
|
159
|
+
}
|
|
160
|
+
case 'uint32': {
|
|
161
|
+
const n = Number(value)
|
|
162
|
+
if (!Number.isFinite(n) || n < 0 || n > 0xffffffff) return null
|
|
163
|
+
const buf = Buffer.alloc(4)
|
|
164
|
+
buf.writeUInt32BE(Math.floor(n), 0)
|
|
165
|
+
return encode32BitBufferToWords(buf, wordOrderMode)
|
|
166
|
+
}
|
|
167
|
+
case 'int32': {
|
|
168
|
+
const n = Number(value)
|
|
169
|
+
if (!Number.isFinite(n) || n < -2147483648 || n > 2147483647) return null
|
|
170
|
+
const buf = Buffer.alloc(4)
|
|
171
|
+
buf.writeInt32BE(Math.floor(n), 0)
|
|
172
|
+
return encode32BitBufferToWords(buf, wordOrderMode)
|
|
173
|
+
}
|
|
174
|
+
case 'float32': {
|
|
175
|
+
const n = Number(value)
|
|
176
|
+
if (!Number.isFinite(n)) return null
|
|
177
|
+
const buf = Buffer.alloc(4)
|
|
178
|
+
buf.writeFloatBE(n, 0)
|
|
179
|
+
return encode32BitBufferToWords(buf, wordOrderMode)
|
|
180
|
+
}
|
|
181
|
+
// Backward-compatible aliases for older flows
|
|
182
|
+
case 'float32be':
|
|
183
|
+
return encodeValueAsWords('float32', value, 'ABCD')
|
|
184
|
+
case 'float32le': {
|
|
185
|
+
return encodeValueAsWords('float32', value, 'BADC')
|
|
186
|
+
}
|
|
187
|
+
default:
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function encode32BitBufferToWords (buf, mode) {
|
|
193
|
+
const normalizedMode = normalizeWordOrderMode(mode)
|
|
194
|
+
let firstWordBytes = [buf[0], buf[1]]
|
|
195
|
+
let secondWordBytes = [buf[2], buf[3]]
|
|
196
|
+
|
|
197
|
+
if (normalizedMode === 'BADC' || normalizedMode === 'DCBA') {
|
|
198
|
+
firstWordBytes = [firstWordBytes[1], firstWordBytes[0]]
|
|
199
|
+
secondWordBytes = [secondWordBytes[1], secondWordBytes[0]]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (normalizedMode === 'CDAB' || normalizedMode === 'DCBA') {
|
|
203
|
+
const tmp = firstWordBytes
|
|
204
|
+
firstWordBytes = secondWordBytes
|
|
205
|
+
secondWordBytes = tmp
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return [
|
|
209
|
+
(firstWordBytes[0] << 8) | firstWordBytes[1],
|
|
210
|
+
(secondWordBytes[0] << 8) | secondWordBytes[1]
|
|
211
|
+
]
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
RED.nodes.registerType('modbus-registers-config', ModbusRegistersConfigNode)
|
|
215
|
+
}
|
|
216
|
+
|