node-red-contrib-knx-ultimate 6.3.24 → 6.3.25
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 +5 -0
- package/examples/IoT Bridge - Modbus Flex Adapter.json +361 -0
- package/nodes/knxUltimate-config.js +13 -4
- package/nodes/knxUltimateIoTBridge.html +349 -20
- package/nodes/knxUltimateIoTBridge.js +474 -44
- package/nodes/locales/de/knxUltimateIoTBridge.html +28 -2
- package/nodes/locales/de/knxUltimateIoTBridge.json +20 -2
- package/nodes/locales/en/knxUltimateIoTBridge.html +28 -2
- package/nodes/locales/en/knxUltimateIoTBridge.json +20 -2
- package/nodes/locales/es/knxUltimateIoTBridge.html +28 -2
- package/nodes/locales/es/knxUltimateIoTBridge.json +20 -2
- package/nodes/locales/fr/knxUltimateIoTBridge.html +28 -2
- package/nodes/locales/fr/knxUltimateIoTBridge.json +20 -2
- package/nodes/locales/it/knxUltimateIoTBridge.html +28 -2
- package/nodes/locales/it/knxUltimateIoTBridge.json +20 -2
- package/nodes/locales/zh-CN/knxUltimateIoTBridge.html +28 -2
- package/nodes/locales/zh-CN/knxUltimateIoTBridge.json +20 -2
- package/package.json +3 -3
|
@@ -98,13 +98,43 @@ module.exports = function (RED) {
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
const normaliseModbusArea = (value, legacyFunction = '') => {
|
|
102
|
+
switch (value) {
|
|
103
|
+
case 'coil':
|
|
104
|
+
case 'discrete-input':
|
|
105
|
+
case 'holding-register':
|
|
106
|
+
case 'input-register':
|
|
107
|
+
return value
|
|
108
|
+
default: {
|
|
109
|
+
const fn = sanitizeString(legacyFunction).toLowerCase()
|
|
110
|
+
if (fn.includes('discrete')) return 'discrete-input'
|
|
111
|
+
if (fn.includes('input') && fn.includes('register')) return 'input-register'
|
|
112
|
+
if (fn.includes('coil')) return 'coil'
|
|
113
|
+
return 'holding-register'
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const normaliseModbusDataType = (value, area) => {
|
|
119
|
+
if (area === 'coil' || area === 'discrete-input') return 'bool'
|
|
120
|
+
switch (value) {
|
|
121
|
+
case 'bool':
|
|
122
|
+
case 'uint16':
|
|
123
|
+
case 'int16':
|
|
124
|
+
return value
|
|
125
|
+
default:
|
|
126
|
+
return 'uint16'
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
101
130
|
const ensureId = (value) => {
|
|
102
131
|
const id = sanitizeString(value)
|
|
103
132
|
return id !== '' ? id : (RED.util && typeof RED.util.generateId === 'function' ? RED.util.generateId() : Math.random().toString(16).slice(2))
|
|
104
133
|
}
|
|
105
134
|
|
|
106
135
|
const cleanMapping = (raw) => {
|
|
107
|
-
const
|
|
136
|
+
const source = raw && typeof raw === 'object' ? raw : {}
|
|
137
|
+
const mapping = { ...source }
|
|
108
138
|
mapping.id = ensureId(mapping.id)
|
|
109
139
|
mapping.label = sanitizeString(mapping.label) || mapping.id
|
|
110
140
|
mapping.ga = sanitizeString(mapping.ga)
|
|
@@ -114,8 +144,27 @@ module.exports = function (RED) {
|
|
|
114
144
|
mapping.target = sanitizeString(mapping.target)
|
|
115
145
|
mapping.method = sanitizeString(mapping.method) || 'POST'
|
|
116
146
|
mapping.modbusFunction = sanitizeString(mapping.modbusFunction) || 'writeHoldingRegister'
|
|
117
|
-
|
|
118
|
-
mapping
|
|
147
|
+
// Existing flows emitted a generic scalar Modbus message. Keep that exact contract unless
|
|
148
|
+
// a mapping explicitly opts into the direct node-red-contrib-modbus Flex message shape.
|
|
149
|
+
mapping.modbusMessageFormat = source.modbusMessageFormat === 'flex' ? 'flex' : 'legacy'
|
|
150
|
+
mapping.modbusArea = normaliseModbusArea(mapping.modbusArea, mapping.modbusFunction)
|
|
151
|
+
mapping.modbusDataType = normaliseModbusDataType(mapping.modbusDataType, mapping.modbusArea)
|
|
152
|
+
if (mapping.modbusMessageFormat === 'flex' && ['discrete-input', 'input-register'].includes(mapping.modbusArea)) {
|
|
153
|
+
mapping.direction = 'iot-to-knx'
|
|
154
|
+
}
|
|
155
|
+
mapping.modbusUnitId = mapping.modbusUnitId === undefined || mapping.modbusUnitId === null || mapping.modbusUnitId === ''
|
|
156
|
+
? 1
|
|
157
|
+
: mapping.modbusUnitId
|
|
158
|
+
mapping.modbusAddress = mapping.modbusAddress === undefined || mapping.modbusAddress === null || mapping.modbusAddress === ''
|
|
159
|
+
? mapping.target
|
|
160
|
+
: mapping.modbusAddress
|
|
161
|
+
if (mapping.modbusMessageFormat === 'flex') {
|
|
162
|
+
mapping.scale = mapping.scale === undefined || mapping.scale === null || mapping.scale === '' ? 1 : Number(mapping.scale)
|
|
163
|
+
mapping.offset = mapping.offset === undefined || mapping.offset === null || mapping.offset === '' ? 0 : Number(mapping.offset)
|
|
164
|
+
} else {
|
|
165
|
+
mapping.scale = safeNumber(mapping.scale, 1)
|
|
166
|
+
mapping.offset = safeNumber(mapping.offset, 0)
|
|
167
|
+
}
|
|
119
168
|
mapping.template = sanitizeString(mapping.template)
|
|
120
169
|
mapping.property = sanitizeString(mapping.property)
|
|
121
170
|
mapping.enabled = mapping.enabled !== false
|
|
@@ -277,6 +326,92 @@ module.exports = function (RED) {
|
|
|
277
326
|
return Boolean(value)
|
|
278
327
|
}
|
|
279
328
|
|
|
329
|
+
const toStrictBoolean = (value) => {
|
|
330
|
+
if (typeof value === 'boolean') return value
|
|
331
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
332
|
+
if (value === 0) return false
|
|
333
|
+
if (value === 1) return true
|
|
334
|
+
}
|
|
335
|
+
if (typeof value === 'string') {
|
|
336
|
+
const lowered = value.trim().toLowerCase()
|
|
337
|
+
if (['true', '1', 'on', 'yes', 'open'].includes(lowered)) return true
|
|
338
|
+
if (['false', '0', 'off', 'no', 'close', 'closed'].includes(lowered)) return false
|
|
339
|
+
}
|
|
340
|
+
throw new Error(`Invalid Modbus boolean value: ${JSON.stringify(value)}`)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const strictInteger = (value, field, min, max) => {
|
|
344
|
+
if (value === '' || value === null || value === undefined || typeof value === 'boolean') {
|
|
345
|
+
throw new Error(`${field} must be an integer between ${min} and ${max}`)
|
|
346
|
+
}
|
|
347
|
+
const parsed = Number(value)
|
|
348
|
+
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
|
349
|
+
throw new Error(`${field} must be an integer between ${min} and ${max}`)
|
|
350
|
+
}
|
|
351
|
+
return parsed
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const validateModbusScale = (mapping) => {
|
|
355
|
+
if (!Number.isFinite(mapping.scale) || mapping.scale === 0) {
|
|
356
|
+
throw new Error('Modbus scale must be a finite number other than zero')
|
|
357
|
+
}
|
|
358
|
+
if (!Number.isFinite(mapping.offset)) {
|
|
359
|
+
throw new Error('Modbus offset must be a finite number')
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const getModbusUnitId = (mapping) => strictInteger(mapping.modbusUnitId, 'Modbus Unit ID', 0, 255)
|
|
364
|
+
const getModbusAddress = (mapping) => strictInteger(mapping.modbusAddress, 'Modbus address', 0, 65535)
|
|
365
|
+
|
|
366
|
+
const getModbusReadFunctionCode = (mapping) => {
|
|
367
|
+
switch (mapping.modbusArea) {
|
|
368
|
+
case 'coil': return 1
|
|
369
|
+
case 'discrete-input': return 2
|
|
370
|
+
case 'holding-register': return 3
|
|
371
|
+
case 'input-register': return 4
|
|
372
|
+
default: throw new Error(`Unsupported Modbus area: ${mapping.modbusArea}`)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const getModbusWriteFunctionCode = (mapping) => {
|
|
377
|
+
if (mapping.modbusArea === 'coil') return 5
|
|
378
|
+
if (mapping.modbusArea === 'holding-register') return 6
|
|
379
|
+
throw new Error(`The Modbus area ${mapping.modbusArea} is read-only`)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const encodeModbusWriteValue = (mapping, value) => {
|
|
383
|
+
validateModbusScale(mapping)
|
|
384
|
+
if (mapping.modbusArea === 'coil') return toStrictBoolean(value)
|
|
385
|
+
|
|
386
|
+
if (value === '' || value === null || value === undefined) {
|
|
387
|
+
throw new Error('Modbus register value must be numeric')
|
|
388
|
+
}
|
|
389
|
+
const numeric = Number(value)
|
|
390
|
+
if (!Number.isFinite(numeric)) throw new Error('Modbus register value must be finite')
|
|
391
|
+
|
|
392
|
+
if (mapping.modbusDataType === 'bool') return toStrictBoolean(value) ? 1 : 0
|
|
393
|
+
|
|
394
|
+
const rounded = Math.round(numeric)
|
|
395
|
+
if (mapping.modbusDataType === 'int16') {
|
|
396
|
+
if (rounded < -32768 || rounded > 32767) throw new Error('Modbus int16 value is outside -32768..32767')
|
|
397
|
+
return rounded < 0 ? 0x10000 + rounded : rounded
|
|
398
|
+
}
|
|
399
|
+
if (rounded < 0 || rounded > 65535) throw new Error('Modbus uint16 value is outside 0..65535')
|
|
400
|
+
return rounded
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const decodeModbusReadValue = (mapping, rawValue) => {
|
|
404
|
+
validateModbusScale(mapping)
|
|
405
|
+
if (mapping.modbusArea === 'coil' || mapping.modbusArea === 'discrete-input') {
|
|
406
|
+
return toStrictBoolean(rawValue)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const word = strictInteger(rawValue, 'Modbus register value', 0, 65535)
|
|
410
|
+
if (mapping.modbusDataType === 'bool') return word !== 0
|
|
411
|
+
if (mapping.modbusDataType === 'int16') return word > 0x7fff ? word - 0x10000 : word
|
|
412
|
+
return word
|
|
413
|
+
}
|
|
414
|
+
|
|
280
415
|
const applyScale = (value, mapping) => {
|
|
281
416
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
282
417
|
return (value * mapping.scale) + mapping.offset
|
|
@@ -318,6 +453,31 @@ module.exports = function (RED) {
|
|
|
318
453
|
.replace(/{{\s*isoTimestamp\s*}}/g, new Date().toISOString())
|
|
319
454
|
}
|
|
320
455
|
|
|
456
|
+
const buildModbusFlexWrite = (mapping, value) => {
|
|
457
|
+
const unitid = getModbusUnitId(mapping)
|
|
458
|
+
const address = getModbusAddress(mapping)
|
|
459
|
+
const fc = getModbusWriteFunctionCode(mapping)
|
|
460
|
+
return {
|
|
461
|
+
request: {
|
|
462
|
+
value: encodeModbusWriteValue(mapping, value),
|
|
463
|
+
fc,
|
|
464
|
+
unitid,
|
|
465
|
+
address,
|
|
466
|
+
quantity: 1
|
|
467
|
+
},
|
|
468
|
+
metadata: {
|
|
469
|
+
contract: 'flex-v1',
|
|
470
|
+
operation: 'write',
|
|
471
|
+
unitid,
|
|
472
|
+
address,
|
|
473
|
+
quantity: 1,
|
|
474
|
+
fc,
|
|
475
|
+
area: mapping.modbusArea,
|
|
476
|
+
dataType: mapping.modbusDataType
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
321
481
|
const buildOutMessage = (mapping, value, meta) => {
|
|
322
482
|
const context = {
|
|
323
483
|
value,
|
|
@@ -328,11 +488,18 @@ module.exports = function (RED) {
|
|
|
328
488
|
isoTimestamp: new Date().toISOString()
|
|
329
489
|
}
|
|
330
490
|
|
|
331
|
-
|
|
332
|
-
|
|
491
|
+
let payload = renderTemplate(mapping.template, context)
|
|
492
|
+
let topic = mapping.iotType === 'mqtt'
|
|
333
493
|
? (mapping.target || node.outputtopic || mapping.ga)
|
|
334
494
|
: (node.outputtopic || mapping.target || mapping.ga)
|
|
335
495
|
|
|
496
|
+
let modbusFlex = null
|
|
497
|
+
if (mapping.iotType === 'modbus' && mapping.modbusMessageFormat === 'flex') {
|
|
498
|
+
modbusFlex = buildModbusFlexWrite(mapping, value)
|
|
499
|
+
payload = modbusFlex.request
|
|
500
|
+
topic = `knxultimate/modbus/${node.id}/${mapping.id}/write`
|
|
501
|
+
}
|
|
502
|
+
|
|
336
503
|
const out = {
|
|
337
504
|
topic,
|
|
338
505
|
payload,
|
|
@@ -369,11 +536,17 @@ module.exports = function (RED) {
|
|
|
369
536
|
out.headers = meta.headers || {}
|
|
370
537
|
}
|
|
371
538
|
if (mapping.iotType === 'modbus') {
|
|
372
|
-
out.modbusFunction =
|
|
373
|
-
|
|
539
|
+
out.modbusFunction = modbusFlex
|
|
540
|
+
? (modbusFlex.request.fc === 5 ? 'writeCoil' : 'writeHoldingRegister')
|
|
541
|
+
: mapping.modbusFunction
|
|
542
|
+
out.address = modbusFlex ? modbusFlex.request.address : mapping.target
|
|
374
543
|
if (mapping.property) out.property = mapping.property
|
|
375
544
|
out.timeout = mapping.timeout
|
|
376
545
|
out.retry = mapping.retry
|
|
546
|
+
if (modbusFlex) {
|
|
547
|
+
out.bridge.modbusFunction = out.modbusFunction
|
|
548
|
+
out.bridge.modbus = modbusFlex.metadata
|
|
549
|
+
}
|
|
377
550
|
}
|
|
378
551
|
if (mapping.iotType === 'mqtt' && mapping.property) {
|
|
379
552
|
out.property = mapping.property
|
|
@@ -395,6 +568,30 @@ module.exports = function (RED) {
|
|
|
395
568
|
node.stateById.set(mapping.id, current)
|
|
396
569
|
}
|
|
397
570
|
|
|
571
|
+
const rememberKnxEchoToSuppress = (mapping, value) => {
|
|
572
|
+
const current = node.stateById.get(mapping.id) || {}
|
|
573
|
+
current.suppressKnxEchoValue = value
|
|
574
|
+
current.suppressKnxEchoUntil = Date.now() + 2000
|
|
575
|
+
node.stateById.set(mapping.id, current)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const shouldSuppressKnxEcho = (mapping, value, msg) => {
|
|
579
|
+
if (mapping.iotType !== 'modbus' || mapping.modbusMessageFormat !== 'flex') return false
|
|
580
|
+
const current = node.stateById.get(mapping.id)
|
|
581
|
+
if (!current) return false
|
|
582
|
+
if (current.suppressKnxEchoUntil < Date.now()) {
|
|
583
|
+
delete current.suppressKnxEchoValue
|
|
584
|
+
delete current.suppressKnxEchoUntil
|
|
585
|
+
return false
|
|
586
|
+
}
|
|
587
|
+
const isEchoed = msg && (msg.echoed === true || (msg.knx && msg.knx.echoed === true))
|
|
588
|
+
if (!isEchoed) return false
|
|
589
|
+
if (!valuesAreEqual(current.suppressKnxEchoValue, value)) return false
|
|
590
|
+
delete current.suppressKnxEchoValue
|
|
591
|
+
delete current.suppressKnxEchoUntil
|
|
592
|
+
return true
|
|
593
|
+
}
|
|
594
|
+
|
|
398
595
|
const shouldEmitKnxValue = (mapping, value) => {
|
|
399
596
|
if (!node.emitOnChangeOnly) return true
|
|
400
597
|
const current = node.stateById.get(mapping.id)
|
|
@@ -402,8 +599,113 @@ module.exports = function (RED) {
|
|
|
402
599
|
return !valuesAreEqual(current.lastKnxValue, value)
|
|
403
600
|
}
|
|
404
601
|
|
|
602
|
+
const shouldWriteModbusValueToKnx = (mapping, value) => {
|
|
603
|
+
if (!node.emitOnChangeOnly) return true
|
|
604
|
+
const current = node.stateById.get(mapping.id)
|
|
605
|
+
if (!current || current.lastIoTValue === undefined) return true
|
|
606
|
+
return !valuesAreEqual(current.lastIoTValue, value)
|
|
607
|
+
}
|
|
608
|
+
|
|
405
609
|
const findMappingsByGA = (ga) => node.gaIndex.get(ga) || []
|
|
406
610
|
|
|
611
|
+
const getModbusRequestFromMessage = (msg) => {
|
|
612
|
+
if (!msg || typeof msg !== 'object') return null
|
|
613
|
+
if (msg.modbusRequest && typeof msg.modbusRequest === 'object') return msg.modbusRequest
|
|
614
|
+
if (msg.input && msg.input.payload && typeof msg.input.payload === 'object' && !Array.isArray(msg.input.payload) && Object.prototype.hasOwnProperty.call(msg.input.payload, 'fc')) {
|
|
615
|
+
return msg.input.payload
|
|
616
|
+
}
|
|
617
|
+
if (msg.responseBuffer && msg.payload && typeof msg.payload === 'object' && !Array.isArray(msg.payload) && Object.prototype.hasOwnProperty.call(msg.payload, 'fc')) {
|
|
618
|
+
return msg.payload
|
|
619
|
+
}
|
|
620
|
+
if (msg.responseBuffer && msg.values && typeof msg.values === 'object' && !Array.isArray(msg.values) && Object.prototype.hasOwnProperty.call(msg.values, 'fc')) {
|
|
621
|
+
return msg.values
|
|
622
|
+
}
|
|
623
|
+
if (msg.bridge && msg.bridge.modbus && typeof msg.bridge.modbus === 'object') return msg.bridge.modbus
|
|
624
|
+
return null
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const isModbusTransportMessage = (msg) => {
|
|
628
|
+
if (!msg || typeof msg !== 'object') return false
|
|
629
|
+
const hasInputRequest = msg.input && msg.input.payload && typeof msg.input.payload === 'object' && Object.prototype.hasOwnProperty.call(msg.input.payload, 'fc')
|
|
630
|
+
const hasExplicitFlexContract = msg.bridge && msg.bridge.modbus && msg.bridge.modbus.contract === 'flex-v1'
|
|
631
|
+
return Boolean(
|
|
632
|
+
msg.modbusRequest ||
|
|
633
|
+
msg.responseBuffer ||
|
|
634
|
+
Object.prototype.hasOwnProperty.call(msg, 'values') ||
|
|
635
|
+
hasInputRequest ||
|
|
636
|
+
hasExplicitFlexContract
|
|
637
|
+
) && getModbusRequestFromMessage(msg) !== null
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const asModbusDataArray = (value) => {
|
|
641
|
+
if (Array.isArray(value)) return value
|
|
642
|
+
if (Buffer.isBuffer(value)) return Array.from(value)
|
|
643
|
+
if (value && typeof value === 'object' && Array.isArray(value.data)) return value.data
|
|
644
|
+
return null
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const getModbusResponseData = (msg, request) => {
|
|
648
|
+
const candidates = [
|
|
649
|
+
msg && msg.payload,
|
|
650
|
+
msg && msg.values,
|
|
651
|
+
msg && msg.responseBuffer && msg.responseBuffer.data,
|
|
652
|
+
msg && msg.responseBuffer
|
|
653
|
+
]
|
|
654
|
+
for (const candidate of candidates) {
|
|
655
|
+
const data = asModbusDataArray(candidate)
|
|
656
|
+
if (data) return data
|
|
657
|
+
}
|
|
658
|
+
const quantity = Number(request && request.quantity)
|
|
659
|
+
if (quantity === 1 && msg && (typeof msg.payload === 'number' || typeof msg.payload === 'boolean')) return [msg.payload]
|
|
660
|
+
return null
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const findMappingsForModbusResponse = (request, dataLength) => {
|
|
664
|
+
const fc = strictInteger(request.fc, 'Modbus function code', 1, 4)
|
|
665
|
+
const unitid = strictInteger(request.unitid !== undefined ? request.unitid : request.unitId, 'Modbus Unit ID', 0, 255)
|
|
666
|
+
const address = strictInteger(request.address, 'Modbus address', 0, 65535)
|
|
667
|
+
const quantityValue = request.quantity === undefined || request.quantity === null || request.quantity === '' ? dataLength : request.quantity
|
|
668
|
+
const quantity = strictInteger(quantityValue, 'Modbus quantity', 1, 65536)
|
|
669
|
+
if (address + quantity > 65536) throw new Error('Modbus address plus quantity exceeds 65536')
|
|
670
|
+
|
|
671
|
+
const matches = []
|
|
672
|
+
for (const mapping of node.mappings) {
|
|
673
|
+
if (mapping.iotType !== 'modbus' || mapping.modbusMessageFormat !== 'flex') continue
|
|
674
|
+
if (mapping.direction === 'knx-to-iot') continue
|
|
675
|
+
try {
|
|
676
|
+
if (getModbusUnitId(mapping) !== unitid) continue
|
|
677
|
+
if (getModbusReadFunctionCode(mapping) !== fc) continue
|
|
678
|
+
const mappingAddress = getModbusAddress(mapping)
|
|
679
|
+
if (mappingAddress < address || mappingAddress >= address + quantity) continue
|
|
680
|
+
matches.push({ mapping, offset: mappingAddress - address })
|
|
681
|
+
} catch (error) {
|
|
682
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: error.message, mapping })
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return matches
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const findMappingsForModbusWriteResponse = (request) => {
|
|
689
|
+
const fc = strictInteger(request.fc, 'Modbus function code', 5, 16)
|
|
690
|
+
const unitid = strictInteger(request.unitid !== undefined ? request.unitid : request.unitId, 'Modbus Unit ID', 0, 255)
|
|
691
|
+
const address = strictInteger(request.address, 'Modbus address', 0, 65535)
|
|
692
|
+
const matches = []
|
|
693
|
+
|
|
694
|
+
for (const mapping of node.mappings) {
|
|
695
|
+
if (mapping.iotType !== 'modbus' || mapping.modbusMessageFormat !== 'flex') continue
|
|
696
|
+
if (mapping.direction === 'iot-to-knx') continue
|
|
697
|
+
try {
|
|
698
|
+
if (getModbusUnitId(mapping) !== unitid) continue
|
|
699
|
+
if (getModbusAddress(mapping) !== address) continue
|
|
700
|
+
if (getModbusWriteFunctionCode(mapping) !== fc) continue
|
|
701
|
+
matches.push(mapping)
|
|
702
|
+
} catch (error) {
|
|
703
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: error.message, mapping })
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return matches
|
|
707
|
+
}
|
|
708
|
+
|
|
407
709
|
const matchMappingForIoT = (msg) => {
|
|
408
710
|
const bridge = msg.bridge || {}
|
|
409
711
|
const type = bridge.type || (msg.iotType) || 'mqtt'
|
|
@@ -450,6 +752,131 @@ module.exports = function (RED) {
|
|
|
450
752
|
}
|
|
451
753
|
}
|
|
452
754
|
|
|
755
|
+
const buildIoTToKnxAck = (mapping, value, modbusRequest) => {
|
|
756
|
+
const ack = {
|
|
757
|
+
topic: mapping.ga,
|
|
758
|
+
payload: value,
|
|
759
|
+
bridge: {
|
|
760
|
+
id: mapping.id,
|
|
761
|
+
label: mapping.label,
|
|
762
|
+
type: mapping.iotType,
|
|
763
|
+
direction: 'iot-to-knx',
|
|
764
|
+
target: mapping.target,
|
|
765
|
+
method: mapping.method,
|
|
766
|
+
modbusFunction: mapping.modbusFunction,
|
|
767
|
+
property: mapping.property,
|
|
768
|
+
timeout: mapping.timeout,
|
|
769
|
+
retry: mapping.retry
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (mapping.iotType === 'rest') {
|
|
773
|
+
ack.url = mapping.target || ''
|
|
774
|
+
ack.method = mapping.method || 'POST'
|
|
775
|
+
}
|
|
776
|
+
if (mapping.iotType === 'modbus') {
|
|
777
|
+
ack.address = mapping.modbusMessageFormat === 'flex' ? getModbusAddress(mapping) : mapping.target
|
|
778
|
+
ack.modbusFunction = mapping.modbusFunction
|
|
779
|
+
if (mapping.modbusMessageFormat === 'flex') {
|
|
780
|
+
ack.bridge.modbus = {
|
|
781
|
+
contract: 'flex-v1',
|
|
782
|
+
operation: 'read',
|
|
783
|
+
unitid: getModbusUnitId(mapping),
|
|
784
|
+
address: getModbusAddress(mapping),
|
|
785
|
+
quantity: 1,
|
|
786
|
+
fc: modbusRequest ? Number(modbusRequest.fc) : getModbusReadFunctionCode(mapping),
|
|
787
|
+
area: mapping.modbusArea,
|
|
788
|
+
dataType: mapping.modbusDataType
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return ack
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const handleModbusTransportInput = (msg, send) => {
|
|
796
|
+
if (!isModbusTransportMessage(msg)) return { handled: false }
|
|
797
|
+
const request = getModbusRequestFromMessage(msg)
|
|
798
|
+
const fc = Number(request && request.fc)
|
|
799
|
+
const hasExplicitFlexContract = msg.bridge && msg.bridge.modbus && msg.bridge.modbus.contract === 'flex-v1'
|
|
800
|
+
const routedMapping = matchMappingForIoT(msg)
|
|
801
|
+
const routedToFlexMapping = routedMapping && routedMapping.iotType === 'modbus' && routedMapping.modbusMessageFormat === 'flex'
|
|
802
|
+
let matches = []
|
|
803
|
+
|
|
804
|
+
try {
|
|
805
|
+
if ([1, 2, 3, 4].includes(fc)) {
|
|
806
|
+
const probeLength = Number.isInteger(Number(request.quantity)) && Number(request.quantity) > 0
|
|
807
|
+
? Number(request.quantity)
|
|
808
|
+
: 1
|
|
809
|
+
matches = findMappingsForModbusResponse(request, probeLength)
|
|
810
|
+
} else if ([5, 6, 15, 16].includes(fc)) {
|
|
811
|
+
matches = findMappingsForModbusWriteResponse(request)
|
|
812
|
+
}
|
|
813
|
+
} catch (error) {
|
|
814
|
+
const routedToModbusMapping = routedMapping && routedMapping.iotType === 'modbus'
|
|
815
|
+
if (!hasExplicitFlexContract && !(msg.error && routedToModbusMapping)) return { handled: false }
|
|
816
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: error.message })
|
|
817
|
+
return { handled: true, error }
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Preserve the generic scalar path for old mappings (and for MQTT/REST messages that
|
|
821
|
+
// merely carry Modbus metadata). A message is owned by this adapter only when the request
|
|
822
|
+
// matches a Flex mapping or carries this bridge's explicit Flex contract.
|
|
823
|
+
if (msg.error && (hasExplicitFlexContract || matches.length > 0 || (routedMapping && routedMapping.iotType === 'modbus'))) {
|
|
824
|
+
const error = msg.error instanceof Error ? msg.error : new Error(String(msg.error))
|
|
825
|
+
const operation = [5, 6, 15, 16].includes(fc) ? 'write' : 'read'
|
|
826
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: `Modbus ${operation}: ${error.message}` })
|
|
827
|
+
return { handled: true, error }
|
|
828
|
+
}
|
|
829
|
+
if (!hasExplicitFlexContract && routedMapping && !routedToFlexMapping) return { handled: false }
|
|
830
|
+
if (!hasExplicitFlexContract && matches.length === 0 && !routedToFlexMapping) return { handled: false }
|
|
831
|
+
|
|
832
|
+
// Flex Write responses are acknowledgements, not device state. They must never be
|
|
833
|
+
// interpreted as a value to write onto KNX, even if wired back to this node by mistake.
|
|
834
|
+
if ([5, 6, 15, 16].includes(fc)) return { handled: true }
|
|
835
|
+
if (![1, 2, 3, 4].includes(fc)) return { handled: true }
|
|
836
|
+
|
|
837
|
+
const data = getModbusResponseData(msg, request)
|
|
838
|
+
if (!data || data.length === 0) {
|
|
839
|
+
const error = new Error('Modbus read response has no data')
|
|
840
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: error.message })
|
|
841
|
+
return { handled: true, error }
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
try {
|
|
845
|
+
matches = findMappingsForModbusResponse(request, data.length)
|
|
846
|
+
} catch (error) {
|
|
847
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: error.message })
|
|
848
|
+
return { handled: true, error }
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (matches.length === 0) {
|
|
852
|
+
node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: 'No Modbus mapping for response', payload: request.address })
|
|
853
|
+
return { handled: true }
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
let firstError = null
|
|
857
|
+
for (const match of matches) {
|
|
858
|
+
const mapping = match.mapping
|
|
859
|
+
try {
|
|
860
|
+
if (match.offset >= data.length) throw new Error('Modbus read response is shorter than the configured mapping')
|
|
861
|
+
const rawValue = decodeModbusReadValue(mapping, data[match.offset])
|
|
862
|
+
let value = typeof rawValue === 'number' ? revertScale(rawValue, mapping) : rawValue
|
|
863
|
+
if (isBooleanDpt(mapping.dpt)) value = toBoolean(value)
|
|
864
|
+
if (!shouldWriteModbusValueToKnx(mapping, value)) continue
|
|
865
|
+
|
|
866
|
+
sendToKNX(mapping, value)
|
|
867
|
+
rememberIoTValue(mapping, value)
|
|
868
|
+
rememberKnxEchoToSuppress(mapping, value)
|
|
869
|
+
const ack = buildIoTToKnxAck(mapping, value, request)
|
|
870
|
+
node.setNodeStatus({ fill: 'blue', shape: 'dot', text: 'Modbus→KNX', mapping, payload: value })
|
|
871
|
+
if (send) send([null, ack]); else node.send([null, ack])
|
|
872
|
+
} catch (error) {
|
|
873
|
+
if (!firstError) firstError = error
|
|
874
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: error.message, mapping })
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return { handled: true, error: firstError }
|
|
878
|
+
}
|
|
879
|
+
|
|
453
880
|
// Home Assistant mode: mirror a decoded KNX telegram to MQTT. Works with a telegram coming
|
|
454
881
|
// from the gateway (standalone) or from the input pin (flow mode); both share the shape
|
|
455
882
|
// produced by the KNXUltimate universal node ({ payload, knx: { destination, event } }).
|
|
@@ -491,19 +918,24 @@ module.exports = function (RED) {
|
|
|
491
918
|
|
|
492
919
|
for (const mapping of mappings) {
|
|
493
920
|
if (mapping.direction === 'iot-to-knx') continue
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
value =
|
|
500
|
-
|
|
501
|
-
|
|
921
|
+
// A Modbus write is a command. Never turn a KNX read response/status telegram into
|
|
922
|
+
// that command; this also prevents readOnDeploy from changing a Modbus device.
|
|
923
|
+
if (mapping.iotType === 'modbus' && mapping.modbusMessageFormat === 'flex' && meta.event !== 'GroupValue_Write') continue
|
|
924
|
+
try {
|
|
925
|
+
let value = msg.payload
|
|
926
|
+
if (isBooleanDpt(mapping.dpt)) value = toBoolean(value)
|
|
927
|
+
if (shouldSuppressKnxEcho(mapping, value, msg)) continue
|
|
928
|
+
if (typeof value === 'number') value = applyScale(value, mapping)
|
|
929
|
+
if (!shouldEmitKnxValue(mapping, value)) continue
|
|
502
930
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
931
|
+
const outMsg = buildOutMessage(mapping, value, meta)
|
|
932
|
+
rememberKnxValue(mapping, value)
|
|
933
|
+
node.setNodeStatus({ fill: 'green', shape: 'dot', text: 'KNX→IoT', mapping, payload: value })
|
|
934
|
+
node.send([outMsg, null])
|
|
935
|
+
} catch (error) {
|
|
936
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: error.message, mapping })
|
|
937
|
+
if (node.sysLogger) node.sysLogger.error(`KNX→IoT mapping failed (${mapping.ga}): ${error.message}`)
|
|
938
|
+
}
|
|
507
939
|
}
|
|
508
940
|
} catch (error) {
|
|
509
941
|
if (node.sysLogger) {
|
|
@@ -529,6 +961,13 @@ module.exports = function (RED) {
|
|
|
529
961
|
if (done) done()
|
|
530
962
|
return
|
|
531
963
|
}
|
|
964
|
+
|
|
965
|
+
const modbusResult = handleModbusTransportInput(msg, send)
|
|
966
|
+
if (modbusResult.handled) {
|
|
967
|
+
if (done) done(modbusResult.error)
|
|
968
|
+
return
|
|
969
|
+
}
|
|
970
|
+
|
|
532
971
|
const bridgeMapping = matchMappingForIoT(msg)
|
|
533
972
|
if (!bridgeMapping) {
|
|
534
973
|
node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: 'No mapping for input', payload: msg.topic })
|
|
@@ -541,6 +980,19 @@ module.exports = function (RED) {
|
|
|
541
980
|
return
|
|
542
981
|
}
|
|
543
982
|
|
|
983
|
+
if (bridgeMapping.iotType === 'modbus' && bridgeMapping.modbusMessageFormat === 'flex') {
|
|
984
|
+
try {
|
|
985
|
+
validateModbusScale(bridgeMapping)
|
|
986
|
+
getModbusUnitId(bridgeMapping)
|
|
987
|
+
getModbusAddress(bridgeMapping)
|
|
988
|
+
getModbusReadFunctionCode(bridgeMapping)
|
|
989
|
+
} catch (error) {
|
|
990
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: error.message, mapping: bridgeMapping })
|
|
991
|
+
if (done) done(error)
|
|
992
|
+
return
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
544
996
|
let value = msg.payload
|
|
545
997
|
if (isBooleanDpt(bridgeMapping.dpt)) {
|
|
546
998
|
value = toBoolean(value)
|
|
@@ -554,31 +1006,8 @@ module.exports = function (RED) {
|
|
|
554
1006
|
|
|
555
1007
|
try {
|
|
556
1008
|
sendToKNX(bridgeMapping, value)
|
|
557
|
-
rememberIoTValue(bridgeMapping,
|
|
558
|
-
const ack =
|
|
559
|
-
topic: bridgeMapping.ga,
|
|
560
|
-
payload: value,
|
|
561
|
-
bridge: {
|
|
562
|
-
id: bridgeMapping.id,
|
|
563
|
-
label: bridgeMapping.label,
|
|
564
|
-
type: bridgeMapping.iotType,
|
|
565
|
-
direction: 'iot-to-knx',
|
|
566
|
-
target: bridgeMapping.target,
|
|
567
|
-
method: bridgeMapping.method,
|
|
568
|
-
modbusFunction: bridgeMapping.modbusFunction,
|
|
569
|
-
property: bridgeMapping.property,
|
|
570
|
-
timeout: bridgeMapping.timeout,
|
|
571
|
-
retry: bridgeMapping.retry
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
if (bridgeMapping.iotType === 'rest') {
|
|
575
|
-
ack.url = bridgeMapping.target || ''
|
|
576
|
-
ack.method = bridgeMapping.method || 'POST'
|
|
577
|
-
}
|
|
578
|
-
if (bridgeMapping.iotType === 'modbus') {
|
|
579
|
-
ack.address = bridgeMapping.target
|
|
580
|
-
ack.modbusFunction = bridgeMapping.modbusFunction
|
|
581
|
-
}
|
|
1009
|
+
rememberIoTValue(bridgeMapping, value)
|
|
1010
|
+
const ack = buildIoTToKnxAck(bridgeMapping, value)
|
|
582
1011
|
node.setNodeStatus({ fill: 'blue', shape: 'dot', text: 'IoT→KNX', mapping: bridgeMapping, payload: msg.payload })
|
|
583
1012
|
if (send) send([null, ack]); else node.send([null, ack])
|
|
584
1013
|
if (done) done()
|
|
@@ -635,6 +1064,7 @@ module.exports = function (RED) {
|
|
|
635
1064
|
if (!node.serverKNX || typeof node.serverKNX.sendKNXTelegramToKNXEngine !== 'function') return
|
|
636
1065
|
for (const mapping of node.mappings) {
|
|
637
1066
|
if (mapping.direction === 'iot-to-knx') continue
|
|
1067
|
+
if (mapping.iotType === 'modbus' && mapping.modbusMessageFormat === 'flex') continue
|
|
638
1068
|
if (!mapping.ga) continue
|
|
639
1069
|
try {
|
|
640
1070
|
node.serverKNX.sendKNXTelegramToKNXEngine({
|
|
@@ -45,7 +45,8 @@ Der Rest dieser Hilfe beschreibt den klassischen **IoT-Bridge**-Modus.
|
|
|
45
45
|
## Zuordnungsfelder
|
|
46
46
|
|
|
47
47
|
- **Richtung** — wählen Sie KNX→IoT, IoT→KNX oder bidirektional.
|
|
48
|
-
- **Kanaltyp** — MQTT verwendet das Target als Topic, REST als Basis-URL
|
|
48
|
+
- **Kanaltyp** — MQTT verwendet das Target als Topic, REST als Basis-URL; bei Modbus ist **Target** die nullbasierte Protokolladresse (0–65535).
|
|
49
|
+
- **Modbus-Format / Unit-ID / Bereich / Datentyp** — wählen Sie für neue Zuordnungen **Mit Flex Write kompatibel** und danach Einheit, Speicherbereich sowie `bool`, `uint16` oder `int16`. Fehlt das Format, bleibt der alte Skalarvertrag aktiv, damit bestehende Flows weiter funktionieren.
|
|
49
50
|
- **Skalierung & Offset** — werden für KNX→IoT angewandt; IoT→KNX nutzt die inverse Transformation.
|
|
50
51
|
- **Template** — optionale Zeichenkette mit Platzhaltern `{{value}}`, `{{ga}}`, `{{label}}`, `{{target}}`, `{{type}}`, `{{isoTimestamp}}`.
|
|
51
52
|
- **Timeout / Wiederholungen** — Informationsfelder im Output zur Steuerung von Retries und Zeitfenstern in nachfolgenden Nodes.
|
|
@@ -88,7 +89,32 @@ Der Rest dieser Hilfe beschreibt den klassischen **IoT-Bridge**-Modus.
|
|
|
88
89
|
|
|
89
90
|
### Modbus-Register-Sync
|
|
90
91
|
|
|
91
|
-
|
|
92
|
+
Die Bridge ist ein Nachrichtenadapter für `node-red-contrib-modbus`; sie öffnet selbst keine TCP-/serielle Verbindung und fragt kein Gerät ab. Installieren Sie eine mit Ihrer Node-RED-Laufzeit kompatible Paketversion und verwenden Sie dann dessen Client- und Transport-Nodes.
|
|
93
|
+
|
|
94
|
+
|Bereich|Lesen|Schreiben|Zulässige Richtung|
|
|
95
|
+
|--|--|--|--|
|
|
96
|
+
| Coil | FC1 | FC5 | Beide Richtungen |
|
|
97
|
+
| Discrete Input | FC2 | — | Nur Modbus → KNX |
|
|
98
|
+
| Holding Register | FC3 | FC6 | Beide Richtungen |
|
|
99
|
+
| Input Register | FC4 | — | Nur Modbus → KNX |
|
|
100
|
+
|
|
101
|
+
Wählen Sie bei einer neuen Zuordnung `Format = Mit Flex Write kompatibel`. Für KNX → Modbus kann Ausgang 1 direkt mit einem `modbus-flex-write`-Node verbunden werden und gibt Folgendes aus:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
msg.payload = {
|
|
105
|
+
value: 215,
|
|
106
|
+
fc: 6,
|
|
107
|
+
unitid: 1,
|
|
108
|
+
address: 9,
|
|
109
|
+
quantity: 1
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`address` ist immer nullbasiert: bezeichnet ein Gerätehandbuch ein Holding Register als `40010`, lautet seine Protokolladresse üblicherweise `9`; prüfen Sie die Konvention des Handbuchs. Die Bridge unterstützt pro Zuordnung ein Bit oder ein einzelnes 16-Bit-Register. Mehrere Wörter, 32-Bit-/Float-Werte und Byte-/Wortreihenfolge werden derzeit nicht verarbeitet.
|
|
114
|
+
|
|
115
|
+
Für Modbus → KNX verbinden Sie den Datenausgang eines `modbus-flex-getter`- oder `modbus-read`-Nodes mit dem Eingang der Bridge. Flex Getter stellt die Anfrage (`fc`, `unitid`, `address`, `quantity`) in `msg.modbusRequest` bereit; Modbus Read behält sie in `msg.input.payload`. Das zurückgegebene Werte-Array kann in `msg.payload` oder `msg.values` liegen. Die Bridge unterstützt beide Nachrichtenformen, ordnet alle von der Antwort abgedeckten Flex-Zuordnungen zu und verwendet das passende Array-Element. Aktivieren Sie **Keep Msg Properties** am Flex Getter. Skalierung und Offset folgen `raw = KNX × Skalierung + Offset`; eingehend wird die inverse Umrechnung angewendet.
|
|
116
|
+
|
|
117
|
+
`KNX-Werte beim Deploy lesen` liest nur KNX; planen Sie Modbus-Lesevorgänge im externen Modbus-Flow. Legacy-Zuordnungen (einschließlich solcher ohne `modbusMessageFormat`) geben weiterhin den bisherigen skalaren Payload mit `msg.address`/`msg.modbusFunction` auf oberster Ebene aus.
|
|
92
118
|
|
|
93
119
|
## Beispiel-Flow
|
|
94
120
|
|