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

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,10 @@
6
6
 
7
7
  # CHANGELOG
8
8
 
9
- **Version 6.0.8** - July 2026<br/>
9
+ **Version 6.0.10** - July 2026<br/>
10
10
 
11
+ - **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/>
12
+ - **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/>
11
13
  - **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/>
12
14
  - **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/>
13
15
  - **Matter terminology cleanup**: removed leftover Philips Hue wording from Matter controller labels, notifications, runtime status, localized strings and help. Backward-compatible saved configuration keys remain unchanged.<br/>
@@ -157,6 +157,8 @@ module.exports = function (RED) {
157
157
  }
158
158
 
159
159
  const safeSendToKNX = (telegram, context = 'write') => {
160
+ // Matter command handlers are asynchronous and shared by many endpoints. Guard
161
+ // each KNX dispatch so one unavailable gateway cannot break other device routes.
160
162
  try {
161
163
  if (!node.serverKNX || typeof node.serverKNX.sendKNXTelegramToKNXEngine !== 'function') {
162
164
  node.setNodeStatus({ fill: 'red', shape: 'dot', text: `KNX server missing (${context})` })
@@ -168,6 +170,24 @@ module.exports = function (RED) {
168
170
  }
169
171
  }
170
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
+
171
191
  const clearCoverStatusTimer = () => {
172
192
  if (coverStatusTimer !== null) {
173
193
  clearTimeout(coverStatusTimer)
@@ -225,7 +245,7 @@ module.exports = function (RED) {
225
245
  clearCoverArrivalTimer()
226
246
 
227
247
  if (node.coverUpdateMode === 'optimistic') {
228
- node.serverMatterBridge.setDeviceState(node.matterDeviceId, 'position', requestedPosition)
248
+ safeSetDeviceState('position', requestedPosition, 'Matter position update error')
229
249
  return
230
250
  }
231
251
 
@@ -239,7 +259,7 @@ module.exports = function (RED) {
239
259
  }
240
260
  coverArrivalTimer = setTimeout(() => {
241
261
  coverArrivalTimer = null
242
- node.serverMatterBridge.setDeviceState(node.matterDeviceId, 'position', requestedPosition)
262
+ safeSetDeviceState('position', requestedPosition, 'Matter position fallback error')
243
263
  node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: 'No exact KNX confirmation in time, assuming arrived at', payload: requestedPosition })
244
264
  }, COVER_ARRIVAL_FALLBACK_MS)
245
265
  }
@@ -339,7 +359,7 @@ module.exports = function (RED) {
339
359
  try {
340
360
  const value = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(route.dpt))
341
361
  if (value === undefined || value === null) return
342
- node.serverMatterBridge.setDeviceState(node.matterDeviceId, route.fn, value)
362
+ safeSetDeviceState(route.fn, value, 'KNX->Matter error')
343
363
  if (node.deviceType === 'windowcovering' && route.fn === 'position') { clearCoverStatusTimer(); clearCoverArrivalTimer() }
344
364
  node.setNodeStatus({ fill: 'blue', shape: 'dot', text: `KNX->Matter ${route.fn}`, payload: value })
345
365
  } catch (error) {
@@ -389,7 +409,7 @@ module.exports = function (RED) {
389
409
 
390
410
  // Flow input pin: updates the Matter state of this device without going through the KNX bus.
391
411
  // msg.payload = { function: 'onoff'|'level'|'position'|'temperature'|..., value: ... }
392
- node.on('input', (msg, send, done) => {
412
+ node.on('input', async (msg, send, done) => {
393
413
  if (!node.enableNodePINS) {
394
414
  if (done) done()
395
415
  return
@@ -399,7 +419,8 @@ module.exports = function (RED) {
399
419
  const fn = (payload.function || payload.fn || '').toString().trim()
400
420
  if (fn === '' || payload.value === undefined) throw new Error('msg.payload must be { function, value }')
401
421
  if (node.serverMatterBridge === undefined) throw new Error('No Matter bridge selected')
402
- 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')
403
424
  node.setNodeStatus({ fill: 'green', shape: 'dot', text: `Flow->Matter ${fn}`, payload: payload.value })
404
425
  if (done) done()
405
426
  } catch (error) {
@@ -23,6 +23,10 @@ module.exports = function (RED) {
23
23
 
24
24
  let matterCapabilities = {}
25
25
  try { matterCapabilities = JSON.parse(config.matterDeviceCapabilities || '{}') } catch (error) { /* empty */ }
26
+ // Non-light endpoints are handled by isolated profiles. A successful profile setup
27
+ // owns the complete node lifecycle and deliberately bypasses the legacy light path.
28
+ // Keeping this boundary here prevents new device types from changing established
29
+ // light behaviour or its saved configuration contract.
26
30
  if (setupMatterControllerProfile(matterCapabilities.profile, RED, node, config)) return
27
31
 
28
32
  // Matter engine (adapter). The manager is resolved lazily at each write, because
@@ -31,7 +31,8 @@ module.exports = (RED) => {
31
31
  node.matterInstanceId = `knxultimate-matter-${node.id.replace(/[^a-zA-Z0-9]/g, '')}`
32
32
  node.matterStoragePath = path.join(RED.settings.userDir || '.', 'knxultimatestorage', 'matter')
33
33
 
34
- // Helper like the hue-config one
34
+ // Expose controller connectivity without copying engine state into the config node.
35
+ // Consumers read the latest value lazily, including during asynchronous startup.
35
36
  Object.defineProperty(node, 'linkStatus', {
36
37
  get: function () {
37
38
  return node.matterManager?.matterConnectionStatus ?? 'disconnected'
@@ -39,9 +40,14 @@ module.exports = (RED) => {
39
40
  })
40
41
 
41
42
  const safeClientCall = (client, fn, label) => {
43
+ // One faulty device node must never interrupt event delivery to the remaining
44
+ // Matter clients registered on this shared controller connection.
42
45
  try {
43
46
  if (!client || typeof fn !== 'function') return
44
- 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
+ }
45
51
  } catch (error) {
46
52
  node.sysLogger?.warn(`Matter client ${label} error: ${error.message}`)
47
53
  }
@@ -129,6 +135,11 @@ module.exports = (RED) => {
129
135
  })
130
136
  })
131
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
+
132
143
  try {
133
144
  await node.matterManager.Connect()
134
145
  } catch (error) {
@@ -147,8 +158,12 @@ module.exports = (RED) => {
147
158
  node.sysLogger?.error(`Errore matter-config: node.startWatchdogTimer: ${error.message}`)
148
159
  }
149
160
  }
150
- await node.startWatchdogTimer()
151
- })()
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}`))
152
167
  }, 60000)
153
168
  }
154
169
 
@@ -162,11 +177,11 @@ module.exports = (RED) => {
162
177
  node.sysLogger?.error(`matter-config: startup error: ${error.message}`)
163
178
  }
164
179
  try {
165
- node.startWatchdogTimer()
180
+ await node.startWatchdogTimer()
166
181
  } catch (error) {
167
182
  node.sysLogger?.error(`matter-config: watchdog start error: ${error.message}`)
168
183
  }
169
- })()
184
+ })().catch((error) => node.sysLogger?.error(`matter-config: startup callback error: ${error.message}`))
170
185
  }, 5000)
171
186
 
172
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 -----------------------------------------
@@ -28,12 +28,16 @@ const lockStateName = (value) => {
28
28
  }
29
29
 
30
30
  const lockStateToBoolean = (value) => {
31
+ // KNX DPT 1 can represent only two states. Do not collapse transitional or
32
+ // ambiguous Matter lock states into a potentially unsafe locked/unlocked value.
31
33
  if (Number(value) === LOCK_STATE.LOCKED) return true
32
34
  if (Number(value) === LOCK_STATE.UNLOCKED) return false
33
35
  return undefined
34
36
  }
35
37
 
36
38
  const setupDoorLockProfile = (RED, node, config) => {
39
+ // Profiles initialize the same flags consumed by knxUltimate-config as ordinary
40
+ // KNX nodes. In particular, listenallga must remain enabled for handleSend calls.
37
41
  node.name = config.name || node.matterDeviceName || 'Control Matter door lock from KNX'
38
42
  node.topic = node.name
39
43
  node.notifyreadrequest = true
@@ -59,6 +63,8 @@ const setupDoorLockProfile = (RED, node, config) => {
59
63
  node.setNodeStatus = ({ fill = 'grey', shape = 'ring', text = '' } = {}) => setStatus(fill, shape, text)
60
64
 
61
65
  const commandArgs = () => {
66
+ // Matter represents the remote credential as bytes. Keep the PIN in Node-RED's
67
+ // credential store and materialize the Buffer only for the outgoing command.
62
68
  const pin = String(node.credentials?.doorLockPin || '')
63
69
  return pin === '' ? {} : { pinCode: Buffer.from(pin, 'utf8') }
64
70
  }
@@ -95,6 +101,8 @@ const setupDoorLockProfile = (RED, node, config) => {
95
101
  node.currentLockState = Number(rawState)
96
102
  const state = lockStateToBoolean(rawState)
97
103
  const name = lockStateName(rawState)
104
+ // Attribute reports update KNX state only; they never enqueue a Matter command.
105
+ // This one-way path is the primary feedback-loop guard for Door Lock endpoints.
98
106
  if (state !== undefined) writeKnxState(state)
99
107
  sendFlow(source, state, rawState)
100
108
  setStatus(state === undefined ? 'yellow' : 'blue', state === undefined ? 'ring' : 'dot', `Matter: ${name}`)
@@ -106,6 +114,8 @@ const setupDoorLockProfile = (RED, node, config) => {
106
114
  const capabilities = (() => {
107
115
  try { return JSON.parse(config.matterDeviceCapabilities || '{}') } catch (error) { return {} }
108
116
  })()
117
+ // Never invent optional operations: the editor persists the commands actually
118
+ // advertised by this endpoint and runtime validation enforces that snapshot.
109
119
  if (locked && capabilities.lockDoor === false) throw new Error('The Matter endpoint does not expose lockDoor')
110
120
  if (!locked && capabilities.unlockDoor === false) throw new Error('The Matter endpoint does not expose unlockDoor')
111
121
  const queued = manager.writeMatterQueueAdd({
@@ -157,6 +167,8 @@ const setupDoorLockProfile = (RED, node, config) => {
157
167
  node.handleMatterClusterEvent = () => {}
158
168
  node.handleMatterNodeInitialized = () => {
159
169
  try {
170
+ // The controller engine already caches the initial attribute read. Reusing that
171
+ // value avoids an extra request and publishes startup state through the same path.
160
172
  const value = node.serverMatter?.matterManager?.getCachedAttribute(
161
173
  node.matterNodeId,
162
174
  node.matterEndpointId,
@@ -13,7 +13,10 @@ const PROFILE_SETUPS = Object.freeze({
13
13
 
14
14
  const setupMatterControllerProfile = (profile, RED, node, config) => {
15
15
  const setup = PROFILE_SETUPS[profile]
16
+ // Returning false is significant: the caller must continue through the unchanged
17
+ // Matter-light implementation when no specialized profile claims the endpoint.
16
18
  if (typeof setup !== 'function') return false
19
+ // A profile that returns successfully owns input, close, KNX and Matter handlers.
17
20
  setup(RED, node, config)
18
21
  return true
19
22
  }
@@ -13,6 +13,8 @@ const isValidGroupAddress = (value) => {
13
13
  const parseMappings = (value) => {
14
14
  try {
15
15
  const parsed = Array.isArray(value) ? value : JSON.parse(value || '[]')
16
+ // Ignore incomplete or stale editor rows. Only mappings with a valid KNX address,
17
+ // resolvable DPT and explicit Matter target are allowed into runtime routing.
16
18
  return parsed.filter((mapping) => {
17
19
  if (!mapping || !isValidGroupAddress(mapping.ga) || !mapping.target || !mapping.dpt) return false
18
20
  try { dptlib.resolve(mapping.dpt); return true } catch (error) { return false }
@@ -59,12 +61,16 @@ const setupMappedEndpointProfile = (RED, node, config) => {
59
61
  const sendCached = (mapping, outputtype) => {
60
62
  const currentManager = manager()
61
63
  if (!currentManager) return false
64
+ // Cached reports are authoritative for KNX read responses and startup publication;
65
+ // reading them must not generate a new Matter write or command.
62
66
  const value = currentManager.getCachedAttribute(node.matterNodeId, mapping.endpointId, mapping.clusterId, mapping.target)
63
67
  return sendKnx(mapping, matterToKnx(mapping.clusterId, mapping.target, value), outputtype)
64
68
  }
65
69
  const enqueue = (mapping, value) => {
66
70
  const currentManager = manager()
67
71
  if (!currentManager) throw new Error('Matter controller not ready')
72
+ // Conversion happens before queueing so the Matter manager receives a canonical,
73
+ // cluster-specific command or attribute write rather than a raw KNX payload.
68
74
  const action = knxToMatter(mapping, value)
69
75
  if (!action) return
70
76
  const queued = currentManager.writeMatterQueueAdd({
@@ -98,6 +104,8 @@ const setupMappedEndpointProfile = (RED, node, config) => {
98
104
  }
99
105
  node.handleSendMatter = (event) => {
100
106
  try {
107
+ // Attribute reports travel only toward KNX/flow. Keeping feedback separate from
108
+ // enqueue() prevents programmatic state synchronization from echoing to Matter.
101
109
  if (String(event?.nodeId) !== String(node.matterNodeId) || Number(event?.endpointId) !== Number(node.matterEndpointId)) return
102
110
  node.mappings.filter((mapping) => mapping.direction === 'status' && Number(mapping.clusterId) === Number(event.clusterId) && mapping.target === event.attributeName).forEach((mapping) => {
103
111
  sendKnx(mapping, matterToKnx(event.clusterId, event.attributeName, event.value))
@@ -110,11 +118,15 @@ const setupMappedEndpointProfile = (RED, node, config) => {
110
118
  }
111
119
  }
112
120
  node.handleMatterClusterEvent = (event) => {
121
+ // Cluster events have no implicit KNX mapping. They are exposed as raw flow output
122
+ // only when the user explicitly enables the node pins.
113
123
  if (enablePins && String(event?.nodeId) === String(node.matterNodeId) && Number(event?.endpointId) === Number(node.matterEndpointId)) {
114
124
  node.send({ topic: `${event.clusterId}.${event.eventName}`, payload: event.events, matter: event })
115
125
  }
116
126
  }
117
127
  node.handleMatterNodeInitialized = () => {
128
+ // nodeInitialized can be emitted more than once while sessions recover. Bound the
129
+ // startup publication to avoid duplicate KNX telegram bursts during reconnects.
118
130
  if (config.readStatusAtStartup === 'no' || Date.now() - lastInitialReadTs < 5000) return
119
131
  let sent = 0
120
132
  node.mappings.filter((mapping) => mapping.direction === 'status').forEach((mapping) => { if (sendCached(mapping, 'write')) sent += 1 })
@@ -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) => {
@@ -443,6 +447,15 @@ class classMatter extends EventEmitter {
443
447
  }
444
448
 
445
449
  _findEndpoint = (node, _endpointId) => {
450
+ // getDevices() can expose only functional/bridged endpoints while the root endpoint
451
+ // lives behind getRootEndpoint(). Resolve endpoint 0 explicitly for node clusters
452
+ // such as BasicInformation and GeneralCommissioning.
453
+ if (Number(_endpointId) === 0 && typeof node?.getRootEndpoint === 'function') {
454
+ try {
455
+ const root = node.getRootEndpoint()
456
+ if (root !== undefined) return root
457
+ } catch (error) { /* The legacy endpoint structure may still be initializing. */ }
458
+ }
446
459
  return this._getAllEndpoints(node).find((ep) => Number(ep.number) === Number(_endpointId))
447
460
  }
448
461
 
@@ -564,12 +577,21 @@ class classMatter extends EventEmitter {
564
577
  if (label.length > 64) throw new Error('Matter device name is too long')
565
578
  const node = this.pairedNodes.get(_nodeIdString)
566
579
  if (node === undefined) throw new Error(`Matter node ${_nodeIdString} unknown or not yet connected`)
580
+ // commission() attaches the PairedNode before its first wildcard read/subscription
581
+ // has necessarily built the legacy endpoint structure. A user-supplied name is
582
+ // applied immediately after commissioning, so wait briefly for BasicInformation
583
+ // instead of reporting a misleading permanent-cluster warning.
584
+ const deadline = Date.now() + 10000
567
585
  let clusterClient
568
- try {
569
- if (typeof node.getRootClusterClient === 'function') clusterClient = node.getRootClusterClient(this._api.BasicInformation)
570
- } catch (error) { /* empty */ }
571
- if (clusterClient === undefined) clusterClient = this._findClusterClient(node, 0, 40) // BasicInformation on the root endpoint
572
- if (clusterClient === undefined) throw new Error(`BasicInformation cluster not found on node ${_nodeIdString}`)
586
+ do {
587
+ try {
588
+ if (typeof node.getRootClusterClient === 'function') clusterClient = node.getRootClusterClient(this._api.BasicInformation)
589
+ } catch (error) { /* Endpoint structure is still initializing. */ }
590
+ if (clusterClient === undefined) clusterClient = this._findClusterClient(node, 0, 40) // BasicInformation on the root endpoint
591
+ if (clusterClient !== undefined) break
592
+ await pleaseWait(100)
593
+ } while (Date.now() < deadline)
594
+ if (clusterClient === undefined) throw new Error(`BasicInformation cluster was not available on node ${_nodeIdString} after initialization`)
573
595
  const attribute = clusterClient.attributes.nodeLabel
574
596
  if (attribute === undefined) throw new Error(`nodeLabel attribute not found on node ${_nodeIdString}`)
575
597
  await attribute.set(label)
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "6.0.8",
6
+ "version": "6.0.10",
7
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.",
8
8
  "files": [
9
9
  "nodes/",
@@ -145,4 +145,4 @@
145
145
  "vite": "^7.1.3",
146
146
  "vue": "^3.5.21"
147
147
  }
148
- }
148
+ }