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
@@ -0,0 +1,456 @@
1
+ const path = require('path')
2
+ const dptlib = require('knxultimate').dptlib
3
+
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.
14
+ module.exports = function (RED) {
15
+ function knxUltimateMatterBridge (config) {
16
+ RED.nodes.createNode(this, config)
17
+ const node = this
18
+ node.serverKNX = RED.nodes.getNode(config.server) || undefined
19
+
20
+ node.topic = node.name
21
+ node.name = config.name === undefined || config.name === '' ? 'Matter Bridge (BETA)' : config.name
22
+ node.dpt = ''
23
+ node.notifyreadrequest = false
24
+ node.notifyreadrequestalsorespondtobus = 'false'
25
+ node.notifyreadrequestalsorespondtobusdefaultvalueifnotinitialized = ''
26
+ node.notifyresponse = false
27
+ node.notifywrite = true
28
+ node.initialread = false
29
+ node.listenallga = true // Don't remove
30
+ node.outputtype = 'write'
31
+ node.outputRBE = 'false'
32
+ node.inputRBE = 'false'
33
+ node.currentPayload = ''
34
+ node.passthrough = 'no'
35
+ node.formatmultiplyvalue = 1
36
+ node.formatnegativevalue = 'leave'
37
+ node.formatdecimalsvalue = 2
38
+
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
+ 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
+
121
+ const formatTs = (date) => {
122
+ const d = date instanceof Date ? date : new Date(date)
123
+ const provider = node.serverKNX
124
+ if (provider && typeof provider.formatStatusTimestamp === 'function') return provider.formatStatusTimestamp(d)
125
+ return `${d.getDate()}, ${d.toLocaleTimeString()}`
126
+ }
127
+
128
+ const pushStatus = (status) => {
129
+ if (!status) return
130
+ try {
131
+ const provider = node.serverKNX
132
+ if (provider && typeof provider.applyStatusUpdate === 'function') {
133
+ provider.applyStatusUpdate(node, status)
134
+ } else {
135
+ node.status(status)
136
+ }
137
+ } catch (error) { /* empty */ }
138
+ }
139
+
140
+ node.setNodeStatus = ({ fill, shape, text, payload }) => {
141
+ try {
142
+ if (payload === undefined) payload = ''
143
+ payload = typeof payload === 'object' ? JSON.stringify(payload) : payload.toString()
144
+ pushStatus({ fill, shape, text: `${text} ${payload} (${formatTs(new Date())})` })
145
+ } catch (error) { /* empty */ }
146
+ }
147
+
148
+ const safeSendToKNX = (telegram, context = 'write') => {
149
+ try {
150
+ if (!node.serverKNX || typeof node.serverKNX.sendKNXTelegramToKNXEngine !== 'function') {
151
+ node.setNodeStatus({ fill: 'red', shape: 'dot', text: `KNX server missing (${context})` })
152
+ return
153
+ }
154
+ node.serverKNX.sendKNXTelegramToKNXEngine({ ...telegram, nodecallerid: node.id })
155
+ } catch (error) {
156
+ node.setNodeStatus({ fill: 'red', shape: 'dot', text: `KNX send error ${error.message}` })
157
+ }
158
+ }
159
+
160
+ // Precompute the routing tables:
161
+ // statusRoutes: GA -> [{ deviceId, fn, dpt }] (KNX -> Matter)
162
+ // commandRoutes: deviceId + fn -> { ga, dpt } (Matter -> KNX)
163
+ const statusRoutes = new Map()
164
+ 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
+ })
185
+ })
186
+
187
+ const updateBridgeStatus = () => {
188
+ try {
189
+ if (node.matterBridge === null) {
190
+ node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Matter bridge starting...' })
191
+ return
192
+ }
193
+ const info = node.matterBridge.getPairingInfo()
194
+ if (!info.running) {
195
+ node.setNodeStatus({ fill: 'red', shape: 'ring', text: 'Matter bridge stopped' })
196
+ } 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` })
198
+ } else {
199
+ node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: `Awaiting pairing. Code ${info.manualPairingCode || ''}, ${info.deviceCount} device(s)` })
200
+ }
201
+ } catch (error) { /* empty */ }
202
+ }
203
+
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
209
+ 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: ''
259
+ })
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}` })
276
+ }
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)
311
+ return
312
+ }
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)
321
+ }
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()
334
+ } 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}`)
337
+ }
338
+ }
339
+
340
+ // KNX -> Matter: telegrams from the bus update the Matter attributes
341
+ node.handleSend = (msg) => {
342
+ try {
343
+ if (!msg || !msg.knx) return
344
+ if (msg.knx.event === 'GroupValue_Read') return
345
+ const routes = statusRoutes.get(msg.knx.destination)
346
+ if (routes === undefined || node.matterBridge === null) return
347
+ routes.forEach((route) => {
348
+ try {
349
+ const value = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(route.dpt))
350
+ 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
+ })
359
+ } catch (error) {
360
+ node.setNodeStatus({ fill: 'red', shape: 'dot', text: `KNX->Matter error ${error.message}` })
361
+ }
362
+ })
363
+ } catch (error) {
364
+ node.setNodeStatus({ fill: 'red', shape: 'dot', text: `KNX->Matter error ${error.message}` })
365
+ }
366
+ }
367
+
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()
378
+ }
379
+
380
+ if (node.serverKNX) {
381
+ try {
382
+ node.serverKNX.removeClient(node)
383
+ node.serverKNX.addClient(node)
384
+ } catch (error) {
385
+ RED.log.error(`knxUltimateMatterBridge: register KNX client error ${error.message}`)
386
+ }
387
+ }
388
+
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: ... }
391
+ node.on('input', (msg, send, done) => {
392
+ if (!node.enableNodePINS) {
393
+ if (done) done()
394
+ return
395
+ }
396
+ try {
397
+ const payload = msg.payload || {}
398
+ const deviceKey = (payload.device !== undefined ? payload.device : msg.topic || '').toString().trim()
399
+ 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 })
408
+ if (done) done()
409
+ } catch (error) {
410
+ node.setNodeStatus({ fill: 'red', shape: 'dot', text: `Flow error ${error.message}`, payload: '' })
411
+ if (done) done(error)
412
+ }
413
+ })
414
+
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
432
+ try {
433
+ if (startupTimer !== null) clearTimeout(startupTimer)
434
+ if (initialReadTimer !== null) clearTimeout(initialReadTimer)
435
+ } catch (error) { /* empty */ }
436
+ try {
437
+ if (node.serverKNX) node.serverKNX.removeClient(node)
438
+ } catch (error) { /* empty */ }
439
+ try {
440
+ if (node.matterBridge !== null) node.matterBridge.removeAllListeners()
441
+ } 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
+ })()
452
+ })
453
+ }
454
+
455
+ RED.nodes.registerType('knxUltimateMatterBridge', knxUltimateMatterBridge)
456
+ }