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

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,9 @@
6
6
 
7
7
  # CHANGELOG
8
8
 
9
- **Version 6.0.8** - July 2026<br/>
9
+ **Version 6.0.9** - July 2026<br/>
10
10
 
11
+ - **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
12
  - **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
13
  - **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
14
  - **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})` })
@@ -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,6 +40,8 @@ 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
47
  fn()
@@ -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 })
@@ -443,6 +443,15 @@ class classMatter extends EventEmitter {
443
443
  }
444
444
 
445
445
  _findEndpoint = (node, _endpointId) => {
446
+ // getDevices() can expose only functional/bridged endpoints while the root endpoint
447
+ // lives behind getRootEndpoint(). Resolve endpoint 0 explicitly for node clusters
448
+ // such as BasicInformation and GeneralCommissioning.
449
+ if (Number(_endpointId) === 0 && typeof node?.getRootEndpoint === 'function') {
450
+ try {
451
+ const root = node.getRootEndpoint()
452
+ if (root !== undefined) return root
453
+ } catch (error) { /* The legacy endpoint structure may still be initializing. */ }
454
+ }
446
455
  return this._getAllEndpoints(node).find((ep) => Number(ep.number) === Number(_endpointId))
447
456
  }
448
457
 
@@ -564,12 +573,21 @@ class classMatter extends EventEmitter {
564
573
  if (label.length > 64) throw new Error('Matter device name is too long')
565
574
  const node = this.pairedNodes.get(_nodeIdString)
566
575
  if (node === undefined) throw new Error(`Matter node ${_nodeIdString} unknown or not yet connected`)
576
+ // commission() attaches the PairedNode before its first wildcard read/subscription
577
+ // has necessarily built the legacy endpoint structure. A user-supplied name is
578
+ // applied immediately after commissioning, so wait briefly for BasicInformation
579
+ // instead of reporting a misleading permanent-cluster warning.
580
+ const deadline = Date.now() + 10000
567
581
  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}`)
582
+ do {
583
+ try {
584
+ if (typeof node.getRootClusterClient === 'function') clusterClient = node.getRootClusterClient(this._api.BasicInformation)
585
+ } catch (error) { /* Endpoint structure is still initializing. */ }
586
+ if (clusterClient === undefined) clusterClient = this._findClusterClient(node, 0, 40) // BasicInformation on the root endpoint
587
+ if (clusterClient !== undefined) break
588
+ await pleaseWait(100)
589
+ } while (Date.now() < deadline)
590
+ if (clusterClient === undefined) throw new Error(`BasicInformation cluster was not available on node ${_nodeIdString} after initialization`)
573
591
  const attribute = clusterClient.attributes.nodeLabel
574
592
  if (attribute === undefined) throw new Error(`nodeLabel attribute not found on node ${_nodeIdString}`)
575
593
  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.9",
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
+ }