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.
- package/CHANGELOG.md +25 -0
- package/nodes/commonFunctions.js +93 -0
- package/nodes/icons/node-matter-icon.svg +12 -0
- package/nodes/knxUltimateIoTBridge.html +10 -3
- package/nodes/knxUltimateMatterBridge.html +429 -0
- package/nodes/knxUltimateMatterBridge.js +456 -0
- package/nodes/knxUltimateMatterDevice.html +586 -0
- package/nodes/knxUltimateMatterDevice.js +308 -0
- package/nodes/locales/de/knxUltimateMatterBridge.html +64 -0
- package/nodes/locales/de/knxUltimateMatterBridge.json +80 -0
- package/nodes/locales/de/knxUltimateMatterDevice.html +56 -0
- package/nodes/locales/de/knxUltimateMatterDevice.json +109 -0
- package/nodes/locales/de/matter-config.html +25 -0
- package/nodes/locales/de/matter-config.json +26 -0
- package/nodes/locales/en/knxUltimateMatterBridge.html +64 -0
- package/nodes/locales/en/knxUltimateMatterBridge.json +80 -0
- package/nodes/locales/en/knxUltimateMatterDevice.html +56 -0
- package/nodes/locales/en/knxUltimateMatterDevice.json +109 -0
- package/nodes/locales/en/matter-config.html +25 -0
- package/nodes/locales/en/matter-config.json +26 -0
- package/nodes/locales/es/knxUltimateMatterBridge.html +64 -0
- package/nodes/locales/es/knxUltimateMatterBridge.json +80 -0
- package/nodes/locales/es/knxUltimateMatterDevice.html +56 -0
- package/nodes/locales/es/knxUltimateMatterDevice.json +109 -0
- package/nodes/locales/es/matter-config.html +25 -0
- package/nodes/locales/es/matter-config.json +26 -0
- package/nodes/locales/fr/knxUltimateMatterBridge.html +64 -0
- package/nodes/locales/fr/knxUltimateMatterBridge.json +80 -0
- package/nodes/locales/fr/knxUltimateMatterDevice.html +56 -0
- package/nodes/locales/fr/knxUltimateMatterDevice.json +109 -0
- package/nodes/locales/fr/matter-config.html +25 -0
- package/nodes/locales/fr/matter-config.json +26 -0
- package/nodes/locales/it/knxUltimateMatterBridge.html +64 -0
- package/nodes/locales/it/knxUltimateMatterBridge.json +80 -0
- package/nodes/locales/it/knxUltimateMatterDevice.html +56 -0
- package/nodes/locales/it/knxUltimateMatterDevice.json +109 -0
- package/nodes/locales/it/matter-config.html +25 -0
- package/nodes/locales/it/matter-config.json +26 -0
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +64 -0
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +80 -0
- package/nodes/locales/zh-CN/knxUltimateMatterDevice.html +56 -0
- package/nodes/locales/zh-CN/knxUltimateMatterDevice.json +109 -0
- package/nodes/locales/zh-CN/matter-config.html +25 -0
- package/nodes/locales/zh-CN/matter-config.json +26 -0
- package/nodes/matter-config.html +226 -0
- package/nodes/matter-config.js +242 -0
- package/nodes/utils/matterBridgeDeviceFactory.mjs +511 -0
- package/nodes/utils/matterBridgeEngine.mjs +290 -0
- package/nodes/utils/matterEngine.mjs +475 -0
- package/nodes/utils/matterKnxConverter.js +204 -0
- package/package.json +7 -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 }
|