node-red-contrib-knx-ultimate 6.0.9 → 6.0.11
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 +8 -1
- package/nodes/knxUltimateMatterBridge.html +67 -2
- package/nodes/knxUltimateMatterBridge.js +24 -5
- package/nodes/knxUltimateMatterControllerDevice.html +11 -3
- package/nodes/matter-config.js +17 -5
- package/nodes/matterbridge-config.js +10 -3
- package/nodes/utils/matterControllerProfiles/doorLock.js +19 -2
- package/nodes/utils/matterControllerProfiles/fan.js +20 -0
- package/nodes/utils/matterControllerProfiles/index.js +8 -0
- 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/windowCovering.js +20 -0
- package/nodes/utils/matterEngine.mjs +15 -3
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -6,8 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
# CHANGELOG
|
|
8
8
|
|
|
9
|
-
**Version 6.0.
|
|
9
|
+
**Version 6.0.11** - July 2026<br/>
|
|
10
10
|
|
|
11
|
+
- **Matter Bridge — editor tab orientation**: the device editor now uses the same left-hand vertical tab layout as Matter Controller, keeping Mappings and Advanced options visually consistent without changing saved configuration.<br/>
|
|
12
|
+
- **Control Matter from KNX — dedicated controller profiles**: Window Covering (`0x0102`), Thermostat (`0x0201`), Fan Control (`0x0202`) and Switch (`0x003B`) endpoints are now selected into explicit capability-driven profiles instead of the anonymous mapped fallback. They share the guarded mapping lifecycle while enforcing their native input/event cluster boundary and presenting semantic canvas status; Switch initial/long/multi-press events are filtered to the selected endpoint and exposed on the optional flow output. Simple actuators and sensors remain on the generic mapped profile.<br/>
|
|
13
|
+
- **Control Matter from KNX — flow attribute reads and persistent Door Lock status**: mapped endpoints now use an unambiguous top-level flow contract: `msg.clusterId` plus `msg.attribute` reads an attribute, `msg.value` makes it an attribute write, and `msg.command` plus `msg.args` invokes a command. Reads return the value in `msg.payload`, may force a remote request with `msg.requestFromRemote = true`, and accept numeric attribute ID `0`. Door Lock nodes now retain their last `locked`/`unlocked` canvas status when generic Matter or KNX notifications arrive.<br/>
|
|
14
|
+
|
|
15
|
+
**Version 6.0.10** - July 2026<br/>
|
|
16
|
+
|
|
17
|
+
- **Matter nodes — crash containment**: asynchronous bridge state updates, controller client callbacks, engine errors, startup/watchdog timers and the controller command queue are now guarded so rejected promises and EventEmitter errors are reported instead of becoming uncaught exceptions or unhandled rejections capable of stopping Node-RED.<br/>
|
|
11
18
|
- **Matter Controller — post-commission naming**: assigning a name immediately after pairing now waits for matter.js to finish exposing the root `BasicInformation` cluster. Root endpoint 0 is resolved explicitly, preventing the misleading `BasicInformation cluster not found` warning seen when re-adding devices such as Shelly Plug.<br/>
|
|
12
19
|
- **Control Matter from KNX — profile deploy crash**: Door Lock and multi-purpose profiles now expose the KNX status callback before registering with `knxUltimate-config`, preventing `_Node.setNodeStatus is not a function` during deploy.<br/>
|
|
13
20
|
- **Control Matter from KNX — flow PIN persistence**: the **Node Input/Output PINs** selection is no longer overwritten from the KNX gateway presence when reopening the editor. The saved choice remains authoritative and `inputs`/`outputs` are updated coherently on save.<br/>
|
|
@@ -109,8 +109,71 @@
|
|
|
109
109
|
try { RED.sidebar.show('help'); } catch (error) { /* empty */ }
|
|
110
110
|
const node = this;
|
|
111
111
|
|
|
112
|
-
//
|
|
113
|
-
$('#
|
|
112
|
+
// Keep the same left-hand vertical tab orientation as Matter Controller.
|
|
113
|
+
if ($('#knxUltimateMatterControllerDeviceVerticalTabs').length === 0) {
|
|
114
|
+
$('head').append(`
|
|
115
|
+
<style id="knxUltimateMatterControllerDeviceVerticalTabs">
|
|
116
|
+
.hue-vertical-tabs.ui-tabs.ui-widget.ui-widget-content.ui-corner-all {
|
|
117
|
+
display: flex;
|
|
118
|
+
border: none;
|
|
119
|
+
padding: 0;
|
|
120
|
+
}
|
|
121
|
+
.hue-vertical-tabs > ul.ui-tabs-nav {
|
|
122
|
+
flex: 0 0 180px;
|
|
123
|
+
border-right: 1px solid #ccc;
|
|
124
|
+
border-left: none;
|
|
125
|
+
border-top: none;
|
|
126
|
+
border-bottom: none;
|
|
127
|
+
padding: 0.5em 0.3em;
|
|
128
|
+
}
|
|
129
|
+
.hue-vertical-tabs > ul.ui-tabs-nav li {
|
|
130
|
+
float: none;
|
|
131
|
+
width: 100%;
|
|
132
|
+
margin: 0 0 2px 0;
|
|
133
|
+
}
|
|
134
|
+
.hue-vertical-tabs > ul.ui-tabs-nav li a {
|
|
135
|
+
display: block;
|
|
136
|
+
width: 100%;
|
|
137
|
+
white-space: normal;
|
|
138
|
+
position: relative;
|
|
139
|
+
border-bottom: none !important;
|
|
140
|
+
}
|
|
141
|
+
.hue-vertical-tabs > ul.ui-tabs-nav li.ui-tabs-active {
|
|
142
|
+
border-bottom: none !important;
|
|
143
|
+
}
|
|
144
|
+
.hue-vertical-tabs > ul.ui-tabs-nav li.ui-tabs-active a::after {
|
|
145
|
+
content: "";
|
|
146
|
+
position: absolute;
|
|
147
|
+
left: 0;
|
|
148
|
+
bottom: 0;
|
|
149
|
+
width: 50%;
|
|
150
|
+
height: 3px;
|
|
151
|
+
background: currentColor;
|
|
152
|
+
}
|
|
153
|
+
.hue-vertical-tabs .ui-tabs-panel {
|
|
154
|
+
flex: 1;
|
|
155
|
+
padding: 0.8em 1em;
|
|
156
|
+
box-sizing: border-box;
|
|
157
|
+
border: none;
|
|
158
|
+
background: transparent;
|
|
159
|
+
}
|
|
160
|
+
.hue-vertical-tabs .form-row > dt {
|
|
161
|
+
flex: 1 1 auto;
|
|
162
|
+
margin: 0;
|
|
163
|
+
}
|
|
164
|
+
.hue-vertical-tabs hr {
|
|
165
|
+
width: 100%;
|
|
166
|
+
border: 0;
|
|
167
|
+
border-top: 1px solid #ccc;
|
|
168
|
+
margin: 8px 0;
|
|
169
|
+
}
|
|
170
|
+
</style>`);
|
|
171
|
+
}
|
|
172
|
+
const $tabs = $('#mb-tabs');
|
|
173
|
+
$tabs.addClass('hue-vertical-tabs');
|
|
174
|
+
$tabs.tabs();
|
|
175
|
+
$tabs.find('ul').addClass('ui-tabs-nav');
|
|
176
|
+
$tabs.find('li').removeClass('ui-corner-top').addClass('ui-corner-left');
|
|
114
177
|
|
|
115
178
|
// Resolves the KNX gateway at query time: the selected one, or - as a
|
|
116
179
|
// fallback - the first knxUltimate-config available, so the GA list works
|
|
@@ -441,6 +504,8 @@ Exposes **one KNX device as a Matter device**. This node is in **BETA**.
|
|
|
441
504
|
|
|
442
505
|
Point it to a **Matter Bridge** configuration node (the actual bridge, paired once by Alexa/Google Home/Apple Home), pick the device type, give it the name the assistant will use, and fill the group addresses.
|
|
443
506
|
|
|
507
|
+
The **Mappings** and **Advanced** tabs are arranged vertically on the left, matching the Matter Controller editor.
|
|
508
|
+
|
|
444
509
|
- **Matter → KNX**: commands from the voice assistant write to the *command* group addresses.
|
|
445
510
|
- **KNX → Matter**: telegrams on the *status* group addresses update the Matter attribute (and the app UI).
|
|
446
511
|
|
|
@@ -170,6 +170,24 @@ module.exports = function (RED) {
|
|
|
170
170
|
}
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
const reportMatterError = (context, error) => {
|
|
174
|
+
const message = error?.message || String(error)
|
|
175
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: `${context} ${message}`, payload: '' })
|
|
176
|
+
try { RED.log.error(`knxUltimateMatterBridge: ${context}: ${message}`) } catch (logError) { /* empty */ }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const safeSetDeviceState = async (fn, value, context) => {
|
|
180
|
+
try {
|
|
181
|
+
if (!node.serverMatterBridge || typeof node.serverMatterBridge.setDeviceState !== 'function') {
|
|
182
|
+
throw new Error('Matter bridge unavailable')
|
|
183
|
+
}
|
|
184
|
+
return await node.serverMatterBridge.setDeviceState(node.matterDeviceId, fn, value)
|
|
185
|
+
} catch (error) {
|
|
186
|
+
reportMatterError(context, error)
|
|
187
|
+
return false
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
173
191
|
const clearCoverStatusTimer = () => {
|
|
174
192
|
if (coverStatusTimer !== null) {
|
|
175
193
|
clearTimeout(coverStatusTimer)
|
|
@@ -227,7 +245,7 @@ module.exports = function (RED) {
|
|
|
227
245
|
clearCoverArrivalTimer()
|
|
228
246
|
|
|
229
247
|
if (node.coverUpdateMode === 'optimistic') {
|
|
230
|
-
|
|
248
|
+
safeSetDeviceState('position', requestedPosition, 'Matter position update error')
|
|
231
249
|
return
|
|
232
250
|
}
|
|
233
251
|
|
|
@@ -241,7 +259,7 @@ module.exports = function (RED) {
|
|
|
241
259
|
}
|
|
242
260
|
coverArrivalTimer = setTimeout(() => {
|
|
243
261
|
coverArrivalTimer = null
|
|
244
|
-
|
|
262
|
+
safeSetDeviceState('position', requestedPosition, 'Matter position fallback error')
|
|
245
263
|
node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: 'No exact KNX confirmation in time, assuming arrived at', payload: requestedPosition })
|
|
246
264
|
}, COVER_ARRIVAL_FALLBACK_MS)
|
|
247
265
|
}
|
|
@@ -341,7 +359,7 @@ module.exports = function (RED) {
|
|
|
341
359
|
try {
|
|
342
360
|
const value = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(route.dpt))
|
|
343
361
|
if (value === undefined || value === null) return
|
|
344
|
-
|
|
362
|
+
safeSetDeviceState(route.fn, value, 'KNX->Matter error')
|
|
345
363
|
if (node.deviceType === 'windowcovering' && route.fn === 'position') { clearCoverStatusTimer(); clearCoverArrivalTimer() }
|
|
346
364
|
node.setNodeStatus({ fill: 'blue', shape: 'dot', text: `KNX->Matter ${route.fn}`, payload: value })
|
|
347
365
|
} catch (error) {
|
|
@@ -391,7 +409,7 @@ module.exports = function (RED) {
|
|
|
391
409
|
|
|
392
410
|
// Flow input pin: updates the Matter state of this device without going through the KNX bus.
|
|
393
411
|
// msg.payload = { function: 'onoff'|'level'|'position'|'temperature'|..., value: ... }
|
|
394
|
-
node.on('input', (msg, send, done) => {
|
|
412
|
+
node.on('input', async (msg, send, done) => {
|
|
395
413
|
if (!node.enableNodePINS) {
|
|
396
414
|
if (done) done()
|
|
397
415
|
return
|
|
@@ -401,7 +419,8 @@ module.exports = function (RED) {
|
|
|
401
419
|
const fn = (payload.function || payload.fn || '').toString().trim()
|
|
402
420
|
if (fn === '' || payload.value === undefined) throw new Error('msg.payload must be { function, value }')
|
|
403
421
|
if (node.serverMatterBridge === undefined) throw new Error('No Matter bridge selected')
|
|
404
|
-
|
|
422
|
+
const updated = await safeSetDeviceState(fn, payload.value, 'Flow->Matter error')
|
|
423
|
+
if (!updated) throw new Error('Matter state update failed')
|
|
405
424
|
node.setNodeStatus({ fill: 'green', shape: 'dot', text: `Flow->Matter ${fn}`, payload: payload.value })
|
|
406
425
|
if (done) done()
|
|
407
426
|
} catch (error) {
|
|
@@ -554,6 +554,11 @@
|
|
|
554
554
|
}
|
|
555
555
|
const isMatterLight = deviceTypes.some((type) => /(?:onoff|dimmable|colortemperature|extendedcolor)light/.test(type));
|
|
556
556
|
if (!isMatterLight) {
|
|
557
|
+
const specializedProfile = getMatterCluster(ep, 258) ? 'windowCovering'
|
|
558
|
+
: getMatterCluster(ep, 513) ? 'thermostat'
|
|
559
|
+
: getMatterCluster(ep, 514) ? 'fan'
|
|
560
|
+
: getMatterCluster(ep, 59) ? 'switch'
|
|
561
|
+
: 'mapped';
|
|
557
562
|
const mappedTargets = [];
|
|
558
563
|
const addCommand = (clusterId, target, label, dpt) => {
|
|
559
564
|
const cluster = getMatterCluster(ep, clusterId);
|
|
@@ -584,7 +589,7 @@
|
|
|
584
589
|
addAttribute(47, 'batPercentRemaining', 'battery', '5.001', false);
|
|
585
590
|
addAttribute(144, 'activePower', 'active_power', '14.056', false);
|
|
586
591
|
addAttribute(145, 'cumulativeEnergyImported', 'imported_energy', '13.013', false);
|
|
587
|
-
return { known: true, profile:
|
|
592
|
+
return { known: true, profile: specializedProfile, deviceTypeDisplay, onOff: false, level: false, colorTemperature: false, color: false, mappedTargets };
|
|
588
593
|
}
|
|
589
594
|
const hasColorTemperatureType = /colortemperature|extendedcolor/.test(typeText);
|
|
590
595
|
const hasColorType = /extendedcolor/.test(typeText);
|
|
@@ -995,7 +1000,7 @@
|
|
|
995
1000
|
const supportsColor = hasHueDevice && caps.color === true;
|
|
996
1001
|
const hasRichBehaviour = supportsDim && (supportsTemperature || supportsColor);
|
|
997
1002
|
const isDoorLock = caps.profile === 'doorLock';
|
|
998
|
-
const isMapped =
|
|
1003
|
+
const isMapped = ['mapped', 'windowCovering', 'thermostat', 'fan', 'switch'].includes(caps.profile);
|
|
999
1004
|
const deviceTypeDisplay = String(caps.deviceTypeDisplay || '').trim();
|
|
1000
1005
|
|
|
1001
1006
|
setMatterTabVisible('tabs-1', supportsSwitch);
|
|
@@ -2150,7 +2155,9 @@
|
|
|
2150
2155
|
|
|
2151
2156
|
The editor detects the selected endpoint capabilities and exposes only the corresponding
|
|
2152
2157
|
Matter functions. Bridged Matter devices are expanded into their individual endpoints in
|
|
2153
|
-
the device picker.
|
|
2158
|
+
the device picker. Door Lock, Window Covering, Thermostat, Fan and Switch endpoints use
|
|
2159
|
+
dedicated profiles. Switch press, long-press and multi-press events are emitted on the
|
|
2160
|
+
optional flow output; simpler endpoints continue through the generic mapped fallback.
|
|
2154
2161
|
|
|
2155
2162
|
|Property|Description|
|
|
2156
2163
|
|--|--|
|
|
@@ -2235,6 +2242,7 @@ _Basic Matter effects_
|
|
|
2235
2242
|
| Read status at startup | Read the Matter endpoint status at Node-RED startup or full deploy and send it to the KNX bus. |
|
|
2236
2243
|
| KNX Brightness Status | Updates the KNX brightness status whenever the Matter light switches ON/OFF. It can send 0% while OFF and restore the previous value when ON, or leave the brightness status unchanged. |
|
|
2237
2244
|
| Update local cached Matter state from KNX bus writes | When enabled, KNX writes immediately update the local Matter-state cache without waiting for the endpoint report. Disable it if the cache should follow only real Matter reports. |
|
|
2245
|
+
| Node Input/Output PINs | For mapped endpoints, all Matter selectors belong directly to `msg`. Read an attribute with `msg.clusterId` and `msg.attribute`; add `msg.requestFromRemote = true` to force a device read instead of using the subscribed value. The result is emitted as `msg.payload`. Add `msg.value` to write an attribute, or use `msg.clusterId`, `msg.command` and `msg.args` to invoke a command. Attribute IDs such as `0` are accepted. Door Lock input accepts `msg.payload = true` to lock and `false` to unlock. |
|
|
2238
2246
|
| Switch on behaviour | It sets the behaviour of your lights when switched on. You can choose from differents behaviours.<br/> **Select color: ** the light will be switched on with the color of your choice. To change color, just CLICK on the color selector (under the _Select color_ control).<br/>**Select temperature and brightness: ** the light will be switched on with the temperature (Kelvin) and brightness (0-100) of your choice.<br/>**None:** the light will retain its last status. In case you've enable the night lighting, after the night time ends, the lamp will resume the color/temperature/brightness state set at day time. |
|
|
2239
2247
|
| Night Lighting | It allows to set a particular light color/brightness at nighttime. The options are the same as the daytime. You could select either a temperature/brightness or color. A cozy temperature of 2700 Kelvin, with a brightness of 10% or 20%, is a good choice for bathroom's night light.|
|
|
2240
2248
|
| Day/Night | Select the group address used to set the day/night behaviour. The group address value is _true_ if daytime, _false_ if nighttime. |
|
package/nodes/matter-config.js
CHANGED
|
@@ -44,7 +44,10 @@ module.exports = (RED) => {
|
|
|
44
44
|
// Matter clients registered on this shared controller connection.
|
|
45
45
|
try {
|
|
46
46
|
if (!client || typeof fn !== 'function') return
|
|
47
|
-
fn()
|
|
47
|
+
const result = fn()
|
|
48
|
+
if (result && typeof result.catch === 'function') {
|
|
49
|
+
result.catch((error) => node.sysLogger?.warn(`Matter client ${label} async error: ${error.message}`))
|
|
50
|
+
}
|
|
48
51
|
} catch (error) {
|
|
49
52
|
node.sysLogger?.warn(`Matter client ${label} error: ${error.message}`)
|
|
50
53
|
}
|
|
@@ -132,6 +135,11 @@ module.exports = (RED) => {
|
|
|
132
135
|
})
|
|
133
136
|
})
|
|
134
137
|
|
|
138
|
+
// EventEmitter treats an unhandled "error" event as a process exception.
|
|
139
|
+
node.matterManager.on('error', (error) => {
|
|
140
|
+
node.sysLogger?.error(`Matter controller engine error: ${error?.message || error}`)
|
|
141
|
+
})
|
|
142
|
+
|
|
135
143
|
try {
|
|
136
144
|
await node.matterManager.Connect()
|
|
137
145
|
} catch (error) {
|
|
@@ -150,8 +158,12 @@ module.exports = (RED) => {
|
|
|
150
158
|
node.sysLogger?.error(`Errore matter-config: node.startWatchdogTimer: ${error.message}`)
|
|
151
159
|
}
|
|
152
160
|
}
|
|
153
|
-
|
|
154
|
-
|
|
161
|
+
try {
|
|
162
|
+
await node.startWatchdogTimer()
|
|
163
|
+
} catch (error) {
|
|
164
|
+
node.sysLogger?.error(`matter-config: watchdog reschedule error: ${error.message}`)
|
|
165
|
+
}
|
|
166
|
+
})().catch((error) => node.sysLogger?.error(`matter-config: watchdog callback error: ${error.message}`))
|
|
155
167
|
}, 60000)
|
|
156
168
|
}
|
|
157
169
|
|
|
@@ -165,11 +177,11 @@ module.exports = (RED) => {
|
|
|
165
177
|
node.sysLogger?.error(`matter-config: startup error: ${error.message}`)
|
|
166
178
|
}
|
|
167
179
|
try {
|
|
168
|
-
node.startWatchdogTimer()
|
|
180
|
+
await node.startWatchdogTimer()
|
|
169
181
|
} catch (error) {
|
|
170
182
|
node.sysLogger?.error(`matter-config: watchdog start error: ${error.message}`)
|
|
171
183
|
}
|
|
172
|
-
})()
|
|
184
|
+
})().catch((error) => node.sysLogger?.error(`matter-config: startup callback error: ${error.message}`))
|
|
173
185
|
}, 5000)
|
|
174
186
|
|
|
175
187
|
// Functions called from the nodes and the admin endpoints ----------------------------------------
|
|
@@ -53,7 +53,10 @@ module.exports = (RED) => {
|
|
|
53
53
|
const safeClientCall = (client, fn, label) => {
|
|
54
54
|
try {
|
|
55
55
|
if (!client || typeof fn !== 'function') return
|
|
56
|
-
fn()
|
|
56
|
+
const result = fn()
|
|
57
|
+
if (result && typeof result.catch === 'function') {
|
|
58
|
+
result.catch((error) => node.sysLogger?.warn(`Matter bridge client ${label} async error: ${error.message}`))
|
|
59
|
+
}
|
|
57
60
|
} catch (error) {
|
|
58
61
|
node.sysLogger?.warn(`Matter bridge client ${label} error: ${error.message}`)
|
|
59
62
|
}
|
|
@@ -80,6 +83,10 @@ module.exports = (RED) => {
|
|
|
80
83
|
|
|
81
84
|
const bindEngineEvents = (engine) => {
|
|
82
85
|
engine.removeAllListeners()
|
|
86
|
+
// An EventEmitter "error" without a listener terminates Node-RED.
|
|
87
|
+
engine.on('error', (error) => {
|
|
88
|
+
node.sysLogger?.error(`Matter bridge engine error: ${error?.message || error}`)
|
|
89
|
+
})
|
|
83
90
|
// Matter -> KNX: a controller (Alexa...) sent a command to a bridged device.
|
|
84
91
|
// Route it to the device node that owns that Matter device.
|
|
85
92
|
engine.on('command', (command) => {
|
|
@@ -109,7 +116,7 @@ module.exports = (RED) => {
|
|
|
109
116
|
} catch (error) {
|
|
110
117
|
node.sysLogger?.warn(`matterbridge-config: reconcile error: ${error.message}`)
|
|
111
118
|
}
|
|
112
|
-
})()
|
|
119
|
+
})().catch((error) => node.sysLogger?.warn(`matterbridge-config: reconcile callback error: ${error.message}`))
|
|
113
120
|
}, 1500)
|
|
114
121
|
}
|
|
115
122
|
|
|
@@ -160,7 +167,7 @@ module.exports = (RED) => {
|
|
|
160
167
|
(async () => {
|
|
161
168
|
if (closing) return
|
|
162
169
|
await ensureEngineStarted()
|
|
163
|
-
})()
|
|
170
|
+
})().catch((error) => node.sysLogger?.error(`matterbridge-config: startup callback error: ${error.message}`))
|
|
164
171
|
}, 5000)
|
|
165
172
|
|
|
166
173
|
// Functions called from the device nodes -----------------------------------------
|
|
@@ -52,6 +52,7 @@ const setupDoorLockProfile = (RED, node, config) => {
|
|
|
52
52
|
node.inputRBE = 'false'
|
|
53
53
|
node.passthrough = 'no'
|
|
54
54
|
node.currentLockState = undefined
|
|
55
|
+
node.matterConnectionStatus = ''
|
|
55
56
|
node.knxUltimateAcceptedGAs = [config.GALightSwitch, config.GALightState]
|
|
56
57
|
.map((ga) => String(ga || '').trim())
|
|
57
58
|
.filter((ga) => ga !== '')
|
|
@@ -60,7 +61,15 @@ const setupDoorLockProfile = (RED, node, config) => {
|
|
|
60
61
|
// knxUltimate-config invokes this synchronously from addClient(). Profiles return
|
|
61
62
|
// early from the main light constructor, so they must expose the callback before
|
|
62
63
|
// registering with the shared KNX configuration node.
|
|
63
|
-
node.setNodeStatus = ({ fill = 'grey', shape = 'ring', text = '' } = {}) =>
|
|
64
|
+
node.setNodeStatus = ({ fill = 'grey', shape = 'ring', text = '' } = {}) => {
|
|
65
|
+
if (node.currentLockState !== undefined) {
|
|
66
|
+
const state = lockStateToBoolean(node.currentLockState)
|
|
67
|
+
const knxText = text ? ` | KNX: ${text}` : ''
|
|
68
|
+
setStatus(state === undefined ? 'yellow' : 'blue', state === undefined ? 'ring' : 'dot', `Matter: ${lockStateName(node.currentLockState)}${knxText}`)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
setStatus(fill, shape, text)
|
|
72
|
+
}
|
|
64
73
|
|
|
65
74
|
const commandArgs = () => {
|
|
66
75
|
// Matter represents the remote credential as bytes. Keep the PIN in Node-RED's
|
|
@@ -181,7 +190,15 @@ const setupDoorLockProfile = (RED, node, config) => {
|
|
|
181
190
|
}
|
|
182
191
|
}
|
|
183
192
|
node.setNodeStatusMatter = (status) => {
|
|
184
|
-
if (status
|
|
193
|
+
if (!status || !status.text) return
|
|
194
|
+
node.matterConnectionStatus = status.text
|
|
195
|
+
const isAvailable = /^(connected|ready|controller ready)$/i.test(status.text)
|
|
196
|
+
if (isAvailable && node.currentLockState !== undefined) {
|
|
197
|
+
const state = lockStateToBoolean(node.currentLockState)
|
|
198
|
+
setStatus(state === undefined ? 'yellow' : 'blue', state === undefined ? 'ring' : 'dot', `Matter: ${lockStateName(node.currentLockState)}`)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
setStatus(status.fill || 'grey', status.shape || 'ring', status.text)
|
|
185
202
|
}
|
|
186
203
|
|
|
187
204
|
if (node.serverKNX) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { setupMappedEndpointProfile } = require('./mappedEndpoint')
|
|
4
|
+
|
|
5
|
+
const FAN_CONTROL_CLUSTER_ID = 0x0202
|
|
6
|
+
|
|
7
|
+
const setupFanProfile = (RED, node, config) => setupMappedEndpointProfile(RED, node, config, {
|
|
8
|
+
profileName: 'fan',
|
|
9
|
+
inputClusterId: FAN_CONTROL_CLUSTER_ID,
|
|
10
|
+
eventClusterId: FAN_CONTROL_CLUSTER_ID,
|
|
11
|
+
formatAttributeStatus: (event) => {
|
|
12
|
+
if (['percentCurrent', 'percentSetting'].includes(event.attributeName) && event.value !== null && event.value !== undefined) {
|
|
13
|
+
return `Matter fan: ${Math.round(Number(event.value))}%`
|
|
14
|
+
}
|
|
15
|
+
if (event.attributeName === 'fanMode') return `Matter fan mode: ${event.value}`
|
|
16
|
+
return ''
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
module.exports = { FAN_CONTROL_CLUSTER_ID, setupFanProfile }
|
|
@@ -2,12 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
const { setupDoorLockProfile } = require('./doorLock')
|
|
4
4
|
const { setupMappedEndpointProfile } = require('./mappedEndpoint')
|
|
5
|
+
const { setupWindowCoveringProfile } = require('./windowCovering')
|
|
6
|
+
const { setupThermostatProfile } = require('./thermostat')
|
|
7
|
+
const { setupFanProfile } = require('./fan')
|
|
8
|
+
const { setupSwitchProfile } = require('./switch')
|
|
5
9
|
|
|
6
10
|
// Controller-side Matter device profiles live behind this registry. Keep profile
|
|
7
11
|
// selection capability-driven: the editor records the profile only after inspecting
|
|
8
12
|
// the endpoint's actual device types, clusters, attributes and supported commands.
|
|
9
13
|
const PROFILE_SETUPS = Object.freeze({
|
|
10
14
|
doorLock: setupDoorLockProfile,
|
|
15
|
+
windowCovering: setupWindowCoveringProfile,
|
|
16
|
+
thermostat: setupThermostatProfile,
|
|
17
|
+
fan: setupFanProfile,
|
|
18
|
+
switch: setupSwitchProfile,
|
|
11
19
|
mapped: setupMappedEndpointProfile
|
|
12
20
|
})
|
|
13
21
|
|
|
@@ -24,7 +24,7 @@ const parseMappings = (value) => {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
const setupMappedEndpointProfile = (RED, node, config) => {
|
|
27
|
+
const setupMappedEndpointProfile = (RED, node, config, options = {}) => {
|
|
28
28
|
node.name = config.name || node.matterDeviceName || 'Control Matter from KNX'
|
|
29
29
|
node.topic = node.name
|
|
30
30
|
node.mappings = parseMappings(config.matterMappings)
|
|
@@ -40,6 +40,7 @@ const setupMappedEndpointProfile = (RED, node, config) => {
|
|
|
40
40
|
node.outputRBE = 'false'
|
|
41
41
|
node.inputRBE = 'false'
|
|
42
42
|
node.passthrough = 'no'
|
|
43
|
+
node.matterProfile = options.profileName || 'mapped'
|
|
43
44
|
const enablePins = config.enableNodePINS === 'yes'
|
|
44
45
|
let lastInitialReadTs = 0
|
|
45
46
|
|
|
@@ -111,7 +112,8 @@ const setupMappedEndpointProfile = (RED, node, config) => {
|
|
|
111
112
|
sendKnx(mapping, matterToKnx(event.clusterId, event.attributeName, event.value))
|
|
112
113
|
})
|
|
113
114
|
if (enablePins) node.send({ topic: `${event.clusterId}.${event.attributeName}`, payload: event.value, matter: event })
|
|
114
|
-
|
|
115
|
+
const profileStatus = typeof options.formatAttributeStatus === 'function' ? options.formatAttributeStatus(event) : ''
|
|
116
|
+
status('blue', 'dot', profileStatus || `Matter→KNX: ${event.attributeName}`)
|
|
115
117
|
} catch (error) {
|
|
116
118
|
RED.log.error(`knxUltimateMatterControllerDevice mapped Matter: ${error.message}`)
|
|
117
119
|
status('red', 'ring', error.message)
|
|
@@ -120,8 +122,11 @@ const setupMappedEndpointProfile = (RED, node, config) => {
|
|
|
120
122
|
node.handleMatterClusterEvent = (event) => {
|
|
121
123
|
// Cluster events have no implicit KNX mapping. They are exposed as raw flow output
|
|
122
124
|
// only when the user explicitly enables the node pins.
|
|
123
|
-
|
|
125
|
+
const clusterAccepted = options.eventClusterId === undefined || Number(event?.clusterId) === Number(options.eventClusterId)
|
|
126
|
+
if (enablePins && clusterAccepted && String(event?.nodeId) === String(node.matterNodeId) && Number(event?.endpointId) === Number(node.matterEndpointId)) {
|
|
124
127
|
node.send({ topic: `${event.clusterId}.${event.eventName}`, payload: event.events, matter: event })
|
|
128
|
+
const profileStatus = typeof options.formatEventStatus === 'function' ? options.formatEventStatus(event) : ''
|
|
129
|
+
if (profileStatus) status('blue', 'dot', profileStatus)
|
|
125
130
|
}
|
|
126
131
|
}
|
|
127
132
|
node.handleMatterNodeInitialized = () => {
|
|
@@ -137,20 +142,53 @@ const setupMappedEndpointProfile = (RED, node, config) => {
|
|
|
137
142
|
if (node.serverKNX) { node.serverKNX.removeClient(node); node.serverKNX.addClient(node) } else status('yellow', 'ring', 'No KNX gateway selected')
|
|
138
143
|
if (node.serverMatter) { node.serverMatter.removeClient(node); node.serverMatter.addClient(node) }
|
|
139
144
|
node.on('input', (msg, send, done) => {
|
|
140
|
-
|
|
141
|
-
|
|
145
|
+
const complete = typeof done === 'function' ? done : () => {}
|
|
146
|
+
const output = typeof send === 'function' ? send : node.send.bind(node)
|
|
147
|
+
Promise.resolve().then(async () => {
|
|
142
148
|
const mapping = {
|
|
143
|
-
endpointId:
|
|
144
|
-
clusterId:
|
|
145
|
-
targetKind:
|
|
146
|
-
target:
|
|
149
|
+
endpointId: msg.endpointId ?? node.matterEndpointId,
|
|
150
|
+
clusterId: msg.clusterId,
|
|
151
|
+
targetKind: msg.command !== undefined ? 'command' : 'attribute',
|
|
152
|
+
target: msg.command ?? msg.attribute
|
|
147
153
|
}
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
+
if (mapping.target === undefined || mapping.target === null || mapping.target === '' || mapping.clusterId === undefined || mapping.clusterId === null) {
|
|
155
|
+
throw new Error('Matter input requires clusterId and command or attribute')
|
|
156
|
+
}
|
|
157
|
+
if (options.inputClusterId !== undefined && Number(mapping.clusterId) !== Number(options.inputClusterId)) {
|
|
158
|
+
throw new Error(`${node.matterProfile} input requires clusterId ${options.inputClusterId}`)
|
|
159
|
+
}
|
|
160
|
+
const isAttributeRead = mapping.targetKind === 'attribute' && msg.value === undefined
|
|
161
|
+
if (isAttributeRead) {
|
|
162
|
+
const currentManager = manager()
|
|
163
|
+
if (!currentManager) throw new Error('Matter controller not ready')
|
|
164
|
+
const value = await currentManager.readAttribute(
|
|
165
|
+
node.matterNodeId,
|
|
166
|
+
mapping.endpointId,
|
|
167
|
+
mapping.clusterId,
|
|
168
|
+
mapping.target,
|
|
169
|
+
msg.requestFromRemote === true
|
|
170
|
+
)
|
|
171
|
+
output({
|
|
172
|
+
...msg,
|
|
173
|
+
payload: value,
|
|
174
|
+
matter: {
|
|
175
|
+
source: 'inputRead',
|
|
176
|
+
nodeId: node.matterNodeId,
|
|
177
|
+
endpointId: mapping.endpointId,
|
|
178
|
+
clusterId: mapping.clusterId,
|
|
179
|
+
attribute: mapping.target
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
status('blue', 'dot', `Matter read: ${mapping.target}`)
|
|
183
|
+
complete()
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
enqueue(mapping, mapping.targetKind === 'command' ? msg.args : msg.value)
|
|
187
|
+
complete()
|
|
188
|
+
}).catch((error) => {
|
|
189
|
+
status('red', 'ring', error.message)
|
|
190
|
+
if (typeof done === 'function') done(error); else node.error(error, msg)
|
|
191
|
+
})
|
|
154
192
|
})
|
|
155
193
|
node.on('close', (done) => {
|
|
156
194
|
try { if (node.serverKNX) node.serverKNX.removeClient(node) } catch (error) { /* empty */ }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { setupMappedEndpointProfile } = require('./mappedEndpoint')
|
|
4
|
+
|
|
5
|
+
const SWITCH_CLUSTER_ID = 0x003b
|
|
6
|
+
|
|
7
|
+
const setupSwitchProfile = (RED, node, config) => setupMappedEndpointProfile(RED, node, config, {
|
|
8
|
+
profileName: 'switch',
|
|
9
|
+
inputClusterId: SWITCH_CLUSTER_ID,
|
|
10
|
+
eventClusterId: SWITCH_CLUSTER_ID,
|
|
11
|
+
formatEventStatus: (event) => `Matter switch: ${event.eventName}`
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
module.exports = { SWITCH_CLUSTER_ID, setupSwitchProfile }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { setupMappedEndpointProfile } = require('./mappedEndpoint')
|
|
4
|
+
|
|
5
|
+
const THERMOSTAT_CLUSTER_ID = 0x0201
|
|
6
|
+
|
|
7
|
+
const setupThermostatProfile = (RED, node, config) => setupMappedEndpointProfile(RED, node, config, {
|
|
8
|
+
profileName: 'thermostat',
|
|
9
|
+
inputClusterId: THERMOSTAT_CLUSTER_ID,
|
|
10
|
+
eventClusterId: THERMOSTAT_CLUSTER_ID,
|
|
11
|
+
formatAttributeStatus: (event) => {
|
|
12
|
+
if (['localTemperature', 'occupiedHeatingSetpoint', 'occupiedCoolingSetpoint'].includes(event.attributeName) && event.value !== null && event.value !== undefined) {
|
|
13
|
+
return `Matter thermostat: ${(Number(event.value) / 100).toFixed(1)} °C`
|
|
14
|
+
}
|
|
15
|
+
if (event.attributeName === 'systemMode') return `Matter thermostat mode: ${event.value}`
|
|
16
|
+
return ''
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
module.exports = { THERMOSTAT_CLUSTER_ID, setupThermostatProfile }
|
|
@@ -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 }
|
|
@@ -37,7 +37,11 @@ class classMatter extends EventEmitter {
|
|
|
37
37
|
this.exitAllQueues = false
|
|
38
38
|
this._api = null // Lazy loaded matter.js exports
|
|
39
39
|
this._logThrottle = new Map()
|
|
40
|
-
if (startQueue)
|
|
40
|
+
if (startQueue) {
|
|
41
|
+
// The queue is intentionally fire-and-forget, but its Promise must always be
|
|
42
|
+
// observed so an unexpected loop failure cannot become an unhandled rejection.
|
|
43
|
+
this.handleQueue().catch((error) => this._log('error', `classMatter: queue loop: ${error.message}`))
|
|
44
|
+
}
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
_log = (level, message) => {
|
|
@@ -466,6 +470,14 @@ class classMatter extends EventEmitter {
|
|
|
466
470
|
}
|
|
467
471
|
}
|
|
468
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
|
+
|
|
469
481
|
// Returns the full structure of a commissioned node: endpoints, clusters, attributes (with cached values) and commands.
|
|
470
482
|
// Used by the editor UI to let the user pick the mapping targets.
|
|
471
483
|
getNodeStructure = (_nodeIdString) => {
|
|
@@ -547,7 +559,7 @@ class classMatter extends EventEmitter {
|
|
|
547
559
|
if (node === undefined) throw new Error(`Matter node ${_nodeIdString} unknown or not yet connected`)
|
|
548
560
|
const clusterClient = this._findClusterClient(node, _endpointId, _clusterId)
|
|
549
561
|
if (clusterClient === undefined) throw new Error(`Cluster ${_clusterId} not found on endpoint ${_endpointId}`)
|
|
550
|
-
const attribute = clusterClient
|
|
562
|
+
const attribute = this._findAttribute(clusterClient, _attributeName)
|
|
551
563
|
if (attribute === undefined) throw new Error(`Attribute ${_attributeName} not found in cluster ${clusterClient.name}`)
|
|
552
564
|
return attribute.get(_requestFromRemote)
|
|
553
565
|
}
|
|
@@ -559,7 +571,7 @@ class classMatter extends EventEmitter {
|
|
|
559
571
|
if (node === undefined) return undefined
|
|
560
572
|
const clusterClient = this._findClusterClient(node, _endpointId, _clusterId)
|
|
561
573
|
if (clusterClient === undefined) return undefined
|
|
562
|
-
const attribute = clusterClient
|
|
574
|
+
const attribute = this._findAttribute(clusterClient, _attributeName)
|
|
563
575
|
if (attribute === undefined) return undefined
|
|
564
576
|
return attribute.getLocal()
|
|
565
577
|
} 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.11",
|
|
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
|
+
}
|