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.
@@ -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 mapping = { ...raw }
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
- mapping.scale = safeNumber(mapping.scale, 1)
118
- mapping.offset = safeNumber(mapping.offset, 0)
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
- const payload = renderTemplate(mapping.template, context)
332
- const topic = mapping.iotType === 'mqtt'
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 = mapping.modbusFunction
373
- out.address = mapping.target
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
- let value = msg.payload
495
- if (isBooleanDpt(mapping.dpt)) {
496
- value = toBoolean(value)
497
- }
498
- if (typeof value === 'number') {
499
- value = applyScale(value, mapping)
500
- }
501
- if (!shouldEmitKnxValue(mapping, value)) continue
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
- const outMsg = buildOutMessage(mapping, value, meta)
504
- rememberKnxValue(mapping, value)
505
- node.setNodeStatus({ fill: 'green', shape: 'dot', text: 'KNX→IoT', mapping, payload: value })
506
- node.send([outMsg, null])
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, msg.payload)
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, Modbus als Registerkennung.
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
- - Kombinieren Sie den Bridge mit `modbus-flex-write`-Nodes aus `node-red-contrib-modbus`. Ausgang&nbsp;1 enthält die Modbus-Adresse in `msg.address` und den Wert in `msg.payload`.
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&nbsp;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