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.
- package/CHANGELOG.md +10 -0
- package/nodes/knxUltimateHueLight.html +1 -5
- package/nodes/knxUltimateMatterBridge.html +67 -2
- package/nodes/knxUltimateMatterControllerDevice.html +329 -44
- package/nodes/locales/de/knxUltimateMatterBridge.html +0 -2
- package/nodes/locales/de/knxUltimateMatterControllerDevice.html +10 -2
- package/nodes/locales/de/knxUltimateMatterControllerDevice.json +17 -1
- package/nodes/locales/en/knxUltimateMatterBridge.html +0 -2
- package/nodes/locales/en/knxUltimateMatterControllerDevice.html +10 -2
- package/nodes/locales/en/knxUltimateMatterControllerDevice.json +17 -1
- package/nodes/locales/es/knxUltimateMatterBridge.html +0 -2
- package/nodes/locales/es/knxUltimateMatterControllerDevice.html +10 -2
- package/nodes/locales/es/knxUltimateMatterControllerDevice.json +17 -1
- package/nodes/locales/fr/knxUltimateMatterBridge.html +0 -2
- package/nodes/locales/fr/knxUltimateMatterControllerDevice.html +10 -2
- package/nodes/locales/fr/knxUltimateMatterControllerDevice.json +17 -1
- package/nodes/locales/it/knxUltimateMatterBridge.html +0 -2
- package/nodes/locales/it/knxUltimateMatterControllerDevice.html +10 -2
- package/nodes/locales/it/knxUltimateMatterControllerDevice.json +17 -1
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +0 -2
- package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.html +10 -2
- package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.json +17 -1
- package/nodes/matter-config.js +5 -4
- package/nodes/utils/matterControllerProfiles/doorLock.js +19 -2
- package/nodes/utils/matterControllerProfiles/fan.js +20 -0
- package/nodes/utils/matterControllerProfiles/index.js +11 -1
- package/nodes/utils/matterControllerProfiles/mappedEndpoint.js +53 -15
- package/nodes/utils/matterControllerProfiles/switch.js +14 -0
- package/nodes/utils/matterControllerProfiles/thermostat.js +20 -0
- package/nodes/utils/matterControllerProfiles/universal.js +462 -0
- package/nodes/utils/matterControllerProfiles/windowCovering.js +20 -0
- package/nodes/utils/matterEngine.mjs +12 -3
- 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
|
-
|
|
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
|
|
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
|
|
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.
|
|
7
|
-
"description": "
|
|
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
|
+
}
|