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.
Files changed (40) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/examples/IoT Bridge - Modbus Flex Adapter.json +361 -0
  3. package/examples/KNX AI - Conversational Control with Confirmation.json +4 -2
  4. package/examples/KNX AI - Summary Anomalies and Ask.json +4 -0
  5. package/examples/KNX AI - Telegrambot Direct Chat.json +7 -5
  6. package/nodes/knxUltimate-config.js +13 -4
  7. package/nodes/knxUltimateAI.html +310 -124
  8. package/nodes/knxUltimateAI.js +1742 -264
  9. package/nodes/knxUltimateIoTBridge.html +349 -20
  10. package/nodes/knxUltimateIoTBridge.js +474 -44
  11. package/nodes/locales/de/knxUltimateAI.html +33 -7
  12. package/nodes/locales/de/knxUltimateAI.json +34 -15
  13. package/nodes/locales/de/knxUltimateIoTBridge.html +28 -2
  14. package/nodes/locales/de/knxUltimateIoTBridge.json +20 -2
  15. package/nodes/locales/en/knxUltimateAI.html +33 -7
  16. package/nodes/locales/en/knxUltimateAI.json +34 -15
  17. package/nodes/locales/en/knxUltimateIoTBridge.html +28 -2
  18. package/nodes/locales/en/knxUltimateIoTBridge.json +20 -2
  19. package/nodes/locales/es/knxUltimateAI.html +33 -7
  20. package/nodes/locales/es/knxUltimateAI.json +34 -15
  21. package/nodes/locales/es/knxUltimateIoTBridge.html +28 -2
  22. package/nodes/locales/es/knxUltimateIoTBridge.json +20 -2
  23. package/nodes/locales/fr/knxUltimateAI.html +33 -7
  24. package/nodes/locales/fr/knxUltimateAI.json +34 -15
  25. package/nodes/locales/fr/knxUltimateIoTBridge.html +28 -2
  26. package/nodes/locales/fr/knxUltimateIoTBridge.json +20 -2
  27. package/nodes/locales/it/knxUltimateAI.html +33 -7
  28. package/nodes/locales/it/knxUltimateAI.json +34 -15
  29. package/nodes/locales/it/knxUltimateIoTBridge.html +28 -2
  30. package/nodes/locales/it/knxUltimateIoTBridge.json +20 -2
  31. package/nodes/locales/zh-CN/knxUltimateAI.html +33 -7
  32. package/nodes/locales/zh-CN/knxUltimateAI.json +34 -15
  33. package/nodes/locales/zh-CN/knxUltimateIoTBridge.html +28 -2
  34. package/nodes/locales/zh-CN/knxUltimateIoTBridge.json +20 -2
  35. package/nodes/plugins/knxUltimateAI-vue/assets/app.css +1 -1
  36. package/nodes/plugins/knxUltimateAI-vue/assets/app.js +4 -4
  37. package/nodes/utils/knxAiTelegramVoice.js +475 -0
  38. package/nodes/utils/knxAiWebAccess.js +631 -0
  39. package/package.json +3 -3
  40. package/resources/KNXAIChatAdapterMappings.js +96 -2
@@ -1,6 +1,52 @@
1
1
  <script type="text/javascript" src="resources/node-red-contrib-knx-ultimate/htmlUtils.js"></script>
2
2
 
3
3
  <script type="text/javascript">
4
+ function knxUltimateIoTBridgeHasValue(value) {
5
+ return value !== undefined && value !== null && !(typeof value === 'string' && value.trim() === '');
6
+ }
7
+
8
+ function knxUltimateIoTBridgeFiniteNumber(value) {
9
+ if (!knxUltimateIoTBridgeHasValue(value) || typeof value === 'boolean') return null;
10
+ const parsed = typeof value === 'number' ? value : Number(String(value).trim());
11
+ return Number.isFinite(parsed) ? parsed : null;
12
+ }
13
+
14
+ function knxUltimateIoTBridgeFlexMappingsValid(mappings) {
15
+ if (!Array.isArray(mappings)) return false;
16
+
17
+ return mappings.every(function (mapping) {
18
+ if (!mapping || typeof mapping !== 'object') return false;
19
+ if (mapping.enabled === false || mapping.iotType !== 'modbus' || mapping.modbusMessageFormat !== 'flex') return true;
20
+
21
+ const unitId = knxUltimateIoTBridgeFiniteNumber(
22
+ knxUltimateIoTBridgeHasValue(mapping.modbusUnitId) ? mapping.modbusUnitId : 1
23
+ );
24
+ const address = knxUltimateIoTBridgeFiniteNumber(
25
+ knxUltimateIoTBridgeHasValue(mapping.modbusAddress) ? mapping.modbusAddress : mapping.target
26
+ );
27
+ const scale = knxUltimateIoTBridgeFiniteNumber(
28
+ knxUltimateIoTBridgeHasValue(mapping.scale) ? mapping.scale : 1
29
+ );
30
+ const offset = knxUltimateIoTBridgeFiniteNumber(
31
+ knxUltimateIoTBridgeHasValue(mapping.offset) ? mapping.offset : 0
32
+ );
33
+
34
+ return Number.isInteger(unitId) && unitId >= 0 && unitId <= 255 &&
35
+ Number.isInteger(address) && address >= 0 && address <= 65535 &&
36
+ scale !== null && scale !== 0 && offset !== null;
37
+ });
38
+ }
39
+
40
+ function knxUltimateIoTBridgeSavedFlexField(originalMapping, key, preserveOriginal, currentValue) {
41
+ if (preserveOriginal) {
42
+ if (originalMapping && Object.prototype.hasOwnProperty.call(originalMapping, key)) {
43
+ return { present: true, value: originalMapping[key] };
44
+ }
45
+ return { present: false };
46
+ }
47
+ return { present: true, value: currentValue };
48
+ }
49
+
4
50
  RED.nodes.registerType('knxUltimateIoTBridge', {
5
51
  category: 'KNX Ultimate',
6
52
  color: '#C7E9C0',
@@ -11,7 +57,13 @@
11
57
  emitOnChangeOnly: { value: true },
12
58
  readOnDeploy: { value: true },
13
59
  acceptFlowInput: { value: true },
14
- mappings: { value: [] },
60
+ mappings: {
61
+ value: [],
62
+ validate: function (value) {
63
+ if (this.nodeMode === 'homeassistant') return true;
64
+ return knxUltimateIoTBridgeFlexMappingsValid(value);
65
+ }
66
+ },
15
67
  nodeMode: { value: 'homeassistant' },
16
68
  haBusMode: { value: 'standalone' },
17
69
  inputs: { value: 1 },
@@ -81,6 +133,24 @@
81
133
  { value: 'modbus', label: node._('knxUltimateIoTBridge.type.modbus') }
82
134
  ];
83
135
 
136
+ const modbusMessageFormats = [
137
+ { value: 'flex', label: node._('knxUltimateIoTBridge.modbus.formatFlex') },
138
+ { value: 'legacy', label: node._('knxUltimateIoTBridge.modbus.formatLegacy') }
139
+ ];
140
+
141
+ const modbusAreas = [
142
+ { value: 'coil', label: node._('knxUltimateIoTBridge.modbus.areaCoil') },
143
+ { value: 'discrete-input', label: node._('knxUltimateIoTBridge.modbus.areaDiscreteInput') },
144
+ { value: 'holding-register', label: node._('knxUltimateIoTBridge.modbus.areaHoldingRegister') },
145
+ { value: 'input-register', label: node._('knxUltimateIoTBridge.modbus.areaInputRegister') }
146
+ ];
147
+
148
+ const modbusDataTypes = [
149
+ { value: 'bool', label: node._('knxUltimateIoTBridge.modbus.dataTypeBool') },
150
+ { value: 'uint16', label: node._('knxUltimateIoTBridge.modbus.dataTypeUint16') },
151
+ { value: 'int16', label: node._('knxUltimateIoTBridge.modbus.dataTypeInt16') }
152
+ ];
153
+
84
154
  function createLabeledField(row, key, element, options = {}) {
85
155
  const wrapper = $('<div/>', {
86
156
  class: 'bridge-field-wrapper',
@@ -111,10 +181,34 @@
111
181
  };
112
182
 
113
183
  const container = $('#node-input-mapping-container');
184
+ const validateAllFlexRows = () => {
185
+ let valid = true;
186
+ try {
187
+ if ($('#node-input-nodeMode').val() === 'homeassistant') {
188
+ container.editableList('items').each(function () {
189
+ const validateRow = $(this).data('validate-flex');
190
+ if (typeof validateRow === 'function') validateRow();
191
+ });
192
+ } else {
193
+ container.editableList('items').each(function () {
194
+ const validateRow = $(this).data('validate-flex');
195
+ if (typeof validateRow === 'function' && !validateRow()) valid = false;
196
+ });
197
+ }
198
+ } catch (error) { }
199
+ $('#node-dialog-ok').prop('disabled', !valid).toggleClass('disabled', !valid);
200
+ return valid;
201
+ };
202
+ const scheduleFlexValidation = () => setTimeout(validateAllFlexRows, 0);
203
+
114
204
  container.css('min-height', '320px').css('min-width', '640px').editableList({
115
205
  sortable: true,
116
206
  removable: true,
207
+ removeItem: scheduleFlexValidation,
117
208
  addItem: function (row, index, data) {
209
+ const originalMapping = data && data.mapping && typeof data.mapping === 'object'
210
+ ? $.extend(true, {}, data.mapping)
211
+ : null;
118
212
  const mapping = $.extend(true, {
119
213
  id: '',
120
214
  enabled: true,
@@ -126,13 +220,26 @@
126
220
  target: '',
127
221
  method: 'POST',
128
222
  modbusFunction: 'writeHoldingRegister',
223
+ modbusMessageFormat: 'flex',
224
+ modbusUnitId: 1,
225
+ modbusArea: 'holding-register',
226
+ modbusDataType: 'uint16',
129
227
  scale: 1,
130
228
  offset: 0,
131
229
  template: '',
132
230
  property: '',
133
231
  timeout: 0,
134
232
  retry: 0
135
- }, data.mapping);
233
+ }, originalMapping || {});
234
+
235
+ // A missing format identifies the scalar contract used by older saved flows.
236
+ // Do not migrate it merely because the editor was opened and saved untouched.
237
+ if (originalMapping && originalMapping.iotType === 'modbus' && !Object.prototype.hasOwnProperty.call(originalMapping, 'modbusMessageFormat')) {
238
+ mapping.modbusMessageFormat = 'legacy';
239
+ }
240
+ if (!knxUltimateIoTBridgeHasValue(mapping.modbusAddress) && knxUltimateIoTBridgeHasValue(mapping.target)) {
241
+ mapping.modbusAddress = mapping.target;
242
+ }
136
243
 
137
244
  if (!mapping.id || mapping.id === '') {
138
245
  try {
@@ -206,7 +313,12 @@
206
313
  const targetInput = createLabeledField(midRow, 'target', $('<input/>', {
207
314
  type: 'text',
208
315
  class: 'form-control bridge-target'
209
- }).attr('placeholder', node._('knxUltimateIoTBridge.placeholders.target')).val(mapping.target), { flex: '1 1 280px' });
316
+ }).attr('placeholder', node._('knxUltimateIoTBridge.placeholders.target')).val(
317
+ mapping.iotType === 'modbus' && mapping.modbusMessageFormat === 'flex'
318
+ ? mapping.modbusAddress
319
+ : mapping.target
320
+ ), { flex: '1 1 280px' });
321
+ targetInput.on('input change', function () { row.data('target-edited', true); });
210
322
 
211
323
  const methodInput = createLabeledField(midRow, 'method', $('<input/>', {
212
324
  type: 'text',
@@ -218,6 +330,20 @@
218
330
  class: 'form-control bridge-modbus'
219
331
  }).attr('placeholder', node._('knxUltimateIoTBridge.placeholders.modbusFunction')).val(mapping.modbusFunction), { width: '180px' });
220
332
 
333
+ const modbusFormatSelect = createLabeledField(midRow, 'modbusMessageFormat', buildSelect(modbusMessageFormats, mapping.modbusMessageFormat).addClass('bridge-modbus-format'), { width: '230px' });
334
+
335
+ const modbusUnitIdInput = createLabeledField(midRow, 'modbusUnitId', $('<input/>', {
336
+ type: 'number',
337
+ class: 'form-control bridge-modbus-unitid',
338
+ min: 0,
339
+ max: 255,
340
+ step: 1
341
+ }).val(mapping.modbusUnitId), { width: '110px' });
342
+
343
+ const modbusAreaSelect = createLabeledField(midRow, 'modbusArea', buildSelect(modbusAreas, mapping.modbusArea).addClass('bridge-modbus-area'), { width: '190px' });
344
+
345
+ const modbusDataTypeSelect = createLabeledField(midRow, 'modbusDataType', buildSelect(modbusDataTypes, mapping.modbusDataType).addClass('bridge-modbus-datatype'), { width: '170px' });
346
+
221
347
  const scaleInput = createLabeledField(midRow, 'scale', $('<input/>', {
222
348
  type: 'number',
223
349
  class: 'form-control bridge-scale'
@@ -248,9 +374,63 @@
248
374
  class: 'form-control bridge-property'
249
375
  }).attr('placeholder', node._('knxUltimateIoTBridge.placeholders.property')).val(mapping.property), { flex: '1 1 260px' });
250
376
 
377
+ const modbusHint = $('<div/>', {
378
+ class: 'bridge-modbus-hint',
379
+ style: 'display:none; width:100%; margin-top:4px; padding:7px 9px; border-left:3px solid #5b8def; background:rgba(91,141,239,0.08); font-size:11px; line-height:1.4;'
380
+ }).appendTo(bottomRow);
381
+
382
+ const validateFlexInputs = () => {
383
+ const isFlexModbus = $('#node-input-nodeMode').val() !== 'homeassistant' && enabled.is(':checked') && typeSelect.val() === 'modbus' && modbusFormatSelect.val() === 'flex';
384
+ const validations = [
385
+ {
386
+ element: modbusUnitIdInput,
387
+ valid: (value) => {
388
+ const parsed = knxUltimateIoTBridgeFiniteNumber(value);
389
+ return Number.isInteger(parsed) && parsed >= 0 && parsed <= 255;
390
+ }
391
+ },
392
+ {
393
+ element: targetInput,
394
+ valid: (value) => {
395
+ const parsed = knxUltimateIoTBridgeFiniteNumber(value);
396
+ return Number.isInteger(parsed) && parsed >= 0 && parsed <= 65535;
397
+ }
398
+ },
399
+ {
400
+ element: scaleInput,
401
+ valid: (value) => {
402
+ const parsed = knxUltimateIoTBridgeFiniteNumber(value);
403
+ return parsed !== null && parsed !== 0;
404
+ }
405
+ },
406
+ {
407
+ element: offsetInput,
408
+ valid: (value) => knxUltimateIoTBridgeFiniteNumber(value) !== null
409
+ }
410
+ ];
411
+
412
+ let valid = true;
413
+ validations.forEach(function (validation) {
414
+ const fieldValid = !isFlexModbus || validation.valid(validation.element.val());
415
+ validation.element.toggleClass('input-error', !fieldValid);
416
+ validation.element.attr('aria-invalid', fieldValid ? 'false' : 'true');
417
+ valid = valid && fieldValid;
418
+ });
419
+ return valid;
420
+ };
421
+
251
422
  const updateChannelPresentation = (channel) => {
423
+ const targetWrapper = targetInput.data('bridgeWrapper');
252
424
  const methodWrapper = methodInput.data('bridgeWrapper');
253
425
  const modbusWrapper = modbusInput.data('bridgeWrapper');
426
+ const modbusFormatWrapper = modbusFormatSelect.data('bridgeWrapper');
427
+ const modbusUnitIdWrapper = modbusUnitIdInput.data('bridgeWrapper');
428
+ const modbusAreaWrapper = modbusAreaSelect.data('bridgeWrapper');
429
+ const modbusDataTypeWrapper = modbusDataTypeSelect.data('bridgeWrapper');
430
+ const timeoutWrapper = timeoutInput.data('bridgeWrapper');
431
+ const retryWrapper = retryInput.data('bridgeWrapper');
432
+ const templateWrapper = templateInput.data('bridgeWrapper');
433
+ const propertyWrapper = propertyInput.data('bridgeWrapper');
254
434
  const targetLabel = targetInput.data('bridgeLabel');
255
435
  const methodLabel = methodInput.data('bridgeLabel');
256
436
  const modbusLabel = modbusInput.data('bridgeLabel');
@@ -274,16 +454,40 @@
274
454
  return placeholder;
275
455
  };
276
456
 
277
- if (channel === 'rest') {
278
- methodWrapper.show();
279
- } else {
280
- methodWrapper.hide();
281
- }
282
-
283
- if (channel === 'modbus') {
284
- modbusWrapper.show();
457
+ const isRest = channel === 'rest';
458
+ const isModbus = channel === 'modbus';
459
+ const isFlexModbus = isModbus && modbusFormatSelect.val() === 'flex';
460
+ const isBitArea = ['coil', 'discrete-input'].includes(modbusAreaSelect.val());
461
+ const isReadOnlyArea = ['discrete-input', 'input-register'].includes(modbusAreaSelect.val());
462
+
463
+ targetWrapper.show();
464
+ methodWrapper.toggle(isRest);
465
+ modbusWrapper.toggle(isModbus && !isFlexModbus);
466
+ modbusFormatWrapper.toggle(isModbus);
467
+ modbusUnitIdWrapper.toggle(isFlexModbus);
468
+ modbusAreaWrapper.toggle(isFlexModbus);
469
+ modbusDataTypeWrapper.toggle(isFlexModbus);
470
+ timeoutWrapper.toggle(isRest || (isModbus && !isFlexModbus));
471
+ retryWrapper.toggle(isRest || (isModbus && !isFlexModbus));
472
+ templateWrapper.toggle(!isFlexModbus);
473
+ propertyWrapper.toggle(!isFlexModbus);
474
+ if (isFlexModbus && isReadOnlyArea) directionSelect.val('iot-to-knx');
475
+ directionSelect.prop('disabled', isFlexModbus && isReadOnlyArea);
476
+
477
+ if (isFlexModbus) {
478
+ targetInput.attr({ type: 'number', min: 0, max: 65535, step: 1 });
479
+ if (isBitArea) modbusDataTypeSelect.val('bool');
480
+ modbusDataTypeSelect.prop('disabled', isBitArea);
481
+ const hints = [
482
+ node._('knxUltimateIoTBridge.modbus.zeroBasedHint'),
483
+ node._('knxUltimateIoTBridge.modbus.flexHint')
484
+ ];
485
+ if (isReadOnlyArea) hints.push(node._('knxUltimateIoTBridge.modbus.readOnlyHint'));
486
+ modbusHint.text(hints.join(' ')).show();
285
487
  } else {
286
- modbusWrapper.hide();
488
+ targetInput.attr('type', 'text').removeAttr('min max step');
489
+ modbusDataTypeSelect.prop('disabled', false);
490
+ modbusHint.hide();
287
491
  }
288
492
 
289
493
  targetLabel.text(translateVariant('target', channel, 'target'));
@@ -291,15 +495,56 @@
291
495
 
292
496
  methodLabel.text(translateVariant('method', channel, 'method'));
293
497
  modbusLabel.text(translateVariant('modbusFunction', channel, 'modbusFunction'));
498
+ scheduleFlexValidation();
294
499
  };
295
500
 
501
+ let previousChannel = typeSelect.val();
296
502
  typeSelect.on('change', function () {
297
- updateChannelPresentation($(this).val());
503
+ row.data('channel-edited', true);
504
+ const channel = $(this).val();
505
+ if (channel === 'modbus' && previousChannel !== 'modbus') modbusFormatSelect.val('flex');
506
+ previousChannel = channel;
507
+ updateChannelPresentation(channel);
298
508
  });
299
509
 
300
- updateChannelPresentation(typeSelect.val());
510
+ modbusFormatSelect.on('change', function () {
511
+ row.data('modbus-format-edited', true);
512
+ updateChannelPresentation(typeSelect.val());
513
+ });
514
+
515
+ modbusAreaSelect.on('change', function () {
516
+ row.data('modbus-area-edited', true);
517
+ if (['discrete-input', 'input-register'].includes($(this).val())) directionSelect.val('iot-to-knx');
518
+ updateChannelPresentation(typeSelect.val());
519
+ });
520
+
521
+ modbusDataTypeSelect.on('change', function () {
522
+ row.data('modbus-datatype-edited', true);
523
+ });
524
+
525
+ modbusUnitIdInput.on('input change', function () {
526
+ row.data('modbus-unitid-edited', true);
527
+ scheduleFlexValidation();
528
+ });
529
+
530
+ targetInput.on('input change', scheduleFlexValidation);
531
+
532
+ enabled.on('change', scheduleFlexValidation);
533
+
534
+ scaleInput.on('input change', function () {
535
+ row.data('scale-edited', true);
536
+ scheduleFlexValidation();
537
+ });
538
+
539
+ offsetInput.on('input change', function () {
540
+ row.data('offset-edited', true);
541
+ scheduleFlexValidation();
542
+ });
301
543
 
302
544
  row.data('mapping-id', mapping.id || '');
545
+ row.data('original-mapping', originalMapping);
546
+ row.data('validate-flex', validateFlexInputs);
547
+ updateChannelPresentation(typeSelect.val());
303
548
  }
304
549
  });
305
550
 
@@ -316,6 +561,7 @@
316
561
  $('.iot-mode-row').toggle(!ha);
317
562
  // The flow-mode hint is only relevant while the "flow msg" bus mode is picked.
318
563
  $('.ha-flow-hint').toggle(ha && $('#node-input-haBusMode').val() === 'flow');
564
+ scheduleFlexValidation();
319
565
  }
320
566
  $('#node-input-nodeMode').on('change', refreshMode);
321
567
  $('#node-input-haBusMode').on('change', refreshMode);
@@ -542,20 +788,34 @@
542
788
  } catch (error) { }
543
789
  },
544
790
  oneditsave: function () {
791
+ $('#node-dialog-ok').prop('disabled', false).removeClass('disabled');
545
792
  const node = this;
546
793
  const items = $('#node-input-mapping-container').editableList('items');
547
794
  node.mappings = [];
548
795
  items.each(function () {
549
796
  const row = $(this);
550
- node.mappings.push({
797
+ const originalMapping = row.data('original-mapping');
798
+ const channel = row.find('.bridge-type').val();
799
+ const target = row.find('.bridge-target').val();
800
+ const modbusFormat = row.find('.bridge-modbus-format').val() || 'legacy';
801
+ const modbusArea = row.find('.bridge-modbus-area').val() || 'holding-register';
802
+ const hasOriginalField = (key) => originalMapping && Object.prototype.hasOwnProperty.call(originalMapping, key);
803
+ const parseEditorNumber = (value) => {
804
+ if (!knxUltimateIoTBridgeHasValue(value)) return '';
805
+ const parsed = knxUltimateIoTBridgeFiniteNumber(value);
806
+ return parsed === null ? value : parsed;
807
+ };
808
+ const savedMapping = {
551
809
  id: row.data('mapping-id') || '',
552
810
  enabled: row.find('.bridge-enabled').is(':checked'),
553
811
  label: row.find('.bridge-label').val(),
554
812
  ga: row.find('.bridge-ga').val(),
555
813
  dpt: row.find('.bridge-dpt').val(),
556
814
  direction: row.find('.bridge-direction').val(),
557
- iotType: row.find('.bridge-type').val(),
558
- target: row.find('.bridge-target').val(),
815
+ iotType: channel,
816
+ target: originalMapping && originalMapping.iotType === 'modbus' && originalMapping.modbusMessageFormat === 'flex' && channel === 'modbus' && modbusFormat === 'flex' && !row.data('target-edited')
817
+ ? originalMapping.target
818
+ : target,
559
819
  method: row.find('.bridge-method').val(),
560
820
  modbusFunction: row.find('.bridge-modbus').val(),
561
821
  scale: row.find('.bridge-scale').val(),
@@ -564,7 +824,63 @@
564
824
  retry: row.find('.bridge-retry').val(),
565
825
  template: row.find('.bridge-template').val(),
566
826
  property: row.find('.bridge-property').val()
567
- });
827
+ };
828
+
829
+ if (channel === 'modbus') {
830
+ const originalHadFormat = hasOriginalField('modbusMessageFormat');
831
+ if (!originalMapping || originalHadFormat || modbusFormat === 'flex') savedMapping.modbusMessageFormat = modbusFormat;
832
+
833
+ if (modbusFormat === 'flex') {
834
+ const sameOriginalFlexMapping = originalMapping &&
835
+ originalMapping.iotType === 'modbus' &&
836
+ originalMapping.modbusMessageFormat === 'flex' &&
837
+ !row.data('channel-edited') &&
838
+ !row.data('modbus-format-edited');
839
+ const saveFlexField = (key, editedDataKey, currentValue, parser, relatedFieldEdited) => {
840
+ const parsedValue = parser ? parser(currentValue) : currentValue;
841
+ const field = knxUltimateIoTBridgeSavedFlexField(
842
+ originalMapping,
843
+ key,
844
+ sameOriginalFlexMapping && !row.data(editedDataKey) && !relatedFieldEdited,
845
+ parsedValue
846
+ );
847
+ if (field.present) savedMapping[key] = field.value;
848
+ else delete savedMapping[key];
849
+ };
850
+
851
+ saveFlexField('modbusUnitId', 'modbus-unitid-edited', row.find('.bridge-modbus-unitid').val(), parseEditorNumber);
852
+ saveFlexField('modbusAddress', 'target-edited', target, parseEditorNumber);
853
+ saveFlexField('modbusArea', 'modbus-area-edited', modbusArea);
854
+ saveFlexField(
855
+ 'modbusDataType',
856
+ 'modbus-datatype-edited',
857
+ row.find('.bridge-modbus-datatype').val(),
858
+ null,
859
+ row.data('modbus-area-edited')
860
+ );
861
+ saveFlexField('scale', 'scale-edited', row.find('.bridge-scale').val(), parseEditorNumber);
862
+ saveFlexField('offset', 'offset-edited', row.find('.bridge-offset').val(), parseEditorNumber);
863
+
864
+ if (!sameOriginalFlexMapping || row.data('modbus-area-edited')) {
865
+ savedMapping.modbusFunction = {
866
+ coil: 'writeCoil',
867
+ 'discrete-input': 'readDiscreteInputs',
868
+ 'holding-register': 'writeHoldingRegister',
869
+ 'input-register': 'readInputRegisters'
870
+ }[modbusArea];
871
+ }
872
+ } else if (originalMapping) {
873
+ ['modbusUnitId', 'modbusAddress', 'modbusArea', 'modbusDataType'].forEach(function (key) {
874
+ if (Object.prototype.hasOwnProperty.call(originalMapping, key)) savedMapping[key] = originalMapping[key];
875
+ });
876
+ }
877
+ } else if (originalMapping) {
878
+ ['modbusMessageFormat', 'modbusUnitId', 'modbusAddress', 'modbusArea', 'modbusDataType'].forEach(function (key) {
879
+ if (Object.prototype.hasOwnProperty.call(originalMapping, key)) savedMapping[key] = originalMapping[key];
880
+ });
881
+ }
882
+
883
+ node.mappings.push(savedMapping);
568
884
  });
569
885
  // Serialize Home Assistant composite entities (cover / climate).
570
886
  try {
@@ -624,6 +940,9 @@
624
940
  RED.sidebar.show('info');
625
941
  } catch (error) { }
626
942
  },
943
+ oneditcancel: function () {
944
+ $('#node-dialog-ok').prop('disabled', false).removeClass('disabled');
945
+ },
627
946
  oneditresize: function (size) {
628
947
  const rows = $('#dialog-form>div:not(.node-input-mapping-container-row)');
629
948
  let height = size.height;
@@ -764,11 +1083,21 @@ Configure bidirectional maps between KNX group addresses and IoT backends such a
764
1083
 
765
1084
  ### Mapping options
766
1085
  - **Direction**: choose between KNX→IoT, IoT→KNX or bidirectional.
767
- - **Channel type**: pick MQTT, REST or Modbus. The `target` field adapts: topic name, URL or register address.
1086
+ - **Channel type**: pick MQTT, REST or Modbus. The editor shows only the fields used by that protocol.
768
1087
  - **Template**: optional string; placeholders `{{value}}`, `{{ga}}`, `{{target}}`, `{{label}}`, `{{type}}`, `{{isoTimestamp}}` are replaced at runtime.
769
1088
  - **Scale & Offset**: numeric transformation applied on KNX→IoT. For IoT→KNX the inverse is used.
770
1089
  - **Timeout/Retries**: retained for flow logic; the node does not execute external requests but exposes the desired values to downstream nodes.
771
1090
 
1091
+ ### Modbus adapter
1092
+
1093
+ The bridge does not open a Modbus connection. Install a version of `node-red-contrib-modbus` compatible with your Node.js/Node-RED host and let its nodes own TCP/RTU, polling, queues and reconnection.
1094
+
1095
+ - Choose **node-red-contrib-modbus (Flex)** for new mappings. Existing mappings without this setting retain their legacy scalar message unchanged.
1096
+ - Configure Unit ID, area, zero-based protocol address and Boolean/unsigned 16-bit/signed 16-bit data format. Vendor references such as `40001` are not converted automatically because their notation is ambiguous.
1097
+ - A KNX `GroupValue_Write` produces the exact `modbus-flex-write` input: `msg.payload = {value, fc, unitid, address, quantity}`. KNX read responses never become Modbus writes.
1098
+ - Wire the data output of `modbus-read` or `modbus-flex-getter` to this node. It reads the request from `msg.input.payload` (Modbus Read) or `msg.modbusRequest` (Flex Getter), accepts values from `msg.payload`/`msg.values`, and writes only changed values to KNX. Flex Write acknowledgements are ignored as state.
1099
+ - Coil and Holding Register support writes (FC5/FC6). Discrete Input and Input Register are read-only (FC2/FC4). This first adapter intentionally maps one bit or one 16-bit register; use Scale/Offset for fixed-point values.
1100
+
772
1101
  ### Tips
773
1102
  - Place HTTP or MQTT nodes after the bridge outputs to perform the actual transport.
774
1103
  - Use `msg.bridge.id` to route acknowledgements or correlate responses.
@@ -809,4 +1138,4 @@ Covers and thermostats group several group addresses into one Home Assistant ent
809
1138
 
810
1139
  Datapoint types come from the imported ETS list when available, otherwise from the KNX defaults shown above. For reliable status feedback, those group addresses should be present in the ETS import.
811
1140
 
812
- </script>
1141
+ </script>