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 CHANGED
@@ -6,8 +6,15 @@
6
6
 
7
7
  # CHANGELOG
8
8
 
9
- **Version 6.0.9** - July 2026<br/>
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
- // Use the same jQuery UI tab structure as the main KNXUltimate node.
113
- $('#mb-tabs').tabs();
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
- node.serverMatterBridge.setDeviceState(node.matterDeviceId, 'position', requestedPosition)
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
- node.serverMatterBridge.setDeviceState(node.matterDeviceId, 'position', requestedPosition)
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
- node.serverMatterBridge.setDeviceState(node.matterDeviceId, route.fn, value)
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
- node.serverMatterBridge.setDeviceState(node.matterDeviceId, fn, payload.value)
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: 'mapped', deviceTypeDisplay, onOff: false, level: false, colorTemperature: false, color: false, mappedTargets };
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 = caps.profile === 'mapped';
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. |
@@ -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
- await node.startWatchdogTimer()
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 = '' } = {}) => setStatus(fill, shape, 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 && status.text) setStatus(status.fill || 'grey', status.shape || 'ring', status.text)
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
- status('blue', 'dot', `Matter→KNX: ${event.attributeName}`)
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
- if (enablePins && String(event?.nodeId) === String(node.matterNodeId) && Number(event?.endpointId) === Number(node.matterEndpointId)) {
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
- try {
141
- const payload = msg.payload || {}
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: payload.endpointId ?? node.matterEndpointId,
144
- clusterId: payload.clusterId,
145
- targetKind: payload.command !== undefined ? 'command' : 'attribute',
146
- target: payload.command ?? payload.attribute
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 (!mapping.target || mapping.clusterId === undefined) throw new Error('Matter input requires clusterId and command or attribute')
149
- enqueue(mapping, payload.args ?? payload.value)
150
- if (done) done()
151
- } catch (error) {
152
- if (done) done(error); else node.error(error, msg)
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) this.handleQueue()
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.attributes[_attributeName]
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.attributes[_attributeName]
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.9",
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.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
+ }