node-red-contrib-knx-ultimate 5.0.0 → 5.0.1

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 CHANGED
@@ -6,6 +6,14 @@
6
6
 
7
7
  # CHANGELOG
8
8
 
9
+ **Version 5.0.1** - June 2026<br/>
10
+
11
+ - **IoT Bridge** node (renamed **MQTT - IoT** in the UI): new **MQTT / Home Assistant (native)** mode selectable via a *Mode* dropdown. In this mode the node connects directly to an MQTT broker and bridges KNX ↔ MQTT both ways, publishing **Home Assistant MQTT Discovery** so KNX appears automatically in Home Assistant (no `mqtt in`/`mqtt out` wiring needed). The classic IoT bridge mode is unchanged.<br/>
12
+ - Every group address imported in the KNX gateway (ETS list) can be **exposed automatically** as a Home Assistant entity (switch, sensor, binary_sensor, number, text), typed from its DPT. A checkbox list with filter and *Select all / none* lets you choose exactly which GAs to publish.<br/>
13
+ - New **Covers & Thermostats** editor: composite entities that aggregate several GAs (cover with up/down, stop and position including KNX↔HA position inversion; thermostat with current temperature, setpoint and optional on/off). The cover/thermostat GA fields have **ETS group-address autocomplete**, like the KNX device node.<br/>
14
+ - Robust lifecycle: the MQTT broker is closed gracefully on deploy / Node-RED exit (retained `offline`, forced disconnect, hard-capped so a slow or unreachable broker never blocks a deploy); all MQTT event handlers are fully guarded.<br/>
15
+ - Editor help and online docs updated, plus Home Assistant / MQTT logos on the docs pages. Localized in EN/IT/DE/FR/ES/zh-CN.<br/>
16
+
9
17
  **Version 5.0.0** - June 2026<br/>
10
18
 
11
19
  - KNXUltimate engine: updated to **6.0.1** (see the engine [CHANGELOG](https://github.com/Supergiovane/knxultimate/blob/main/CHANGELOG.md)).<br/>
@@ -981,8 +981,6 @@
981
981
  $("#tabs").tabs();
982
982
  // *****************************
983
983
 
984
-
985
-
986
984
  var sRetDebugText = "";
987
985
  $("#getinfocam").click(function () {
988
986
  sRetDebugText = "";
@@ -1634,7 +1632,7 @@
1634
1632
  </div>
1635
1633
 
1636
1634
  </div>
1637
-
1635
+
1638
1636
  </script>
1639
1637
 
1640
1638
  <script type="text/html" data-help-name="knxUltimate-config">
@@ -11,7 +11,19 @@
11
11
  emitOnChangeOnly: { value: true },
12
12
  readOnDeploy: { value: true },
13
13
  acceptFlowInput: { value: true },
14
- mappings: { value: [] }
14
+ mappings: { value: [] },
15
+ nodeMode: { value: 'iot' },
16
+ mqttUrl: { value: '' },
17
+ mqttBaseTopic: { value: 'knx-ultimate' },
18
+ mqttDiscovery: { value: true },
19
+ mqttDiscoveryPrefix: { value: 'homeassistant' },
20
+ mqttCustomEntities: { value: [] },
21
+ mqttExposedGAs: { value: [] },
22
+ mqttExposeConfigured: { value: false }
23
+ },
24
+ credentials: {
25
+ mqttUsername: { type: 'text' },
26
+ mqttPassword: { type: 'password' }
15
27
  },
16
28
  inputs: 1,
17
29
  outputs: 2,
@@ -277,6 +289,180 @@
277
289
  (node.mappings || []).forEach((m) => {
278
290
  container.editableList('addItem', { mapping: m });
279
291
  });
292
+
293
+ // Operation mode toggle: show IoT mapping fields or Home Assistant (MQTT) fields.
294
+ try {
295
+ if (node.mqttDiscovery === undefined) $('#node-input-mqttDiscovery').prop('checked', true);
296
+ function refreshMode() {
297
+ const ha = $('#node-input-nodeMode').val() === 'homeassistant';
298
+ $('.ha-mode-row').toggle(ha);
299
+ $('.iot-mode-row').toggle(!ha);
300
+ }
301
+ $('#node-input-nodeMode').on('change', refreshMode);
302
+ refreshMode();
303
+ } catch (error) { }
304
+
305
+ // Home Assistant: selectable list of group addresses to expose as simple entities.
306
+ try {
307
+ const savedSet = new Set(Array.isArray(node.mqttExposedGAs) ? node.mqttExposedGAs : []);
308
+ const exposeConfigured = node.mqttExposeConfigured === true;
309
+
310
+ function updateGaCount() {
311
+ const total = $('#mqtt-ga-list input.mqtt-ga-cb').length;
312
+ const sel = $('#mqtt-ga-list input.mqtt-ga-cb:checked').length;
313
+ $('#mqtt-ga-count').text(sel + ' / ' + total + ' ' + node._('knxUltimateIoTBridge.ha.exposed_count'));
314
+ }
315
+
316
+ function buildGaList() {
317
+ const list = $('#mqtt-ga-list').empty();
318
+ const sid = oNodeServer ? oNodeServer.id : ($('#node-input-server').val() || '');
319
+ if (!sid) {
320
+ list.append($('<div/>').css('color', '#999').text(node._('knxUltimateIoTBridge.ha.no_gateway')));
321
+ updateGaCount();
322
+ return;
323
+ }
324
+ $.getJSON('knxUltimatecsv?nodeID=' + sid, function (data) {
325
+ list.empty();
326
+ if (!Array.isArray(data) || data.length === 0) {
327
+ list.append($('<div/>').css('color', '#999').text(node._('knxUltimateIoTBridge.ha.no_ga')));
328
+ updateGaCount();
329
+ return;
330
+ }
331
+ data.forEach(function (item) {
332
+ if (!item || !item.ga) return;
333
+ // Use a <div> (not <label>) so Node-RED's ".form-row label" width rule
334
+ // doesn't squash the columns; keep everything on a single line.
335
+ const row = $('<div/>').addClass('mqtt-ga-row').css({
336
+ display: 'flex', gap: '8px', alignItems: 'center', padding: '3px 2px',
337
+ width: '100%', whiteSpace: 'nowrap', cursor: 'pointer', borderBottom: '1px solid #f3f3f3'
338
+ }).appendTo(list);
339
+ const cb = $('<input/>', { type: 'checkbox', class: 'mqtt-ga-cb', value: item.ga, style: 'width:auto; margin:0; flex:0 0 auto;' }).appendTo(row);
340
+ cb.prop('checked', exposeConfigured ? savedSet.has(item.ga) : true);
341
+ $('<span/>').css({ flex: '0 0 auto', fontFamily: 'monospace', minWidth: '64px' }).text(item.ga).appendTo(row);
342
+ $('<span/>').css({ flex: '1 1 auto', minWidth: '0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#444' }).attr('title', item.devicename || '').text(item.devicename || '').appendTo(row);
343
+ $('<span/>').css({ flex: '0 0 auto', color: '#888', fontSize: '11px' }).text('DPT' + (item.dpt || '')).appendTo(row);
344
+ row.on('click', function (ev) {
345
+ if (ev.target === cb[0]) return;
346
+ cb.prop('checked', !cb.prop('checked'));
347
+ updateGaCount();
348
+ });
349
+ });
350
+ $('#mqtt-ga-list input.mqtt-ga-cb').on('change', updateGaCount);
351
+ updateGaCount();
352
+ }).fail(function () {
353
+ list.empty().append($('<div/>').css('color', '#c0392b').text(node._('knxUltimateIoTBridge.ha.csv_error')));
354
+ updateGaCount();
355
+ });
356
+ }
357
+
358
+ $('#mqtt-ga-all').on('click', function (e) { e.preventDefault(); $('#mqtt-ga-list .mqtt-ga-row:visible').find('input.mqtt-ga-cb').prop('checked', true); updateGaCount(); });
359
+ $('#mqtt-ga-none').on('click', function (e) { e.preventDefault(); $('#mqtt-ga-list .mqtt-ga-row:visible').find('input.mqtt-ga-cb').prop('checked', false); updateGaCount(); });
360
+ $('#mqtt-ga-filter').on('input', function () {
361
+ const term = ($(this).val() || '').toLowerCase();
362
+ $('#mqtt-ga-list .mqtt-ga-row').each(function () {
363
+ $(this).toggle($(this).text().toLowerCase().indexOf(term) !== -1);
364
+ });
365
+ });
366
+
367
+ buildGaList();
368
+ $('#node-input-server').on('change', buildGaList);
369
+ } catch (error) { }
370
+
371
+ // Home Assistant composite entities (cover / climate): editable list.
372
+ try {
373
+ const T = (k) => node._('knxUltimateIoTBridge.ha.' + k);
374
+ const coverFields = ['gaUpDown', 'gaStop', 'gaPosSet', 'gaPosState'];
375
+ const climateFields = ['gaCurrentTemp', 'gaSetpointSet', 'gaSetpointState', 'gaOnOff'];
376
+
377
+ // Group-address autocomplete, identical to the mapping GA field (and the
378
+ // knxUltimate node): suggests GAs from the gateway's ETS CSV.
379
+ function attachGaAutocomplete(input) {
380
+ try {
381
+ if (oNodeServer && oNodeServer.id && typeof KNX_enableSecureFormatting === 'function') {
382
+ KNX_enableSecureFormatting(input, oNodeServer.id);
383
+ }
384
+ } catch (error) { }
385
+ input.autocomplete({
386
+ minLength: 0,
387
+ source: function (request, response) {
388
+ const sid = oNodeServer ? oNodeServer.id : ($('#node-input-server').val() || '');
389
+ if (!sid) { response([]); return; }
390
+ $.getJSON('knxUltimatecsv?nodeID=' + sid, (data) => {
391
+ response($.map(data || [], (value) => {
392
+ const search = (value.ga + ' (' + value.devicename + ') DPT' + value.dpt);
393
+ if (htmlUtilsfullCSVSearch(search, request.term + ' 1.')) {
394
+ return {
395
+ label: value.ga + ' # ' + value.devicename + ' # ' + value.dpt,
396
+ value: value.ga
397
+ };
398
+ }
399
+ return null;
400
+ }));
401
+ });
402
+ }
403
+ });
404
+ input.on('focus.knxUltimateIoTBridge click.knxUltimateIoTBridge', function () {
405
+ try { $(this).autocomplete('search', ''); } catch (error) { }
406
+ });
407
+ }
408
+
409
+ function gaRow(parent, key, value) {
410
+ const row = $('<div/>').addClass('form-row').css({ margin: '2px 0' }).appendTo(parent);
411
+ $('<label/>').css({ width: '160px' }).text(T('ce_' + key)).appendTo(row);
412
+ const input = $('<input/>', { type: 'text', class: 'ce-' + key })
413
+ .attr('placeholder', '1/2/3').css({ width: '130px' }).val(value || '').appendTo(row);
414
+ attachGaAutocomplete(input);
415
+ return input;
416
+ }
417
+ function numRow(parent, key, value) {
418
+ const row = $('<div/>').addClass('form-row').css({ margin: '2px 0' }).appendTo(parent);
419
+ $('<label/>').css({ width: '160px' }).text(T('ce_' + key)).appendTo(row);
420
+ return $('<input/>', { type: 'number', class: 'ce-' + key, step: 'any' })
421
+ .css({ width: '90px' }).val(value).appendTo(row);
422
+ }
423
+
424
+ $('#node-input-mqttentities-container').css('min-width', '560px').editableList({
425
+ sortable: true,
426
+ removable: true,
427
+ addItem: function (rowEl, index, data) {
428
+ const e = data.entity || {};
429
+ const block = $('<div/>').css({ padding: '4px 2px' }).appendTo(rowEl);
430
+ const head = $('<div/>').addClass('form-row').css({ display: 'flex', gap: '10px', alignItems: 'center', margin: '2px 0' }).appendTo(block);
431
+ const typeSel = $('<select/>', { class: 'ce-type' }).css({ width: '120px' }).appendTo(head);
432
+ typeSel.append($('<option/>', { value: 'cover', text: T('ce_type_cover') }));
433
+ typeSel.append($('<option/>', { value: 'climate', text: T('ce_type_climate') }));
434
+ typeSel.val(e.type === 'climate' ? 'climate' : 'cover');
435
+ $('<input/>', { type: 'text', class: 'ce-name' })
436
+ .attr('placeholder', T('ce_name')).css({ flex: '1 1 180px' }).val(e.name || '').appendTo(head);
437
+
438
+ const coverBox = $('<div/>', { class: 'ce-cover-box' }).appendTo(block);
439
+ coverFields.forEach((f) => gaRow(coverBox, f, e[f]));
440
+ const invRow = $('<div/>').addClass('form-row').css({ margin: '2px 0' }).appendTo(coverBox);
441
+ const invLabel = $('<label/>', { style: 'width:auto; display:flex; align-items:center; gap:6px; cursor:pointer;' }).appendTo(invRow);
442
+ const inv = $('<input/>', { type: 'checkbox', class: 'ce-invertPosition', style: 'margin:0; width:auto;' }).appendTo(invLabel);
443
+ $('<span/>').text(T('ce_invertPosition')).appendTo(invLabel);
444
+ inv.prop('checked', e.invertPosition !== false);
445
+
446
+ const climateBox = $('<div/>', { class: 'ce-climate-box' }).appendTo(block);
447
+ climateFields.forEach((f) => gaRow(climateBox, f, e[f]));
448
+ numRow(climateBox, 'minTemp', e.minTemp === undefined ? 5 : e.minTemp);
449
+ numRow(climateBox, 'maxTemp', e.maxTemp === undefined ? 35 : e.maxTemp);
450
+ numRow(climateBox, 'tempStep', e.tempStep === undefined ? 0.5 : e.tempStep);
451
+
452
+ function refreshType() {
453
+ const t = typeSel.val();
454
+ coverBox.toggle(t === 'cover');
455
+ climateBox.toggle(t === 'climate');
456
+ }
457
+ typeSel.on('change', refreshType);
458
+ refreshType();
459
+ }
460
+ });
461
+
462
+ (node.mqttCustomEntities || []).forEach((en) => {
463
+ $('#node-input-mqttentities-container').editableList('addItem', { entity: en });
464
+ });
465
+ } catch (error) { }
280
466
  },
281
467
  oneditsave: function () {
282
468
  const node = this;
@@ -303,6 +489,43 @@
303
489
  property: row.find('.bridge-property').val()
304
490
  });
305
491
  });
492
+ // Serialize Home Assistant composite entities (cover / climate).
493
+ try {
494
+ const entities = [];
495
+ $('#node-input-mqttentities-container').editableList('items').each(function () {
496
+ const row = $(this);
497
+ const type = row.find('.ce-type').val();
498
+ const entity = { type: type, name: (row.find('.ce-name').val() || '').trim() };
499
+ if (type === 'cover') {
500
+ ['gaUpDown', 'gaStop', 'gaPosSet', 'gaPosState'].forEach((f) => {
501
+ entity[f] = (row.find('.ce-' + f).val() || '').trim();
502
+ });
503
+ entity.invertPosition = row.find('.ce-invertPosition').is(':checked');
504
+ } else {
505
+ ['gaCurrentTemp', 'gaSetpointSet', 'gaSetpointState', 'gaOnOff'].forEach((f) => {
506
+ entity[f] = (row.find('.ce-' + f).val() || '').trim();
507
+ });
508
+ entity.minTemp = parseFloat(row.find('.ce-minTemp').val());
509
+ entity.maxTemp = parseFloat(row.find('.ce-maxTemp').val());
510
+ entity.tempStep = parseFloat(row.find('.ce-tempStep').val());
511
+ }
512
+ entities.push(entity);
513
+ });
514
+ node.mqttCustomEntities = entities;
515
+ } catch (error) { }
516
+ // Serialize the selected group addresses to expose (only when the list is populated,
517
+ // so a failed/unopened list never wipes a previous selection).
518
+ try {
519
+ if ($('#node-input-nodeMode').val() === 'homeassistant') {
520
+ const cbs = $('#mqtt-ga-list input.mqtt-ga-cb');
521
+ if (cbs.length > 0) {
522
+ const exposed = [];
523
+ cbs.each(function () { if ($(this).is(':checked')) exposed.push($(this).val()); });
524
+ node.mqttExposedGAs = exposed;
525
+ node.mqttExposeConfigured = true;
526
+ }
527
+ }
528
+ } catch (error) { }
306
529
  try {
307
530
  RED.sidebar.show('info');
308
531
  } catch (error) { }
@@ -331,36 +554,79 @@
331
554
  <input type="text" id="node-input-name" />
332
555
  </div>
333
556
  <div class="form-row">
557
+ <label for="node-input-nodeMode" data-i18n="knxUltimateIoTBridge.node-input-nodeMode"></label>
558
+ <select id="node-input-nodeMode" style="width:auto;">
559
+ <option value="iot" data-i18n="knxUltimateIoTBridge.mode.iot"></option>
560
+ <option value="homeassistant" data-i18n="knxUltimateIoTBridge.mode.homeassistant"></option>
561
+ </select>
562
+ </div>
563
+
564
+ <!-- IoT (classic) mode fields -->
565
+ <div class="form-row iot-mode-row">
334
566
  <label for="node-input-outputtopic" data-i18n="knxUltimateIoTBridge.node-input-outputtopic"></label>
335
567
  <input type="text" id="node-input-outputtopic" />
336
568
  </div>
337
- <div class="form-row" style="display:flex; align-items:flex-start; gap:8px;">
569
+ <div class="form-row iot-mode-row" style="display:flex; align-items:flex-start; gap:8px;">
338
570
  <input type="checkbox" id="node-input-emitOnChangeOnly" style="width:auto; margin-top:4px;" />
339
- <label for="node-input-emitOnChangeOnly" style="flex:1; margin:0;">
340
- <span data-i18n="knxUltimateIoTBridge.node-input-emitOnChangeOnly"></span>
341
- </label>
571
+ <label for="node-input-emitOnChangeOnly" style="flex:1; margin:0;" data-i18n="knxUltimateIoTBridge.node-input-emitOnChangeOnly"></label>
342
572
  </div>
343
- <div class="form-row" style="display:flex; align-items:flex-start; gap:8px;">
573
+ <div class="form-row iot-mode-row" style="display:flex; align-items:flex-start; gap:8px;">
344
574
  <input type="checkbox" id="node-input-readOnDeploy" style="width:auto; margin-top:4px;" />
345
- <label for="node-input-readOnDeploy" style="flex:1; margin:0;">
346
- <span data-i18n="knxUltimateIoTBridge.node-input-readOnDeploy"></span>
347
- </label>
575
+ <label for="node-input-readOnDeploy" style="flex:1; margin:0;" data-i18n="knxUltimateIoTBridge.node-input-readOnDeploy"></label>
348
576
  </div>
349
- <div class="form-row" style="display:flex; align-items:flex-start; gap:8px;">
577
+ <div class="form-row iot-mode-row" style="display:flex; align-items:flex-start; gap:8px;">
350
578
  <input type="checkbox" id="node-input-acceptFlowInput" style="width:auto; margin-top:4px;" />
351
- <label for="node-input-acceptFlowInput" style="flex:1; margin:0;">
352
- <span data-i18n="knxUltimateIoTBridge.node-input-acceptFlowInput"></span>
353
- </label>
579
+ <label for="node-input-acceptFlowInput" style="flex:1; margin:0;" data-i18n="knxUltimateIoTBridge.node-input-acceptFlowInput"></label>
354
580
  </div>
355
- <div class="form-row node-input-mapping-container-row">
581
+ <div class="form-row node-input-mapping-container-row iot-mode-row">
356
582
  <label style="width:auto;" data-i18n="knxUltimateIoTBridge.section_mappings"></label>
357
583
  <ol id="node-input-mapping-container"></ol>
358
584
  </div>
585
+
586
+ <!-- Home Assistant (MQTT) mode fields -->
587
+ <div class="form-row ha-mode-row">
588
+ <label for="node-input-mqttUrl" data-i18n="knxUltimateIoTBridge.ha.broker_url"></label>
589
+ <input type="text" id="node-input-mqttUrl" placeholder="mqtt://localhost:1883" />
590
+ </div>
591
+ <div class="form-row ha-mode-row">
592
+ <label for="node-input-mqttUsername" data-i18n="knxUltimateIoTBridge.ha.username"></label>
593
+ <input type="text" id="node-input-mqttUsername" data-i18n="[placeholder]knxUltimateIoTBridge.ha.optional" />
594
+ </div>
595
+ <div class="form-row ha-mode-row">
596
+ <label for="node-input-mqttPassword" data-i18n="knxUltimateIoTBridge.ha.password"></label>
597
+ <input type="password" id="node-input-mqttPassword" data-i18n="[placeholder]knxUltimateIoTBridge.ha.optional" />
598
+ </div>
599
+ <div class="form-row ha-mode-row">
600
+ <label for="node-input-mqttBaseTopic" data-i18n="knxUltimateIoTBridge.ha.base_topic"></label>
601
+ <input type="text" id="node-input-mqttBaseTopic" placeholder="knx-ultimate" />
602
+ </div>
603
+ <div class="form-row ha-mode-row" style="display:flex; align-items:flex-start; gap:8px;">
604
+ <input type="checkbox" id="node-input-mqttDiscovery" style="width:auto; margin-top:4px;" />
605
+ <label for="node-input-mqttDiscovery" style="flex:1; margin:0;" data-i18n="knxUltimateIoTBridge.ha.discovery"></label>
606
+ </div>
607
+ <div class="form-row ha-mode-row">
608
+ <label for="node-input-mqttDiscoveryPrefix" data-i18n="knxUltimateIoTBridge.ha.discovery_prefix"></label>
609
+ <input type="text" id="node-input-mqttDiscoveryPrefix" placeholder="homeassistant" />
610
+ </div>
611
+ <div class="form-row ha-mode-row" id="rowMqttGaSelect">
612
+ <label style="width:auto; font-weight:600;" data-i18n="knxUltimateIoTBridge.ha.exposed_gas"></label>
613
+ <div style="margin:4px 0; display:flex; gap:6px; align-items:center;">
614
+ <input type="text" id="mqtt-ga-filter" data-i18n="[placeholder]knxUltimateIoTBridge.ha.filter_placeholder" style="flex:1 1 auto;">
615
+ <button type="button" id="mqtt-ga-all" class="ui-button ui-corner-all ui-widget" style="width:auto;" data-i18n="knxUltimateIoTBridge.ha.select_all"></button>
616
+ <button type="button" id="mqtt-ga-none" class="ui-button ui-corner-all ui-widget" style="width:auto;" data-i18n="knxUltimateIoTBridge.ha.select_none"></button>
617
+ </div>
618
+ <div id="mqtt-ga-count" style="font-size:11px; color:#666; margin:2px 0;"></div>
619
+ <div id="mqtt-ga-list" style="max-height:220px; overflow:auto; border:1px solid #ccc; padding:6px; border-radius:4px; background:#fff;"></div>
620
+ </div>
621
+ <div class="form-row ha-mode-row node-input-mqttentities-container-row">
622
+ <label style="width:auto; font-weight:600;" data-i18n="knxUltimateIoTBridge.ha.custom_entities"></label>
623
+ <ol id="node-input-mqttentities-container"></ol>
624
+ </div>
359
625
  <br/><br/><br/><br/>
360
626
  </script>
361
627
 
362
628
  <script type="text/markdown" data-help-name="knxUltimateIoTBridge">
363
- ## KNX IoT Bridge
629
+ # MQTT Home Assistant - IoT
364
630
 
365
631
  Configure bidirectional maps between KNX group addresses and IoT backends such as MQTT, REST APIs or Modbus registers. Each mapping can scale values, format payloads and define single direction behaviour.
366
632
 
@@ -384,4 +650,28 @@ Configure bidirectional maps between KNX group addresses and IoT backends such a
384
650
  - Use `msg.bridge.id` to route acknowledgements or correlate responses.
385
651
  - Enable "Read KNX values on deploy" to bootstrap dashboards after deploys.
386
652
 
653
+ ## Mode
654
+
655
+ The **Mode** selector switches the node between two behaviours:
656
+
657
+ - **IoT bridge** (default): the classic behaviour described above (mapping list, MQTT/REST/Modbus output messages).
658
+ - **MQTT / Home Assistant (native)**: the node connects directly to an MQTT broker and bridges KNX ↔ MQTT both ways, publishing Home Assistant MQTT Discovery so entities appear automatically.
659
+
660
+ ## MQTT / Home Assistant mode
661
+
662
+ Native MQTT bridge with Home Assistant discovery. Every group address imported in the KNX gateway (ETS list) can be exposed automatically as a Home Assistant entity (switch, sensor, binary_sensor, number, text), chosen from its datapoint type (DPT). KNX bus values are published to MQTT and writable datapoints accept commands from Home Assistant.
663
+
664
+ Requires an MQTT broker reachable by both Node-RED and Home Assistant, with the MQTT integration enabled in HA. Entities appear under a device named after this node.
665
+
666
+ ### Group addresses to expose
667
+ Tick the group addresses you want to publish to Home Assistant. By default every imported address is selected. Addresses used by a cover/thermostat (below) are handled there and don't need to be ticked here. Use the filter box and the Select all / Select none buttons to curate large lists.
668
+
669
+ ### Covers & Thermostats
670
+ Covers and thermostats group several group addresses into one Home Assistant entity, so they cannot be created automatically from the DPT - add them in the list:
671
+
672
+ - **Cover**: Up/Down GA (DPT 1.008), optional Stop GA (1.007), optional Set/Status position GA (5.001). *Invert position* maps the KNX convention (0% = open) to Home Assistant (100% = open).
673
+ - **Thermostat**: Current temperature GA (9.001), Setpoint set/status GA (9.001), optional On/Off GA (1.001 → off/heat), plus min/max temperature and step.
674
+
675
+ 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.
676
+
387
677
  </script>
@@ -22,7 +22,7 @@ module.exports = function (RED) {
22
22
  return
23
23
  }
24
24
 
25
- node.name = config.name || 'KNX IoT Bridge'
25
+ node.name = config.name || 'KNX MQTT - IoT'
26
26
  node.outputtopic = config.outputtopic || ''
27
27
 
28
28
  node.listenallga = true
@@ -40,6 +40,20 @@ module.exports = function (RED) {
40
40
 
41
41
  node.mappings = Array.isArray(config.mappings) ? config.mappings : []
42
42
 
43
+ // Operation mode: 'iot' (classic IoT mappings, default) or 'homeassistant' (native
44
+ // MQTT bridge with Home Assistant discovery for every group address + cover/climate).
45
+ node.nodeMode = config.nodeMode === 'homeassistant' ? 'homeassistant' : 'iot'
46
+ node.mqttUrl = typeof config.mqttUrl === 'string' ? config.mqttUrl.trim() : ''
47
+ node.mqttBaseTopic = typeof config.mqttBaseTopic === 'string' && config.mqttBaseTopic.trim() !== '' ? config.mqttBaseTopic.trim() : 'knx-ultimate'
48
+ node.mqttDiscovery = config.mqttDiscovery !== false && config.mqttDiscovery !== 'false'
49
+ node.mqttDiscoveryPrefix = typeof config.mqttDiscoveryPrefix === 'string' && config.mqttDiscoveryPrefix.trim() !== '' ? config.mqttDiscoveryPrefix.trim() : 'homeassistant'
50
+ node.mqttCustomEntities = Array.isArray(config.mqttCustomEntities) ? config.mqttCustomEntities : []
51
+ // Group addresses to expose as simple entities. Once the user curates the list
52
+ // (mqttExposeConfigured), only the listed GAs are exposed; otherwise all imported GAs are.
53
+ node.mqttExposeConfigured = config.mqttExposeConfigured === true
54
+ node.mqttExposedGAs = Array.isArray(config.mqttExposedGAs) ? config.mqttExposedGAs : []
55
+ node.mqttBridge = null
56
+
43
57
  const safeNumber = (value, fallback = 0) => {
44
58
  if (value === null || value === undefined || value === '') return fallback
45
59
  const parsed = Number(value)
@@ -143,6 +157,85 @@ module.exports = function (RED) {
143
157
  }
144
158
  }
145
159
 
160
+ // HOME ASSISTANT (MQTT) BRIDGE -----------------------------------------------------------
161
+ node.startMqttBridge = () => {
162
+ if (node.mqttBridge !== null) return // already running
163
+ if (!node.mqttUrl) {
164
+ pushStatus({ fill: 'red', shape: 'dot', text: 'MQTT broker URL missing' })
165
+ return
166
+ }
167
+ try {
168
+ // Lazy-require so the node still loads if the optional mqtt dependency is missing.
169
+ const { createMqttBridge } = require('./lib/mqtt-bridge.js')
170
+ node.mqttBridge = createMqttBridge({
171
+ node,
172
+ url: node.mqttUrl,
173
+ baseTopic: node.mqttBaseTopic,
174
+ discovery: node.mqttDiscovery,
175
+ discoveryPrefix: node.mqttDiscoveryPrefix,
176
+ username: node.credentials ? node.credentials.mqttUsername : undefined,
177
+ password: node.credentials ? node.credentials.mqttPassword : undefined,
178
+ groupAddresses: (node.serverKNX && Array.isArray(node.serverKNX.csv)) ? node.serverKNX.csv : [],
179
+ customEntities: node.mqttCustomEntities,
180
+ // null => expose all imported GAs (until the user curates the list).
181
+ exposedGAs: node.mqttExposeConfigured ? node.mqttExposedGAs : null,
182
+ onCommand: ({ ga, dpt, value }) => {
183
+ // A Home Assistant command arrived: write it to the KNX bus.
184
+ try {
185
+ node.serverKNX.sendKNXTelegramToKNXEngine({
186
+ grpaddr: ga,
187
+ payload: value,
188
+ dpt,
189
+ outputtype: 'write',
190
+ nodecallerid: node.id
191
+ })
192
+ } catch (error) {
193
+ if (node.sysLogger) node.sysLogger.error('HA bridge write failed (' + ga + '): ' + error.message)
194
+ }
195
+ },
196
+ onStatus: (status) => {
197
+ if (!status) return
198
+ if (status.state === 'connected') {
199
+ pushStatus({ fill: 'green', shape: 'dot', text: 'HA connected (' + (status.detail || '0') + ' entities)' })
200
+ } else if (status.state === 'error') {
201
+ pushStatus({ fill: 'red', shape: 'dot', text: 'MQTT ' + (status.detail || 'error') })
202
+ } else if (status.state === 'reconnect' || status.state === 'offline') {
203
+ pushStatus({ fill: 'yellow', shape: 'ring', text: 'MQTT ' + status.state })
204
+ }
205
+ }
206
+ })
207
+ node.mqttBridge.connect()
208
+ pushStatus({ fill: 'grey', shape: 'ring', text: 'HA mode: connecting (' + node.mqttBridge.entityCount + ' entities)' })
209
+ } catch (error) {
210
+ node.mqttBridge = null
211
+ if (node.sysLogger) node.sysLogger.error('startMqttBridge failed: ' + error.message)
212
+ pushStatus({ fill: 'red', shape: 'dot', text: 'MQTT bridge: ' + error.message })
213
+ }
214
+ }
215
+
216
+ node.stopMqttBridge = (done) => {
217
+ const bridge = node.mqttBridge
218
+ node.mqttBridge = null
219
+ let called = false
220
+ const cb = () => {
221
+ if (called) return
222
+ called = true
223
+ if (typeof done === 'function') {
224
+ try { done() } catch (error) { /* ignore */ }
225
+ }
226
+ }
227
+ if (!bridge) {
228
+ cb()
229
+ return
230
+ }
231
+ try {
232
+ bridge.close(cb)
233
+ } catch (error) {
234
+ if (node.sysLogger) node.sysLogger.error('stopMqttBridge error: ' + (error && error.message))
235
+ cb()
236
+ }
237
+ }
238
+
146
239
  const isBooleanDpt = (dpt) => typeof dpt === 'string' && dpt.startsWith('1.')
147
240
 
148
241
  const toBoolean = (value) => {
@@ -339,6 +432,15 @@ module.exports = function (RED) {
339
432
  const destination = msg.knx && msg.knx.destination ? msg.knx.destination : sanitizeString(msg.topic)
340
433
  if (!destination) return
341
434
 
435
+ // Home Assistant mode: mirror the decoded value to MQTT and stop (no IoT mappings).
436
+ if (node.nodeMode === 'homeassistant') {
437
+ const event = msg.knx ? msg.knx.event : undefined
438
+ if (node.mqttBridge && event !== 'GroupValue_Read') {
439
+ node.mqttBridge.publishState(destination, msg.payload)
440
+ }
441
+ return
442
+ }
443
+
342
444
  const meta = {
343
445
  event: msg.knx ? msg.knx.event : undefined,
344
446
  source: msg.knx ? msg.knx.source : undefined,
@@ -382,6 +484,11 @@ module.exports = function (RED) {
382
484
  node.handleSend = handleKnxTelegram
383
485
 
384
486
  node.on('input', (msg, send, done) => {
487
+ // In Home Assistant mode, commands flow in over MQTT, not via the flow input.
488
+ if (node.nodeMode === 'homeassistant') {
489
+ if (done) done()
490
+ return
491
+ }
385
492
  if (!node.acceptFlowInput) {
386
493
  if (done) done()
387
494
  return
@@ -446,14 +553,30 @@ module.exports = function (RED) {
446
553
  })
447
554
 
448
555
  node.on('close', (done) => {
449
- if (node.serverKNX && typeof node.serverKNX.removeClient === 'function') {
450
- try {
451
- node.serverKNX.removeClient(node)
452
- } catch (error) {
453
- /* empty */
556
+ // Always call done() exactly once, even if something throws, so a deploy / Node-RED exit
557
+ // is never blocked by the bridge teardown.
558
+ let finished = false
559
+ const finish = () => {
560
+ if (finished) return
561
+ finished = true
562
+ if (typeof done === 'function') {
563
+ try { done() } catch (error) { /* ignore */ }
454
564
  }
455
565
  }
456
- if (done) done()
566
+ try {
567
+ if (node.serverKNX && typeof node.serverKNX.removeClient === 'function') {
568
+ try {
569
+ node.serverKNX.removeClient(node)
570
+ } catch (error) {
571
+ /* empty */
572
+ }
573
+ }
574
+ // Stop the MQTT bridge (best-effort, hard-capped so redeploy never blocks on the broker).
575
+ node.stopMqttBridge(finish)
576
+ } catch (error) {
577
+ if (node.sysLogger) node.sysLogger.error('close handler error: ' + (error && error.message))
578
+ finish()
579
+ }
457
580
  })
458
581
 
459
582
  const registerClient = () => {
@@ -492,9 +615,18 @@ module.exports = function (RED) {
492
615
  }
493
616
 
494
617
  registerClient()
495
- updateIdleStatus()
496
- issueInitialReads()
618
+ if (node.nodeMode === 'homeassistant') {
619
+ node.startMqttBridge()
620
+ } else {
621
+ updateIdleStatus()
622
+ issueInitialReads()
623
+ }
497
624
  }
498
625
 
499
- RED.nodes.registerType('knxUltimateIoTBridge', knxUltimateIoTBridge)
626
+ RED.nodes.registerType('knxUltimateIoTBridge', knxUltimateIoTBridge, {
627
+ credentials: {
628
+ mqttUsername: { type: 'text' },
629
+ mqttPassword: { type: 'password' }
630
+ }
631
+ })
500
632
  }