node-red-contrib-knx-ultimate 6.3.24 → 6.3.26
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 +14 -1
- package/examples/IoT Bridge - Modbus Flex Adapter.json +361 -0
- package/examples/KNX AI - Conversational Control with Confirmation.json +4 -2
- package/examples/KNX AI - Summary Anomalies and Ask.json +4 -0
- package/examples/KNX AI - Telegrambot Direct Chat.json +7 -5
- package/nodes/knxUltimate-config.js +13 -4
- package/nodes/knxUltimateAI.html +310 -124
- package/nodes/knxUltimateAI.js +1742 -264
- package/nodes/knxUltimateIoTBridge.html +349 -20
- package/nodes/knxUltimateIoTBridge.js +474 -44
- package/nodes/locales/de/knxUltimateAI.html +33 -7
- package/nodes/locales/de/knxUltimateAI.json +34 -15
- package/nodes/locales/de/knxUltimateIoTBridge.html +28 -2
- package/nodes/locales/de/knxUltimateIoTBridge.json +20 -2
- package/nodes/locales/en/knxUltimateAI.html +33 -7
- package/nodes/locales/en/knxUltimateAI.json +34 -15
- package/nodes/locales/en/knxUltimateIoTBridge.html +28 -2
- package/nodes/locales/en/knxUltimateIoTBridge.json +20 -2
- package/nodes/locales/es/knxUltimateAI.html +33 -7
- package/nodes/locales/es/knxUltimateAI.json +34 -15
- package/nodes/locales/es/knxUltimateIoTBridge.html +28 -2
- package/nodes/locales/es/knxUltimateIoTBridge.json +20 -2
- package/nodes/locales/fr/knxUltimateAI.html +33 -7
- package/nodes/locales/fr/knxUltimateAI.json +34 -15
- package/nodes/locales/fr/knxUltimateIoTBridge.html +28 -2
- package/nodes/locales/fr/knxUltimateIoTBridge.json +20 -2
- package/nodes/locales/it/knxUltimateAI.html +33 -7
- package/nodes/locales/it/knxUltimateAI.json +34 -15
- package/nodes/locales/it/knxUltimateIoTBridge.html +28 -2
- package/nodes/locales/it/knxUltimateIoTBridge.json +20 -2
- package/nodes/locales/zh-CN/knxUltimateAI.html +33 -7
- package/nodes/locales/zh-CN/knxUltimateAI.json +34 -15
- package/nodes/locales/zh-CN/knxUltimateIoTBridge.html +28 -2
- package/nodes/locales/zh-CN/knxUltimateIoTBridge.json +20 -2
- package/nodes/plugins/knxUltimateAI-vue/assets/app.css +1 -1
- package/nodes/plugins/knxUltimateAI-vue/assets/app.js +4 -4
- package/nodes/utils/knxAiTelegramVoice.js +475 -0
- package/nodes/utils/knxAiWebAccess.js +631 -0
- package/package.json +3 -3
- package/resources/KNXAIChatAdapterMappings.js +96 -2
|
@@ -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({
|