node-red-contrib-knx-ultimate 4.3.24 → 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,19 @@
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
+
17
+ **Version 5.0.0** - June 2026<br/>
18
+
19
+ - KNXUltimate engine: updated to **6.0.1** (see the engine [CHANGELOG](https://github.com/Supergiovane/knxultimate/blob/main/CHANGELOG.md)).<br/>
20
+ - KNX config node: new **"Reveal keyring passwords"** button in the **Utility** tab, enabled only when **KNX Secure** is selected. It shows, in clear text, all the keyring passwords (interfaces, backbone, group addresses and devices keys/passwords) plus the general keyring password, decoded from the loaded keyring file. Localized in EN/IT/DE/FR/ES/zh-CN.<br/>
21
+
9
22
  **Version 4.3.24** - June 2026<br/>
10
23
 
11
24
  - Hue **Contact Sensor** and **Motion** nodes: fixed the state on the KNX bus getting stuck on a wrong value (e.g. a contact sensor showing **closed** while the door stayed open) after the Hue event-stream reconnected. The nodes now re-publish the authoritative state at startup and after every (re)connection, and ignore stale/out-of-order reports by checking the `contact_report.changed` / `motion_report.changed` timestamp. Closes [#514](https://github.com/Supergiovane/node-red-contrib-knx-ultimate/issues/514).<br/>
@@ -408,6 +408,121 @@ module.exports = (RED) => {
408
408
  }
409
409
  })
410
410
 
411
+ // 2026-06 Reveal all keyring passwords (and the general keyring password) in clear text.
412
+ // Used by the "Utility" tab button, enabled only when KNX Secure is selected.
413
+ RED.httpAdmin.get('/knxUltimateKeyringDump', RED.auth.needsPermission('knxUltimate-config.read'), async (req, res) => {
414
+ try {
415
+ let keyringContent = (req.query.keyring || '').toString()
416
+ let password = (req.query.pwd || '').toString()
417
+ // '__PWRD__' is the Node-RED placeholder for an unchanged password credential: ignore it.
418
+ if (password === '__PWRD__') password = ''
419
+ // Fill any missing field (keyring and/or password) from the existing config node.
420
+ if ((!keyringContent || !password) && req.query.serverId) {
421
+ const cfg = RED.nodes.getNode(req.query.serverId)
422
+ if (cfg) {
423
+ if (!keyringContent) { try { keyringContent = cfg.keyringFileXML || keyringContent } catch (e) { } }
424
+ if (!password) { try { password = (cfg.credentials && cfg.credentials.keyringFilePassword) ? cfg.credentials.keyringFilePassword : password } catch (e) { } }
425
+ }
426
+ }
427
+ if (!keyringContent || !password) {
428
+ return res.json({ ok: false, error: 'MISSING_KEYRING_OR_PASSWORD' })
429
+ }
430
+ let Keyring
431
+ try {
432
+ ({ Keyring } = require('knxultimate/build/secure/keyring'))
433
+ } catch (e) {
434
+ try { RED.log.error(`KNXUltimate: cannot load Keyring module: ${e.message}`) } catch (err) { }
435
+ return res.json({ ok: false, error: 'KEYRING_MODULE_UNAVAILABLE' })
436
+ }
437
+ const kr = new Keyring()
438
+ try {
439
+ await kr.load(keyringContent, password)
440
+ } catch (e) {
441
+ try { RED.log.error(`KNXUltimate: keyring load error: ${e.message}`) } catch (err) { }
442
+ return res.json({ ok: false, error: 'KEYRING_LOAD_FAILED' })
443
+ }
444
+
445
+ const toIAString = (value) => {
446
+ if (!value) return ''
447
+ return typeof value.toString === 'function' ? value.toString() : String(value)
448
+ }
449
+ const toBufferString = (value) => {
450
+ if (!value) return ''
451
+ if (Buffer.isBuffer(value)) return value.toString('hex')
452
+ return String(value)
453
+ }
454
+
455
+ const lines = []
456
+ lines.push('================ KNX Secure keyring passwords ================')
457
+ lines.push(`Created By: ${kr.getCreatedBy?.() || ''}`)
458
+ lines.push(`Created On: ${kr.getCreated?.() || ''}`)
459
+ lines.push(`General keyring password: ${password}`)
460
+ lines.push('')
461
+
462
+ lines.push('Interfaces:')
463
+ const interfaceMap = kr.getInterfaces?.()
464
+ const interfaces = Array.from(interfaceMap ? interfaceMap.values() : [])
465
+ if (interfaces.length === 0) {
466
+ lines.push(' (none)')
467
+ } else {
468
+ interfaces.forEach((iface, idx) => {
469
+ lines.push(` [${idx + 1}] ${toIAString(iface.individualAddress) || '(unknown)'} (${iface.type || ''})`)
470
+ lines.push(` Host: ${toIAString(iface.host) || ''}`)
471
+ lines.push(` User ID: ${typeof iface.userId === 'number' ? iface.userId : ''}`)
472
+ lines.push(` Password: ${iface.decryptedPassword || ''}`)
473
+ lines.push(` Authentication: ${iface.decryptedAuthentication || ''}`)
474
+ })
475
+ }
476
+ lines.push('')
477
+
478
+ lines.push('Backbones:')
479
+ const backbones = kr.getBackbones?.() || []
480
+ if (backbones.length === 0) {
481
+ lines.push(' (none)')
482
+ } else {
483
+ backbones.forEach((backbone, idx) => {
484
+ lines.push(` [${idx + 1}] Multicast: ${backbone.multicastAddress || ''}`)
485
+ lines.push(` Key (hex): ${toBufferString(backbone.decryptedKey)}`)
486
+ })
487
+ }
488
+ lines.push('')
489
+
490
+ lines.push('Group Addresses:')
491
+ const groupAddressMap = kr.getGroupAddresses?.()
492
+ const groupAddresses = Array.from(groupAddressMap ? groupAddressMap.values() : [])
493
+ if (groupAddresses.length === 0) {
494
+ lines.push(' (none)')
495
+ } else {
496
+ groupAddresses.forEach((group, idx) => {
497
+ lines.push(` [${idx + 1}] ${toIAString(group.address) || ''}`)
498
+ lines.push(` Key (hex): ${toBufferString(group.decryptedKey)}`)
499
+ })
500
+ }
501
+ lines.push('')
502
+
503
+ lines.push('Devices:')
504
+ const deviceMap = kr.getDevices?.()
505
+ const devices = Array.from(deviceMap ? deviceMap.values() : [])
506
+ if (devices.length === 0) {
507
+ lines.push(' (none)')
508
+ } else {
509
+ devices.forEach((device, idx) => {
510
+ lines.push(` [${idx + 1}] ${toIAString(device.individualAddress) || ''}`)
511
+ lines.push(` Tool Key (hex): ${toBufferString(device.decryptedToolKey)}`)
512
+ lines.push(` Management Password: ${device.decryptedManagementPassword || ''}`)
513
+ lines.push(` Authentication: ${device.decryptedAuthentication || ''}`)
514
+ lines.push(` Serial Number: ${device.serialNumber || ''}`)
515
+ })
516
+ }
517
+ lines.push('================ End of keyring passwords ================')
518
+
519
+ res.json({ ok: true, dump: lines.join('\n') })
520
+ } catch (error) {
521
+ try { RED.log.error(`KNXUltimate: knxUltimateKeyringDump error: ${error.message}`) } catch (e) { }
522
+ res.json({ ok: false, error: 'UNEXPECTED_ERROR' })
523
+ }
524
+ })
525
+
411
526
  RED.httpAdmin.get('/knxUltimateGetHueColor', (req, res) => {
412
527
  try {
413
528
  const serverId = RED.nodes.getNode(req.query.serverId) // Retrieve node.id of the config node.
@@ -164,13 +164,13 @@
164
164
  active: (node.knxSecureSelected === true || node.knxSecureSelected === 'true') ? 1 : 0,
165
165
  activate: function (event, ui) {
166
166
  node.knxSecureSelected = $(ui.newTab).index() === 1;
167
- try { updateTunnelIAVisibility(); updatePhysAddrVisibility(); updateSecureMulticastHint(); enforceProtocolFromIP(); } catch (e) { }
167
+ try { updateTunnelIAVisibility(); updatePhysAddrVisibility(); updateSecureMulticastHint(); enforceProtocolFromIP(); updateRevealKeyringButton(); } catch (e) { }
168
168
  }
169
169
  });
170
170
  // Ensure the correct tab is enforced after all UI init
171
171
  try {
172
172
  const initialActive = (node.knxSecureSelected === true || node.knxSecureSelected === 'true') ? 1 : 0;
173
- setTimeout(() => { $("#tabsMain").tabs("option", "active", initialActive); updateTunnelIAVisibility(); updatePhysAddrVisibility(); updateSecureMulticastHint(); enforceProtocolFromIP(); }, 0);
173
+ setTimeout(() => { $("#tabsMain").tabs("option", "active", initialActive); updateTunnelIAVisibility(); updatePhysAddrVisibility(); updateSecureMulticastHint(); enforceProtocolFromIP(); updateRevealKeyringButton(); }, 0);
174
174
  } catch (e) { }
175
175
 
176
176
  // Keyring ACE editor setup
@@ -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 = "";
@@ -1041,6 +1039,56 @@
1041
1039
  } catch (e) { }
1042
1040
  });
1043
1041
 
1042
+ // Utility: reveal all keyring passwords in clear text.
1043
+ // Enabled only when KNX Secure is selected.
1044
+ function updateRevealKeyringButton() {
1045
+ try {
1046
+ const isSecure = isSecureTabActive();
1047
+ $("#revealkeyringpwd").prop('disabled', !isSecure)
1048
+ .css('opacity', isSecure ? 1 : 0.5)
1049
+ .css('cursor', isSecure ? 'pointer' : 'not-allowed');
1050
+ } catch (e) { }
1051
+ }
1052
+ updateRevealKeyringButton();
1053
+
1054
+ $("#revealkeyringpwd").click(function () {
1055
+ if (!isSecureTabActive()) return;
1056
+ try {
1057
+ const keyring = (node._keyringEditor && typeof node._keyringEditor.getValue === 'function')
1058
+ ? node._keyringEditor.getValue()
1059
+ : $("#node-config-input-keyringFileXML").val();
1060
+ const pwd = $("#node-config-input-keyringFilePassword").val();
1061
+ const params = { serverId: node.id };
1062
+ if (typeof keyring === 'string' && keyring.trim() !== '') params.keyring = keyring.trim();
1063
+ // '__PWRD__' is the Node-RED placeholder for an unchanged password credential:
1064
+ // never send it, so the backend falls back to the real stored credential.
1065
+ if (typeof pwd === 'string' && pwd.trim() !== '' && pwd !== '__PWRD__') params.pwd = pwd.trim();
1066
+ $("#divDebugText").show();
1067
+ $("#debugText").val(node._('knxUltimate-config.utility.reveal_keyring_loading'));
1068
+ const scrollToDebugText = function () {
1069
+ try {
1070
+ const el = document.getElementById('debugText');
1071
+ if (el && typeof el.scrollIntoView === 'function') {
1072
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
1073
+ }
1074
+ } catch (e) { }
1075
+ };
1076
+ scrollToDebugText();
1077
+ $.getJSON('knxUltimateKeyringDump', params, (data) => {
1078
+ if (data && data.ok) {
1079
+ $("#debugText").val(data.dump);
1080
+ } else {
1081
+ const code = (data && data.error) ? data.error : 'UNEXPECTED_ERROR';
1082
+ $("#debugText").val(node._('knxUltimate-config.utility.reveal_keyring_fail') + ' (' + code + ')');
1083
+ }
1084
+ scrollToDebugText();
1085
+ }).fail(function (jqXHR, textStatus, errorThrown) {
1086
+ const err = (errorThrown || textStatus || '').toString();
1087
+ $("#debugText").val(node._('knxUltimate-config.utility.reveal_keyring_fail') + (err ? (': ' + err) : ''));
1088
+ });
1089
+ } catch (e) { }
1090
+ });
1091
+
1044
1092
 
1045
1093
 
1046
1094
  },
@@ -1569,8 +1617,14 @@
1569
1617
  <input type="button" id="clearpersistga" class="ui-button ui-corner-all ui-widget"
1570
1618
  style="background-color:#FFD2D2;width:150px" data-i18n="[value]knxUltimate-config.utility.clear_persist_button">
1571
1619
  </div>
1572
-
1573
- <div class="form-row" id="divDebugText" style="display: none;">
1620
+
1621
+ <div class="form-row">
1622
+ <label><i class="fa fa-key"></i> <span data-i18n="knxUltimate-config.utility.reveal_keyring_label"></span></label>
1623
+ <input type="button" id="revealkeyringpwd" class="ui-button ui-corner-all ui-widget"
1624
+ style="background-color:#FFE9B3;width:150px" data-i18n="[value]knxUltimate-config.utility.reveal_keyring_button">
1625
+ </div>
1626
+
1627
+ <div class="form-row" id="divDebugText" style="display: none;">
1574
1628
  <textarea rows="10" id="debugText" style="width:100%"></textarea>
1575
1629
  </div>
1576
1630
  </p>
@@ -1578,7 +1632,7 @@
1578
1632
  </div>
1579
1633
 
1580
1634
  </div>
1581
-
1635
+
1582
1636
  </script>
1583
1637
 
1584
1638
  <script type="text/html" data-help-name="knxUltimate-config">
@@ -24,6 +24,12 @@ const loggerClass = require('./utils/sysLogger')
24
24
  const payloadRounder = require('./utils/payloadManipulation')
25
25
  const utils = require('./utils/utils')
26
26
 
27
+ // Versions logged once at startup (node package + KNXUltimate engine)
28
+ let NODE_VERSION = 'unknown'
29
+ try { NODE_VERSION = require('../package.json').version } catch (e) { /* empty */ }
30
+ let KNX_ENGINE_VERSION = 'unknown'
31
+ try { KNX_ENGINE_VERSION = require('knxultimate/package.json').version } catch (e) { /* empty */ }
32
+
27
33
  // DATAPONT MANIPULATION HELPERS
28
34
  // ####################
29
35
  const sortBy = (field) => (a, b) => {
@@ -185,6 +191,11 @@ const findAutoEthernetInterface = (targetIP) => {
185
191
  }
186
192
 
187
193
  module.exports = (RED) => {
194
+ // Log node and KNXUltimate engine versions once, at Node-RED startup.
195
+ try {
196
+ RED.log.info(`KNXUltimate: node-red-contrib-knx-ultimate v${NODE_VERSION} (KNXUltimate engine v${KNX_ENGINE_VERSION})`)
197
+ } catch (e) { /* empty */ }
198
+
188
199
  function knxUltimateConfigNode (config) {
189
200
  RED.nodes.createNode(this, config)
190
201
  const node = this
@@ -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>