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,290 @@
1
+ /* eslint-disable max-len */
2
+ import { EventEmitter } from 'events'
3
+
4
+ // Wrapper around a matter.js ServerNode acting as a Matter BRIDGE (Aggregator):
5
+ // external Matter controllers (Alexa, Google Home, Apple Home...) commission it once
6
+ // and see every configured KNX virtual device as a bridged Matter device.
7
+ // Mirrors the contract of matterEngine.mjs: an EventEmitter with
8
+ // 'online', 'offline', 'commissioned', 'decommissioned', 'fabricsChanged' and
9
+ // 'command' ({ deviceId, fn, value }) events.
10
+ class classMatterBridge extends EventEmitter {
11
+ constructor (_storagePath, _instanceId, _sysLogger, _options = {}) {
12
+ super()
13
+ this.storagePath = _storagePath
14
+ this.instanceId = _instanceId
15
+ this.sysLogger = _sysLogger
16
+ this.options = {
17
+ port: Number(_options.port) || 5540,
18
+ deviceName: (_options.deviceName || 'KNX-Ultimate Bridge').toString().slice(0, 32)
19
+ }
20
+ this.server = null
21
+ this.aggregator = null
22
+ this.endpoints = new Map() // deviceId -> Endpoint
23
+ this.deviceDefs = new Map() // deviceId -> definition
24
+ this.bridgeStatus = 'stopped' // stopped | running
25
+ this._api = null
26
+ this._factory = null
27
+ this._logThrottle = new Map()
28
+ }
29
+
30
+ _log = (level, message) => {
31
+ try {
32
+ const logger = this.sysLogger
33
+ if (logger && typeof logger[level] === 'function') return logger[level](message)
34
+ if (logger && typeof logger.log === 'function') return logger.log(message)
35
+ } catch (error) { /* empty */ }
36
+ if (level === 'error') console.error(message)
37
+ else if (level === 'warn') console.warn(message)
38
+ else console.log(message)
39
+ }
40
+
41
+ _logThrottled = (level, throttleKey, message, intervalMs = 30000) => {
42
+ const now = Date.now()
43
+ const entry = this._logThrottle.get(throttleKey) || { last: 0, suppressed: 0 }
44
+ if (this._logThrottle.size > 1000) this._logThrottle.clear()
45
+ if (entry.last === 0 || (now - entry.last) >= intervalMs) {
46
+ const suffix = entry.suppressed > 0 ? ` (suppressed ${entry.suppressed} similar)` : ''
47
+ entry.last = now
48
+ entry.suppressed = 0
49
+ this._logThrottle.set(throttleKey, entry)
50
+ this._log(level, `${message}${suffix}`)
51
+ return
52
+ }
53
+ entry.suppressed += 1
54
+ this._logThrottle.set(throttleKey, entry)
55
+ }
56
+
57
+ _loadApi = async () => {
58
+ if (this._api !== null) return this._api
59
+ const { Environment, ServerNode, Endpoint, Logger, LogLevel, VendorId } = await import('@matter/main')
60
+ const { AggregatorEndpoint } = await import('@matter/main/endpoints/aggregator')
61
+ const { QrCode } = await import('@matter/main/types')
62
+ this._factory = await import('./matterBridgeDeviceFactory.mjs')
63
+ this._api = { Environment, ServerNode, Endpoint, Logger, LogLevel, VendorId, AggregatorEndpoint, QrCode }
64
+ return this._api
65
+ }
66
+
67
+ // Starts the bridge and exposes the given virtual devices.
68
+ // _deviceDefs: [{ id, type, name }]
69
+ start = async (_deviceDefs = []) => {
70
+ const api = await this._loadApi()
71
+ try {
72
+ api.Logger.level = api.LogLevel.ERROR
73
+ } catch (error) { /* empty */ }
74
+ const environment = api.Environment.default
75
+ // Same storage root as the Matter controller engine: instances are separated by their id.
76
+ environment.vars.set('storage.path', this.storagePath)
77
+
78
+ this.server = await api.ServerNode.create({
79
+ id: this.instanceId,
80
+ network: { port: this.options.port },
81
+ productDescription: {
82
+ name: this.options.deviceName,
83
+ deviceType: api.AggregatorEndpoint.deviceType
84
+ },
85
+ basicInformation: {
86
+ vendorName: 'KNX-Ultimate',
87
+ vendorId: api.VendorId(0xFFF1), // Test vendor ID
88
+ nodeLabel: this.options.deviceName,
89
+ productName: this.options.deviceName,
90
+ productLabel: this.options.deviceName,
91
+ productId: 0x8000,
92
+ serialNumber: `knxu-${this.instanceId}`.slice(0, 32),
93
+ uniqueId: this.instanceId.slice(-32)
94
+ }
95
+ })
96
+
97
+ this.aggregator = new api.Endpoint(api.AggregatorEndpoint, { id: 'aggregator' })
98
+ await this.server.add(this.aggregator)
99
+
100
+ for (let index = 0; index < _deviceDefs.length; index++) {
101
+ const def = _deviceDefs[index]
102
+ try {
103
+ await this._addDeviceEndpoint(def)
104
+ } catch (error) {
105
+ this._log('error', `classMatterBridge: cannot add device "${def?.name}" (${def?.type}): ${error.message}`)
106
+ }
107
+ }
108
+
109
+ // Commissioning lifecycle events
110
+ try {
111
+ this.server.events.commissioning.commissioned.on(() => {
112
+ try { this.emit('commissioned') } catch (error) { /* empty */ }
113
+ })
114
+ this.server.events.commissioning.decommissioned.on(() => {
115
+ try { this.emit('decommissioned') } catch (error) { /* empty */ }
116
+ })
117
+ this.server.events.commissioning.fabricsChanged.on((fabricIndex, action) => {
118
+ try { this.emit('fabricsChanged', Number(fabricIndex), action) } catch (error) { /* empty */ }
119
+ })
120
+ } catch (error) {
121
+ this._log('warn', `classMatterBridge: cannot hook commissioning events: ${error.message}`)
122
+ }
123
+
124
+ await this.server.start()
125
+ this.bridgeStatus = 'running'
126
+ this.emit('online')
127
+ this._log('info', `classMatterBridge: bridge "${this.options.deviceName}" started on port ${this.options.port} with ${this.endpoints.size} device(s)`)
128
+ }
129
+
130
+ _addDeviceEndpoint = async (def) => {
131
+ if (!def || !def.id || !def.type) throw new Error('Invalid device definition')
132
+ const endpoint = this._factory.createBridgedEndpoint(def, `knxu-${this.instanceId.slice(-8)}`, (command) => {
133
+ try {
134
+ this.emit('command', command)
135
+ } catch (error) {
136
+ this._logThrottled('error', 'bridge:command', `classMatterBridge: command handler error: ${error.message}`)
137
+ }
138
+ })
139
+ await this.aggregator.add(endpoint)
140
+ this.endpoints.set(def.id, endpoint)
141
+ this.deviceDefs.set(def.id, def)
142
+ return endpoint
143
+ }
144
+
145
+ // Removes a bridged device endpoint from the running server.
146
+ removeDeviceEndpoint = async (_deviceId) => {
147
+ const endpoint = this.endpoints.get(_deviceId)
148
+ if (endpoint === undefined) return false
149
+ try {
150
+ await endpoint.close()
151
+ } catch (error) {
152
+ this._log('warn', `classMatterBridge: removeDeviceEndpoint ${_deviceId}: ${error.message}`)
153
+ }
154
+ this.endpoints.delete(_deviceId)
155
+ this.deviceDefs.delete(_deviceId)
156
+ return true
157
+ }
158
+
159
+ // Reconciles the exposed devices with a new definition list WITHOUT restarting the server:
160
+ // paired controllers receive the endpoint changes live (PartsList subscription reports),
161
+ // while a full restart would resume the session without re-reading the structure.
162
+ reconcileDevices = async (_deviceDefs = []) => {
163
+ if (this.server === null) throw new Error('Matter bridge not started')
164
+ const newById = new Map(_deviceDefs.filter((d) => d && d.id).map((d) => [d.id, d]))
165
+ // Remove deleted devices; recreate the ones whose type/behavior changed
166
+ const currentIds = Array.from(this.endpoints.keys())
167
+ for (let index = 0; index < currentIds.length; index++) {
168
+ const id = currentIds[index]
169
+ const newDef = newById.get(id)
170
+ const oldDef = this.deviceDefs.get(id)
171
+ const needsRecreate = newDef !== undefined && oldDef !== undefined &&
172
+ (newDef.type !== oldDef.type || (newDef.invertPosition === true) !== (oldDef.invertPosition === true))
173
+ if (newDef === undefined || needsRecreate) {
174
+ await this.removeDeviceEndpoint(id)
175
+ }
176
+ }
177
+ // Add new devices and apply renames on the existing ones
178
+ for (const def of newById.values()) {
179
+ if (!this.endpoints.has(def.id)) {
180
+ try {
181
+ await this._addDeviceEndpoint(def)
182
+ } catch (error) {
183
+ this._log('error', `classMatterBridge: reconcile cannot add "${def.name}" (${def.type}): ${error.message}`)
184
+ }
185
+ } else {
186
+ const oldDef = this.deviceDefs.get(def.id)
187
+ if ((oldDef.name || '') !== (def.name || '')) {
188
+ const name = (def.name || def.type).toString().slice(0, 32)
189
+ try {
190
+ await this.endpoints.get(def.id).set({
191
+ bridgedDeviceBasicInformation: { nodeLabel: name, productName: name, productLabel: name }
192
+ })
193
+ } catch (error) {
194
+ this._log('warn', `classMatterBridge: reconcile rename ${def.id}: ${error.message}`)
195
+ }
196
+ }
197
+ this.deviceDefs.set(def.id, def)
198
+ }
199
+ }
200
+ this._log('info', `classMatterBridge: devices reconciled, now exposing ${this.endpoints.size} device(s)`)
201
+ }
202
+
203
+ // KNX -> Matter: applies a KNX status value to the Matter attribute of a virtual device.
204
+ setDeviceState = async (_deviceId, _fn, _knxValue) => {
205
+ try {
206
+ const endpoint = this.endpoints.get(_deviceId)
207
+ const def = this.deviceDefs.get(_deviceId)
208
+ if (endpoint === undefined || def === undefined) return false
209
+ const patch = this._factory.knxValueToMatterPatch(def, _fn, _knxValue)
210
+ if (patch === undefined) return false
211
+ await endpoint.set(patch)
212
+ return true
213
+ } catch (error) {
214
+ this._logThrottled('warn', `bridge:set:${_deviceId}:${_fn}`, `classMatterBridge: setDeviceState ${_deviceId}/${_fn}: ${error.message}`)
215
+ return false
216
+ }
217
+ }
218
+
219
+ // Marks a device as reachable/unreachable on the Matter side
220
+ setDeviceReachable = async (_deviceId, _reachable) => {
221
+ try {
222
+ const endpoint = this.endpoints.get(_deviceId)
223
+ if (endpoint === undefined) return false
224
+ await endpoint.set({ bridgedDeviceBasicInformation: { reachable: _reachable === true } })
225
+ return true
226
+ } catch (error) {
227
+ return false
228
+ }
229
+ }
230
+
231
+ // Pairing info for the editor: QR code (text + ASCII art), manual code, commissioned fabrics.
232
+ getPairingInfo = () => {
233
+ if (this.server === null) return { running: false }
234
+ try {
235
+ const commissioningState = this.server.state.commissioning
236
+ const fabrics = []
237
+ try {
238
+ Object.values(commissioningState.fabrics || {}).forEach((fabric) => {
239
+ fabrics.push({
240
+ label: fabric.label || '',
241
+ vendorId: Number(fabric.rootVendorId !== undefined ? fabric.rootVendorId : (fabric.vendorId || 0)),
242
+ fabricIndex: Number(fabric.fabricIndex || 0)
243
+ })
244
+ })
245
+ } catch (error) { /* empty */ }
246
+ const info = {
247
+ running: this.bridgeStatus === 'running',
248
+ commissioned: commissioningState.commissioned === true,
249
+ fabrics,
250
+ deviceCount: this.endpoints.size,
251
+ port: this.options.port
252
+ }
253
+ if (commissioningState.commissioned !== true && commissioningState.pairingCodes !== undefined) {
254
+ info.manualPairingCode = commissioningState.pairingCodes.manualPairingCode
255
+ info.qrPairingCode = commissioningState.pairingCodes.qrPairingCode
256
+ try {
257
+ info.qrAscii = this._api.QrCode.get(commissioningState.pairingCodes.qrPairingCode)
258
+ } catch (error) { /* empty */ }
259
+ }
260
+ return info
261
+ } catch (error) {
262
+ this._log('warn', `classMatterBridge: getPairingInfo: ${error.message}`)
263
+ return { running: this.bridgeStatus === 'running', error: error.message }
264
+ }
265
+ }
266
+
267
+ // Factory reset: removes all fabrics and commissioning data. The bridge restarts advertising for pairing.
268
+ factoryReset = async () => {
269
+ if (this.server === null) throw new Error('Matter bridge not started')
270
+ await this.server.erase()
271
+ this.emit('decommissioned')
272
+ }
273
+
274
+ close = async () => {
275
+ this.bridgeStatus = 'stopped'
276
+ this._logThrottle.clear()
277
+ try {
278
+ if (this.server !== null) await this.server.close()
279
+ } catch (error) {
280
+ this._log('warn', `classMatterBridge: close: ${error.message}`)
281
+ }
282
+ this.server = null
283
+ this.aggregator = null
284
+ this.endpoints.clear()
285
+ this.deviceDefs.clear()
286
+ this.emit('offline')
287
+ }
288
+ }
289
+
290
+ export { classMatterBridge }