node-red-contrib-knx-ultimate 5.2.1 → 5.2.2

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 (32) hide show
  1. package/CHANGELOG.md +13 -10
  2. package/nodes/commonFunctions.js +9 -9
  3. package/nodes/knxUltimateMatterBridge.html +212 -299
  4. package/nodes/knxUltimateMatterBridge.js +166 -309
  5. package/nodes/knxUltimateMatterDevice.html +46 -0
  6. package/nodes/locales/de/knxUltimateMatterBridge.html +13 -24
  7. package/nodes/locales/de/knxUltimateMatterBridge.json +4 -1
  8. package/nodes/locales/de/matterbridge-config.html +34 -0
  9. package/nodes/locales/de/matterbridge-config.json +20 -0
  10. package/nodes/locales/en/knxUltimateMatterBridge.html +14 -25
  11. package/nodes/locales/en/knxUltimateMatterBridge.json +4 -1
  12. package/nodes/locales/en/matterbridge-config.html +34 -0
  13. package/nodes/locales/en/matterbridge-config.json +20 -0
  14. package/nodes/locales/es/knxUltimateMatterBridge.html +13 -24
  15. package/nodes/locales/es/knxUltimateMatterBridge.json +4 -1
  16. package/nodes/locales/es/matterbridge-config.html +34 -0
  17. package/nodes/locales/es/matterbridge-config.json +20 -0
  18. package/nodes/locales/fr/knxUltimateMatterBridge.html +13 -24
  19. package/nodes/locales/fr/knxUltimateMatterBridge.json +4 -1
  20. package/nodes/locales/fr/matterbridge-config.html +34 -0
  21. package/nodes/locales/fr/matterbridge-config.json +20 -0
  22. package/nodes/locales/it/knxUltimateMatterBridge.html +13 -24
  23. package/nodes/locales/it/knxUltimateMatterBridge.json +4 -1
  24. package/nodes/locales/it/matterbridge-config.html +34 -0
  25. package/nodes/locales/it/matterbridge-config.json +20 -0
  26. package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +13 -24
  27. package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +4 -1
  28. package/nodes/locales/zh-CN/matterbridge-config.html +34 -0
  29. package/nodes/locales/zh-CN/matterbridge-config.json +20 -0
  30. package/nodes/matterbridge-config.html +121 -0
  31. package/nodes/matterbridge-config.js +220 -0
  32. package/package.json +2 -1
@@ -1,24 +1,88 @@
1
- const path = require('path')
2
1
  const dptlib = require('knxultimate').dptlib
3
2
 
4
- // The running Matter servers, keyed by node id. Kept OUTSIDE the node lifecycle:
5
- // a re-deploy must NOT restart the Matter server, otherwise the paired controllers
6
- // resume their session without re-reading the structure and never see device changes.
7
- // The devices are reconciled live instead; the server is closed only when the node
8
- // is deleted/disabled or Node-RED shuts down.
9
- const runningBridges = new Map()
10
-
11
- // Exposes KNX group addresses as Matter devices (bridge/aggregator):
12
- // external Matter controllers (Alexa, Google Home, Apple Home...) commission this
13
- // bridge once and control/see every configured KNX virtual device.
3
+ // One KNX device exposed as a Matter device. It points to a matterbridge-config node
4
+ // (the actual Matter server/bridge, paired once by Alexa/Google/Apple Home) and registers
5
+ // itself as one bridged device. This is the opposite direction of the Matter Device node.
14
6
  module.exports = function (RED) {
7
+ // The functions of each device type, with their GA/DPT config fields.
8
+ // direction 'command' = Matter -> KNX (write to the bus), 'status' = KNX -> Matter.
9
+ const DEVICE_FUNCTIONS = {
10
+ onofflight: [
11
+ { fn: 'onoff', direction: 'command', ga: 'gaOnOff', dpt: 'dptOnOff', fallbackDpt: '1.001' },
12
+ { fn: 'onoff', direction: 'status', ga: 'gaOnOffStatus', dpt: 'dptOnOffStatus', fallbackDpt: '1.001' }
13
+ ],
14
+ onoffplug: [
15
+ { fn: 'onoff', direction: 'command', ga: 'gaOnOff', dpt: 'dptOnOff', fallbackDpt: '1.001' },
16
+ { fn: 'onoff', direction: 'status', ga: 'gaOnOffStatus', dpt: 'dptOnOffStatus', fallbackDpt: '1.001' }
17
+ ],
18
+ dimmablelight: [
19
+ { fn: 'onoff', direction: 'command', ga: 'gaOnOff', dpt: 'dptOnOff', fallbackDpt: '1.001' },
20
+ { fn: 'onoff', direction: 'status', ga: 'gaOnOffStatus', dpt: 'dptOnOffStatus', fallbackDpt: '1.001' },
21
+ { fn: 'level', direction: 'command', ga: 'gaLevel', dpt: 'dptLevel', fallbackDpt: '5.001' },
22
+ { fn: 'level', direction: 'status', ga: 'gaLevelStatus', dpt: 'dptLevelStatus', fallbackDpt: '5.001' }
23
+ ],
24
+ rgblight: [
25
+ { fn: 'onoff', direction: 'command', ga: 'gaOnOff', dpt: 'dptOnOff', fallbackDpt: '1.001' },
26
+ { fn: 'onoff', direction: 'status', ga: 'gaOnOffStatus', dpt: 'dptOnOffStatus', fallbackDpt: '1.001' },
27
+ { fn: 'level', direction: 'command', ga: 'gaLevel', dpt: 'dptLevel', fallbackDpt: '5.001' },
28
+ { fn: 'level', direction: 'status', ga: 'gaLevelStatus', dpt: 'dptLevelStatus', fallbackDpt: '5.001' },
29
+ { fn: 'rgb', direction: 'command', ga: 'gaRGB', dpt: 'dptRGB', fallbackDpt: '232.600' },
30
+ { fn: 'rgb', direction: 'status', ga: 'gaRGBStatus', dpt: 'dptRGBStatus', fallbackDpt: '232.600' }
31
+ ],
32
+ colortemperaturelight: [
33
+ { fn: 'onoff', direction: 'command', ga: 'gaOnOff', dpt: 'dptOnOff', fallbackDpt: '1.001' },
34
+ { fn: 'onoff', direction: 'status', ga: 'gaOnOffStatus', dpt: 'dptOnOffStatus', fallbackDpt: '1.001' },
35
+ { fn: 'level', direction: 'command', ga: 'gaLevel', dpt: 'dptLevel', fallbackDpt: '5.001' },
36
+ { fn: 'level', direction: 'status', ga: 'gaLevelStatus', dpt: 'dptLevelStatus', fallbackDpt: '5.001' },
37
+ { fn: 'colortemp', direction: 'command', ga: 'gaCT', dpt: 'dptCT', fallbackDpt: '7.600' },
38
+ { fn: 'colortemp', direction: 'status', ga: 'gaCTStatus', dpt: 'dptCTStatus', fallbackDpt: '7.600' }
39
+ ],
40
+ temperaturesensor: [{ fn: 'temperature', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '9.001' }],
41
+ humiditysensor: [{ fn: 'humidity', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '9.007' }],
42
+ lightsensor: [{ fn: 'illuminance', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '9.004' }],
43
+ occupancysensor: [{ fn: 'occupancy', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '1.011' }],
44
+ contactsensor: [{ fn: 'contact', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '1.002' }],
45
+ windowcovering: [
46
+ { fn: 'updown', direction: 'command', ga: 'gaUpDown', dpt: 'dptUpDown', fallbackDpt: '1.008' },
47
+ { fn: 'stop', direction: 'command', ga: 'gaStop', dpt: 'dptStop', fallbackDpt: '1.017' },
48
+ { fn: 'position', direction: 'command', ga: 'gaPosition', dpt: 'dptPosition', fallbackDpt: '5.001' },
49
+ { fn: 'position', direction: 'status', ga: 'gaPositionStatus', dpt: 'dptPositionStatus', fallbackDpt: '5.001' }
50
+ ],
51
+ thermostat: [
52
+ { fn: 'currenttemp', direction: 'status', ga: 'gaCurrentTemp', dpt: 'dptCurrentTemp', fallbackDpt: '9.001' },
53
+ { fn: 'setpoint', direction: 'command', ga: 'gaSetpoint', dpt: 'dptSetpoint', fallbackDpt: '9.001' },
54
+ { fn: 'setpoint', direction: 'status', ga: 'gaSetpointStatus', dpt: 'dptSetpointStatus', fallbackDpt: '9.001' }
55
+ ],
56
+ smokecoalarm: [
57
+ { fn: 'smoke', direction: 'status', ga: 'gaSmoke', dpt: 'dptSmoke', fallbackDpt: '1.005' },
58
+ { fn: 'co', direction: 'status', ga: 'gaCO', dpt: 'dptCO', fallbackDpt: '1.005' }
59
+ ],
60
+ waterleakdetector: [{ fn: 'leak', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '1.005' }],
61
+ airqualitysensor: [{ fn: 'co2', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '9.008' }],
62
+ fan: [
63
+ { fn: 'fanspeed', direction: 'command', ga: 'gaFanSpeed', dpt: 'dptFanSpeed', fallbackDpt: '5.001' },
64
+ { fn: 'fanspeed', direction: 'status', ga: 'gaFanSpeedStatus', dpt: 'dptFanSpeedStatus', fallbackDpt: '5.001' }
65
+ ],
66
+ robotvacuum: [] // Flow-only device: talks to the flow through the node PINs
67
+ }
68
+
15
69
  function knxUltimateMatterBridge (config) {
16
70
  RED.nodes.createNode(this, config)
17
71
  const node = this
18
72
  node.serverKNX = RED.nodes.getNode(config.server) || undefined
73
+ node.serverMatterBridge = RED.nodes.getNode(config.serverMatterBridge) || undefined
74
+
75
+ node.deviceType = config.deviceType || 'onofflight'
76
+ node.name = config.name === undefined || config.name === '' ? 'Matter device' : config.name
77
+ // The Matter device id is the (stable) Node-RED node id, so the endpoint survives re-deploys.
78
+ node.matterDeviceId = node.id
79
+ node.invertPosition = config.invertPosition === true || config.invertPosition === 'true'
80
+ node.readStatusAtStartup = config.readStatusAtStartup === undefined || config.readStatusAtStartup === 'yes'
81
+ node.enableNodePINS = config.enableNodePINS === 'yes'
82
+ node.inputs = node.enableNodePINS ? 1 : 0
83
+ node.outputs = node.enableNodePINS ? 1 : 0
19
84
 
20
- node.topic = node.name
21
- node.name = config.name === undefined || config.name === '' ? 'Matter Bridge (BETA)' : config.name
85
+ node.topic = ''
22
86
  node.dpt = ''
23
87
  node.notifyreadrequest = false
24
88
  node.notifyreadrequestalsorespondtobus = 'false'
@@ -36,87 +100,7 @@ module.exports = function (RED) {
36
100
  node.formatnegativevalue = 'leave'
37
101
  node.formatdecimalsvalue = 2
38
102
 
39
- node.bridgePort = Number(config.bridgePort) || 5540
40
- node.bridgeDeviceName = config.bridgeDeviceName || 'KNX-Ultimate Bridge'
41
- node.readStatusAtStartup = config.readStatusAtStartup === undefined || config.readStatusAtStartup === 'yes'
42
- node.enableNodePINS = config.enableNodePINS === 'yes'
43
- node.inputs = node.enableNodePINS ? 1 : 0
44
- node.outputs = node.enableNodePINS ? 1 : 0
45
- node.devices = []
46
- try {
47
- node.devices = Array.isArray(config.devices) ? config.devices : JSON.parse(config.devices || '[]')
48
- } catch (error) {
49
- node.devices = []
50
- }
51
-
52
- node.matterBridge = null
53
- node.matterInstanceId = `knxultimate-bridge-${node.id.replace(/[^a-zA-Z0-9]/g, '')}`
54
- node.matterStoragePath = path.join(RED.settings.userDir || '.', 'knxultimatestorage', 'matter')
55
- let startupTimer = null
56
103
  let initialReadTimer = null
57
- let closing = false
58
-
59
- // The functions of each device type, with their GA/DPT config fields.
60
- // direction 'command' = Matter -> KNX (write to the bus), 'status' = KNX -> Matter.
61
- const DEVICE_FUNCTIONS = {
62
- onofflight: [
63
- { fn: 'onoff', direction: 'command', ga: 'gaOnOff', dpt: 'dptOnOff', fallbackDpt: '1.001' },
64
- { fn: 'onoff', direction: 'status', ga: 'gaOnOffStatus', dpt: 'dptOnOffStatus', fallbackDpt: '1.001' }
65
- ],
66
- onoffplug: [
67
- { fn: 'onoff', direction: 'command', ga: 'gaOnOff', dpt: 'dptOnOff', fallbackDpt: '1.001' },
68
- { fn: 'onoff', direction: 'status', ga: 'gaOnOffStatus', dpt: 'dptOnOffStatus', fallbackDpt: '1.001' }
69
- ],
70
- dimmablelight: [
71
- { fn: 'onoff', direction: 'command', ga: 'gaOnOff', dpt: 'dptOnOff', fallbackDpt: '1.001' },
72
- { fn: 'onoff', direction: 'status', ga: 'gaOnOffStatus', dpt: 'dptOnOffStatus', fallbackDpt: '1.001' },
73
- { fn: 'level', direction: 'command', ga: 'gaLevel', dpt: 'dptLevel', fallbackDpt: '5.001' },
74
- { fn: 'level', direction: 'status', ga: 'gaLevelStatus', dpt: 'dptLevelStatus', fallbackDpt: '5.001' }
75
- ],
76
- rgblight: [
77
- { fn: 'onoff', direction: 'command', ga: 'gaOnOff', dpt: 'dptOnOff', fallbackDpt: '1.001' },
78
- { fn: 'onoff', direction: 'status', ga: 'gaOnOffStatus', dpt: 'dptOnOffStatus', fallbackDpt: '1.001' },
79
- { fn: 'level', direction: 'command', ga: 'gaLevel', dpt: 'dptLevel', fallbackDpt: '5.001' },
80
- { fn: 'level', direction: 'status', ga: 'gaLevelStatus', dpt: 'dptLevelStatus', fallbackDpt: '5.001' },
81
- { fn: 'rgb', direction: 'command', ga: 'gaRGB', dpt: 'dptRGB', fallbackDpt: '232.600' },
82
- { fn: 'rgb', direction: 'status', ga: 'gaRGBStatus', dpt: 'dptRGBStatus', fallbackDpt: '232.600' }
83
- ],
84
- colortemperaturelight: [
85
- { fn: 'onoff', direction: 'command', ga: 'gaOnOff', dpt: 'dptOnOff', fallbackDpt: '1.001' },
86
- { fn: 'onoff', direction: 'status', ga: 'gaOnOffStatus', dpt: 'dptOnOffStatus', fallbackDpt: '1.001' },
87
- { fn: 'level', direction: 'command', ga: 'gaLevel', dpt: 'dptLevel', fallbackDpt: '5.001' },
88
- { fn: 'level', direction: 'status', ga: 'gaLevelStatus', dpt: 'dptLevelStatus', fallbackDpt: '5.001' },
89
- { fn: 'colortemp', direction: 'command', ga: 'gaCT', dpt: 'dptCT', fallbackDpt: '7.600' },
90
- { fn: 'colortemp', direction: 'status', ga: 'gaCTStatus', dpt: 'dptCTStatus', fallbackDpt: '7.600' }
91
- ],
92
- temperaturesensor: [{ fn: 'temperature', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '9.001' }],
93
- humiditysensor: [{ fn: 'humidity', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '9.007' }],
94
- lightsensor: [{ fn: 'illuminance', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '9.004' }],
95
- occupancysensor: [{ fn: 'occupancy', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '1.011' }],
96
- contactsensor: [{ fn: 'contact', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '1.002' }],
97
- windowcovering: [
98
- { fn: 'updown', direction: 'command', ga: 'gaUpDown', dpt: 'dptUpDown', fallbackDpt: '1.008' },
99
- { fn: 'stop', direction: 'command', ga: 'gaStop', dpt: 'dptStop', fallbackDpt: '1.017' },
100
- { fn: 'position', direction: 'command', ga: 'gaPosition', dpt: 'dptPosition', fallbackDpt: '5.001' },
101
- { fn: 'position', direction: 'status', ga: 'gaPositionStatus', dpt: 'dptPositionStatus', fallbackDpt: '5.001' }
102
- ],
103
- thermostat: [
104
- { fn: 'currenttemp', direction: 'status', ga: 'gaCurrentTemp', dpt: 'dptCurrentTemp', fallbackDpt: '9.001' },
105
- { fn: 'setpoint', direction: 'command', ga: 'gaSetpoint', dpt: 'dptSetpoint', fallbackDpt: '9.001' },
106
- { fn: 'setpoint', direction: 'status', ga: 'gaSetpointStatus', dpt: 'dptSetpointStatus', fallbackDpt: '9.001' }
107
- ],
108
- smokecoalarm: [
109
- { fn: 'smoke', direction: 'status', ga: 'gaSmoke', dpt: 'dptSmoke', fallbackDpt: '1.005' },
110
- { fn: 'co', direction: 'status', ga: 'gaCO', dpt: 'dptCO', fallbackDpt: '1.005' }
111
- ],
112
- waterleakdetector: [{ fn: 'leak', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '1.005' }],
113
- airqualitysensor: [{ fn: 'co2', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '9.008' }],
114
- fan: [
115
- { fn: 'fanspeed', direction: 'command', ga: 'gaFanSpeed', dpt: 'dptFanSpeed', fallbackDpt: '5.001' },
116
- { fn: 'fanspeed', direction: 'status', ga: 'gaFanSpeedStatus', dpt: 'dptFanSpeedStatus', fallbackDpt: '5.001' }
117
- ],
118
- robotvacuum: [] // Flow-only device: talks to the flow through the node PINs
119
- }
120
104
 
121
105
  const formatTs = (date) => {
122
106
  const d = date instanceof Date ? date : new Date(date)
@@ -157,205 +141,94 @@ module.exports = function (RED) {
157
141
  }
158
142
  }
159
143
 
160
- // Precompute the routing tables:
161
- // statusRoutes: GA -> [{ deviceId, fn, dpt }] (KNX -> Matter)
162
- // commandRoutes: deviceId + fn -> { ga, dpt } (Matter -> KNX)
144
+ // Precompute the routing tables for this single device:
145
+ // statusRoutes: GA -> [{ fn, dpt }] (KNX -> Matter)
146
+ // commandRoutes: fn -> { ga, dpt } (Matter -> KNX)
163
147
  const statusRoutes = new Map()
164
148
  const commandRoutes = new Map()
165
- // Reads a device config value, also recovering the corrupted keys written by early
166
- // 5.2.0 editors (jQuery UI polluted the key with " ui-autocomplete-input").
167
- const devConfigValue = (dev, key) => {
168
- if (dev[key] !== undefined) return (dev[key] || '').trim()
169
- const corrupted = dev[`${key} ui-autocomplete-input`]
170
- return (corrupted || '').trim()
171
- }
172
- node.devices.forEach((dev) => {
173
- const functions = DEVICE_FUNCTIONS[dev.type] || []
174
- functions.forEach((fnDef) => {
175
- const ga = devConfigValue(dev, fnDef.ga)
176
- if (ga === '') return
177
- const dpt = devConfigValue(dev, fnDef.dpt) || fnDef.fallbackDpt
178
- if (fnDef.direction === 'status') {
179
- if (!statusRoutes.has(ga)) statusRoutes.set(ga, [])
180
- statusRoutes.get(ga).push({ deviceId: dev.id, fn: fnDef.fn, dpt })
181
- } else {
182
- commandRoutes.set(`${dev.id}#${fnDef.fn}`, { ga, dpt })
183
- }
184
- })
149
+ const devConfigValue = (key) => (config[key] || '').toString().trim()
150
+ ;(DEVICE_FUNCTIONS[node.deviceType] || []).forEach((fnDef) => {
151
+ const ga = devConfigValue(fnDef.ga)
152
+ if (ga === '') return
153
+ const dpt = devConfigValue(fnDef.dpt) || fnDef.fallbackDpt
154
+ if (fnDef.direction === 'status') {
155
+ if (!statusRoutes.has(ga)) statusRoutes.set(ga, [])
156
+ statusRoutes.get(ga).push({ fn: fnDef.fn, dpt })
157
+ } else {
158
+ commandRoutes.set(fnDef.fn, { ga, dpt })
159
+ }
185
160
  })
186
161
 
187
- const updateBridgeStatus = () => {
162
+ // The device definition consumed by the bridge engine (via the config node).
163
+ node.getMatterDef = () => ({
164
+ id: node.matterDeviceId,
165
+ type: node.deviceType,
166
+ name: node.name,
167
+ invertPosition: node.invertPosition === true
168
+ })
169
+
170
+ // Reflects the bridge (config node) status on this device node.
171
+ node.handleBridgeStatus = () => {
188
172
  try {
189
- if (node.matterBridge === null) {
190
- node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Matter bridge starting...' })
173
+ if (node.serverMatterBridge === undefined) {
174
+ node.setNodeStatus({ fill: 'red', shape: 'ring', text: 'No Matter bridge selected' })
191
175
  return
192
176
  }
193
- const info = node.matterBridge.getPairingInfo()
177
+ const info = node.serverMatterBridge.getPairingInfo()
194
178
  if (!info.running) {
195
- node.setNodeStatus({ fill: 'red', shape: 'ring', text: 'Matter bridge stopped' })
179
+ node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Matter bridge starting...' })
196
180
  } else if (info.commissioned) {
197
- node.setNodeStatus({ fill: 'green', shape: 'dot', text: `Bridge online, paired with ${info.fabrics.length} controller(s), ${info.deviceCount} device(s), ${commandRoutes.size} cmd GA, ${statusRoutes.size} status GA` })
181
+ node.setNodeStatus({ fill: 'green', shape: 'dot', text: `Ready (bridge paired with ${info.fabrics.length} controller(s))` })
198
182
  } else {
199
- node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: `Awaiting pairing. Code ${info.manualPairingCode || ''}, ${info.deviceCount} device(s)` })
183
+ node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: `Bridge awaiting pairing (code ${info.manualPairingCode || ''})` })
200
184
  }
201
185
  } catch (error) { /* empty */ }
202
186
  }
203
187
 
204
- // Ask the KNX bus for the current values of all status GAs, so the Matter
205
- // attributes are populated before a controller reads them.
206
- const doInitialRead = () => {
207
- if (!node.readStatusAtStartup) return
208
- if (!node.serverKNX) return // Flow-only bridge (no KNX gateway): nothing to read
188
+ // Matter -> KNX: a controller (Alexa...) sent a command to this device.
189
+ node.handleMatterCommand = (command) => {
209
190
  try {
210
- let delay = 0
211
- statusRoutes.forEach((routes, ga) => {
212
- delay += 200
213
- setTimeout(() => {
214
- safeSendToKNX({ grpaddr: ga, payload: '', dpt: '', outputtype: 'read' }, 'read')
215
- }, delay)
216
- })
217
- } catch (error) {
218
- node.setNodeStatus({ fill: 'red', shape: 'dot', text: `Initial read error ${error.message}` })
219
- }
220
- }
221
-
222
- const bindEngineEvents = (engine) => {
223
- // Matter -> KNX: a controller (Alexa...) sent a command to a bridged device
224
- engine.on('command', (command) => {
225
- try {
226
- const dev = node.devices.find((d) => d.id === command.deviceId)
227
- // Forward every controller command to the flow, when the pins are enabled
228
- if (node.enableNodePINS) {
229
- try {
230
- node.send({
231
- topic: dev !== undefined ? dev.name : command.deviceId,
232
- payload: command.value,
233
- device: dev !== undefined ? { id: dev.id, type: dev.type, name: dev.name } : { id: command.deviceId },
234
- matter: command
235
- })
236
- } catch (error) { /* empty */ }
237
- }
238
- const route = commandRoutes.get(`${command.deviceId}#${command.fn}`)
239
- if (route === undefined) {
240
- if (node.enableNodePINS) return // Flow-only device: the flow already got the command
241
- // Make the misconfiguration visible instead of silently dropping the command
242
- node.setNodeStatus({
243
- fill: 'yellow',
244
- shape: 'ring',
245
- text: `No command GA configured for "${dev !== undefined ? dev.name : command.deviceId}" (${command.fn}): open the node, check the GA fields and re-deploy`,
246
- payload: ''
247
- })
248
- RED.log.warn(`knxUltimateMatterBridge: Matter command dropped, no GA route for ${command.deviceId}#${command.fn}`)
249
- return
250
- }
251
- if (!node.serverKNX) {
252
- // Flow-only bridge: with the PINs enabled the flow already received the command
253
- if (node.enableNodePINS) return
254
- node.setNodeStatus({
255
- fill: 'yellow',
256
- shape: 'ring',
257
- text: `No KNX gateway configured: enable the node PINs to handle "${dev !== undefined ? dev.name : command.deviceId}" from the flow`,
258
- payload: ''
191
+ if (node.enableNodePINS) {
192
+ try {
193
+ node.send({
194
+ topic: node.name,
195
+ payload: command.value,
196
+ device: { id: node.matterDeviceId, type: node.deviceType, name: node.name },
197
+ matter: command
259
198
  })
260
- return
261
- }
262
- safeSendToKNX({
263
- grpaddr: route.ga,
264
- payload: command.value,
265
- dpt: route.dpt,
266
- outputtype: 'write'
267
- }, 'write')
268
- node.setNodeStatus({
269
- fill: 'green',
270
- shape: 'dot',
271
- text: `Matter->KNX ${dev !== undefined ? dev.name : command.deviceId} ${command.fn}`,
272
- payload: command.value
273
- })
274
- } catch (error) {
275
- node.setNodeStatus({ fill: 'red', shape: 'dot', text: `Matter->KNX error ${error.message}` })
199
+ } catch (error) { /* empty */ }
276
200
  }
277
- })
278
-
279
- engine.on('commissioned', () => updateBridgeStatus())
280
- engine.on('decommissioned', () => updateBridgeStatus())
281
- engine.on('fabricsChanged', () => updateBridgeStatus())
282
- engine.on('online', () => {
283
- updateBridgeStatus()
284
- if (initialReadTimer !== null) clearTimeout(initialReadTimer)
285
- initialReadTimer = setTimeout(() => doInitialRead(), 5000)
286
- })
287
- }
288
-
289
- const buildEngineDefs = () => node.devices
290
- .filter((dev) => dev.id && dev.type && DEVICE_FUNCTIONS[dev.type] !== undefined)
291
- .map((dev) => ({ id: dev.id, type: dev.type, name: dev.name, invertPosition: dev.invertPosition === true }))
292
-
293
- const startBridge = async () => {
294
- try {
295
- const { classMatterBridge } = await import('./utils/matterBridgeEngine.mjs')
296
- const engineDefs = buildEngineDefs()
297
- const existing = runningBridges.get(node.id)
298
-
299
- if (existing !== undefined && existing.bridgeStatus === 'running' &&
300
- Number(existing.options.port) === node.bridgePort &&
301
- existing.options.deviceName === node.bridgeDeviceName) {
302
- // Re-deploy: reuse the running Matter server and reconcile the devices live,
303
- // so the paired controllers (Alexa & Co.) pick up the changes immediately.
304
- existing.removeAllListeners()
305
- node.matterBridge = existing
306
- bindEngineEvents(existing)
307
- await existing.reconcileDevices(engineDefs)
308
- updateBridgeStatus()
309
- if (initialReadTimer !== null) clearTimeout(initialReadTimer)
310
- initialReadTimer = setTimeout(() => doInitialRead(), 5000)
201
+ const route = commandRoutes.get(command.fn)
202
+ if (route === undefined) {
203
+ if (node.enableNodePINS) return // Flow-only device: the flow already got the command
204
+ node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: `No command GA for ${command.fn}: check the node and re-deploy` })
311
205
  return
312
206
  }
313
-
314
- if (existing !== undefined) {
315
- // Port or bridge name changed: a full restart is unavoidable
316
- try {
317
- existing.removeAllListeners()
318
- await existing.close()
319
- } catch (error) { /* empty */ }
320
- runningBridges.delete(node.id)
207
+ if (!node.serverKNX) {
208
+ if (node.enableNodePINS) return
209
+ node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: `No KNX gateway: enable the node PINs to handle ${command.fn}` })
210
+ return
321
211
  }
322
-
323
- const engine = new classMatterBridge(node.matterStoragePath, node.matterInstanceId, {
324
- info: (m) => RED.log.info(m),
325
- warn: (m) => RED.log.warn(m),
326
- error: (m) => RED.log.error(m),
327
- debug: (m) => RED.log.debug(m)
328
- }, { port: node.bridgePort, deviceName: node.bridgeDeviceName })
329
- runningBridges.set(node.id, engine)
330
- node.matterBridge = engine
331
- bindEngineEvents(engine)
332
- await engine.start(engineDefs)
333
- updateBridgeStatus()
212
+ safeSendToKNX({ grpaddr: route.ga, payload: command.value, dpt: route.dpt, outputtype: 'write' }, 'write')
213
+ node.setNodeStatus({ fill: 'green', shape: 'dot', text: `Matter->KNX ${command.fn}`, payload: command.value })
334
214
  } catch (error) {
335
- node.setNodeStatus({ fill: 'red', shape: 'dot', text: `Matter bridge start error: ${error.message}` })
336
- RED.log.error(`knxUltimateMatterBridge: start error: ${error.message}`)
215
+ node.setNodeStatus({ fill: 'red', shape: 'dot', text: `Matter->KNX error ${error.message}` })
337
216
  }
338
217
  }
339
218
 
340
- // KNX -> Matter: telegrams from the bus update the Matter attributes
219
+ // KNX -> Matter: telegrams from the bus update the Matter attributes of this device.
341
220
  node.handleSend = (msg) => {
342
221
  try {
343
222
  if (!msg || !msg.knx) return
344
223
  if (msg.knx.event === 'GroupValue_Read') return
345
224
  const routes = statusRoutes.get(msg.knx.destination)
346
- if (routes === undefined || node.matterBridge === null) return
225
+ if (routes === undefined || node.serverMatterBridge === undefined) return
347
226
  routes.forEach((route) => {
348
227
  try {
349
228
  const value = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(route.dpt))
350
229
  if (value === undefined || value === null) return
351
- node.matterBridge.setDeviceState(route.deviceId, route.fn, value)
352
- const dev = node.devices.find((d) => d.id === route.deviceId)
353
- node.setNodeStatus({
354
- fill: 'blue',
355
- shape: 'dot',
356
- text: `KNX->Matter ${dev !== undefined ? dev.name : route.deviceId} ${route.fn}`,
357
- payload: value
358
- })
230
+ node.serverMatterBridge.setDeviceState(node.matterDeviceId, route.fn, value)
231
+ node.setNodeStatus({ fill: 'blue', shape: 'dot', text: `KNX->Matter ${route.fn}`, payload: value })
359
232
  } catch (error) {
360
233
  node.setNodeStatus({ fill: 'red', shape: 'dot', text: `KNX->Matter error ${error.message}` })
361
234
  }
@@ -365,18 +238,22 @@ module.exports = function (RED) {
365
238
  }
366
239
  }
367
240
 
368
- // Called by the admin endpoints (editor UI)
369
- node.getBridgePairingInfo = () => {
370
- if (node.matterBridge === null) return { running: false }
371
- return node.matterBridge.getPairingInfo()
372
- }
373
-
374
- node.factoryResetBridge = async () => {
375
- if (node.matterBridge === null) throw new Error('Matter bridge not started')
376
- await node.matterBridge.factoryReset()
377
- updateBridgeStatus()
241
+ // Ask the KNX bus for the current values of the status GAs, so the Matter
242
+ // attributes are populated before a controller reads them.
243
+ const doInitialRead = () => {
244
+ if (!node.readStatusAtStartup || !node.serverKNX) return
245
+ try {
246
+ let delay = 0
247
+ statusRoutes.forEach((routes, ga) => {
248
+ delay += 200
249
+ setTimeout(() => {
250
+ safeSendToKNX({ grpaddr: ga, payload: '', dpt: '', outputtype: 'read' }, 'read')
251
+ }, delay)
252
+ })
253
+ } catch (error) { /* empty */ }
378
254
  }
379
255
 
256
+ // Register with the KNX gateway (to receive bus telegrams) and the Matter bridge.
380
257
  if (node.serverKNX) {
381
258
  try {
382
259
  node.serverKNX.removeClient(node)
@@ -385,9 +262,20 @@ module.exports = function (RED) {
385
262
  RED.log.error(`knxUltimateMatterBridge: register KNX client error ${error.message}`)
386
263
  }
387
264
  }
265
+ if (node.serverMatterBridge) {
266
+ try {
267
+ node.serverMatterBridge.registerDevice(node)
268
+ } catch (error) {
269
+ RED.log.error(`knxUltimateMatterBridge: register bridge device error ${error.message}`)
270
+ }
271
+ } else {
272
+ node.setNodeStatus({ fill: 'red', shape: 'ring', text: 'No Matter bridge selected' })
273
+ }
274
+
275
+ initialReadTimer = setTimeout(() => doInitialRead(), 12000)
388
276
 
389
- // Flow input pin: updates the Matter state of a virtual device without going through the KNX bus.
390
- // msg.payload = { device: 'Kitchen light' (name or id), function: 'onoff'|'level'|'position'|'temperature'|..., value: ... }
277
+ // Flow input pin: updates the Matter state of this device without going through the KNX bus.
278
+ // msg.payload = { function: 'onoff'|'level'|'position'|'temperature'|..., value: ... }
391
279
  node.on('input', (msg, send, done) => {
392
280
  if (!node.enableNodePINS) {
393
281
  if (done) done()
@@ -395,16 +283,11 @@ module.exports = function (RED) {
395
283
  }
396
284
  try {
397
285
  const payload = msg.payload || {}
398
- const deviceKey = (payload.device !== undefined ? payload.device : msg.topic || '').toString().trim()
399
286
  const fn = (payload.function || payload.fn || '').toString().trim()
400
- if (deviceKey === '' || fn === '' || payload.value === undefined) {
401
- throw new Error('msg.payload must be { device, function, value }')
402
- }
403
- const dev = node.devices.find((d) => d.id === deviceKey || (d.name || '').toLowerCase() === deviceKey.toLowerCase())
404
- if (dev === undefined) throw new Error(`Unknown bridge device "${deviceKey}"`)
405
- if (node.matterBridge === null) throw new Error('Matter bridge not started yet')
406
- node.matterBridge.setDeviceState(dev.id, fn, payload.value)
407
- node.setNodeStatus({ fill: 'green', shape: 'dot', text: `Flow->Matter ${dev.name} ${fn}`, payload: payload.value })
287
+ if (fn === '' || payload.value === undefined) throw new Error('msg.payload must be { function, value }')
288
+ if (node.serverMatterBridge === undefined) throw new Error('No Matter bridge selected')
289
+ node.serverMatterBridge.setDeviceState(node.matterDeviceId, fn, payload.value)
290
+ node.setNodeStatus({ fill: 'green', shape: 'dot', text: `Flow->Matter ${fn}`, payload: payload.value })
408
291
  if (done) done()
409
292
  } catch (error) {
410
293
  node.setNodeStatus({ fill: 'red', shape: 'dot', text: `Flow error ${error.message}`, payload: '' })
@@ -412,43 +295,17 @@ module.exports = function (RED) {
412
295
  }
413
296
  })
414
297
 
415
- node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Matter bridge starting in 5s...' })
416
- startupTimer = setTimeout(() => {
417
- (async () => {
418
- if (closing) return
419
- try {
420
- await startBridge()
421
- } catch (error) {
422
- RED.log.error(`knxUltimateMatterBridge: startup error: ${error.message}`)
423
- }
424
- })()
425
- }, 5000)
426
-
427
- // On re-deploy (removed === false) the Matter server stays alive: the new node
428
- // instance will reuse it and reconcile the devices, so paired controllers never
429
- // lose the session. The server is closed only when the node is deleted/disabled.
430
- node.on('close', (removed, done) => {
431
- closing = true
298
+ node.on('close', (done) => {
432
299
  try {
433
- if (startupTimer !== null) clearTimeout(startupTimer)
434
300
  if (initialReadTimer !== null) clearTimeout(initialReadTimer)
435
301
  } catch (error) { /* empty */ }
436
302
  try {
437
303
  if (node.serverKNX) node.serverKNX.removeClient(node)
438
304
  } catch (error) { /* empty */ }
439
305
  try {
440
- if (node.matterBridge !== null) node.matterBridge.removeAllListeners()
306
+ if (node.serverMatterBridge) node.serverMatterBridge.unregisterDevice(node)
441
307
  } catch (error) { /* empty */ }
442
- (async () => {
443
- try {
444
- if (removed === true && node.matterBridge !== null) {
445
- await node.matterBridge.close()
446
- runningBridges.delete(node.id)
447
- }
448
- } catch (error) { /* empty */ }
449
- node.matterBridge = null
450
- done()
451
- })()
308
+ if (done) done()
452
309
  })
453
310
  }
454
311
 
@@ -3,6 +3,32 @@
3
3
 
4
4
  <script type="text/javascript">
5
5
  (function () {
6
+ let manualListResize = false;
7
+ let programmaticListHeight = -1;
8
+
9
+ // Adds the native browser resize grip (bottom-right corner) to the mappings list,
10
+ // so the user can drag it to any height, exactly like a textarea.
11
+ function enableManualListResize(container) {
12
+ try {
13
+ manualListResize = false;
14
+ programmaticListHeight = -1;
15
+ const $ui = container.closest('.red-ui-editableList-container');
16
+ if (!$ui.length) return;
17
+ $ui.css('resize', 'vertical');
18
+ if (window.ResizeObserver) {
19
+ const observer = new ResizeObserver(() => {
20
+ try {
21
+ const h = $ui.outerHeight();
22
+ if (programmaticListHeight > 0 && Math.abs(h - programmaticListHeight) > 8) {
23
+ manualListResize = true;
24
+ }
25
+ } catch (error) { /* empty */ }
26
+ });
27
+ observer.observe($ui[0]);
28
+ }
29
+ } catch (error) { /* empty */ }
30
+ }
31
+
6
32
  RED.nodes.registerType('knxUltimateMatterDevice', {
7
33
  category: 'KNX Ultimate Matter',
8
34
  color: '#D7C7E9',
@@ -443,6 +469,8 @@
443
469
  container.editableList('addItem', { mapping: m });
444
470
  });
445
471
 
472
+ enableManualListResize(container);
473
+
446
474
  // Load the structure of the already selected device
447
475
  if (node.matterNodeId) {
448
476
  loadStructure(node.matterNodeId);
@@ -487,6 +515,24 @@
487
515
  },
488
516
  oneditcancel() {
489
517
  try { RED.sidebar.show('info'); } catch (error) { /* empty */ }
518
+ },
519
+ oneditresize(size) {
520
+ // Grow the mappings list to fill the edit panel. Once the user grabs the native
521
+ // resize grip of the list, the manual height wins and the auto-fit backs off.
522
+ try {
523
+ if (manualListResize) return;
524
+ const rows = $('#dialog-form>div:not(.node-input-mapping-container-row)');
525
+ let height = size.height;
526
+ for (let i = 0; i < rows.length; i++) {
527
+ height -= $(rows[i]).outerHeight(true);
528
+ }
529
+ const editorRow = $('#dialog-form>div.node-input-mapping-container-row');
530
+ height -= (parseInt(editorRow.css('marginTop')) + parseInt(editorRow.css('marginBottom')));
531
+ height += 16;
532
+ const target = Math.max(220, height);
533
+ programmaticListHeight = target;
534
+ $('#node-input-mapping-container').editableList('height', target);
535
+ } catch (error) { /* empty */ }
490
536
  }
491
537
  });
492
538
  }());