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,286 @@
|
|
|
1
|
+
const { respondWithPayload } = require('./modbus-response-context')
|
|
2
|
+
|
|
3
|
+
module.exports = function (RED) {
|
|
4
|
+
const EXCEPTION_ILLEGAL_FUNCTION = 1
|
|
5
|
+
const EXCEPTION_ILLEGAL_DATA_ADDRESS = 2
|
|
6
|
+
|
|
7
|
+
function ModbusRegistersRespondNode (config) {
|
|
8
|
+
RED.nodes.createNode(this, config)
|
|
9
|
+
const node = this
|
|
10
|
+
|
|
11
|
+
node.name = config.name || ''
|
|
12
|
+
node.registerMap = RED.nodes.getNode(config.registerMap)
|
|
13
|
+
|
|
14
|
+
if (!node.registerMap) {
|
|
15
|
+
node.status({ fill: 'red', shape: 'ring', text: 'no register map' })
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
node.status({ fill: 'grey', shape: 'ring', text: 'ready' })
|
|
20
|
+
|
|
21
|
+
node.on('input', function (msg, send, done) {
|
|
22
|
+
const context = msg._modbus || {}
|
|
23
|
+
const requestId = context.requestId
|
|
24
|
+
const eventName = context.eventName
|
|
25
|
+
const request = msg.payload || {}
|
|
26
|
+
const body = request.body || {}
|
|
27
|
+
|
|
28
|
+
if (!requestId || !eventName) {
|
|
29
|
+
node.error('Missing Modbus request context', msg)
|
|
30
|
+
node.status({ fill: 'red', shape: 'ring', text: 'missing context' })
|
|
31
|
+
done()
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const responsePayload = buildResponsePayload(node.registerMap, eventName, request, body)
|
|
36
|
+
const actualDataSent = deriveActualDataSent(eventName, request.quantity, responsePayload)
|
|
37
|
+
|
|
38
|
+
const responseResult = respondWithPayload(RED, node, msg, responsePayload)
|
|
39
|
+
if (!responseResult.context) {
|
|
40
|
+
node.status({ fill: 'red', shape: 'ring', text: 'missing context' })
|
|
41
|
+
done()
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
const ok = responseResult.ok
|
|
45
|
+
if (!ok) {
|
|
46
|
+
node.status({ fill: 'red', shape: 'ring', text: 'unknown request' })
|
|
47
|
+
done(new Error(`modbus-registers-respond: request ${requestId} could not be responded to`))
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
msg.modbusResponse = {
|
|
52
|
+
requestId,
|
|
53
|
+
eventName,
|
|
54
|
+
address: request.address,
|
|
55
|
+
quantity: request.quantity,
|
|
56
|
+
payloadSubmitted: clonePayload(responsePayload),
|
|
57
|
+
payloadType: describePayload(responsePayload),
|
|
58
|
+
actualDataSent,
|
|
59
|
+
actualDataType: describePayload(actualDataSent),
|
|
60
|
+
ok,
|
|
61
|
+
timestamp: new Date().toISOString()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (typeof eventName === 'string' && eventName.startsWith('write')) {
|
|
65
|
+
msg.modbusResponse.note = 'Write FC response is auto-built from request body by server config'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (responsePayload && responsePayload.exception) {
|
|
69
|
+
node.status({ fill: 'yellow', shape: 'ring', text: `exception ${responsePayload.exception}` })
|
|
70
|
+
} else {
|
|
71
|
+
node.status({ fill: 'green', shape: 'dot', text: eventName })
|
|
72
|
+
}
|
|
73
|
+
send(msg)
|
|
74
|
+
done()
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildResponsePayload (registerMap, eventName, request, body) {
|
|
79
|
+
const address = Number(request.address)
|
|
80
|
+
const quantity = Number(request.quantity)
|
|
81
|
+
|
|
82
|
+
switch (eventName) {
|
|
83
|
+
case 'readHoldingRegisters':
|
|
84
|
+
return readWordsOrException(registerMap, 'holding', address, quantity)
|
|
85
|
+
case 'readInputRegisters':
|
|
86
|
+
return readWordsOrException(registerMap, 'input', address, quantity)
|
|
87
|
+
case 'readCoils':
|
|
88
|
+
return readBitsOrException(registerMap, 'coil', address, quantity)
|
|
89
|
+
case 'readDiscreteInputs':
|
|
90
|
+
return readBitsOrException(registerMap, 'discrete', address, quantity)
|
|
91
|
+
case 'writeSingleRegister':
|
|
92
|
+
return handleSingleWordWrite(registerMap, 'holding', body)
|
|
93
|
+
case 'writeMultipleRegisters':
|
|
94
|
+
return handleMultipleWordWrite(registerMap, 'holding', body)
|
|
95
|
+
case 'writeSingleCoil':
|
|
96
|
+
return handleSingleBitWrite(registerMap, 'coil', body)
|
|
97
|
+
case 'writeMultipleCoils':
|
|
98
|
+
return handleMultipleBitWrite(registerMap, 'coil', body)
|
|
99
|
+
default:
|
|
100
|
+
return { exception: EXCEPTION_ILLEGAL_FUNCTION }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readWordsOrException (registerMap, registerType, address, quantity) {
|
|
105
|
+
const values = registerMap.readWords(registerType, address, quantity)
|
|
106
|
+
return values === null ? { exception: EXCEPTION_ILLEGAL_DATA_ADDRESS } : values
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readBitsOrException (registerMap, registerType, address, quantity) {
|
|
110
|
+
const values = registerMap.readBits(registerType, address, quantity)
|
|
111
|
+
return values === null ? { exception: EXCEPTION_ILLEGAL_DATA_ADDRESS } : values
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function handleSingleWordWrite (registerMap, registerType, body) {
|
|
115
|
+
const address = Number(body.address)
|
|
116
|
+
const value = readSingleWordValue(body)
|
|
117
|
+
const result = registerMap.writeWords(registerType, address, [value])
|
|
118
|
+
return result.ok ? undefined : { exception: EXCEPTION_ILLEGAL_DATA_ADDRESS }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function handleMultipleWordWrite (registerMap, registerType, body) {
|
|
122
|
+
const address = Number(body.address)
|
|
123
|
+
const values = readWordValues(body)
|
|
124
|
+
const result = registerMap.writeWords(registerType, address, values)
|
|
125
|
+
return result.ok ? undefined : { exception: EXCEPTION_ILLEGAL_DATA_ADDRESS }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function handleSingleBitWrite (registerMap, registerType, body) {
|
|
129
|
+
const address = Number(body.address)
|
|
130
|
+
const value = readSingleBitValue(body)
|
|
131
|
+
const result = registerMap.writeBits(registerType, address, [value])
|
|
132
|
+
return result.ok ? undefined : { exception: EXCEPTION_ILLEGAL_DATA_ADDRESS }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function handleMultipleBitWrite (registerMap, registerType, body) {
|
|
136
|
+
const address = Number(body.address)
|
|
137
|
+
const values = readBitValues(body)
|
|
138
|
+
const result = registerMap.writeBits(registerType, address, values)
|
|
139
|
+
return result.ok ? undefined : { exception: EXCEPTION_ILLEGAL_DATA_ADDRESS }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readSingleWordValue (body) {
|
|
143
|
+
if (body.value !== undefined) return Number(body.value) & 0xffff
|
|
144
|
+
if (body.values && body.values.length) return Number(body.values[0]) & 0xffff
|
|
145
|
+
return 0
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function readWordValues (body) {
|
|
149
|
+
if (Array.isArray(body.values)) {
|
|
150
|
+
return body.values.map(function (value) {
|
|
151
|
+
return Number(value) & 0xffff
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (Buffer.isBuffer(body.valuesAsBuffer)) {
|
|
156
|
+
const values = []
|
|
157
|
+
for (let offset = 0; offset + 1 < body.valuesAsBuffer.length; offset += 2) {
|
|
158
|
+
values.push(body.valuesAsBuffer.readUInt16BE(offset))
|
|
159
|
+
}
|
|
160
|
+
return values
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return []
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function readSingleBitValue (body) {
|
|
167
|
+
if (body.value !== undefined) return body.value ? 1 : 0
|
|
168
|
+
if (body.values && body.values.length) return body.values[0] ? 1 : 0
|
|
169
|
+
return 0
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function readBitValues (body) {
|
|
173
|
+
if (Array.isArray(body.values)) {
|
|
174
|
+
return body.values.map(function (value) {
|
|
175
|
+
return value ? 1 : 0
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (Buffer.isBuffer(body.valuesAsBuffer)) {
|
|
180
|
+
const bits = []
|
|
181
|
+
for (let i = 0; i < body.valuesAsBuffer.length; i++) {
|
|
182
|
+
const byte = body.valuesAsBuffer[i]
|
|
183
|
+
for (let b = 0; b < 8; b++) {
|
|
184
|
+
bits.push((byte >> b) & 1)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return bits
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return []
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function clonePayload (payload) {
|
|
194
|
+
if (Buffer.isBuffer(payload)) {
|
|
195
|
+
return Buffer.from(payload)
|
|
196
|
+
}
|
|
197
|
+
if (Array.isArray(payload)) {
|
|
198
|
+
return payload.slice()
|
|
199
|
+
}
|
|
200
|
+
if (payload && typeof payload === 'object') {
|
|
201
|
+
return { ...payload }
|
|
202
|
+
}
|
|
203
|
+
return payload
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function describePayload (payload) {
|
|
207
|
+
if (Buffer.isBuffer(payload)) return 'buffer'
|
|
208
|
+
if (Array.isArray(payload)) return 'array'
|
|
209
|
+
return typeof payload
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function deriveActualDataSent (eventName, quantity, payload) {
|
|
213
|
+
const count = Number(quantity)
|
|
214
|
+
if (!Number.isFinite(count) || count <= 0) {
|
|
215
|
+
return payload
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (eventName === 'readHoldingRegisters' || eventName === 'readInputRegisters') {
|
|
219
|
+
return normalizeWordPayload(payload, count)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (eventName === 'readCoils' || eventName === 'readDiscreteInputs') {
|
|
223
|
+
return normalizeBitPayload(payload, count)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return payload
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function normalizeWordPayload (payload, count) {
|
|
230
|
+
if (Buffer.isBuffer(payload)) {
|
|
231
|
+
const expectedBytes = count * 2
|
|
232
|
+
const buf = Buffer.alloc(expectedBytes, 0)
|
|
233
|
+
payload.copy(buf, 0, 0, Math.min(payload.length, expectedBytes))
|
|
234
|
+
const words = []
|
|
235
|
+
for (let i = 0; i < expectedBytes; i += 2) {
|
|
236
|
+
words.push(buf.readUInt16BE(i))
|
|
237
|
+
}
|
|
238
|
+
return words
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (Array.isArray(payload)) {
|
|
242
|
+
const out = new Array(count).fill(0)
|
|
243
|
+
payload.slice(0, count).forEach(function (v, i) {
|
|
244
|
+
out[i] = Number(v) & 0xffff
|
|
245
|
+
})
|
|
246
|
+
return out
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (payload && typeof payload === 'object' && Array.isArray(payload.values)) {
|
|
250
|
+
return normalizeWordPayload(payload.values, count)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return new Array(count).fill(0)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function normalizeBitPayload (payload, count) {
|
|
257
|
+
if (Buffer.isBuffer(payload)) {
|
|
258
|
+
const out = new Array(count).fill(0)
|
|
259
|
+
for (let i = 0; i < count; i++) {
|
|
260
|
+
const byteIndex = Math.floor(i / 8)
|
|
261
|
+
const bitIndex = i % 8
|
|
262
|
+
if (byteIndex < payload.length) {
|
|
263
|
+
out[i] = (payload[byteIndex] >> bitIndex) & 1
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return out
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (Array.isArray(payload)) {
|
|
270
|
+
const out = new Array(count).fill(0)
|
|
271
|
+
payload.slice(0, count).forEach(function (v, i) {
|
|
272
|
+
out[i] = v ? 1 : 0
|
|
273
|
+
})
|
|
274
|
+
return out
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (payload && typeof payload === 'object' && Array.isArray(payload.values)) {
|
|
278
|
+
return normalizeBitPayload(payload.values, count)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return new Array(count).fill(0)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
RED.nodes.registerType('modbus-registers-respond', ModbusRegistersRespondNode)
|
|
285
|
+
}
|
|
286
|
+
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
<script type="text/html" data-template-name="modbus-registers-write">
|
|
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
|
+
<div class="form-row">
|
|
8
|
+
<label for="node-input-registerMap"><i class="fa fa-database"></i> Register Map</label>
|
|
9
|
+
<input type="text" id="node-input-registerMap">
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<div class="form-row">
|
|
13
|
+
<label for="node-input-registerType"><i class="fa fa-filter"></i> Register Type</label>
|
|
14
|
+
<select id="node-input-registerType">
|
|
15
|
+
<option value="holding">holding</option>
|
|
16
|
+
<option value="input">input</option>
|
|
17
|
+
<option value="coil">coil</option>
|
|
18
|
+
<option value="discrete">discrete</option>
|
|
19
|
+
</select>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<div class="form-row">
|
|
23
|
+
<label for="node-input-registerAddress"><i class="fa fa-hashtag"></i> Register Address</label>
|
|
24
|
+
<input type="number" id="node-input-registerAddress" min="0" placeholder="0">
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div class="form-row">
|
|
28
|
+
<label for="node-input-registerFormat"><i class="fa fa-code"></i> Format</label>
|
|
29
|
+
<select id="node-input-registerFormat">
|
|
30
|
+
<option value="int16">int16</option>
|
|
31
|
+
<option value="uint16">uint16</option>
|
|
32
|
+
<option value="int32">int32</option>
|
|
33
|
+
<option value="uint32">uint32</option>
|
|
34
|
+
<option value="float32">float32</option>
|
|
35
|
+
</select>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div class="form-row">
|
|
39
|
+
<label for="node-input-exceptionOnOutOfRangeValues"><i class="fa fa-exclamation-triangle"></i> Exception On Out Of Range Values</label>
|
|
40
|
+
<input type="checkbox" id="node-input-exceptionOnOutOfRangeValues" style="display:inline-block; width:auto;">
|
|
41
|
+
</div>
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<script type="text/html" data-help-name="modbus-registers-write">
|
|
45
|
+
<p>Writes a value into a shared <code>modbus-registers-config</code> register map on each
|
|
46
|
+
input message. Supports 16-bit and 32-bit signed/unsigned integer and floating-point formats.
|
|
47
|
+
The message is forwarded unchanged (plus write metadata) after a successful write.</p>
|
|
48
|
+
|
|
49
|
+
<h3>Inputs</h3>
|
|
50
|
+
<dl class="message-properties">
|
|
51
|
+
<dt>payload <span class="property-type">number | string | boolean</span></dt>
|
|
52
|
+
<dd>The value to write. For integer formats the value is truncated to a whole number before
|
|
53
|
+
writing. For <code>coil</code> and <code>discrete</code> register types any truthy value
|
|
54
|
+
writes <code>1</code>, falsy writes <code>0</code>.</dd>
|
|
55
|
+
|
|
56
|
+
<dt class="optional">registerType <span class="property-type">string</span></dt>
|
|
57
|
+
<dd>Overrides the configured register type for this message only.
|
|
58
|
+
Allowed values: <code>holding</code>, <code>input</code>, <code>coil</code>,
|
|
59
|
+
<code>discrete</code>.</dd>
|
|
60
|
+
|
|
61
|
+
<dt class="optional">registerAddress <span class="property-type">number</span></dt>
|
|
62
|
+
<dd>Overrides the configured register address (0-based) for this message only.</dd>
|
|
63
|
+
|
|
64
|
+
<dt class="optional">registerFormat <span class="property-type">string</span></dt>
|
|
65
|
+
<dd>Overrides the configured data format for this message only.
|
|
66
|
+
Allowed values: <code>int16</code>, <code>uint16</code>, <code>int32</code>,
|
|
67
|
+
<code>uint32</code>, <code>float32</code>.</dd>
|
|
68
|
+
|
|
69
|
+
<dt class="optional">exceptionOnOutOfRangeValues <span class="property-type">boolean</span></dt>
|
|
70
|
+
<dd>Overrides the node's <em>Exception On Out Of Range Values</em> setting for this
|
|
71
|
+
message only.</dd>
|
|
72
|
+
</dl>
|
|
73
|
+
|
|
74
|
+
<h3>Outputs</h3>
|
|
75
|
+
<ol class="node-ports">
|
|
76
|
+
<li>Passthrough message
|
|
77
|
+
<dl class="message-properties">
|
|
78
|
+
<dt>modbusRegistersWrite <span class="property-type">object</span></dt>
|
|
79
|
+
<dd>Metadata added to the message after a successful write:
|
|
80
|
+
<ul>
|
|
81
|
+
<li><code>registerType</code> — register type written to.</li>
|
|
82
|
+
<li><code>registerAddress</code> — address written to.</li>
|
|
83
|
+
<li><code>registerFormat</code> — data format used.</li>
|
|
84
|
+
<li><code>originalValue</code> — value received in <code>msg.payload</code>.</li>
|
|
85
|
+
<li><code>writtenValue</code> — value actually stored (may differ if clamped).</li>
|
|
86
|
+
<li><code>outOfRange</code> — <code>true</code> if the value exceeded the format's valid range.</li>
|
|
87
|
+
<li><code>clamped</code> — <code>true</code> if the value was clamped rather than rejected.</li>
|
|
88
|
+
<li><code>wordsWritten</code> — number of 16-bit registers written (1 for 16-bit formats, 2 for 32-bit).</li>
|
|
89
|
+
<li><code>bitsWritten</code> — number of bits written (for coil/discrete writes).</li>
|
|
90
|
+
</ul>
|
|
91
|
+
</dd>
|
|
92
|
+
</dl>
|
|
93
|
+
</li>
|
|
94
|
+
</ol>
|
|
95
|
+
|
|
96
|
+
<h3>Configuration</h3>
|
|
97
|
+
<dl class="message-properties">
|
|
98
|
+
<dt>Register Map <span class="property-type">modbus-registers-config</span></dt>
|
|
99
|
+
<dd>The shared register map this node writes to. Required.</dd>
|
|
100
|
+
|
|
101
|
+
<dt>Register Type <span class="property-type">select</span></dt>
|
|
102
|
+
<dd>The type of register to write:
|
|
103
|
+
<ul>
|
|
104
|
+
<li><code>holding</code> — FC6/FC16 holding registers (read/write).</li>
|
|
105
|
+
<li><code>input</code> — input registers (read-only by Modbus clients, but writable here for simulation).</li>
|
|
106
|
+
<li><code>coil</code> — FC5/FC15 single-bit coil registers.</li>
|
|
107
|
+
<li><code>discrete</code> — discrete input bits (read-only by clients, writable here for simulation).</li>
|
|
108
|
+
</ul>
|
|
109
|
+
</dd>
|
|
110
|
+
|
|
111
|
+
<dt>Register Address <span class="property-type">number</span></dt>
|
|
112
|
+
<dd>Zero-based register address to write to. For 32-bit formats, registers
|
|
113
|
+
<code>address</code> and <code>address+1</code> are both written.</dd>
|
|
114
|
+
|
|
115
|
+
<dt>Format <span class="property-type">select</span></dt>
|
|
116
|
+
<dd>Data format to use when encoding the value into 16-bit register words:
|
|
117
|
+
<ul>
|
|
118
|
+
<li><code>int16</code> — signed 16-bit integer (−32768 to 32767).</li>
|
|
119
|
+
<li><code>uint16</code> — unsigned 16-bit integer (0 to 65535).</li>
|
|
120
|
+
<li><code>int32</code> — signed 32-bit integer (−2147483648 to 2147483647), occupies 2 registers.</li>
|
|
121
|
+
<li><code>uint32</code> — unsigned 32-bit integer (0 to 4294967295), occupies 2 registers.</li>
|
|
122
|
+
<li><code>float32</code> — IEEE 754 single-precision float, occupies 2 registers.</li>
|
|
123
|
+
</ul>
|
|
124
|
+
32-bit word/byte ordering is controlled by the linked register map's <em>32-bit Word Order</em> setting.</dd>
|
|
125
|
+
|
|
126
|
+
<dt>Exception On Out Of Range Values <span class="property-type">boolean</span></dt>
|
|
127
|
+
<dd>Controls behaviour when a value falls outside the valid range for the selected format:
|
|
128
|
+
<ul>
|
|
129
|
+
<li><b>Checked (default)</b> — the write is aborted and an error is raised, catchable by a Catch node.</li>
|
|
130
|
+
<li><b>Unchecked</b> — the value is clamped to the nearest min/max boundary and written. <code>msg.modbusRegistersWrite.clamped</code> will be <code>true</code>.</li>
|
|
131
|
+
</ul>
|
|
132
|
+
Range checking applies to integer formats only. <code>float32</code> values are always passed through.
|
|
133
|
+
</dd>
|
|
134
|
+
</dl>
|
|
135
|
+
</script>
|
|
136
|
+
|
|
137
|
+
<script type="text/javascript">
|
|
138
|
+
RED.nodes.registerType('modbus-registers-write', {
|
|
139
|
+
category: 'modbus',
|
|
140
|
+
color: '#E9967A',
|
|
141
|
+
defaults: {
|
|
142
|
+
name: { value: '' },
|
|
143
|
+
registerMap: { value: '', type: 'modbus-registers-config', required: true },
|
|
144
|
+
registerType: { value: 'holding', required: true },
|
|
145
|
+
registerAddress: { value: 0, required: true, validate: RED.validators.number() },
|
|
146
|
+
registerFormat: { value: 'uint16', required: true },
|
|
147
|
+
exceptionOnOutOfRangeValues: { value: true }
|
|
148
|
+
},
|
|
149
|
+
inputs: 1,
|
|
150
|
+
outputs: 1,
|
|
151
|
+
icon: 'file.svg',
|
|
152
|
+
label: function () {
|
|
153
|
+
return this.name || 'Modbus Registers Write'
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
</script>
|
|
157
|
+
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
function formatStatusValue (value) {
|
|
2
|
+
if (value === null) return 'null'
|
|
3
|
+
if (value === undefined) return 'undefined'
|
|
4
|
+
|
|
5
|
+
if (typeof value === 'number') {
|
|
6
|
+
if (!Number.isFinite(value)) return String(value)
|
|
7
|
+
return String(value)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (typeof value === 'bigint' || typeof value === 'boolean') {
|
|
11
|
+
return String(value)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (typeof value === 'string') {
|
|
15
|
+
return value.length > 20 ? `${value.slice(0, 17)}...` : value
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
return `[${value.length} items]`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (typeof value === 'object') {
|
|
23
|
+
return '[object]'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return String(value)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = function (RED) {
|
|
30
|
+
function ModbusRegistersWriteNode (config) {
|
|
31
|
+
RED.nodes.createNode(this, config)
|
|
32
|
+
const node = this
|
|
33
|
+
|
|
34
|
+
node.name = config.name || ''
|
|
35
|
+
node.registerMap = RED.nodes.getNode(config.registerMap)
|
|
36
|
+
node.registerType = config.registerType || 'holding'
|
|
37
|
+
node.registerAddress = normalizeAddress(config.registerAddress)
|
|
38
|
+
node.registerFormat = config.registerFormat || 'uint16'
|
|
39
|
+
node.exceptionOnOutOfRangeValues = normalizeBooleanDefaultTrue(config.exceptionOnOutOfRangeValues)
|
|
40
|
+
|
|
41
|
+
if (!node.registerMap) {
|
|
42
|
+
node.status({ fill: 'red', shape: 'ring', text: 'no register map' })
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
node.status({ fill: 'grey', shape: 'ring', text: 'ready' })
|
|
47
|
+
|
|
48
|
+
node.on('input', function (msg, send, done) {
|
|
49
|
+
const registerType = msg.registerType || node.registerType
|
|
50
|
+
const registerAddress = normalizeAddress(msg.registerAddress, node.registerAddress)
|
|
51
|
+
const registerFormat = msg.registerFormat || node.registerFormat
|
|
52
|
+
const exceptionOnOutOfRangeValues = normalizeBooleanDefaultTrue(
|
|
53
|
+
msg.exceptionOnOutOfRangeValues,
|
|
54
|
+
node.exceptionOnOutOfRangeValues
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const valueResult = normalizeValueForFormat(registerFormat, msg.payload)
|
|
58
|
+
if (valueResult.outOfRange && exceptionOnOutOfRangeValues) {
|
|
59
|
+
node.status({ fill: 'red', shape: 'ring', text: 'value out of range' })
|
|
60
|
+
done(new Error(`modbus-registers-write: value ${valueResult.originalValue} out of range for ${registerFormat}`))
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const value = valueResult.value
|
|
65
|
+
|
|
66
|
+
const result = node.registerMap.writeValue(registerType, registerAddress, registerFormat, value)
|
|
67
|
+
if (!result.ok) {
|
|
68
|
+
node.status({ fill: 'red', shape: 'ring', text: 'write failed' })
|
|
69
|
+
done(new Error(`modbus-registers-write: ${result.error}`))
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
msg.modbusRegistersWrite = {
|
|
74
|
+
registerType,
|
|
75
|
+
registerAddress,
|
|
76
|
+
registerFormat,
|
|
77
|
+
originalValue: valueResult.originalValue,
|
|
78
|
+
writtenValue: value,
|
|
79
|
+
outOfRange: valueResult.outOfRange,
|
|
80
|
+
clamped: valueResult.outOfRange && !exceptionOnOutOfRangeValues,
|
|
81
|
+
wordsWritten: result.wordsWritten || 0,
|
|
82
|
+
bitsWritten: result.bitsWritten || 0
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const valueText = formatStatusValue(value)
|
|
86
|
+
node.status({ fill: 'green', shape: 'dot', text: `wrote ${valueText}` })
|
|
87
|
+
|
|
88
|
+
send(msg)
|
|
89
|
+
done()
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeAddress (value, fallback) {
|
|
94
|
+
const parsed = Number(value)
|
|
95
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
96
|
+
return fallback !== undefined ? fallback : 0
|
|
97
|
+
}
|
|
98
|
+
return Math.floor(parsed)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeBooleanDefaultTrue (value, fallback) {
|
|
102
|
+
if (value === undefined || value === null || value === '') {
|
|
103
|
+
if (fallback === undefined) return true
|
|
104
|
+
return fallback
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (typeof value === 'boolean') {
|
|
108
|
+
return value
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const normalized = String(value).trim().toLowerCase()
|
|
112
|
+
if (normalized === 'false' || normalized === '0' || normalized === 'off' || normalized === 'no') {
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeValueForFormat (format, value) {
|
|
120
|
+
if (format === 'uint16') return normalizeIntegerRange(value, 0, 65535)
|
|
121
|
+
if (format === 'int16') return normalizeIntegerRange(value, -32768, 32767)
|
|
122
|
+
if (format === 'uint32') return normalizeIntegerRange(value, 0, 0xffffffff)
|
|
123
|
+
if (format === 'int32') return normalizeIntegerRange(value, -2147483648, 2147483647)
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
value,
|
|
127
|
+
originalValue: value,
|
|
128
|
+
outOfRange: false
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function normalizeIntegerRange (value, min, max) {
|
|
133
|
+
const originalValue = value
|
|
134
|
+
const parsed = Number(value)
|
|
135
|
+
if (!Number.isFinite(parsed)) {
|
|
136
|
+
return { value: min, originalValue, outOfRange: true }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const integerValue = Math.trunc(parsed)
|
|
140
|
+
if (integerValue < min) {
|
|
141
|
+
return { value: min, originalValue, outOfRange: true }
|
|
142
|
+
}
|
|
143
|
+
if (integerValue > max) {
|
|
144
|
+
return { value: max, originalValue, outOfRange: true }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
value: integerValue,
|
|
149
|
+
originalValue,
|
|
150
|
+
outOfRange: false
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
RED.nodes.registerType('modbus-registers-write', ModbusRegistersWriteNode)
|
|
155
|
+
}
|
|
156
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
function resolveRequestContext (RED, node, msg) {
|
|
4
|
+
const context = msg && msg._modbus
|
|
5
|
+
if (!context || typeof context !== 'object') {
|
|
6
|
+
node.error('Missing Modbus request context', msg)
|
|
7
|
+
return null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const requestId = context.requestId
|
|
11
|
+
const configNodeId = context.configNodeId
|
|
12
|
+
|
|
13
|
+
if (!requestId || !configNodeId) {
|
|
14
|
+
node.error('Missing Modbus request context', msg)
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const serverNode = RED.nodes.getNode(configNodeId)
|
|
19
|
+
if (!serverNode || typeof serverNode.respondToRequest !== 'function') {
|
|
20
|
+
node.error('Invalid Modbus request context', msg)
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
requestId,
|
|
26
|
+
configNodeId,
|
|
27
|
+
eventName: context.eventName,
|
|
28
|
+
address: context.address,
|
|
29
|
+
quantity: context.quantity,
|
|
30
|
+
serverNode
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function respondWithPayload (RED, node, msg, payload) {
|
|
35
|
+
const context = resolveRequestContext(RED, node, msg)
|
|
36
|
+
if (!context) {
|
|
37
|
+
return { ok: false, context: null }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ok = context.serverNode.respondToRequest(context.requestId, payload)
|
|
41
|
+
if (!ok) {
|
|
42
|
+
node.warn(`respondToRequest returned false for requestId ${context.requestId}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { ok, context }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = {
|
|
49
|
+
resolveRequestContext,
|
|
50
|
+
respondWithPayload
|
|
51
|
+
}
|
|
52
|
+
|