node-red-contrib-knx-ultimate 5.0.4 → 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 (64) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/nodes/commonFunctions.js +93 -0
  3. package/nodes/icons/node-matter-icon.svg +12 -0
  4. package/nodes/knxUltimateMatterBridge.html +342 -0
  5. package/nodes/knxUltimateMatterBridge.js +313 -0
  6. package/nodes/knxUltimateMatterDevice.html +632 -0
  7. package/nodes/knxUltimateMatterDevice.js +308 -0
  8. package/nodes/locales/de/knxUltimateMatterBridge.html +53 -0
  9. package/nodes/locales/de/knxUltimateMatterBridge.json +83 -0
  10. package/nodes/locales/de/knxUltimateMatterDevice.html +56 -0
  11. package/nodes/locales/de/knxUltimateMatterDevice.json +109 -0
  12. package/nodes/locales/de/matter-config.html +25 -0
  13. package/nodes/locales/de/matter-config.json +26 -0
  14. package/nodes/locales/de/matterbridge-config.html +34 -0
  15. package/nodes/locales/de/matterbridge-config.json +20 -0
  16. package/nodes/locales/en/knxUltimateMatterBridge.html +53 -0
  17. package/nodes/locales/en/knxUltimateMatterBridge.json +83 -0
  18. package/nodes/locales/en/knxUltimateMatterDevice.html +56 -0
  19. package/nodes/locales/en/knxUltimateMatterDevice.json +109 -0
  20. package/nodes/locales/en/matter-config.html +25 -0
  21. package/nodes/locales/en/matter-config.json +26 -0
  22. package/nodes/locales/en/matterbridge-config.html +34 -0
  23. package/nodes/locales/en/matterbridge-config.json +20 -0
  24. package/nodes/locales/es/knxUltimateMatterBridge.html +53 -0
  25. package/nodes/locales/es/knxUltimateMatterBridge.json +83 -0
  26. package/nodes/locales/es/knxUltimateMatterDevice.html +56 -0
  27. package/nodes/locales/es/knxUltimateMatterDevice.json +109 -0
  28. package/nodes/locales/es/matter-config.html +25 -0
  29. package/nodes/locales/es/matter-config.json +26 -0
  30. package/nodes/locales/es/matterbridge-config.html +34 -0
  31. package/nodes/locales/es/matterbridge-config.json +20 -0
  32. package/nodes/locales/fr/knxUltimateMatterBridge.html +53 -0
  33. package/nodes/locales/fr/knxUltimateMatterBridge.json +83 -0
  34. package/nodes/locales/fr/knxUltimateMatterDevice.html +56 -0
  35. package/nodes/locales/fr/knxUltimateMatterDevice.json +109 -0
  36. package/nodes/locales/fr/matter-config.html +25 -0
  37. package/nodes/locales/fr/matter-config.json +26 -0
  38. package/nodes/locales/fr/matterbridge-config.html +34 -0
  39. package/nodes/locales/fr/matterbridge-config.json +20 -0
  40. package/nodes/locales/it/knxUltimateMatterBridge.html +53 -0
  41. package/nodes/locales/it/knxUltimateMatterBridge.json +83 -0
  42. package/nodes/locales/it/knxUltimateMatterDevice.html +56 -0
  43. package/nodes/locales/it/knxUltimateMatterDevice.json +109 -0
  44. package/nodes/locales/it/matter-config.html +25 -0
  45. package/nodes/locales/it/matter-config.json +26 -0
  46. package/nodes/locales/it/matterbridge-config.html +34 -0
  47. package/nodes/locales/it/matterbridge-config.json +20 -0
  48. package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +53 -0
  49. package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +83 -0
  50. package/nodes/locales/zh-CN/knxUltimateMatterDevice.html +56 -0
  51. package/nodes/locales/zh-CN/knxUltimateMatterDevice.json +109 -0
  52. package/nodes/locales/zh-CN/matter-config.html +25 -0
  53. package/nodes/locales/zh-CN/matter-config.json +26 -0
  54. package/nodes/locales/zh-CN/matterbridge-config.html +34 -0
  55. package/nodes/locales/zh-CN/matterbridge-config.json +20 -0
  56. package/nodes/matter-config.html +226 -0
  57. package/nodes/matter-config.js +242 -0
  58. package/nodes/matterbridge-config.html +121 -0
  59. package/nodes/matterbridge-config.js +220 -0
  60. package/nodes/utils/matterBridgeDeviceFactory.mjs +511 -0
  61. package/nodes/utils/matterBridgeEngine.mjs +290 -0
  62. package/nodes/utils/matterEngine.mjs +475 -0
  63. package/nodes/utils/matterKnxConverter.js +204 -0
  64. package/package.json +8 -2
@@ -0,0 +1,313 @@
1
+ const dptlib = require('knxultimate').dptlib
2
+
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.
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
+
69
+ function knxUltimateMatterBridge (config) {
70
+ RED.nodes.createNode(this, config)
71
+ const node = this
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
84
+
85
+ node.topic = ''
86
+ node.dpt = ''
87
+ node.notifyreadrequest = false
88
+ node.notifyreadrequestalsorespondtobus = 'false'
89
+ node.notifyreadrequestalsorespondtobusdefaultvalueifnotinitialized = ''
90
+ node.notifyresponse = false
91
+ node.notifywrite = true
92
+ node.initialread = false
93
+ node.listenallga = true // Don't remove
94
+ node.outputtype = 'write'
95
+ node.outputRBE = 'false'
96
+ node.inputRBE = 'false'
97
+ node.currentPayload = ''
98
+ node.passthrough = 'no'
99
+ node.formatmultiplyvalue = 1
100
+ node.formatnegativevalue = 'leave'
101
+ node.formatdecimalsvalue = 2
102
+
103
+ let initialReadTimer = null
104
+
105
+ const formatTs = (date) => {
106
+ const d = date instanceof Date ? date : new Date(date)
107
+ const provider = node.serverKNX
108
+ if (provider && typeof provider.formatStatusTimestamp === 'function') return provider.formatStatusTimestamp(d)
109
+ return `${d.getDate()}, ${d.toLocaleTimeString()}`
110
+ }
111
+
112
+ const pushStatus = (status) => {
113
+ if (!status) return
114
+ try {
115
+ const provider = node.serverKNX
116
+ if (provider && typeof provider.applyStatusUpdate === 'function') {
117
+ provider.applyStatusUpdate(node, status)
118
+ } else {
119
+ node.status(status)
120
+ }
121
+ } catch (error) { /* empty */ }
122
+ }
123
+
124
+ node.setNodeStatus = ({ fill, shape, text, payload }) => {
125
+ try {
126
+ if (payload === undefined) payload = ''
127
+ payload = typeof payload === 'object' ? JSON.stringify(payload) : payload.toString()
128
+ pushStatus({ fill, shape, text: `${text} ${payload} (${formatTs(new Date())})` })
129
+ } catch (error) { /* empty */ }
130
+ }
131
+
132
+ const safeSendToKNX = (telegram, context = 'write') => {
133
+ try {
134
+ if (!node.serverKNX || typeof node.serverKNX.sendKNXTelegramToKNXEngine !== 'function') {
135
+ node.setNodeStatus({ fill: 'red', shape: 'dot', text: `KNX server missing (${context})` })
136
+ return
137
+ }
138
+ node.serverKNX.sendKNXTelegramToKNXEngine({ ...telegram, nodecallerid: node.id })
139
+ } catch (error) {
140
+ node.setNodeStatus({ fill: 'red', shape: 'dot', text: `KNX send error ${error.message}` })
141
+ }
142
+ }
143
+
144
+ // Precompute the routing tables for this single device:
145
+ // statusRoutes: GA -> [{ fn, dpt }] (KNX -> Matter)
146
+ // commandRoutes: fn -> { ga, dpt } (Matter -> KNX)
147
+ const statusRoutes = new Map()
148
+ const commandRoutes = new Map()
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
+ }
160
+ })
161
+
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 = () => {
172
+ try {
173
+ if (node.serverMatterBridge === undefined) {
174
+ node.setNodeStatus({ fill: 'red', shape: 'ring', text: 'No Matter bridge selected' })
175
+ return
176
+ }
177
+ const info = node.serverMatterBridge.getPairingInfo()
178
+ if (!info.running) {
179
+ node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Matter bridge starting...' })
180
+ } else if (info.commissioned) {
181
+ node.setNodeStatus({ fill: 'green', shape: 'dot', text: `Ready (bridge paired with ${info.fabrics.length} controller(s))` })
182
+ } else {
183
+ node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: `Bridge awaiting pairing (code ${info.manualPairingCode || ''})` })
184
+ }
185
+ } catch (error) { /* empty */ }
186
+ }
187
+
188
+ // Matter -> KNX: a controller (Alexa...) sent a command to this device.
189
+ node.handleMatterCommand = (command) => {
190
+ try {
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
198
+ })
199
+ } catch (error) { /* empty */ }
200
+ }
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` })
205
+ return
206
+ }
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
211
+ }
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 })
214
+ } catch (error) {
215
+ node.setNodeStatus({ fill: 'red', shape: 'dot', text: `Matter->KNX error ${error.message}` })
216
+ }
217
+ }
218
+
219
+ // KNX -> Matter: telegrams from the bus update the Matter attributes of this device.
220
+ node.handleSend = (msg) => {
221
+ try {
222
+ if (!msg || !msg.knx) return
223
+ if (msg.knx.event === 'GroupValue_Read') return
224
+ const routes = statusRoutes.get(msg.knx.destination)
225
+ if (routes === undefined || node.serverMatterBridge === undefined) return
226
+ routes.forEach((route) => {
227
+ try {
228
+ const value = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(route.dpt))
229
+ if (value === undefined || value === null) return
230
+ node.serverMatterBridge.setDeviceState(node.matterDeviceId, route.fn, value)
231
+ node.setNodeStatus({ fill: 'blue', shape: 'dot', text: `KNX->Matter ${route.fn}`, payload: value })
232
+ } catch (error) {
233
+ node.setNodeStatus({ fill: 'red', shape: 'dot', text: `KNX->Matter error ${error.message}` })
234
+ }
235
+ })
236
+ } catch (error) {
237
+ node.setNodeStatus({ fill: 'red', shape: 'dot', text: `KNX->Matter error ${error.message}` })
238
+ }
239
+ }
240
+
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 */ }
254
+ }
255
+
256
+ // Register with the KNX gateway (to receive bus telegrams) and the Matter bridge.
257
+ if (node.serverKNX) {
258
+ try {
259
+ node.serverKNX.removeClient(node)
260
+ node.serverKNX.addClient(node)
261
+ } catch (error) {
262
+ RED.log.error(`knxUltimateMatterBridge: register KNX client error ${error.message}`)
263
+ }
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)
276
+
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: ... }
279
+ node.on('input', (msg, send, done) => {
280
+ if (!node.enableNodePINS) {
281
+ if (done) done()
282
+ return
283
+ }
284
+ try {
285
+ const payload = msg.payload || {}
286
+ const fn = (payload.function || payload.fn || '').toString().trim()
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 })
291
+ if (done) done()
292
+ } catch (error) {
293
+ node.setNodeStatus({ fill: 'red', shape: 'dot', text: `Flow error ${error.message}`, payload: '' })
294
+ if (done) done(error)
295
+ }
296
+ })
297
+
298
+ node.on('close', (done) => {
299
+ try {
300
+ if (initialReadTimer !== null) clearTimeout(initialReadTimer)
301
+ } catch (error) { /* empty */ }
302
+ try {
303
+ if (node.serverKNX) node.serverKNX.removeClient(node)
304
+ } catch (error) { /* empty */ }
305
+ try {
306
+ if (node.serverMatterBridge) node.serverMatterBridge.unregisterDevice(node)
307
+ } catch (error) { /* empty */ }
308
+ if (done) done()
309
+ })
310
+ }
311
+
312
+ RED.nodes.registerType('knxUltimateMatterBridge', knxUltimateMatterBridge)
313
+ }