node-red-contrib-knx-ultimate 6.0.10 → 6.0.12

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 (33) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/nodes/knxUltimateHueLight.html +1 -5
  3. package/nodes/knxUltimateMatterBridge.html +67 -2
  4. package/nodes/knxUltimateMatterControllerDevice.html +329 -44
  5. package/nodes/locales/de/knxUltimateMatterBridge.html +0 -2
  6. package/nodes/locales/de/knxUltimateMatterControllerDevice.html +10 -2
  7. package/nodes/locales/de/knxUltimateMatterControllerDevice.json +17 -1
  8. package/nodes/locales/en/knxUltimateMatterBridge.html +0 -2
  9. package/nodes/locales/en/knxUltimateMatterControllerDevice.html +10 -2
  10. package/nodes/locales/en/knxUltimateMatterControllerDevice.json +17 -1
  11. package/nodes/locales/es/knxUltimateMatterBridge.html +0 -2
  12. package/nodes/locales/es/knxUltimateMatterControllerDevice.html +10 -2
  13. package/nodes/locales/es/knxUltimateMatterControllerDevice.json +17 -1
  14. package/nodes/locales/fr/knxUltimateMatterBridge.html +0 -2
  15. package/nodes/locales/fr/knxUltimateMatterControllerDevice.html +10 -2
  16. package/nodes/locales/fr/knxUltimateMatterControllerDevice.json +17 -1
  17. package/nodes/locales/it/knxUltimateMatterBridge.html +0 -2
  18. package/nodes/locales/it/knxUltimateMatterControllerDevice.html +10 -2
  19. package/nodes/locales/it/knxUltimateMatterControllerDevice.json +17 -1
  20. package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +0 -2
  21. package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.html +10 -2
  22. package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.json +17 -1
  23. package/nodes/matter-config.js +5 -4
  24. package/nodes/utils/matterControllerProfiles/doorLock.js +19 -2
  25. package/nodes/utils/matterControllerProfiles/fan.js +20 -0
  26. package/nodes/utils/matterControllerProfiles/index.js +11 -1
  27. package/nodes/utils/matterControllerProfiles/mappedEndpoint.js +53 -15
  28. package/nodes/utils/matterControllerProfiles/switch.js +14 -0
  29. package/nodes/utils/matterControllerProfiles/thermostat.js +20 -0
  30. package/nodes/utils/matterControllerProfiles/universal.js +462 -0
  31. package/nodes/utils/matterControllerProfiles/windowCovering.js +20 -0
  32. package/nodes/utils/matterEngine.mjs +12 -3
  33. package/package.json +3 -3
@@ -0,0 +1,462 @@
1
+ 'use strict'
2
+
3
+ const UNIVERSAL_NODE_ID = '__UNIVERSAL__'
4
+ const POWER_SOURCE_FEATURE_BATTERY = 0x02
5
+
6
+ const setupUniversalProfile = (RED, node, config) => {
7
+ node.name = config.name || node.matterDeviceName || 'Matter Universal Controller'
8
+ node.topic = node.name
9
+ node.matterProfile = 'universal'
10
+ node.notifyreadrequest = true
11
+ node.notifyresponse = false
12
+ node.notifywrite = true
13
+ node.listenallga = true
14
+ node.outputtype = 'write'
15
+ node.outputRBE = 'false'
16
+ node.inputRBE = 'false'
17
+ node.passthrough = 'no'
18
+ const universalService = config.universalService || 'batteryMonitor'
19
+ const lowBatteryGa = String(config.universalLowBatteryGA || '').trim()
20
+ const lowBatteryTextGa = String(config.universalLowBatteryTextGA || '').trim()
21
+ node.knxUltimateAcceptedGAs = [lowBatteryGa, lowBatteryTextGa].filter((ga) => ga !== '')
22
+ const enabledUnlessFalse = (value) => value !== false && value !== 'false'
23
+ const batteryOnlyLow = enabledUnlessFalse(config.universalBatteryOnlyLow)
24
+ const emitInitialState = enabledUnlessFalse(config.universalEmitInitialState)
25
+ const includeRawEvent = enabledUnlessFalse(config.universalIncludeRawEvent)
26
+ const configuredBatteryThreshold = Number(config.universalBatteryThreshold ?? 20)
27
+ const batteryThreshold = Number.isFinite(configuredBatteryThreshold)
28
+ ? Math.min(100, Math.max(0, configuredBatteryThreshold))
29
+ : 20
30
+ const batteryStates = new Map()
31
+ const lastEmittedBatteryStates = new Map()
32
+ const deviceNames = new Map()
33
+ const endpointNames = new Map()
34
+ let lastKnxAlarm
35
+ let lastLowKeys = ''
36
+ let lowBatteryCycleTimer
37
+ let lowBatteryCycleIndex = 0
38
+ let currentLowBatteryText = ''
39
+
40
+ const status = (fill, shape, text) => node.status({ fill, shape, text })
41
+ const manager = () => node.serverMatter?.matterManager
42
+ const valueFrom = (msg, key) => msg[key] !== undefined ? msg[key] : msg.matter?.[key]
43
+ const batteryKey = (nodeId, endpointId) => `${String(nodeId)}/${Number(endpointId)}`
44
+ const chargeLevelName = (value) => ['ok', 'warning', 'critical'][Number(value)] || value
45
+ const replaceabilityName = (value) => ['unspecified', 'notReplaceable', 'userReplaceable', 'factoryReplaceable'][Number(value)] || value
46
+ const isBatteryPowerSource = (state) => {
47
+ const featureMap = Number(state.attributes.featureMap)
48
+ return Number.isFinite(featureMap) && (featureMap & POWER_SOURCE_FEATURE_BATTERY) !== 0
49
+ }
50
+
51
+ const normalizedBattery = (state) => {
52
+ const attributes = state.attributes
53
+ const rawPercentRemaining = attributes.batPercentRemaining
54
+ const percent = Number.isFinite(Number(rawPercentRemaining)) ? Number(rawPercentRemaining) / 2 : undefined
55
+ const chargeLevel = attributes.batChargeLevel
56
+ const replacementNeeded = attributes.batReplacementNeeded === true
57
+ const lowBattery = (percent !== undefined && percent <= batteryThreshold) ||
58
+ [1, 2, 'warning', 'critical'].includes(chargeLevel) ||
59
+ replacementNeeded
60
+ return {
61
+ percent,
62
+ rawPercentRemaining,
63
+ chargeLevel: chargeLevel === undefined ? undefined : chargeLevelName(chargeLevel),
64
+ replacementNeeded: attributes.batReplacementNeeded,
65
+ replaceability: attributes.batReplaceability === undefined ? undefined : replaceabilityName(attributes.batReplaceability),
66
+ voltageMv: attributes.batVoltage,
67
+ status: attributes.status,
68
+ lowBattery,
69
+ threshold: batteryThreshold
70
+ }
71
+ }
72
+
73
+ const batteryList = () => [...batteryStates.values()]
74
+ .filter(isBatteryPowerSource)
75
+ .map((state) => ({
76
+ nodeId: state.nodeId,
77
+ deviceName: state.deviceName,
78
+ endpointId: state.endpointId,
79
+ battery: normalizedBattery(state),
80
+ lastSeen: state.lastSeen
81
+ }))
82
+
83
+ const sendKnx = (ga, payload, dpt, outputtype = 'write') => {
84
+ if (ga === '' || !node.serverKNX?.sendKNXTelegramToKNXEngine) return
85
+ node.serverKNX.sendKNXTelegramToKNXEngine({ grpaddr: ga, payload, dpt, outputtype, nodecallerid: node.id })
86
+ }
87
+
88
+ const lowBatteries = () => batteryList().filter((entry) => entry.battery.lowBattery)
89
+ const batteryDisplayName = (entry) => String(entry.deviceName || `Matter ${entry.nodeId}/${entry.endpointId}`).slice(0, 14)
90
+ const stopBatteryCycle = () => {
91
+ if (lowBatteryCycleTimer) clearInterval(lowBatteryCycleTimer)
92
+ lowBatteryCycleTimer = undefined
93
+ }
94
+ const sendNextLowBatteryName = () => {
95
+ const entries = lowBatteries()
96
+ if (entries.length === 0) return
97
+ const entry = entries[lowBatteryCycleIndex % entries.length]
98
+ lowBatteryCycleIndex = (lowBatteryCycleIndex + 1) % entries.length
99
+ currentLowBatteryText = batteryDisplayName(entry)
100
+ sendKnx(lowBatteryTextGa, currentLowBatteryText, '16.001')
101
+ }
102
+ const syncKnxBatteryOutputs = () => {
103
+ const entries = lowBatteries()
104
+ const alarm = entries.length > 0
105
+ if (lastKnxAlarm !== alarm) {
106
+ sendKnx(lowBatteryGa, alarm, '1.005')
107
+ lastKnxAlarm = alarm
108
+ }
109
+ const lowKeys = entries.map((entry) => batteryKey(entry.nodeId, entry.endpointId)).sort().join(',')
110
+ if (lowKeys === lastLowKeys) return
111
+ lastLowKeys = lowKeys
112
+ stopBatteryCycle()
113
+ lowBatteryCycleIndex = 0
114
+ if (!alarm) {
115
+ currentLowBatteryText = ''
116
+ sendKnx(lowBatteryTextGa, '', '16.001')
117
+ return
118
+ }
119
+ sendNextLowBatteryName()
120
+ lowBatteryCycleTimer = setInterval(sendNextLowBatteryName, 2000)
121
+ lowBatteryCycleTimer.unref?.()
122
+ }
123
+
124
+ const refreshBatteryStatus = () => {
125
+ const batteries = batteryList()
126
+ const lowCount = batteries.filter((entry) => entry.battery.lowBattery).length
127
+ status(lowCount > 0 ? 'yellow' : 'green', 'dot',
128
+ lowCount > 0 ? `Batteries: ${batteries.length} (${lowCount} low)` : `Batteries: ${batteries.length}`)
129
+ }
130
+
131
+ const emitBatteryState = (state, source, rawEvent) => {
132
+ const key = batteryKey(state.nodeId, state.endpointId)
133
+ if (!isBatteryPowerSource(state)) {
134
+ lastEmittedBatteryStates.delete(key)
135
+ return
136
+ }
137
+ const battery = normalizedBattery(state)
138
+ if (batteryOnlyLow && !battery.lowBattery) {
139
+ lastEmittedBatteryStates.delete(key)
140
+ return
141
+ }
142
+ const signature = JSON.stringify(battery)
143
+ if (lastEmittedBatteryStates.get(key) === signature) return
144
+ lastEmittedBatteryStates.set(key, signature)
145
+ const timestamp = new Date().toISOString()
146
+ node.send({
147
+ topic: `matter/battery/${state.nodeId}/${state.endpointId}`,
148
+ payload: {
149
+ nodeId: state.nodeId,
150
+ deviceName: state.deviceName,
151
+ endpointId: state.endpointId,
152
+ battery,
153
+ timestamp
154
+ },
155
+ matter: {
156
+ source,
157
+ nodeId: state.nodeId,
158
+ endpointId: state.endpointId,
159
+ clusterId: 47,
160
+ clusterName: rawEvent?.clusterName || 'PowerSource',
161
+ attributeId: rawEvent?.attributeId,
162
+ attributeName: rawEvent?.attributeName,
163
+ value: rawEvent?.value,
164
+ raw: includeRawEvent ? rawEvent : undefined
165
+ }
166
+ })
167
+ syncKnxBatteryOutputs()
168
+ refreshBatteryStatus()
169
+ }
170
+
171
+ const updateBatteryState = (event, source = 'batteryReport', emit = true) => {
172
+ if (Number(event?.clusterId) !== 47 || event.attributeName === undefined) return
173
+ const key = batteryKey(event.nodeId, event.endpointId)
174
+ const state = batteryStates.get(key) || {
175
+ nodeId: String(event.nodeId),
176
+ deviceName: endpointNames.get(key) || deviceNames.get(String(event.nodeId)) || '',
177
+ endpointId: Number(event.endpointId),
178
+ attributes: {}
179
+ }
180
+ state.deviceName = endpointNames.get(key) || deviceNames.get(state.nodeId) || state.deviceName
181
+ state.attributes[event.attributeName] = event.value
182
+ state.lastSeen = new Date().toISOString()
183
+ if (event.attributeName === 'featureMap' && !isBatteryPowerSource(state)) {
184
+ batteryStates.delete(key)
185
+ lastEmittedBatteryStates.delete(key)
186
+ syncKnxBatteryOutputs()
187
+ return
188
+ }
189
+ batteryStates.set(key, state)
190
+ if (emit) {
191
+ emitBatteryState(state, source, event)
192
+ syncKnxBatteryOutputs()
193
+ }
194
+ }
195
+
196
+ const scanBatterySnapshot = (nodeId) => {
197
+ if (!node.serverMatter || !emitInitialState) return
198
+ const details = node.serverMatter.getCommissionedNodesDetails?.() || []
199
+ details.forEach((detail) => deviceNames.set(String(detail.nodeId), detail.name || ''))
200
+ const selected = nodeId === undefined
201
+ ? details
202
+ : details.filter((detail) => String(detail.nodeId) === String(nodeId))
203
+ selected.forEach((detail) => {
204
+ try {
205
+ const structure = node.serverMatter.getNodeStructure(String(detail.nodeId))
206
+ ;(structure?.endpoints || []).forEach((endpoint) => {
207
+ endpointNames.set(batteryKey(detail.nodeId, endpoint.endpointId), endpoint.name || detail.name || '')
208
+ ;(endpoint.clusters || []).filter((cluster) => Number(cluster.id) === 47).forEach((cluster) => {
209
+ const events = (cluster.attributes || [])
210
+ .filter((attribute) => attribute.value !== undefined)
211
+ .map((attribute) => ({
212
+ nodeId: String(detail.nodeId),
213
+ endpointId: Number(endpoint.endpointId),
214
+ clusterId: 47,
215
+ clusterName: cluster.name,
216
+ attributeId: attribute.id,
217
+ attributeName: attribute.name,
218
+ value: attribute.value
219
+ }))
220
+ events.forEach((event) => updateBatteryState(event, 'batterySnapshot', false))
221
+ const state = batteryStates.get(batteryKey(detail.nodeId, endpoint.endpointId))
222
+ if (state) emitBatteryState(state, 'batterySnapshot', events.at(-1))
223
+ })
224
+ })
225
+ } catch (error) {
226
+ RED.log.debug(`knxUltimateMatterControllerDevice Universal battery snapshot: ${error.message}`)
227
+ }
228
+ })
229
+ syncKnxBatteryOutputs()
230
+ refreshBatteryStatus()
231
+ }
232
+
233
+ node.setNodeStatus = ({ fill = 'grey', shape = 'ring', text = '' } = {}) => {
234
+ if (/disconnected|waiting|error/i.test(text)) status(fill, shape, text)
235
+ }
236
+
237
+ node.handleSend = (msg) => {
238
+ if (msg?.knx?.event !== 'GroupValue_Read') return
239
+ const destination = String(msg.knx.destination || '')
240
+ if (destination === lowBatteryGa) sendKnx(lowBatteryGa, lowBatteries().length > 0, '1.005', 'response')
241
+ if (destination === lowBatteryTextGa) sendKnx(lowBatteryTextGa, currentLowBatteryText, '16.001', 'response')
242
+ }
243
+
244
+ node.setNodeStatusMatter = (value) => {
245
+ if (!value?.text) return
246
+ if (/^(matter disconnected|waiting for matter controller)$/i.test(value.text)) {
247
+ status(value.fill || 'red', value.shape || 'ring', value.text)
248
+ return
249
+ }
250
+ if (universalService === 'batteryMonitor') {
251
+ refreshBatteryStatus()
252
+ return
253
+ }
254
+ status('green', 'dot', 'Universal Matter mode')
255
+ }
256
+
257
+ node.handleSendMatter = (event) => {
258
+ try {
259
+ if (!event || event.nodeId === undefined || event.endpointId === undefined || event.clusterId === undefined) return
260
+ const attributeName = event.attributeName ?? event.attributeId ?? 'attribute'
261
+ if (universalService === 'batteryMonitor') {
262
+ updateBatteryState(event)
263
+ return
264
+ }
265
+ const matter = {
266
+ ...event,
267
+ source: 'attributeReport',
268
+ nodeId: String(event.nodeId),
269
+ endpointId: Number(event.endpointId),
270
+ clusterId: Number(event.clusterId),
271
+ attributeName,
272
+ value: event.value,
273
+ timestamp: Date.now(),
274
+ raw: event
275
+ }
276
+ node.send({
277
+ topic: `matter/${matter.nodeId}/${matter.endpointId}/${matter.clusterId}/${attributeName}`,
278
+ payload: event.value,
279
+ matter
280
+ })
281
+ status('blue', 'dot', `Attribute: ${attributeName}`)
282
+ } catch (error) {
283
+ RED.log.error(`knxUltimateMatterControllerDevice Universal attribute: ${error.message}`)
284
+ status('red', 'ring', error.message)
285
+ }
286
+ }
287
+
288
+ node.handleMatterClusterEvent = (event) => {
289
+ try {
290
+ if (universalService === 'batteryMonitor') return
291
+ if (!event || event.nodeId === undefined || event.endpointId === undefined || event.clusterId === undefined) return
292
+ const eventName = event.eventName ?? event.eventId ?? 'event'
293
+ const matter = {
294
+ ...event,
295
+ source: 'clusterEvent',
296
+ nodeId: String(event.nodeId),
297
+ endpointId: Number(event.endpointId),
298
+ clusterId: Number(event.clusterId),
299
+ eventName,
300
+ timestamp: Date.now(),
301
+ raw: event
302
+ }
303
+ node.send({
304
+ topic: `matter/${matter.nodeId}/${matter.endpointId}/${matter.clusterId}/event/${eventName}`,
305
+ payload: event.events !== undefined ? event.events : event.value,
306
+ matter
307
+ })
308
+ status('blue', 'ring', `Event: ${eventName}`)
309
+ } catch (error) {
310
+ RED.log.error(`knxUltimateMatterControllerDevice Universal cluster event: ${error.message}`)
311
+ status('red', 'ring', error.message)
312
+ }
313
+ }
314
+
315
+ node.handleMatterNodeInitialized = (event) => {
316
+ try {
317
+ const nodeId = event && typeof event === 'object' ? event.nodeId : event
318
+ if (universalService === 'batteryMonitor') {
319
+ scanBatterySnapshot(nodeId)
320
+ return
321
+ }
322
+ if (nodeId === undefined || nodeId === null || String(nodeId) === '') return
323
+ node.send({
324
+ topic: `matter/${String(nodeId)}/initialized`,
325
+ payload: true,
326
+ matter: {
327
+ source: 'nodeInitialized',
328
+ nodeId: String(nodeId),
329
+ timestamp: Date.now(),
330
+ raw: event
331
+ }
332
+ })
333
+ status('green', 'dot', `Universal: node ${String(nodeId)}`)
334
+ } catch (error) {
335
+ RED.log.error(`knxUltimateMatterControllerDevice Universal initialization: ${error.message}`)
336
+ status('red', 'ring', error.message)
337
+ }
338
+ }
339
+
340
+ if (node.serverMatter) {
341
+ node.serverMatter.removeClient(node)
342
+ node.serverMatter.addClient(node)
343
+ if (universalService === 'batteryMonitor') {
344
+ Promise.resolve().then(() => scanBatterySnapshot()).catch((error) => {
345
+ RED.log.error(`knxUltimateMatterControllerDevice Universal battery startup: ${error.message}`)
346
+ })
347
+ }
348
+ } else {
349
+ status('yellow', 'ring', 'Matter controller not ready')
350
+ }
351
+ if (node.serverKNX) {
352
+ node.serverKNX.removeClient(node)
353
+ node.serverKNX.addClient(node)
354
+ }
355
+
356
+ node.on('input', (msg, send, done) => {
357
+ const output = typeof send === 'function' ? send : node.send.bind(node)
358
+ Promise.resolve().then(async () => {
359
+ const action = msg.action ?? msg.payload?.action ?? msg.matter?.action
360
+ if (action === 'getAllBatteries') {
361
+ output({
362
+ ...msg,
363
+ payload: batteryList(),
364
+ matter: {
365
+ ...(msg.matter || {}),
366
+ source: 'getAllBatteries',
367
+ timestamp: Date.now()
368
+ }
369
+ })
370
+ refreshBatteryStatus()
371
+ if (done) done()
372
+ return
373
+ }
374
+ const nodeIdValue = valueFrom(msg, 'nodeId')
375
+ const endpointIdValue = valueFrom(msg, 'endpointId')
376
+ const clusterIdValue = valueFrom(msg, 'clusterId')
377
+ const command = valueFrom(msg, 'command')
378
+ const attribute = valueFrom(msg, 'attribute')
379
+ if (nodeIdValue === undefined || nodeIdValue === null || String(nodeIdValue) === '' ||
380
+ endpointIdValue === undefined || endpointIdValue === null || String(endpointIdValue).trim() === '' ||
381
+ clusterIdValue === undefined || clusterIdValue === null || String(clusterIdValue).trim() === '') {
382
+ throw new Error('Matter input requires nodeId, endpointId and clusterId')
383
+ }
384
+ if ((command === undefined || command === null || command === '') &&
385
+ (attribute === undefined || attribute === null || attribute === '')) {
386
+ throw new Error('Matter input requires command or attribute')
387
+ }
388
+ const currentManager = manager()
389
+ if (!currentManager) throw new Error('Matter controller not ready')
390
+ const nodeId = String(nodeIdValue)
391
+ const endpointId = Number(endpointIdValue)
392
+ const clusterId = Number(clusterIdValue)
393
+ if (!Number.isFinite(endpointId) || !Number.isFinite(clusterId)) {
394
+ throw new Error('Matter input requires nodeId, endpointId and clusterId')
395
+ }
396
+
397
+ if (command !== undefined && command !== null && command !== '') {
398
+ await currentManager.writeMatterQueueAdd({
399
+ nodeId,
400
+ endpointId,
401
+ clusterId,
402
+ kind: 'command',
403
+ name: command,
404
+ args: valueFrom(msg, 'args') ?? {}
405
+ })
406
+ status('green', 'dot', `Matter command: ${command}`)
407
+ if (done) done()
408
+ return
409
+ }
410
+
411
+ const value = valueFrom(msg, 'value')
412
+ if (value !== undefined) {
413
+ await currentManager.writeMatterQueueAdd({
414
+ nodeId,
415
+ endpointId,
416
+ clusterId,
417
+ kind: 'attributeWrite',
418
+ name: attribute,
419
+ args: value
420
+ })
421
+ status('green', 'dot', `Matter write: ${attribute}`)
422
+ if (done) done()
423
+ return
424
+ }
425
+
426
+ const readValue = await currentManager.readAttribute(
427
+ nodeId,
428
+ endpointId,
429
+ clusterId,
430
+ attribute,
431
+ valueFrom(msg, 'requestFromRemote') === true
432
+ )
433
+ output({
434
+ ...msg,
435
+ payload: readValue,
436
+ matter: {
437
+ ...(msg.matter || {}),
438
+ source: 'inputRead',
439
+ nodeId,
440
+ endpointId,
441
+ clusterId,
442
+ attribute
443
+ }
444
+ })
445
+ status('blue', 'dot', `Matter read: ${attribute}`)
446
+ if (done) done()
447
+ }).catch((error) => {
448
+ status('red', 'ring', error.message)
449
+ if (typeof done === 'function') done(error)
450
+ else node.error(error, msg)
451
+ })
452
+ })
453
+
454
+ node.on('close', (done) => {
455
+ stopBatteryCycle()
456
+ try { if (node.serverKNX) node.serverKNX.removeClient(node) } catch (error) { /* empty */ }
457
+ try { if (node.serverMatter) node.serverMatter.removeClient(node) } catch (error) { /* empty */ }
458
+ done()
459
+ })
460
+ }
461
+
462
+ module.exports = { UNIVERSAL_NODE_ID, setupUniversalProfile }
@@ -0,0 +1,20 @@
1
+ 'use strict'
2
+
3
+ const { setupMappedEndpointProfile } = require('./mappedEndpoint')
4
+
5
+ const WINDOW_COVERING_CLUSTER_ID = 0x0102
6
+
7
+ const setupWindowCoveringProfile = (RED, node, config) => setupMappedEndpointProfile(RED, node, config, {
8
+ profileName: 'windowCovering',
9
+ inputClusterId: WINDOW_COVERING_CLUSTER_ID,
10
+ eventClusterId: WINDOW_COVERING_CLUSTER_ID,
11
+ formatAttributeStatus: (event) => {
12
+ if (event.attributeName === 'currentPositionLiftPercent100ths' && event.value !== null && event.value !== undefined) {
13
+ return `Matter cover: ${Math.round(Number(event.value) / 100)}%`
14
+ }
15
+ if (event.attributeName === 'operationalStatus') return 'Matter cover: operational status updated'
16
+ return ''
17
+ }
18
+ })
19
+
20
+ module.exports = { WINDOW_COVERING_CLUSTER_ID, setupWindowCoveringProfile }
@@ -470,6 +470,14 @@ class classMatter extends EventEmitter {
470
470
  }
471
471
  }
472
472
 
473
+ _findAttribute = (clusterClient, attributeNameOrId) => {
474
+ if (clusterClient === undefined || attributeNameOrId === undefined || attributeNameOrId === null) return undefined
475
+ if (typeof attributeNameOrId === 'string' && clusterClient.attributes[attributeNameOrId] !== undefined) {
476
+ return clusterClient.attributes[attributeNameOrId]
477
+ }
478
+ return Object.values(clusterClient.attributes).find((attribute) => Number(attribute?.id) === Number(attributeNameOrId))
479
+ }
480
+
473
481
  // Returns the full structure of a commissioned node: endpoints, clusters, attributes (with cached values) and commands.
474
482
  // Used by the editor UI to let the user pick the mapping targets.
475
483
  getNodeStructure = (_nodeIdString) => {
@@ -497,7 +505,8 @@ class classMatter extends EventEmitter {
497
505
  if (typeof cc.isAttributeSupportedByName === 'function' && !cc.isAttributeSupportedByName(attrName)) return
498
506
  // Skip the global meta attributes (FeatureMap, AttributeList, ClusterRevision...):
499
507
  // they are useless for KNX mappings and only confuse the user.
500
- if (Number(cc.attributes[attrName].id) >= 65528) return
508
+ const exposeFeatureMap = [47, 768].includes(Number(cc.id)) && attrName === 'featureMap'
509
+ if (Number(cc.attributes[attrName].id) >= 65528 && !exposeFeatureMap) return
501
510
  let value
502
511
  try {
503
512
  value = cc.attributes[attrName].getLocal()
@@ -551,7 +560,7 @@ class classMatter extends EventEmitter {
551
560
  if (node === undefined) throw new Error(`Matter node ${_nodeIdString} unknown or not yet connected`)
552
561
  const clusterClient = this._findClusterClient(node, _endpointId, _clusterId)
553
562
  if (clusterClient === undefined) throw new Error(`Cluster ${_clusterId} not found on endpoint ${_endpointId}`)
554
- const attribute = clusterClient.attributes[_attributeName]
563
+ const attribute = this._findAttribute(clusterClient, _attributeName)
555
564
  if (attribute === undefined) throw new Error(`Attribute ${_attributeName} not found in cluster ${clusterClient.name}`)
556
565
  return attribute.get(_requestFromRemote)
557
566
  }
@@ -563,7 +572,7 @@ class classMatter extends EventEmitter {
563
572
  if (node === undefined) return undefined
564
573
  const clusterClient = this._findClusterClient(node, _endpointId, _clusterId)
565
574
  if (clusterClient === undefined) return undefined
566
- const attribute = clusterClient.attributes[_attributeName]
575
+ const attribute = this._findAttribute(clusterClient, _attributeName)
567
576
  if (attribute === undefined) return undefined
568
577
  return attribute.getLocal()
569
578
  } catch (error) {
package/package.json CHANGED
@@ -3,8 +3,8 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "6.0.10",
7
- "description": "Control your KNX and KNX Secure intallation via Node-Red! A bunch of KNX nodes, with integrated Philips HUE control, ETS group address importer, KNX AI for diagnosticsand KNX routing between interfaces. Easy to use and highly configurable.",
6
+ "version": "6.0.12",
7
+ "description": "KNX Ultimate is the most advanced KNX integration for Node-RED, providing secure KNX/IP communication, routing, ETS project import, Philips Hue, Matter Controller and Matter Bridge (control matter device via KNX and expose KNX GA via Matter), MQTT, diagnostics with AI, virtual devices, and powerful automation nodes. Build professional, reliable, and scalable smart home and building automation projects with minimal effort.",
8
8
  "files": [
9
9
  "nodes/",
10
10
  "resources/",
@@ -145,4 +145,4 @@
145
145
  "vite": "^7.1.3",
146
146
  "vue": "^3.5.21"
147
147
  }
148
- }
148
+ }