node-red-contrib-knx-ultimate 5.0.3 → 5.2.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/nodes/commonFunctions.js +93 -0
  3. package/nodes/icons/node-matter-icon.svg +12 -0
  4. package/nodes/knxUltimateIoTBridge.html +10 -3
  5. package/nodes/knxUltimateMatterBridge.html +429 -0
  6. package/nodes/knxUltimateMatterBridge.js +456 -0
  7. package/nodes/knxUltimateMatterDevice.html +586 -0
  8. package/nodes/knxUltimateMatterDevice.js +308 -0
  9. package/nodes/locales/de/knxUltimateMatterBridge.html +64 -0
  10. package/nodes/locales/de/knxUltimateMatterBridge.json +80 -0
  11. package/nodes/locales/de/knxUltimateMatterDevice.html +56 -0
  12. package/nodes/locales/de/knxUltimateMatterDevice.json +109 -0
  13. package/nodes/locales/de/matter-config.html +25 -0
  14. package/nodes/locales/de/matter-config.json +26 -0
  15. package/nodes/locales/en/knxUltimateMatterBridge.html +64 -0
  16. package/nodes/locales/en/knxUltimateMatterBridge.json +80 -0
  17. package/nodes/locales/en/knxUltimateMatterDevice.html +56 -0
  18. package/nodes/locales/en/knxUltimateMatterDevice.json +109 -0
  19. package/nodes/locales/en/matter-config.html +25 -0
  20. package/nodes/locales/en/matter-config.json +26 -0
  21. package/nodes/locales/es/knxUltimateMatterBridge.html +64 -0
  22. package/nodes/locales/es/knxUltimateMatterBridge.json +80 -0
  23. package/nodes/locales/es/knxUltimateMatterDevice.html +56 -0
  24. package/nodes/locales/es/knxUltimateMatterDevice.json +109 -0
  25. package/nodes/locales/es/matter-config.html +25 -0
  26. package/nodes/locales/es/matter-config.json +26 -0
  27. package/nodes/locales/fr/knxUltimateMatterBridge.html +64 -0
  28. package/nodes/locales/fr/knxUltimateMatterBridge.json +80 -0
  29. package/nodes/locales/fr/knxUltimateMatterDevice.html +56 -0
  30. package/nodes/locales/fr/knxUltimateMatterDevice.json +109 -0
  31. package/nodes/locales/fr/matter-config.html +25 -0
  32. package/nodes/locales/fr/matter-config.json +26 -0
  33. package/nodes/locales/it/knxUltimateMatterBridge.html +64 -0
  34. package/nodes/locales/it/knxUltimateMatterBridge.json +80 -0
  35. package/nodes/locales/it/knxUltimateMatterDevice.html +56 -0
  36. package/nodes/locales/it/knxUltimateMatterDevice.json +109 -0
  37. package/nodes/locales/it/matter-config.html +25 -0
  38. package/nodes/locales/it/matter-config.json +26 -0
  39. package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +64 -0
  40. package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +80 -0
  41. package/nodes/locales/zh-CN/knxUltimateMatterDevice.html +56 -0
  42. package/nodes/locales/zh-CN/knxUltimateMatterDevice.json +109 -0
  43. package/nodes/locales/zh-CN/matter-config.html +25 -0
  44. package/nodes/locales/zh-CN/matter-config.json +26 -0
  45. package/nodes/matter-config.html +226 -0
  46. package/nodes/matter-config.js +242 -0
  47. package/nodes/utils/matterBridgeDeviceFactory.mjs +511 -0
  48. package/nodes/utils/matterBridgeEngine.mjs +290 -0
  49. package/nodes/utils/matterEngine.mjs +475 -0
  50. package/nodes/utils/matterKnxConverter.js +204 -0
  51. package/package.json +7 -2
package/CHANGELOG.md CHANGED
@@ -6,6 +6,31 @@
6
6
 
7
7
  # CHANGELOG
8
8
 
9
+ **Version 5.2.1** - July 2026<br/>
10
+
11
+ - New **Matter Bridge (BETA)** node: exposes **KNX group addresses as Matter devices**. Alexa, Google Home, Apple Home (or any Matter controller) commission the bridge once — via QR code or 11-digit manual code shown right in the node editor — and see all your KNX devices with the names you typed, ready for app and voice control. It is the opposite direction of the Matter Device node introduced in 5.1.0.<br/>
12
+ - **17 virtual device types**: on/off light, dimmable light, **RGB color light** (DPT 232.600, converted to/from the Matter color wheel), **tunable white light** (color temperature in Kelvin, DPT 7.600), plug, cover/shutter (up/down, stop, position % with optional inversion), thermostat (current temperature + heating setpoint, both ways), **fan/ventilation** (speed %), sensors (temperature, humidity, illuminance, occupancy, contact), **smoke/CO alarm**, **water leak detector**, **air quality sensor** (CO2 ppm with automatic air-quality classification) and **robot vacuum** (flow-only: integrate your robot with any Node-RED node and expose it to the voice assistants).<br/>
13
+ - Bidirectional with loop protection: assistant commands write to the KNX *command* group addresses; telegrams on the *status* group addresses update the Matter attributes (and the apps). At startup the node can send a `GroupValue_Read` to every status GA so the Matter attributes are populated before the first app query.<br/>
14
+ - **Live device reconciliation**: the Matter server stays alive across re-deploys — added/renamed/removed devices appear on the paired controllers within seconds, without restarting Node-RED and without re-pairing.<br/>
15
+ - Optional **node Input/Output PINs**: the output forwards every controller command to the flow; the input updates the Matter state of a virtual device from flow messages (`msg.payload = { device, function, value }`). Devices without a command GA become flow-only devices.<br/>
16
+ - The **KNX gateway is optional**: without it the bridge runs in flow-only mode, useful to expose flow-computed or non-KNX devices to Alexa & Co.<br/>
17
+ - Multi-fabric: several controllers can be paired at the same time; the pairing panel lists them and a **Reset pairing** button decommissions everything. The bridge identity persists across re-deploys (no re-pairing needed).<br/>
18
+ - Devices editor with ETS group-address autocomplete filtered per function and per-type fields. Localized in EN/IT/DE/FR/ES/zh-CN.<br/>
19
+ - Matter integration hardening: try/catch protection on all critical paths (controller startup, client registration, structure queries, commissioning aftermath) so errors never crash Node-RED. Fixed the devices editor losing the configured group addresses on close/reopen (with automatic recovery of configurations saved by the affected versions).<br/>
20
+ - **Documentation**: node help for the three Matter nodes in all six languages, new **Matter section** in the online docs sidebar with the Matter Controller, Matter Device and Matter Bridge pages (EN/IT/DE/FR/ES/zh-CN), the two new nodes on the homepage node list and the Matter logo alongside Home Assistant and MQTT.<br/>
21
+
22
+ **Version 5.1.0** - July 2026<br/>
23
+
24
+ - New **Matter integration (BETA)**: control any commissioned **Matter device** from the KNX bus and track its states on KNX group addresses.<br/>
25
+ - New **Matter Controller** configuration node: a full Matter controller with its own fabric (based on [matter.js](https://github.com/project-chip/matter.js)). Pair devices directly from the editor using the 11-digit manual pairing code or the QR code content (`MT:...`); commissioning works over the IP network (WiFi/Ethernet or Thread via border router). The commissioned devices list shows name, product, node ID and connection state, with one-click unpair. Fabric credentials are stored under `knxultimatestorage/matter` in the Node-RED user directory.<br/>
26
+ - New **Matter Device (BETA)** node: pick a paired Matter device and map KNX group addresses to its functions with a dynamic mapping list. The function list is **human friendly**: it shows only the functions the device really exposes, with plain-language localized names (e.g. *"On/Off switch"*, *"Instantaneous power (W)"*, *"Room temperature (°C)"*) and the current value in brackets, instead of raw Matter cluster/attribute names. Friendly endpoint names for 34 common device types (smart plug, dimmable light, cover, thermostat, sensors...).<br/>
27
+ - Mappings are bidirectional: **KNX → Matter** (commands/attribute writes, e.g. GA 1/1/1 DPT 1.001 → OnOff) and **Matter → KNX** (attribute changes written to the GA; `GroupValue_Read` requests answered from the cache). Automatic value conversion for the common clusters: OnOff, LevelControl (0-254 ↔ %), WindowCovering (percent100ths ↔ %, up/down), ColorControl (mireds ↔ Kelvin), Thermostat and temperature/humidity sensors (centi-units ↔ units), illuminance (log scale ↔ Lux), battery (half-percent ↔ %), power/energy metering (mW ↔ W, mWh ↔ kWh), door lock, occupancy and contact sensors.<br/>
28
+ - Selecting a function pre-fills the **suggested KNX DPT** and the GA name; the group address field has the usual **ETS autocomplete**. Optional node input/output pins for raw Matter access from the flow. Editor and help localized in EN/IT/DE/FR/ES/zh-CN.<br/>
29
+
30
+ **Version 5.0.4** - July 2026<br/>
31
+
32
+ - **IoT Bridge** node, **Covers & Thermostats** editor: fixed the group-address autocomplete, which suggested only DPT **1.x** addresses in every field. Each field now filters by the datapoint it expects: cover **Set/Status position** GAs list DPT **5.x** (5.001), and the thermostat **Current temperature** and **Setpoint set/status** GAs list DPT **9.x** (9.001). Up/Down, Stop and On/Off keep their 1.x filter.<br/>
33
+
9
34
  **Version 5.0.3** - July 2026<br/>
10
35
 
11
36
  - Added a link to the **KNX-Ultimate YouTube tutorials playlist** at the top of every node's configuration panel, so the video tutorials are one click away from any node.<br/>
@@ -944,6 +944,99 @@ module.exports = (RED) => {
944
944
  }
945
945
  })
946
946
 
947
+ // MATTER: returns the list of commissioned Matter devices of a matter-config node
948
+ RED.httpAdmin.get('/KNXUltimateMatterGetNodes', RED.auth.needsPermission('matter-config.read'), (req, res) => {
949
+ try {
950
+ const matterServer = RED.nodes.getNode(req.query.serverId)
951
+ if (matterServer === null || matterServer === undefined) {
952
+ res.json({ error: 'PLEASE DEPLOY FIRST: then try again.' })
953
+ return
954
+ }
955
+ res.json({ devices: matterServer.getCommissionedNodesDetails() })
956
+ } catch (error) {
957
+ RED.log.error(`Err KNXUltimateMatterGetNodes: ${error.message}`)
958
+ res.json({ error: error.message })
959
+ }
960
+ })
961
+
962
+ // MATTER: returns endpoints/clusters/attributes/commands of a commissioned Matter device
963
+ RED.httpAdmin.get('/KNXUltimateMatterGetStructure', RED.auth.needsPermission('matter-config.read'), (req, res) => {
964
+ try {
965
+ const matterServer = RED.nodes.getNode(req.query.serverId)
966
+ if (matterServer === null || matterServer === undefined) {
967
+ res.json({ error: 'PLEASE DEPLOY FIRST: then try again.' })
968
+ return
969
+ }
970
+ res.json(matterServer.getNodeStructure(req.query.nodeId))
971
+ } catch (error) {
972
+ RED.log.error(`Err KNXUltimateMatterGetStructure: ${error.message}`)
973
+ res.json({ error: error.message })
974
+ }
975
+ })
976
+
977
+ // MATTER: commissions (pairs) a new Matter device using a pairing code or QR code string
978
+ RED.httpAdmin.get('/KNXUltimateMatterPair', RED.auth.needsPermission('matter-config.write'), async (req, res) => {
979
+ try {
980
+ const matterServer = RED.nodes.getNode(req.query.serverId)
981
+ if (matterServer === null || matterServer === undefined) {
982
+ res.json({ error: 'PLEASE DEPLOY FIRST: then try again.' })
983
+ return
984
+ }
985
+ const nodeId = await matterServer.commission(req.query.code)
986
+ res.json({ nodeId })
987
+ } catch (error) {
988
+ RED.log.error(`Err KNXUltimateMatterPair: ${error.message}`)
989
+ res.json({ error: error.message })
990
+ }
991
+ })
992
+
993
+ // MATTER: decommissions (unpairs) a Matter device
994
+ RED.httpAdmin.get('/KNXUltimateMatterUnpair', RED.auth.needsPermission('matter-config.write'), async (req, res) => {
995
+ try {
996
+ const matterServer = RED.nodes.getNode(req.query.serverId)
997
+ if (matterServer === null || matterServer === undefined) {
998
+ res.json({ error: 'PLEASE DEPLOY FIRST: then try again.' })
999
+ return
1000
+ }
1001
+ await matterServer.removeCommissionedNode(req.query.nodeId)
1002
+ res.json({ status: 'ok' })
1003
+ } catch (error) {
1004
+ RED.log.error(`Err KNXUltimateMatterUnpair: ${error.message}`)
1005
+ res.json({ error: error.message })
1006
+ }
1007
+ })
1008
+
1009
+ // MATTER BRIDGE: returns the pairing info (QR code, manual code, commissioned fabrics) of a bridge node
1010
+ RED.httpAdmin.get('/KNXUltimateMatterBridgeInfo', RED.auth.needsPermission('knxUltimateMatterBridge.read'), (req, res) => {
1011
+ try {
1012
+ const bridgeNode = RED.nodes.getNode(req.query.nodeId)
1013
+ if (bridgeNode === null || bridgeNode === undefined || typeof bridgeNode.getBridgePairingInfo !== 'function') {
1014
+ res.json({ error: 'PLEASE DEPLOY FIRST: then try again.' })
1015
+ return
1016
+ }
1017
+ res.json(bridgeNode.getBridgePairingInfo())
1018
+ } catch (error) {
1019
+ RED.log.error(`Err KNXUltimateMatterBridgeInfo: ${error.message}`)
1020
+ res.json({ error: error.message })
1021
+ }
1022
+ })
1023
+
1024
+ // MATTER BRIDGE: factory reset (removes all paired controllers, restarts pairing advertising)
1025
+ RED.httpAdmin.get('/KNXUltimateMatterBridgeReset', RED.auth.needsPermission('knxUltimateMatterBridge.write'), async (req, res) => {
1026
+ try {
1027
+ const bridgeNode = RED.nodes.getNode(req.query.nodeId)
1028
+ if (bridgeNode === null || bridgeNode === undefined || typeof bridgeNode.factoryResetBridge !== 'function') {
1029
+ res.json({ error: 'PLEASE DEPLOY FIRST: then try again.' })
1030
+ return
1031
+ }
1032
+ await bridgeNode.factoryResetBridge()
1033
+ res.json({ status: 'ok' })
1034
+ } catch (error) {
1035
+ RED.log.error(`Err KNXUltimateMatterBridgeReset: ${error.message}`)
1036
+ res.json({ error: error.message })
1037
+ }
1038
+ })
1039
+
947
1040
  RED.httpAdmin.get('/knxUltimateDpts', (req, res) => {
948
1041
  try {
949
1042
  const dpts = Object.entries(dptlib.dpts).filter(onlyDptKeys).map(extractBaseNo).sort(sortBy('base'))
@@ -0,0 +1,12 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg width="40" height="60" viewBox="0 0 40 60" xmlns="http://www.w3.org/2000/svg">
3
+ <g fill="#ffffff" stroke="none">
4
+ <circle cx="20" cy="30" r="4.5"/>
5
+ <g id="matterArrow">
6
+ <rect x="18.4" y="9" width="3.2" height="11"/>
7
+ <path d="M 14.5 18.5 L 25.5 18.5 L 20 25.5 Z"/>
8
+ </g>
9
+ <use href="#matterArrow" transform="rotate(120 20 30)"/>
10
+ <use href="#matterArrow" transform="rotate(240 20 30)"/>
11
+ </g>
12
+ </svg>
@@ -402,10 +402,17 @@
402
402
  const T = (k) => node._('knxUltimateIoTBridge.ha.' + k);
403
403
  const coverFields = ['gaUpDown', 'gaStop', 'gaPosSet', 'gaPosState'];
404
404
  const climateFields = ['gaCurrentTemp', 'gaSetpointSet', 'gaSetpointState', 'gaOnOff'];
405
+ // DPT main-type each field expects, used to filter the GA autocomplete so
406
+ // e.g. the position GAs suggest DPT 5.x and the temperature GAs suggest 9.x
407
+ // instead of everything defaulting to 1.x.
408
+ const gaDptFilter = {
409
+ gaUpDown: '1.', gaStop: '1.', gaPosSet: '5.', gaPosState: '5.',
410
+ gaCurrentTemp: '9.', gaSetpointSet: '9.', gaSetpointState: '9.', gaOnOff: '1.'
411
+ };
405
412
 
406
413
  // Group-address autocomplete, identical to the mapping GA field (and the
407
414
  // knxUltimate node): suggests GAs from the gateway's ETS CSV.
408
- function attachGaAutocomplete(input) {
415
+ function attachGaAutocomplete(input, dptFilter) {
409
416
  try {
410
417
  if (oNodeServer && oNodeServer.id && typeof KNX_enableSecureFormatting === 'function') {
411
418
  KNX_enableSecureFormatting(input, oNodeServer.id);
@@ -419,7 +426,7 @@
419
426
  $.getJSON('knxUltimatecsv?nodeID=' + sid, (data) => {
420
427
  response($.map(data || [], (value) => {
421
428
  const search = (value.ga + ' (' + value.devicename + ') DPT' + value.dpt);
422
- if (htmlUtilsfullCSVSearch(search, request.term + ' 1.')) {
429
+ if (htmlUtilsfullCSVSearch(search, request.term + ' ' + (dptFilter || '1.'))) {
423
430
  return {
424
431
  label: value.ga + ' # ' + value.devicename + ' # ' + value.dpt,
425
432
  value: value.ga
@@ -440,7 +447,7 @@
440
447
  $('<label/>').css({ width: '160px' }).text(T('ce_' + key)).appendTo(row);
441
448
  const input = $('<input/>', { type: 'text', class: 'ce-' + key })
442
449
  .attr('placeholder', '1/2/3').css({ width: '130px' }).val(value || '').appendTo(row);
443
- attachGaAutocomplete(input);
450
+ attachGaAutocomplete(input, gaDptFilter[key]);
444
451
  return input;
445
452
  }
446
453
  function numRow(parent, key, value) {
@@ -0,0 +1,429 @@
1
+ <script type="text/javascript" src="resources/node-red-contrib-knx-ultimate/11f26b4500.js"></script>
2
+ <script type="text/javascript" src="resources/node-red-contrib-knx-ultimate/htmlUtils.js"></script>
3
+
4
+ <script type="text/javascript">
5
+ (function () {
6
+ RED.nodes.registerType('knxUltimateMatterBridge', {
7
+ category: 'KNX Ultimate Matter',
8
+ color: '#D7C7E9',
9
+ defaults: {
10
+ server: { type: 'knxUltimate-config', required: false },
11
+ name: { value: '' },
12
+ bridgeDeviceName: { value: 'KNX-Ultimate Bridge' },
13
+ bridgePort: { value: 5540, validate: RED.validators.number() },
14
+ readStatusAtStartup: { value: 'yes' },
15
+ enableNodePINS: { value: 'no' },
16
+ devices: { value: [] },
17
+ outputs: { value: 0 },
18
+ inputs: { value: 0 }
19
+ },
20
+ inputs: 0,
21
+ outputs: 0,
22
+ icon: 'node-matter-icon.svg',
23
+ label() {
24
+ return this.name || 'Matter Bridge (BETA)';
25
+ },
26
+ paletteLabel: 'Matter Bridge (BETA)',
27
+ oneditprepare() {
28
+ try { RED.sidebar.show('help'); } catch (error) { /* empty */ }
29
+ const node = this;
30
+
31
+ let oNodeServer = RED.nodes.node($('#node-input-server').val());
32
+ $('#node-input-server').on('change.knxUltimateMatterBridge', function () {
33
+ try { oNodeServer = RED.nodes.node($(this).val()); } catch (error) { /* empty */ }
34
+ });
35
+
36
+ // GA fields of each virtual device type.
37
+ // dir 'command' = Matter -> KNX (GA written on Alexa commands), 'status' = KNX -> Matter.
38
+ const TYPE_FIELDS = {
39
+ onofflight: [
40
+ { key: 'OnOff', labelKey: 'fn_onoff_cmd', dir: 'command', dptPrefix: '1.', fallbackDpt: '1.001' },
41
+ { key: 'OnOffStatus', labelKey: 'fn_onoff_status', dir: 'status', dptPrefix: '1.', fallbackDpt: '1.001' }
42
+ ],
43
+ onoffplug: [
44
+ { key: 'OnOff', labelKey: 'fn_onoff_cmd', dir: 'command', dptPrefix: '1.', fallbackDpt: '1.001' },
45
+ { key: 'OnOffStatus', labelKey: 'fn_onoff_status', dir: 'status', dptPrefix: '1.', fallbackDpt: '1.001' }
46
+ ],
47
+ dimmablelight: [
48
+ { key: 'OnOff', labelKey: 'fn_onoff_cmd', dir: 'command', dptPrefix: '1.', fallbackDpt: '1.001' },
49
+ { key: 'OnOffStatus', labelKey: 'fn_onoff_status', dir: 'status', dptPrefix: '1.', fallbackDpt: '1.001' },
50
+ { key: 'Level', labelKey: 'fn_level_cmd', dir: 'command', dptPrefix: '5.', fallbackDpt: '5.001' },
51
+ { key: 'LevelStatus', labelKey: 'fn_level_status', dir: 'status', dptPrefix: '5.', fallbackDpt: '5.001' }
52
+ ],
53
+ rgblight: [
54
+ { key: 'OnOff', labelKey: 'fn_onoff_cmd', dir: 'command', dptPrefix: '1.', fallbackDpt: '1.001' },
55
+ { key: 'OnOffStatus', labelKey: 'fn_onoff_status', dir: 'status', dptPrefix: '1.', fallbackDpt: '1.001' },
56
+ { key: 'Level', labelKey: 'fn_level_cmd', dir: 'command', dptPrefix: '5.', fallbackDpt: '5.001' },
57
+ { key: 'LevelStatus', labelKey: 'fn_level_status', dir: 'status', dptPrefix: '5.', fallbackDpt: '5.001' },
58
+ { key: 'RGB', labelKey: 'fn_rgb_cmd', dir: 'command', dptPrefix: '232.', fallbackDpt: '232.600' },
59
+ { key: 'RGBStatus', labelKey: 'fn_rgb_status', dir: 'status', dptPrefix: '232.', fallbackDpt: '232.600' }
60
+ ],
61
+ colortemperaturelight: [
62
+ { key: 'OnOff', labelKey: 'fn_onoff_cmd', dir: 'command', dptPrefix: '1.', fallbackDpt: '1.001' },
63
+ { key: 'OnOffStatus', labelKey: 'fn_onoff_status', dir: 'status', dptPrefix: '1.', fallbackDpt: '1.001' },
64
+ { key: 'Level', labelKey: 'fn_level_cmd', dir: 'command', dptPrefix: '5.', fallbackDpt: '5.001' },
65
+ { key: 'LevelStatus', labelKey: 'fn_level_status', dir: 'status', dptPrefix: '5.', fallbackDpt: '5.001' },
66
+ { key: 'CT', labelKey: 'fn_ct_cmd', dir: 'command', dptPrefix: '7.', fallbackDpt: '7.600' },
67
+ { key: 'CTStatus', labelKey: 'fn_ct_status', dir: 'status', dptPrefix: '7.', fallbackDpt: '7.600' }
68
+ ],
69
+ temperaturesensor: [{ key: 'Status', labelKey: 'fn_sensor_ga', dir: 'status', dptPrefix: '9.', fallbackDpt: '9.001' }],
70
+ humiditysensor: [{ key: 'Status', labelKey: 'fn_sensor_ga', dir: 'status', dptPrefix: '9.', fallbackDpt: '9.007' }],
71
+ lightsensor: [{ key: 'Status', labelKey: 'fn_sensor_ga', dir: 'status', dptPrefix: '9.', fallbackDpt: '9.004' }],
72
+ occupancysensor: [{ key: 'Status', labelKey: 'fn_sensor_ga', dir: 'status', dptPrefix: '1.', fallbackDpt: '1.011' }],
73
+ contactsensor: [{ key: 'Status', labelKey: 'fn_sensor_ga', dir: 'status', dptPrefix: '1.', fallbackDpt: '1.002' }],
74
+ windowcovering: [
75
+ { key: 'UpDown', labelKey: 'fn_updown', dir: 'command', dptPrefix: '1.', fallbackDpt: '1.008' },
76
+ { key: 'Stop', labelKey: 'fn_stop', dir: 'command', dptPrefix: '1.', fallbackDpt: '1.017' },
77
+ { key: 'Position', labelKey: 'fn_position_cmd', dir: 'command', dptPrefix: '5.', fallbackDpt: '5.001' },
78
+ { key: 'PositionStatus', labelKey: 'fn_position_status', dir: 'status', dptPrefix: '5.', fallbackDpt: '5.001' }
79
+ ],
80
+ thermostat: [
81
+ { key: 'CurrentTemp', labelKey: 'fn_currenttemp', dir: 'status', dptPrefix: '9.', fallbackDpt: '9.001' },
82
+ { key: 'Setpoint', labelKey: 'fn_setpoint_cmd', dir: 'command', dptPrefix: '9.', fallbackDpt: '9.001' },
83
+ { key: 'SetpointStatus', labelKey: 'fn_setpoint_status', dir: 'status', dptPrefix: '9.', fallbackDpt: '9.001' }
84
+ ],
85
+ smokecoalarm: [
86
+ { key: 'Smoke', labelKey: 'fn_smoke_ga', dir: 'status', dptPrefix: '1.', fallbackDpt: '1.005' },
87
+ { key: 'CO', labelKey: 'fn_co_ga', dir: 'status', dptPrefix: '1.', fallbackDpt: '1.005' }
88
+ ],
89
+ waterleakdetector: [{ key: 'Status', labelKey: 'fn_leak_ga', dir: 'status', dptPrefix: '1.', fallbackDpt: '1.005' }],
90
+ airqualitysensor: [{ key: 'Status', labelKey: 'fn_co2_ga', dir: 'status', dptPrefix: '9.', fallbackDpt: '9.008' }],
91
+ fan: [
92
+ { key: 'FanSpeed', labelKey: 'fn_fanspeed_cmd', dir: 'command', dptPrefix: '5.', fallbackDpt: '5.001' },
93
+ { key: 'FanSpeedStatus', labelKey: 'fn_fanspeed_status', dir: 'status', dptPrefix: '5.', fallbackDpt: '5.001' }
94
+ ],
95
+ robotvacuum: [] // Flow-only: no GA fields, controlled via the node PINs
96
+ };
97
+ const TYPE_ORDER = ['onofflight', 'dimmablelight', 'rgblight', 'colortemperaturelight', 'onoffplug', 'windowcovering', 'thermostat', 'fan', 'temperaturesensor', 'humiditysensor', 'lightsensor', 'occupancysensor', 'contactsensor', 'smokecoalarm', 'waterleakdetector', 'airqualitysensor', 'robotvacuum'];
98
+
99
+ function createLabeledField(row, labelText, element, options = {}) {
100
+ const wrapper = $('<div/>', {
101
+ style: 'display:flex; flex-direction:column; margin-right:10px;'
102
+ }).appendTo(row);
103
+ if (options.width) wrapper.css('width', options.width);
104
+ if (options.flex) wrapper.css('flex', options.flex);
105
+ $('<span/>', {
106
+ text: labelText,
107
+ style: 'font-size:11px; color:#666; margin-bottom:2px;'
108
+ }).appendTo(wrapper);
109
+ element.css('width', '100%');
110
+ element.appendTo(wrapper);
111
+ element.data('fieldWrapper', wrapper);
112
+ return element;
113
+ }
114
+
115
+ function attachGaAutocomplete($ga, $dptHidden, fieldDef) {
116
+ $ga.autocomplete({
117
+ minLength: 0,
118
+ source(request, response) {
119
+ if (!oNodeServer) { response([]); return; }
120
+ $.getJSON(`knxUltimatecsv?nodeID=${oNodeServer.id}`, (data) => {
121
+ response($.map(data, (value) => {
122
+ const search = `${value.ga} (${value.devicename}) DPT${value.dpt}`;
123
+ if (htmlUtilsfullCSVSearch(search, `${request.term} ${fieldDef.dptPrefix}`)) {
124
+ return {
125
+ label: `${value.ga} # ${value.devicename} # ${value.dpt}`,
126
+ value: value.ga,
127
+ dpt: value.dpt
128
+ };
129
+ }
130
+ return null;
131
+ }));
132
+ });
133
+ },
134
+ select(event, ui) {
135
+ $dptHidden.val(ui.item.dpt || fieldDef.fallbackDpt);
136
+ }
137
+ });
138
+ $ga.on('focus.knxUltimateMatterBridge click.knxUltimateMatterBridge', function () {
139
+ try { $(this).autocomplete('search', ''); } catch (error) { /* empty */ }
140
+ });
141
+ try {
142
+ if (oNodeServer && oNodeServer.id) KNX_enableSecureFormatting($ga, oNodeServer.id);
143
+ } catch (error) { /* empty */ }
144
+ }
145
+
146
+ // ------------------------------------------------------------------
147
+ // Devices editable list
148
+ // ------------------------------------------------------------------
149
+ const container = $('#node-input-devices-container');
150
+ container.css('min-height', '260px').css('min-width', '640px').editableList({
151
+ sortable: true,
152
+ removable: true,
153
+ addItem: function (row, index, data) {
154
+ const device = $.extend(true, { id: '', type: 'onofflight', name: '' }, data.device);
155
+ if (!device.id) {
156
+ try { device.id = RED.util.generateId(); } catch (error) { device.id = Math.random().toString(16).slice(2); }
157
+ }
158
+ row.data('deviceId', device.id);
159
+
160
+ const block = $('<div/>').appendTo(row);
161
+ const topRow = $('<div/>').addClass('form-row').css({ display: 'flex', alignItems: 'flex-end', flexWrap: 'wrap', gap: '10px' }).appendTo(block);
162
+ const gaRow = $('<div/>').addClass('form-row').css({ display: 'flex', alignItems: 'flex-end', flexWrap: 'wrap', gap: '10px', marginTop: '6px' }).appendTo(block);
163
+
164
+ // Type
165
+ const $type = $('<select/>', { class: 'mb-type' });
166
+ TYPE_ORDER.forEach((t) => {
167
+ $type.append($('<option></option>').attr('value', t).text(node._('knxUltimateMatterBridge.types.' + t)));
168
+ });
169
+ createLabeledField(topRow, node._('knxUltimateMatterBridge.fields.type'), $type, { width: '190px' });
170
+ $type.val(device.type || 'onofflight');
171
+
172
+ // Name (the one Alexa & Co. show and use)
173
+ const $name = $('<input/>', { type: 'text', class: 'mb-name' }).attr('placeholder', node._('knxUltimateMatterBridge.fields.name_placeholder')).val(device.name);
174
+ createLabeledField(topRow, node._('knxUltimateMatterBridge.fields.name'), $name, { flex: '1 1 220px' });
175
+
176
+ // Position inversion (covers only)
177
+ const $invertWrapper = $('<label/>', { style: 'display:flex; align-items:center; gap:6px; padding:4px 8px; border:1px solid #ced4da; border-radius:4px; cursor:pointer;' }).appendTo(topRow);
178
+ const $invert = $('<input/>', { type: 'checkbox', class: 'mb-invert', style: 'width:auto; margin:0;' }).appendTo($invertWrapper);
179
+ $('<span/>', { text: node._('knxUltimateMatterBridge.fields.invert'), style: 'font-size:12px;' }).appendTo($invertWrapper);
180
+ $invert.prop('checked', device.invertPosition === true);
181
+
182
+ // GA fields: created for every possible function; visibility follows the selected type
183
+ const allFieldKeys = {};
184
+ Object.keys(TYPE_FIELDS).forEach((t) => {
185
+ TYPE_FIELDS[t].forEach((f) => { allFieldKeys[f.key] = f; });
186
+ });
187
+ const gaInputs = {};
188
+ Object.keys(allFieldKeys).forEach((key) => {
189
+ const fieldDef = allFieldKeys[key];
190
+ // The "corrupted key" fallbacks recover values saved by versions where the
191
+ // jQuery UI autocomplete class polluted the field key extraction.
192
+ const gaValue = device[`ga${key}`] !== undefined ? device[`ga${key}`] : (device[`ga${key} ui-autocomplete-input`] || '');
193
+ const dptValue = device[`dpt${key}`] !== undefined ? device[`dpt${key}`] : (device[`dpt${key} ui-autocomplete-input`] || '');
194
+ const $ga = $('<input/>', { type: 'text', class: `mb-ga-${key}`, placeholder: '1/1/1' }).val(gaValue);
195
+ const $dpt = $('<input/>', { type: 'hidden', class: `mb-dpt-${key}` }).val(dptValue);
196
+ createLabeledField(gaRow, '', $ga, { width: '120px' });
197
+ gaRow.append($dpt);
198
+ attachGaAutocomplete($ga, $dpt, fieldDef);
199
+ gaInputs[key] = { $ga, $dpt, fieldDef };
200
+ });
201
+
202
+ const updateTypePresentation = () => {
203
+ const selected = $type.val();
204
+ const active = TYPE_FIELDS[selected] || [];
205
+ Object.keys(gaInputs).forEach((key) => {
206
+ const entry = gaInputs[key];
207
+ const fieldForType = active.find((f) => f.key === key);
208
+ const wrapper = entry.$ga.data('fieldWrapper');
209
+ if (fieldForType) {
210
+ wrapper.show();
211
+ wrapper.find('span').first().text(node._('knxUltimateMatterBridge.functions.' + fieldForType.labelKey));
212
+ } else {
213
+ wrapper.hide();
214
+ }
215
+ });
216
+ if (selected === 'windowcovering') $invertWrapper.show();
217
+ else $invertWrapper.hide();
218
+ };
219
+ $type.on('change', updateTypePresentation);
220
+ updateTypePresentation();
221
+ }
222
+ });
223
+
224
+ (node.devices || []).forEach((d) => {
225
+ container.editableList('addItem', { device: d });
226
+ });
227
+
228
+ // ------------------------------------------------------------------
229
+ // Pairing panel
230
+ // ------------------------------------------------------------------
231
+ const $pairingBody = $('#mb-pairing-body');
232
+ const $refreshPairing = $('#mb-refresh-pairing');
233
+ const $resetButton = $('#mb-factory-reset');
234
+
235
+ function loadPairingInfo() {
236
+ $pairingBody.html('<i class="fa fa-circle-notch fa-spin"></i>');
237
+ $.getJSON(`KNXUltimateMatterBridgeInfo?nodeId=${node.id}&_=${Date.now()}`, (info) => {
238
+ $pairingBody.empty();
239
+ if (info.error) {
240
+ $pairingBody.append($('<p style="color:#999;"></p>').text(info.error));
241
+ return;
242
+ }
243
+ if (!info.running) {
244
+ $pairingBody.append($('<p style="color:#999;"></p>').text(node._('knxUltimateMatterBridge.pairing.not_running')));
245
+ return;
246
+ }
247
+ if (info.commissioned) {
248
+ $pairingBody.append($('<p style="color:#1b7d33;"></p>').text(`${node._('knxUltimateMatterBridge.pairing.commissioned')} (${info.fabrics.length})`));
249
+ info.fabrics.forEach((f) => {
250
+ $pairingBody.append($('<div style="margin-left:10px;"></div>').text(`• ${f.label || 'Controller'} (vendor ${f.vendorId})`));
251
+ });
252
+ $resetButton.show();
253
+ return;
254
+ }
255
+ $pairingBody.append($('<p style="color:#b8860b;"></p>').text(node._('knxUltimateMatterBridge.pairing.awaiting')));
256
+ if (info.manualPairingCode) {
257
+ $pairingBody.append($('<p></p>').append($('<b style="font-size:18px; letter-spacing:2px;"></b>').text(info.manualPairingCode)));
258
+ }
259
+ if (info.qrAscii) {
260
+ $pairingBody.append($('<pre style="font-family:monospace; line-height:1.05; font-size:7px; display:inline-block; padding:6px; background:#fff; color:#000;"></pre>').text(info.qrAscii));
261
+ }
262
+ if (info.qrPairingCode) {
263
+ const url = `https://project-chip.github.io/connectedhomeip/qrcode.html?data=${encodeURIComponent(info.qrPairingCode)}`;
264
+ $pairingBody.append($('<p></p>').append($('<a target="_blank"></a>').attr('href', url).text(node._('knxUltimateMatterBridge.pairing.qr_link'))));
265
+ }
266
+ $resetButton.hide();
267
+ }).fail(() => {
268
+ $pairingBody.empty();
269
+ $pairingBody.append($('<p style="color:#999;"></p>').text(node._('knxUltimateMatterBridge.pairing.deploy_first')));
270
+ });
271
+ }
272
+
273
+ $refreshPairing.on('click.knxUltimateMatterBridge', () => loadPairingInfo());
274
+ $resetButton.on('click.knxUltimateMatterBridge', () => {
275
+ if (!window.confirm(node._('knxUltimateMatterBridge.pairing.reset_confirm'))) return;
276
+ $resetButton.prop('disabled', true);
277
+ $.getJSON(`KNXUltimateMatterBridgeReset?nodeId=${node.id}`, (data) => {
278
+ if (data && data.error) RED.notify(data.error, { type: 'error' });
279
+ else RED.notify(node._('knxUltimateMatterBridge.pairing.reset_ok'), { type: 'success' });
280
+ loadPairingInfo();
281
+ }).always(() => $resetButton.prop('disabled', false));
282
+ });
283
+
284
+ loadPairingInfo();
285
+
286
+ $('#node-input-readStatusAtStartup').val(node.readStatusAtStartup || 'yes');
287
+ $('#node-input-enableNodePINS').val(node.enableNodePINS || 'no');
288
+ },
289
+ oneditsave() {
290
+ try { RED.sidebar.show('info'); } catch (error) { /* empty */ }
291
+ const node = this;
292
+ const items = $('#node-input-devices-container').editableList('items');
293
+ node.devices = [];
294
+ items.each(function () {
295
+ const row = $(this);
296
+ const type = row.find('.mb-type').val();
297
+ const device = {
298
+ id: row.data('deviceId') || '',
299
+ type,
300
+ name: (row.find('.mb-name').val() || '').trim()
301
+ };
302
+ if (!device.name) return; // Skip incomplete rows
303
+ if (type === 'windowcovering') device.invertPosition = row.find('.mb-invert').is(':checked');
304
+ row.find('input[class*="mb-ga-"]').each(function () {
305
+ const $ga = $(this);
306
+ // The class attribute may contain extra classes added by jQuery UI
307
+ // (e.g. "mb-ga-OnOff ui-autocomplete-input"): extract the key safely.
308
+ const match = ($ga.attr('class') || '').match(/mb-ga-([A-Za-z0-9]+)/);
309
+ if (!match) return;
310
+ const key = match[1];
311
+ const ga = ($ga.val() || '').trim();
312
+ if (ga !== '') {
313
+ device[`ga${key}`] = ga;
314
+ device[`dpt${key}`] = (row.find(`.mb-dpt-${key}`).val() || '').trim();
315
+ }
316
+ });
317
+ node.devices.push(device);
318
+ });
319
+ this.enableNodePINS = $('#node-input-enableNodePINS').val() || 'no';
320
+ this.outputs = this.enableNodePINS === 'yes' ? 1 : 0;
321
+ this.inputs = this.enableNodePINS === 'yes' ? 1 : 0;
322
+ },
323
+ oneditcancel() {
324
+ try { RED.sidebar.show('info'); } catch (error) { /* empty */ }
325
+ }
326
+ });
327
+ }());
328
+ </script>
329
+
330
+ <script type="text/html" data-template-name="knxUltimateMatterBridge">
331
+ <div class="form-row" style="margin-bottom:10px;">
332
+ <span style="color:#ff0000"><i class="fa fa-youtube"></i></span>&nbsp;<a target="_blank" href="https://www.youtube.com/playlist?list=PL9Yh1bjbLAYrU8PsVhW4xzEug2WtVFv3E"><b>KNX-Ultimate video tutorials (YouTube playlist)</b></a>
333
+ </div>
334
+
335
+ <div class="form-row">
336
+ <label for="node-input-server">
337
+ <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAKnRFWHRDcmVhdGlvbiBUaW1lAEZyIDYgQXVnIDIwMTAgMjE6NTI6MTkgKzAxMDD84aS8AAAAB3RJTUUH3gYYCicNV+4WIQAAAAlwSFlzAAALEgAACxIB0t1+/AAAAARnQU1BAACxjwv8YQUAAACUSURBVHjaY2CgFZg5c+Z/ZEyWAZ8+f/6/ZsWs/xoamqMGkGrA6Wla/1+fVARjEBuGsSoGmY4eZSCNL59d/g8DIDbIAHR14OgFGQByKjIGKX5+6/T///8gGMQGiV1+/B0Fg70GIkD+RMYgxf/O5/7//2MSmAZhkBi6OrgB6Bg5DGB4ajr3f2xqsYYLSDE2THJUDg0AAAqyDVd4tp4YAAAAAElFTkSuQmCC" />
338
+ <span data-i18n="common.knx_gw"></span>
339
+ </label>
340
+ <input type="text" id="node-input-server">
341
+ </div>
342
+
343
+ <div class="form-row">
344
+ <label for="node-input-name">
345
+ <i class="fa fa-tag"></i> <span data-i18n="common.name"></span>
346
+ </label>
347
+ <input type="text" id="node-input-name">
348
+ </div>
349
+
350
+ <div class="form-row">
351
+ <label for="node-input-bridgeDeviceName" style="width:180px;">
352
+ <i class="fa fa-share-alt"></i> <span data-i18n="knxUltimateMatterBridge.bridge_name"></span>
353
+ </label>
354
+ <input type="text" id="node-input-bridgeDeviceName" style="width:220px;">
355
+ <label for="node-input-bridgePort" style="width:60px; text-align:right;"><span data-i18n="knxUltimateMatterBridge.port"></span></label>
356
+ <input type="number" id="node-input-bridgePort" style="width:90px;">
357
+ </div>
358
+
359
+ <div class="form-tips" style="margin-bottom:8px;">
360
+ <i class="fa fa-circle-info"></i>
361
+ <span data-i18n="knxUltimateMatterBridge.devices_help"></span>
362
+ </div>
363
+
364
+ <div class="form-row node-input-devices-container-row">
365
+ <ol id="node-input-devices-container"></ol>
366
+ </div>
367
+
368
+ <div class="form-row">
369
+ <label for="node-input-readStatusAtStartup" style="width:260px;">
370
+ <i class="fa fa-question-circle"></i> <span data-i18n="knxUltimateMatterBridge.read_status_startup"></span>
371
+ </label>
372
+ <select id="node-input-readStatusAtStartup" style="width:120px;">
373
+ <option value="yes" data-i18n="knxUltimateMatterBridge.opt_yes"></option>
374
+ <option value="no" data-i18n="knxUltimateMatterBridge.opt_no"></option>
375
+ </select>
376
+ </div>
377
+
378
+ <div class="form-row">
379
+ <label for="node-input-enableNodePINS" style="width:260px;">
380
+ <i class="fa fa-code"></i> <span data-i18n="knxUltimateMatterBridge.node_pins"></span>
381
+ </label>
382
+ <select id="node-input-enableNodePINS" style="width:220px;">
383
+ <option value="no" data-i18n="knxUltimateMatterBridge.node_pins_hide"></option>
384
+ <option value="yes" data-i18n="knxUltimateMatterBridge.node_pins_show"></option>
385
+ </select>
386
+ </div>
387
+
388
+ <hr/>
389
+
390
+ <div class="form-row">
391
+ <b><i class="fa fa-qrcode"></i>&nbsp;<span data-i18n="knxUltimateMatterBridge.pairing.title"></span></b>
392
+ <button type="button" id="mb-refresh-pairing" class="red-ui-button" style="margin-left:6px; color:#1b7d33; border-color:#1b7d33;">
393
+ <i class="fa fa-refresh"></i>
394
+ </button>
395
+ <button type="button" id="mb-factory-reset" class="red-ui-button" style="margin-left:6px; color:#c00; border-color:#c00; display:none;">
396
+ <i class="fa fa-trash"></i>&nbsp;<span data-i18n="knxUltimateMatterBridge.pairing.reset_button"></span>
397
+ </button>
398
+ </div>
399
+ <div class="form-row" id="mb-pairing-body" style="text-align:center;"></div>
400
+
401
+ <br/>
402
+ <br/>
403
+ <br/>
404
+ <br/>
405
+ <br/>
406
+ <br/>
407
+ <br/>
408
+ <br/>
409
+ <br/>
410
+ <br/>
411
+ <br/>
412
+ <br/>
413
+ </script>
414
+
415
+ <script type="text/markdown" data-help-name="knxUltimateMatterBridge">
416
+ Exposes **KNX group addresses as Matter devices** (bridge). This node is in **BETA**.
417
+
418
+ Add your KNX virtual devices (lights, plugs, dimmers, sensors), deploy, then open the node again: it shows the **pairing code and QR code**. Scan it with Alexa, Google Home or Apple Home: the app sees a Matter bridge with all your KNX devices inside, using the names you typed.
419
+
420
+ - **Matter → KNX**: commands from the voice assistant write to the *command* group addresses.
421
+ - **KNX → Matter**: telegrams on the *status* group addresses update the Matter attributes (and the app UI).
422
+
423
+ The device must be reachable over the IP network; IPv6 link-local must be enabled on the Node-RED host (standard Matter requirement).
424
+
425
+ If you enable the **node PINs** you can also talk to the bridge from the flow:
426
+
427
+ - input: `msg.payload = { device: "Kitchen light", function: "onoff", value: true }` updates the Matter state without the KNX bus
428
+ - output: every command from a Matter controller is forwarded as `msg` (topic = device name, payload = value, `msg.matter` = raw command)
429
+ </script>