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 +3 -1
- package/nodes/knxUltimateMatterBridge.js +26 -5
- package/nodes/knxUltimateMatterControllerDevice.js +4 -0
- package/nodes/matter-config.js +21 -6
- package/nodes/matterbridge-config.js +10 -3
- package/nodes/utils/matterControllerProfiles/doorLock.js +12 -0
- package/nodes/utils/matterControllerProfiles/index.js +3 -0
- package/nodes/utils/matterControllerProfiles/mappedEndpoint.js +12 -0
- package/nodes/utils/matterEngine.mjs +28 -6
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
# CHANGELOG
|
|
8
8
|
|
|
9
|
-
**Version 6.0.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/nodes/matter-config.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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.
|
|
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
|
+
}
|