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,475 @@
1
+ /* eslint-disable max-len */
2
+ import { EventEmitter } from 'events'
3
+ import { setTimeout as pleaseWait } from 'timers/promises'
4
+
5
+ // Wrapper around the matter.js CommissioningController.
6
+ // It mirrors the contract of hueEngine.mjs: an EventEmitter with a command queue,
7
+ // emitting 'connected', 'disconnected', 'event' (attribute changes), 'matterEvent' (cluster events),
8
+ // 'nodeState' and 'structureChanged'.
9
+ class classMatter extends EventEmitter {
10
+ constructor (_storagePath, _instanceId, _fabricLabel, _sysLogger, { startQueue = true } = {}) {
11
+ super()
12
+ this.matterConnectionStatus = 'disconnected'
13
+ this.storagePath = _storagePath
14
+ this.instanceId = _instanceId
15
+ this.fabricLabel = _fabricLabel || 'KNX-Ultimate'
16
+ this.sysLogger = _sysLogger
17
+ this.controller = null
18
+ this.pairedNodes = new Map() // nodeId (string) -> PairedNode
19
+ this.commandQueue = []
20
+ this.queueMaxLength = 2000
21
+ this.exitAllQueues = false
22
+ this._api = null // Lazy loaded matter.js exports
23
+ this._logThrottle = new Map()
24
+ if (startQueue) this.handleQueue()
25
+ }
26
+
27
+ _log = (level, message) => {
28
+ try {
29
+ const logger = this.sysLogger
30
+ if (logger && typeof logger[level] === 'function') return logger[level](message)
31
+ if (logger && typeof logger.log === 'function') return logger.log(message)
32
+ } catch (error) { /* empty */ }
33
+ if (level === 'error') console.error(message)
34
+ else if (level === 'warn') console.warn(message)
35
+ else console.log(message)
36
+ }
37
+
38
+ _logThrottled = (level, throttleKey, message, intervalMs = 30000) => {
39
+ const now = Date.now()
40
+ const entry = this._logThrottle.get(throttleKey) || { last: 0, suppressed: 0 }
41
+ if (this._logThrottle.size > 1000) this._logThrottle.clear()
42
+ if (entry.last === 0 || (now - entry.last) >= intervalMs) {
43
+ const suffix = entry.suppressed > 0 ? ` (suppressed ${entry.suppressed} similar)` : ''
44
+ entry.last = now
45
+ entry.suppressed = 0
46
+ this._logThrottle.set(throttleKey, entry)
47
+ this._log(level, `${message}${suffix}`)
48
+ return
49
+ }
50
+ entry.suppressed += 1
51
+ this._logThrottle.set(throttleKey, entry)
52
+ }
53
+
54
+ _loadApi = async () => {
55
+ if (this._api !== null) return this._api
56
+ const { Environment, StorageService, Logger, LogLevel } = await import('@matter/main')
57
+ const { CommissioningController, NodeStates } = await import('@project-chip/matter.js')
58
+ const { ManualPairingCodeCodec, QrPairingCodeCodec, NodeId } = await import('@matter/main/types')
59
+ const { GeneralCommissioning } = await import('@matter/main/clusters')
60
+ this._api = {
61
+ Environment, StorageService, Logger, LogLevel, CommissioningController, NodeStates, ManualPairingCodeCodec, QrPairingCodeCodec, NodeId, GeneralCommissioning
62
+ }
63
+ return this._api
64
+ }
65
+
66
+ nodeStateToString = (state) => {
67
+ try {
68
+ const { NodeStates } = this._api
69
+ switch (state) {
70
+ case NodeStates.Connected: return 'connected'
71
+ case NodeStates.Disconnected: return 'disconnected'
72
+ case NodeStates.Reconnecting: return 'reconnecting'
73
+ case NodeStates.WaitingForDeviceDiscovery: return 'waitingfordiscovery'
74
+ default: return `unknown (${state})`
75
+ }
76
+ } catch (error) {
77
+ return `unknown (${state})`
78
+ }
79
+ }
80
+
81
+ Connect = async () => {
82
+ const api = await this._loadApi()
83
+ try {
84
+ // Quiet down the very verbose default matter.js console logging.
85
+ api.Logger.level = api.LogLevel.ERROR
86
+ } catch (error) { /* empty */ }
87
+ const environment = api.Environment.default
88
+ environment.vars.set('storage.path', this.storagePath)
89
+ this.controller = new api.CommissioningController({
90
+ environment: { environment, id: this.instanceId },
91
+ autoConnect: false,
92
+ adminFabricLabel: this.fabricLabel
93
+ })
94
+ await this.controller.start()
95
+ this.matterConnectionStatus = 'connected'
96
+ this.emit('connected')
97
+ // Connect all already commissioned nodes and hook their events.
98
+ const nodeIds = this.controller.getCommissionedNodes()
99
+ for (let index = 0; index < nodeIds.length; index++) {
100
+ try {
101
+ await this._attachNode(nodeIds[index])
102
+ } catch (error) {
103
+ this._log('error', `classMatter: Connect: cannot attach node ${nodeIds[index]}: ${error.message}`)
104
+ }
105
+ }
106
+ }
107
+
108
+ _attachNode = async (_nodeId) => {
109
+ const key = _nodeId.toString()
110
+ const node = await this.controller.getNode(_nodeId)
111
+ this.pairedNodes.set(key, node)
112
+ if (node.__knxUltimateHooked !== true) {
113
+ node.__knxUltimateHooked = true
114
+ node.events.attributeChanged.on((data) => {
115
+ try {
116
+ this.emit('event', {
117
+ nodeId: key,
118
+ endpointId: Number(data.path.endpointId),
119
+ clusterId: Number(data.path.clusterId),
120
+ attributeId: Number(data.path.attributeId),
121
+ attributeName: data.path.attributeName,
122
+ value: data.value
123
+ })
124
+ } catch (error) {
125
+ this._logThrottled('error', 'matter:attrchanged', `classMatter: attributeChanged handler: ${error.message}`)
126
+ }
127
+ })
128
+ node.events.eventTriggered.on((data) => {
129
+ try {
130
+ this.emit('matterEvent', {
131
+ nodeId: key,
132
+ endpointId: Number(data.path.endpointId),
133
+ clusterId: Number(data.path.clusterId),
134
+ eventId: Number(data.path.eventId),
135
+ eventName: data.path.eventName,
136
+ events: data.events
137
+ })
138
+ } catch (error) {
139
+ this._logThrottled('error', 'matter:eventtriggered', `classMatter: eventTriggered handler: ${error.message}`)
140
+ }
141
+ })
142
+ node.events.stateChanged.on((state) => {
143
+ try {
144
+ this.emit('nodeState', key, this.nodeStateToString(state))
145
+ } catch (error) { /* empty */ }
146
+ })
147
+ node.events.structureChanged.on(() => {
148
+ try {
149
+ this.emit('structureChanged', key)
150
+ } catch (error) { /* empty */ }
151
+ })
152
+ node.events.initializedFromRemote?.on(() => {
153
+ try {
154
+ this.emit('nodeInitialized', key)
155
+ } catch (error) { /* empty */ }
156
+ })
157
+ }
158
+ try {
159
+ node.connect() // Establishes connection and auto-subscribes to all attributes/events
160
+ } catch (error) {
161
+ this._log('warn', `classMatter: _attachNode connect ${key}: ${error.message}`)
162
+ }
163
+ return node
164
+ }
165
+
166
+ // Commission (pair) a new device using an 11-digit manual pairing code or a QR code string (MT:...)
167
+ commission = async (_pairingCode) => {
168
+ if (this.controller === null) throw new Error('Matter controller not started')
169
+ const api = await this._loadApi()
170
+ const code = (_pairingCode || '').toString().trim()
171
+ if (code === '') throw new Error('Empty pairing code')
172
+ let passcode
173
+ let identifierData
174
+ if (code.toUpperCase().startsWith('MT:')) {
175
+ const qr = api.QrPairingCodeCodec.decode(code)[0]
176
+ passcode = qr.passcode
177
+ identifierData = { longDiscriminator: qr.discriminator }
178
+ } else {
179
+ const manual = api.ManualPairingCodeCodec.decode(code.replace(/[^0-9]/g, ''))
180
+ passcode = manual.passcode
181
+ identifierData = { shortDiscriminator: manual.shortDiscriminator }
182
+ }
183
+ const nodeId = await this.controller.commissionNode({
184
+ commissioning: {
185
+ regulatoryLocation: api.GeneralCommissioning.RegulatoryLocationType.IndoorOutdoor
186
+ },
187
+ discovery: {
188
+ identifierData,
189
+ discoveryCapabilities: { onIpNetwork: true }
190
+ },
191
+ passcode
192
+ }, { connectNodeAfterCommissioning: false })
193
+ try {
194
+ await this._attachNode(nodeId)
195
+ } catch (error) {
196
+ // Pairing succeeded: never report it as failed just because the first connection
197
+ // attempt errored. The node will be attached again at the next controller start.
198
+ this._log('warn', `classMatter: commission: node ${nodeId} paired but not yet attached: ${error.message}`)
199
+ }
200
+ return nodeId.toString()
201
+ }
202
+
203
+ // Decommission (unpair) a device. Falls back to forced removal if the device is unreachable.
204
+ removeNode = async (_nodeIdString) => {
205
+ if (this.controller === null) throw new Error('Matter controller not started')
206
+ const api = await this._loadApi()
207
+ const nodeId = api.NodeId(BigInt(_nodeIdString))
208
+ const node = this.pairedNodes.get(_nodeIdString)
209
+ try {
210
+ if (node !== undefined) {
211
+ await node.decommission()
212
+ } else {
213
+ await this.controller.removeNode(nodeId, true)
214
+ }
215
+ } catch (error) {
216
+ this._log('warn', `classMatter: removeNode: decommission failed (${error.message}), forcing removal`)
217
+ await this.controller.removeNode(nodeId, false)
218
+ }
219
+ this.pairedNodes.delete(_nodeIdString)
220
+ this.commandQueue = this.commandQueue.filter((el) => el.nodeId !== _nodeIdString)
221
+ }
222
+
223
+ // Returns [{ nodeId, name, vendorName, productName, connectionState }]
224
+ getCommissionedNodesDetails = () => {
225
+ if (this.controller === null) return []
226
+ const retArray = []
227
+ let details = []
228
+ try {
229
+ details = this.controller.getCommissionedNodesDetails()
230
+ } catch (error) {
231
+ this._log('warn', `classMatter: getCommissionedNodesDetails: ${error.message}`)
232
+ }
233
+ details.forEach((detail) => {
234
+ try {
235
+ const key = detail.nodeId.toString()
236
+ const node = this.pairedNodes.get(key)
237
+ let basicInfo = detail.basicInformationData || {}
238
+ try {
239
+ if (node !== undefined && node.basicInformation !== undefined) basicInfo = node.basicInformation
240
+ } catch (error) { /* empty */ }
241
+ let connectionState = 'disconnected'
242
+ try {
243
+ if (node !== undefined) connectionState = this.nodeStateToString(node.connectionState)
244
+ } catch (error) { /* empty */ }
245
+ retArray.push({
246
+ nodeId: key,
247
+ name: basicInfo.nodeLabel || basicInfo.productLabel || basicInfo.productName || detail.advertisedName || `Node ${key}`,
248
+ vendorName: basicInfo.vendorName || '',
249
+ productName: basicInfo.productName || '',
250
+ serialNumber: basicInfo.serialNumber || '',
251
+ connectionState
252
+ })
253
+ } catch (error) {
254
+ this._log('warn', `classMatter: getCommissionedNodesDetails item: ${error.message}`)
255
+ }
256
+ })
257
+ return retArray
258
+ }
259
+
260
+ _walkEndpoints = (endpoint, _callback) => {
261
+ _callback(endpoint)
262
+ try {
263
+ const parts = endpoint.parts
264
+ if (parts !== undefined) {
265
+ parts.forEach((child) => {
266
+ this._walkEndpoints(child, _callback)
267
+ })
268
+ }
269
+ } catch (error) { /* empty */ }
270
+ }
271
+
272
+ _getAllEndpoints = (node) => {
273
+ const endpoints = []
274
+ try {
275
+ node.getDevices().forEach((device) => {
276
+ this._walkEndpoints(device, (ep) => {
277
+ if (ep.number !== undefined && !endpoints.some((e) => e.number === ep.number)) endpoints.push(ep)
278
+ })
279
+ })
280
+ } catch (error) {
281
+ this._log('warn', `classMatter: _getAllEndpoints: ${error.message}`)
282
+ }
283
+ return endpoints
284
+ }
285
+
286
+ _findEndpoint = (node, _endpointId) => {
287
+ return this._getAllEndpoints(node).find((ep) => Number(ep.number) === Number(_endpointId))
288
+ }
289
+
290
+ _findClusterClient = (node, _endpointId, _clusterId) => {
291
+ try {
292
+ const endpoint = this._findEndpoint(node, _endpointId)
293
+ if (endpoint === undefined) return undefined
294
+ return endpoint.getAllClusterClients().find((cc) => Number(cc.id) === Number(_clusterId))
295
+ } catch (error) {
296
+ this._logThrottled('warn', 'matter:findcluster', `classMatter: _findClusterClient: ${error.message}`)
297
+ return undefined
298
+ }
299
+ }
300
+
301
+ // Returns the full structure of a commissioned node: endpoints, clusters, attributes (with cached values) and commands.
302
+ // Used by the editor UI to let the user pick the mapping targets.
303
+ getNodeStructure = (_nodeIdString) => {
304
+ const node = this.pairedNodes.get(_nodeIdString)
305
+ if (node === undefined) throw new Error(`Matter node ${_nodeIdString} unknown or not yet connected`)
306
+ const retEndpoints = []
307
+ this._getAllEndpoints(node).forEach((endpoint) => {
308
+ let deviceTypes = []
309
+ try {
310
+ deviceTypes = endpoint.getDeviceTypes().map((dt) => dt.name)
311
+ } catch (error) { /* empty */ }
312
+ const clusters = []
313
+ let clusterClients = []
314
+ try {
315
+ clusterClients = endpoint.getAllClusterClients()
316
+ } catch (error) {
317
+ this._logThrottled('warn', 'matter:structure:clients', `classMatter: getNodeStructure getAllClusterClients: ${error.message}`)
318
+ }
319
+ clusterClients.forEach((cc) => {
320
+ try {
321
+ const attributes = []
322
+ Object.keys(cc.attributes).forEach((attrName) => {
323
+ try {
324
+ if (/^\d+$/.test(attrName)) return // Skip numeric aliases
325
+ if (typeof cc.isAttributeSupportedByName === 'function' && !cc.isAttributeSupportedByName(attrName)) return
326
+ // Skip the global meta attributes (FeatureMap, AttributeList, ClusterRevision...):
327
+ // they are useless for KNX mappings and only confuse the user.
328
+ if (Number(cc.attributes[attrName].id) >= 65528) return
329
+ let value
330
+ try {
331
+ value = cc.attributes[attrName].getLocal()
332
+ } catch (error) { value = undefined }
333
+ attributes.push({ name: attrName, id: Number(cc.attributes[attrName].id), value: this._jsonSafe(value) })
334
+ } catch (error) { /* empty */ }
335
+ })
336
+ const commands = []
337
+ Object.keys(cc.commands).forEach((cmdName) => {
338
+ try {
339
+ if (/^\d+$/.test(cmdName)) return
340
+ if (typeof cc.isCommandSupportedByName === 'function' && !cc.isCommandSupportedByName(cmdName)) return
341
+ commands.push({ name: cmdName })
342
+ } catch (error) { /* empty */ }
343
+ })
344
+ clusters.push({
345
+ id: Number(cc.id),
346
+ name: cc.name,
347
+ attributes,
348
+ commands
349
+ })
350
+ } catch (error) {
351
+ this._logThrottled('warn', 'matter:structure', `classMatter: getNodeStructure cluster error: ${error.message}`)
352
+ }
353
+ })
354
+ try {
355
+ retEndpoints.push({
356
+ endpointId: Number(endpoint.number),
357
+ name: endpoint.name,
358
+ deviceTypes,
359
+ clusters
360
+ })
361
+ } catch (error) {
362
+ this._logThrottled('warn', 'matter:structure:endpoint', `classMatter: getNodeStructure endpoint error: ${error.message}`)
363
+ }
364
+ })
365
+ return { nodeId: _nodeIdString, endpoints: retEndpoints }
366
+ }
367
+
368
+ _jsonSafe = (value) => {
369
+ try {
370
+ return JSON.parse(JSON.stringify(value, (k, v) => (typeof v === 'bigint' ? v.toString() : v)))
371
+ } catch (error) {
372
+ return undefined
373
+ }
374
+ }
375
+
376
+ // Read an attribute value. By default returns the locally cached (subscribed) value.
377
+ readAttribute = async (_nodeIdString, _endpointId, _clusterId, _attributeName, _requestFromRemote = false) => {
378
+ const node = this.pairedNodes.get(_nodeIdString)
379
+ if (node === undefined) throw new Error(`Matter node ${_nodeIdString} unknown or not yet connected`)
380
+ const clusterClient = this._findClusterClient(node, _endpointId, _clusterId)
381
+ if (clusterClient === undefined) throw new Error(`Cluster ${_clusterId} not found on endpoint ${_endpointId}`)
382
+ const attribute = clusterClient.attributes[_attributeName]
383
+ if (attribute === undefined) throw new Error(`Attribute ${_attributeName} not found in cluster ${clusterClient.name}`)
384
+ return attribute.get(_requestFromRemote)
385
+ }
386
+
387
+ // Synchronous read of the locally cached attribute value (undefined if not yet received).
388
+ getCachedAttribute = (_nodeIdString, _endpointId, _clusterId, _attributeName) => {
389
+ try {
390
+ const node = this.pairedNodes.get(_nodeIdString)
391
+ if (node === undefined) return undefined
392
+ const clusterClient = this._findClusterClient(node, _endpointId, _clusterId)
393
+ if (clusterClient === undefined) return undefined
394
+ const attribute = clusterClient.attributes[_attributeName]
395
+ if (attribute === undefined) return undefined
396
+ return attribute.getLocal()
397
+ } catch (error) {
398
+ return undefined
399
+ }
400
+ }
401
+
402
+ _enqueueCommand = (command) => {
403
+ if (!command) return false
404
+ if (!Array.isArray(this.commandQueue)) this.commandQueue = []
405
+ this.commandQueue.unshift(command)
406
+ if (this.commandQueue.length > this.queueMaxLength) {
407
+ this.commandQueue.splice(this.queueMaxLength)
408
+ this._logThrottled('warn', 'matter:queue:overflow', `Matter command queue overflow: capped at ${this.queueMaxLength}`, 60000)
409
+ }
410
+ return true
411
+ }
412
+
413
+ // Add a command or attribute write to the queue.
414
+ // _item: { nodeId, endpointId, clusterId, kind: 'command'|'attributeWrite', name, args }
415
+ writeMatterQueueAdd = async (_item) => {
416
+ return this._enqueueCommand(_item)
417
+ }
418
+
419
+ deleteMatterQueue = async (_nodeIdString) => {
420
+ this.commandQueue = this.commandQueue.filter((el) => el.nodeId !== _nodeIdString)
421
+ }
422
+
423
+ processQueueItem = async () => {
424
+ let item = null
425
+ try {
426
+ if (this.matterConnectionStatus !== 'connected') return
427
+ item = this.commandQueue.pop()
428
+ if (!item) return
429
+ const node = this.pairedNodes.get(item.nodeId)
430
+ if (node === undefined) throw new Error(`Matter node ${item.nodeId} unknown or not yet connected`)
431
+ const clusterClient = this._findClusterClient(node, item.endpointId, item.clusterId)
432
+ if (clusterClient === undefined) throw new Error(`Cluster ${item.clusterId} not found on endpoint ${item.endpointId} of node ${item.nodeId}`)
433
+ if (item.kind === 'attributeWrite') {
434
+ const attribute = clusterClient.attributes[item.name]
435
+ if (attribute === undefined) throw new Error(`Attribute ${item.name} not found in cluster ${clusterClient.name}`)
436
+ await attribute.set(item.args)
437
+ } else {
438
+ const command = clusterClient.commands[item.name]
439
+ if (typeof command !== 'function') throw new Error(`Command ${item.name} not found in cluster ${clusterClient.name}`)
440
+ await command(item.args)
441
+ }
442
+ } catch (error) {
443
+ const target = item ? `${item.nodeId}/${item.endpointId}/${item.clusterId}/${item.name}` : 'unknown'
444
+ this._logThrottled('error', `matter:queue:error:${target}`, `classMatter: processQueueItem (${target}): ${error.message}`, 30000)
445
+ this.emit('commandError', { item, error: error.message })
446
+ }
447
+ }
448
+
449
+ handleQueue = async () => {
450
+ do {
451
+ if (this.matterConnectionStatus === 'connected' && this.commandQueue && this.commandQueue.length > 0) {
452
+ try {
453
+ await this.processQueueItem()
454
+ } catch (error) { /* empty */ }
455
+ }
456
+ await pleaseWait(150)
457
+ } while (!this.exitAllQueues)
458
+ }
459
+
460
+ close = async () => {
461
+ this.exitAllQueues = true
462
+ this.commandQueue = []
463
+ this._logThrottle.clear()
464
+ this.matterConnectionStatus = 'disconnected'
465
+ try {
466
+ if (this.controller !== null) await this.controller.close()
467
+ } catch (error) {
468
+ this._log('warn', `classMatter: close: ${error.message}`)
469
+ }
470
+ this.controller = null
471
+ this.pairedNodes.clear()
472
+ }
473
+ }
474
+
475
+ export { classMatter }
@@ -0,0 +1,204 @@
1
+ /* eslint-disable max-len */
2
+ // Value conversions between KNX (decoded DPT payloads) and Matter clusters.
3
+ // Used by knxUltimateMatterDevice to translate group address payloads into Matter
4
+ // commands/attribute-writes and Matter attribute reports into KNX payloads.
5
+
6
+ // Matter cluster IDs
7
+ const CLUSTER = {
8
+ IDENTIFY: 3,
9
+ ON_OFF: 6,
10
+ LEVEL_CONTROL: 8,
11
+ POWER_SOURCE: 47,
12
+ BOOLEAN_STATE: 69,
13
+ DOOR_LOCK: 257,
14
+ WINDOW_COVERING: 258,
15
+ COLOR_CONTROL: 768,
16
+ ILLUMINANCE: 1024,
17
+ TEMPERATURE: 1026,
18
+ PRESSURE: 1027,
19
+ FLOW: 1028,
20
+ HUMIDITY: 1029,
21
+ OCCUPANCY: 1030,
22
+ THERMOSTAT: 513,
23
+ FAN_CONTROL: 514,
24
+ ELECTRICAL_POWER: 144,
25
+ ELECTRICAL_ENERGY: 145
26
+ }
27
+
28
+ const clampByte = (value, max) => {
29
+ let v = Math.round(Number(value))
30
+ if (Number.isNaN(v)) v = 0
31
+ if (v < 0) v = 0
32
+ if (v > max) v = max
33
+ return v
34
+ }
35
+
36
+ const truthy = (value) => value === true || value === 1 || value === '1' || value === 'true' || value === 'on'
37
+
38
+ // Standard "options" arguments required by several Matter commands
39
+ const LEVEL_OPTS = { optionsMask: {}, optionsOverride: {} }
40
+
41
+ /**
42
+ * Converts a decoded KNX payload into a Matter queue item ({ kind, name, args }).
43
+ * @param {object} mapping - { clusterId, targetKind ('command'|'attribute'), target (name) }
44
+ * @param {*} value - Decoded KNX payload (dptlib.fromBuffer output)
45
+ * @returns {object|null} { kind: 'command'|'attributeWrite', name, args } or null to skip
46
+ */
47
+ function knxToMatter (mapping, value) {
48
+ const clusterId = Number(mapping.clusterId)
49
+ const target = mapping.target
50
+
51
+ if (mapping.targetKind === 'attribute') {
52
+ // Attribute write. Apply known unit scaling, otherwise write raw.
53
+ let outValue = value
54
+ switch (clusterId) {
55
+ case CLUSTER.THERMOSTAT:
56
+ if (['occupiedHeatingSetpoint', 'occupiedCoolingSetpoint', 'unoccupiedHeatingSetpoint', 'unoccupiedCoolingSetpoint'].includes(target)) {
57
+ outValue = Math.round(Number(value) * 100) // °C -> centi-°C
58
+ }
59
+ break
60
+ case CLUSTER.FAN_CONTROL:
61
+ if (target === 'percentSetting') outValue = clampByte(value, 100)
62
+ if (target === 'speedSetting') outValue = Math.round(Number(value))
63
+ break
64
+ case CLUSTER.ON_OFF:
65
+ if (target === 'onOff') outValue = truthy(value)
66
+ break
67
+ default:
68
+ break
69
+ }
70
+ return { kind: 'attributeWrite', name: target, args: outValue }
71
+ }
72
+
73
+ // Command invocation
74
+ switch (clusterId) {
75
+ case CLUSTER.ON_OFF:
76
+ if (target === 'toggle') return { kind: 'command', name: 'toggle', args: undefined }
77
+ // For on/off honour the KNX boolean payload, so a single DPT1 GA drives both.
78
+ return { kind: 'command', name: truthy(value) ? 'on' : 'off', args: undefined }
79
+
80
+ case CLUSTER.LEVEL_CONTROL:
81
+ if (target === 'stop' || target === 'stopWithOnOff') return { kind: 'command', name: target, args: { ...LEVEL_OPTS } }
82
+ // moveToLevel / moveToLevelWithOnOff: KNX percent (0-100) -> Matter level (0-254)
83
+ return {
84
+ kind: 'command',
85
+ name: target,
86
+ args: { level: clampByte(Number(value) * 254 / 100, 254), transitionTime: 0, ...LEVEL_OPTS }
87
+ }
88
+
89
+ case CLUSTER.WINDOW_COVERING:
90
+ if (target === 'stopMotion') return { kind: 'command', name: 'stopMotion', args: undefined }
91
+ if (target === 'goToLiftPercentage') return { kind: 'command', name: 'goToLiftPercentage', args: { liftPercent100thsValue: clampByte(Number(value) * 100, 10000) } }
92
+ if (target === 'goToTiltPercentage') return { kind: 'command', name: 'goToTiltPercentage', args: { tiltPercent100thsValue: clampByte(Number(value) * 100, 10000) } }
93
+ // upOrOpen / downOrClose: honour KNX DPT 1.008 (0 = up, 1 = down)
94
+ return { kind: 'command', name: truthy(value) ? 'downOrClose' : 'upOrOpen', args: undefined }
95
+
96
+ case CLUSTER.DOOR_LOCK:
97
+ // Honour KNX boolean payload (1 = lock, 0 = unlock)
98
+ return { kind: 'command', name: truthy(value) ? 'lockDoor' : 'unlockDoor', args: {} }
99
+
100
+ case CLUSTER.COLOR_CONTROL:
101
+ if (target === 'moveToColorTemperature') {
102
+ // KNX DPT 7.600 carries Kelvin; Matter wants mireds. Accept mireds too (values < 1000).
103
+ const numeric = Number(value)
104
+ const mireds = numeric >= 1000 ? Math.round(1000000 / numeric) : Math.round(numeric)
105
+ return { kind: 'command', name: 'moveToColorTemperature', args: { colorTemperatureMireds: mireds, transitionTime: 0, ...LEVEL_OPTS } }
106
+ }
107
+ if (target === 'moveToHue') {
108
+ return { kind: 'command', name: 'moveToHue', args: { hue: clampByte(Number(value) * 254 / 100, 254), direction: 0, transitionTime: 0, ...LEVEL_OPTS } }
109
+ }
110
+ if (target === 'moveToSaturation') {
111
+ return { kind: 'command', name: 'moveToSaturation', args: { saturation: clampByte(Number(value) * 254 / 100, 254), transitionTime: 0, ...LEVEL_OPTS } }
112
+ }
113
+ break
114
+
115
+ case CLUSTER.IDENTIFY:
116
+ if (target === 'identify') return { kind: 'command', name: 'identify', args: { identifyTime: 15 } }
117
+ break
118
+
119
+ default:
120
+ break
121
+ }
122
+
123
+ // Generic fallback: pass objects through as command arguments, otherwise invoke without arguments.
124
+ return { kind: 'command', name: target, args: (value !== null && typeof value === 'object') ? value : undefined }
125
+ }
126
+
127
+ /**
128
+ * Converts a Matter attribute report into a KNX-friendly payload.
129
+ * The final DPT encoding is done by the KNX engine using the DPT selected in the mapping.
130
+ * @returns {*} converted payload, or undefined to skip sending
131
+ */
132
+ function matterToKnx (clusterId, attributeName, value) {
133
+ if (value === null || value === undefined) return undefined
134
+ const cid = Number(clusterId)
135
+ switch (cid) {
136
+ case CLUSTER.ON_OFF:
137
+ if (attributeName === 'onOff') return value === true
138
+ break
139
+ case CLUSTER.LEVEL_CONTROL:
140
+ if (attributeName === 'currentLevel') return Math.round(Number(value) * 100 / 254) // -> KNX percent
141
+ break
142
+ case CLUSTER.WINDOW_COVERING:
143
+ if (attributeName === 'currentPositionLiftPercent100ths' || attributeName === 'targetPositionLiftPercent100ths') return Math.round(Number(value) / 100)
144
+ if (attributeName === 'currentPositionTiltPercent100ths' || attributeName === 'targetPositionTiltPercent100ths') return Math.round(Number(value) / 100)
145
+ if (attributeName === 'currentPositionLiftPercentage' || attributeName === 'currentPositionTiltPercentage') return Number(value)
146
+ break
147
+ case CLUSTER.COLOR_CONTROL:
148
+ if (attributeName === 'colorTemperatureMireds') return Number(value) > 0 ? Math.round(1000000 / Number(value)) : undefined // -> Kelvin
149
+ if (attributeName === 'currentHue' || attributeName === 'currentSaturation') return Math.round(Number(value) * 100 / 254)
150
+ break
151
+ case CLUSTER.THERMOSTAT:
152
+ if (['localTemperature', 'outdoorTemperature', 'occupiedHeatingSetpoint', 'occupiedCoolingSetpoint', 'unoccupiedHeatingSetpoint', 'unoccupiedCoolingSetpoint'].includes(attributeName)) {
153
+ return Number(value) / 100 // centi-°C -> °C
154
+ }
155
+ break
156
+ case CLUSTER.TEMPERATURE:
157
+ case CLUSTER.HUMIDITY:
158
+ if (attributeName === 'measuredValue') return Number(value) / 100
159
+ break
160
+ case CLUSTER.PRESSURE:
161
+ if (attributeName === 'measuredValue') return Number(value) / 10 // kPa*10 -> kPa... DPT 9.006 expects Pa: user can scale
162
+ break
163
+ case CLUSTER.ILLUMINANCE:
164
+ if (attributeName === 'measuredValue') return Math.round(Math.pow(10, (Number(value) - 1) / 10000) * 100) / 100 // -> Lux
165
+ break
166
+ case CLUSTER.OCCUPANCY:
167
+ if (attributeName === 'occupancy') {
168
+ if (value !== null && typeof value === 'object') return value.occupied === true
169
+ return (Number(value) & 1) === 1
170
+ }
171
+ break
172
+ case CLUSTER.BOOLEAN_STATE:
173
+ if (attributeName === 'stateValue') return value === true
174
+ break
175
+ case CLUSTER.DOOR_LOCK:
176
+ if (attributeName === 'lockState') return Number(value) === 1 // 1 = locked
177
+ break
178
+ case CLUSTER.POWER_SOURCE:
179
+ if (attributeName === 'batPercentRemaining') return Math.round(Number(value) / 2) // half-percent units
180
+ if (attributeName === 'batChargeLevel') return Number(value)
181
+ break
182
+ case CLUSTER.ELECTRICAL_POWER:
183
+ if (attributeName === 'activePower' || attributeName === 'apparentPower' || attributeName === 'reactivePower') return Number(value) / 1000 // mW -> W
184
+ if (attributeName === 'voltage') return Number(value) / 1000 // mV -> V
185
+ if (attributeName === 'activeCurrent' || attributeName === 'rmsCurrent') return Number(value) / 1000 // mA -> A
186
+ break
187
+ case CLUSTER.ELECTRICAL_ENERGY:
188
+ if (attributeName === 'cumulativeEnergyImported' || attributeName === 'cumulativeEnergyExported') {
189
+ if (value !== null && typeof value === 'object' && value.energy !== undefined) return Number(value.energy) / 1000000 // mWh -> kWh
190
+ }
191
+ break
192
+ case CLUSTER.FAN_CONTROL:
193
+ if (attributeName === 'percentSetting' || attributeName === 'percentCurrent') return Number(value)
194
+ break
195
+ default:
196
+ break
197
+ }
198
+ // Generic fallback: booleans and numbers as-is; objects are passed through (the KNX engine may stringify)
199
+ if (typeof value === 'boolean' || typeof value === 'number') return value
200
+ if (typeof value === 'string') return value
201
+ return value
202
+ }
203
+
204
+ module.exports = { knxToMatter, matterToKnx, CLUSTER }