node-red-contrib-knx-ultimate 5.2.4 → 6.0.0

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.
Files changed (79) hide show
  1. package/CHANGELOG.md +21 -8
  2. package/README.md +10 -3
  3. package/img/readmemain.png +0 -0
  4. package/nodes/commonFunctions.js +40 -3
  5. package/nodes/knxUltimate-config.js +379 -335
  6. package/nodes/knxUltimate.html +8 -9
  7. package/nodes/knxUltimate.js +5 -5
  8. package/nodes/knxUltimateAI.js +1 -1
  9. package/nodes/knxUltimateHueLight.html +3 -3
  10. package/nodes/knxUltimateHueLight.js +1 -1
  11. package/nodes/knxUltimateMatterBridge.html +321 -247
  12. package/nodes/knxUltimateMatterBridge.js +48 -1
  13. package/nodes/knxUltimateMatterControllerDevice.html +2119 -0
  14. package/nodes/knxUltimateMatterControllerDevice.js +1701 -0
  15. package/nodes/locales/de/knxUltimateMatterBridge.html +6 -2
  16. package/nodes/locales/de/knxUltimateMatterBridge.json +14 -1
  17. package/nodes/locales/de/knxUltimateMatterControllerDevice.html +27 -0
  18. package/nodes/locales/de/knxUltimateMatterControllerDevice.json +97 -0
  19. package/nodes/locales/de/matter-config.json +5 -1
  20. package/nodes/locales/de/matterbridge-config.json +1 -0
  21. package/nodes/locales/en/knxUltimateMatterBridge.html +6 -2
  22. package/nodes/locales/en/knxUltimateMatterBridge.json +14 -1
  23. package/nodes/locales/en/knxUltimateMatterControllerDevice.html +27 -0
  24. package/nodes/locales/en/knxUltimateMatterControllerDevice.json +97 -0
  25. package/nodes/locales/en/matter-config.json +6 -1
  26. package/nodes/locales/en/matterbridge-config.json +1 -0
  27. package/nodes/locales/es/knxUltimateMatterBridge.html +6 -2
  28. package/nodes/locales/es/knxUltimateMatterBridge.json +14 -1
  29. package/nodes/locales/es/knxUltimateMatterControllerDevice.html +27 -0
  30. package/nodes/locales/es/knxUltimateMatterControllerDevice.json +97 -0
  31. package/nodes/locales/es/matter-config.json +5 -1
  32. package/nodes/locales/es/matterbridge-config.json +1 -0
  33. package/nodes/locales/fr/knxUltimateMatterBridge.html +6 -2
  34. package/nodes/locales/fr/knxUltimateMatterBridge.json +14 -1
  35. package/nodes/locales/fr/knxUltimateMatterControllerDevice.html +27 -0
  36. package/nodes/locales/fr/knxUltimateMatterControllerDevice.json +89 -0
  37. package/nodes/locales/fr/matter-config.json +5 -1
  38. package/nodes/locales/fr/matterbridge-config.json +1 -0
  39. package/nodes/locales/it/knxUltimateMatterBridge.html +6 -2
  40. package/nodes/locales/it/knxUltimateMatterBridge.json +14 -1
  41. package/nodes/locales/it/knxUltimateMatterControllerDevice.html +27 -0
  42. package/nodes/locales/it/knxUltimateMatterControllerDevice.json +97 -0
  43. package/nodes/locales/it/matter-config.json +6 -1
  44. package/nodes/locales/it/matterbridge-config.json +1 -0
  45. package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +6 -2
  46. package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +14 -1
  47. package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.html +27 -0
  48. package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.json +89 -0
  49. package/nodes/locales/zh-CN/matter-config.json +5 -1
  50. package/nodes/locales/zh-CN/matterbridge-config.json +1 -0
  51. package/nodes/matter-config.html +58 -6
  52. package/nodes/matter-config.js +7 -2
  53. package/nodes/matterbridge-config.html +1 -0
  54. package/nodes/utils/lightEngines/canonicalLight.js +75 -0
  55. package/nodes/utils/lightEngines/hueLightEngine.js +51 -0
  56. package/nodes/utils/lightEngines/index.js +25 -0
  57. package/nodes/utils/lightEngines/knxLightCommand.js +79 -0
  58. package/nodes/utils/lightEngines/lightEngine.js +46 -0
  59. package/nodes/utils/lightEngines/matterHueShim.js +59 -0
  60. package/nodes/utils/lightEngines/matterLightEngine.js +117 -0
  61. package/nodes/utils/matterEngine.mjs +185 -14
  62. package/nodes/utils/matterKnxConverter.js +1 -1
  63. package/package.json +21 -3
  64. package/resources/KNXSendSnippets.js +53 -53
  65. package/resources/htmlUtils.js +6 -4
  66. package/nodes/knxUltimateMatterDevice.html +0 -632
  67. package/nodes/knxUltimateMatterDevice.js +0 -308
  68. package/nodes/locales/de/knxUltimateMatterDevice.html +0 -56
  69. package/nodes/locales/de/knxUltimateMatterDevice.json +0 -109
  70. package/nodes/locales/en/knxUltimateMatterDevice.html +0 -56
  71. package/nodes/locales/en/knxUltimateMatterDevice.json +0 -109
  72. package/nodes/locales/es/knxUltimateMatterDevice.html +0 -56
  73. package/nodes/locales/es/knxUltimateMatterDevice.json +0 -109
  74. package/nodes/locales/fr/knxUltimateMatterDevice.html +0 -56
  75. package/nodes/locales/fr/knxUltimateMatterDevice.json +0 -109
  76. package/nodes/locales/it/knxUltimateMatterDevice.html +0 -56
  77. package/nodes/locales/it/knxUltimateMatterDevice.json +0 -109
  78. package/nodes/locales/zh-CN/knxUltimateMatterDevice.html +0 -56
  79. package/nodes/locales/zh-CN/knxUltimateMatterDevice.json +0 -109
@@ -0,0 +1,117 @@
1
+ 'use strict'
2
+ const { LightEngine } = require('./lightEngine')
3
+ const { CLUSTER, matterToKnx } = require('../matterKnxConverter')
4
+ const { clampByte, kelvinToMired, rgbToHsv } = require('./canonicalLight')
5
+
6
+ // Standard "options" arguments required by several Matter cluster commands.
7
+ const LEVEL_OPTS = { optionsMask: {}, optionsOverride: {} }
8
+
9
+ // Matter speaks DISCRETE, per-cluster commands, so a single canonical patch
10
+ // DECOMPOSES into N writeMatterQueueAdd calls (on + moveToLevel + colour...).
11
+ // This engine wraps matter-config's matterManager (the controller), NOT the
12
+ // generic Matter controller paths.
13
+ class MatterLightEngine extends LightEngine {
14
+ constructor (opts = {}) {
15
+ super(opts)
16
+ this.manager = opts.manager // matter-config's matterManager (writeMatterQueueAdd)
17
+ this.nodeId = opts.nodeId !== undefined ? opts.nodeId : opts.deviceId // commissioned Matter node id
18
+ this.endpointId = opts.endpointId !== undefined ? opts.endpointId : 1
19
+ }
20
+
21
+ get kind () { return 'matter' }
22
+
23
+ capabilities () {
24
+ // No native "effects" (candle/fire/prism) equivalent -> node emulates or skips.
25
+ return { onOff: true, dimming: true, colorTemp: true, color: true, effects: false }
26
+ }
27
+
28
+ translate (patch) {
29
+ const ops = []
30
+ if (patch.on !== undefined) {
31
+ ops.push({ clusterId: CLUSTER.ON_OFF, kind: 'command', name: patch.on ? 'on' : 'off', args: undefined })
32
+ }
33
+ if (patch.brightnessPct !== undefined) {
34
+ ops.push({
35
+ clusterId: CLUSTER.LEVEL_CONTROL,
36
+ kind: 'command',
37
+ name: 'moveToLevelWithOnOff',
38
+ args: { level: clampByte(Number(patch.brightnessPct) * 254 / 100, 254), transitionTime: 0, ...LEVEL_OPTS }
39
+ })
40
+ }
41
+ if (patch.colorTempK !== undefined) {
42
+ ops.push({
43
+ clusterId: CLUSTER.COLOR_CONTROL,
44
+ kind: 'command',
45
+ name: 'moveToColorTemperature',
46
+ args: { colorTemperatureMireds: kelvinToMired(patch.colorTempK), transitionTime: 0, ...LEVEL_OPTS }
47
+ })
48
+ }
49
+ if (patch.rgb !== undefined) {
50
+ const { h, s } = rgbToHsv(patch.rgb)
51
+ ops.push({
52
+ clusterId: CLUSTER.COLOR_CONTROL,
53
+ kind: 'command',
54
+ name: 'moveToHue',
55
+ args: { hue: clampByte(h * 254 / 360, 254), direction: 0, transitionTime: 0, ...LEVEL_OPTS }
56
+ })
57
+ ops.push({
58
+ clusterId: CLUSTER.COLOR_CONTROL,
59
+ kind: 'command',
60
+ name: 'moveToSaturation',
61
+ args: { saturation: clampByte(s * 254, 254), transitionTime: 0, ...LEVEL_OPTS }
62
+ })
63
+ }
64
+ return ops
65
+ }
66
+
67
+ applyState (patch) {
68
+ const issued = []
69
+ let ops = []
70
+ try {
71
+ ops = this.translate(patch)
72
+ } catch (error) {
73
+ ops = []
74
+ }
75
+ ops.forEach((op) => {
76
+ const item = {
77
+ nodeId: this.nodeId,
78
+ endpointId: this.endpointId,
79
+ clusterId: op.clusterId,
80
+ kind: op.kind,
81
+ name: op.name,
82
+ args: op.args
83
+ }
84
+ try {
85
+ if (this.manager && typeof this.manager.writeMatterQueueAdd === 'function') {
86
+ const ret = this.manager.writeMatterQueueAdd(item)
87
+ // writeMatterQueueAdd is async: swallow a late rejection so this
88
+ // fire-and-forget call can never become an unhandled rejection (process crash).
89
+ if (ret && typeof ret.catch === 'function') ret.catch(() => { })
90
+ }
91
+ } catch (error) { /* one failed write must never break the batch */ }
92
+ issued.push({ api: 'writeMatterQueueAdd', ...item })
93
+ })
94
+ return issued
95
+ }
96
+
97
+ // Matter attribute report -> canonical patch. Reuses matterToKnx value scaling
98
+ // (currentLevel -> percent, colorTemperatureMireds -> kelvin, onOff -> bool).
99
+ parseIncoming (event) {
100
+ try {
101
+ if (!event) return null
102
+ if (event.nodeId !== undefined && this.nodeId !== undefined && event.nodeId !== this.nodeId) return null
103
+ const value = matterToKnx(Number(event.clusterId), event.attributeName, event.value)
104
+ if (value === undefined) return null
105
+ switch (event.attributeName) {
106
+ case 'onOff': return { on: !!value }
107
+ case 'currentLevel': return { brightnessPct: Number(value) }
108
+ case 'colorTemperatureMireds': return { colorTempK: Number(value) }
109
+ default: return null
110
+ }
111
+ } catch (error) {
112
+ return null
113
+ }
114
+ }
115
+ }
116
+
117
+ module.exports = { MatterLightEngine }
@@ -1,7 +1,23 @@
1
1
  /* eslint-disable max-len */
2
2
  import { EventEmitter } from 'events'
3
+ import dns from 'dns/promises'
3
4
  import { setTimeout as pleaseWait } from 'timers/promises'
4
5
 
6
+ // Last 4 hex chars of an id-ish string (serial, uniqueId, mDNS name...), uppercased.
7
+ // Used to disambiguate identical device names (Shelly encode the MAC in the serial).
8
+ const shortHexSuffix = (value) => {
9
+ if (value === undefined || value === null) return ''
10
+ const hex = String(value).replace(/[^0-9a-fA-F]/g, '')
11
+ if (hex.length >= 4) return hex.slice(-4).toUpperCase()
12
+ const alnum = String(value).replace(/[^0-9a-zA-Z]/g, '')
13
+ return alnum.length >= 4 ? alnum.slice(-4).toUpperCase() : alnum.toUpperCase()
14
+ }
15
+
16
+ const normalizeHostForCompare = (value) => {
17
+ if (value === undefined || value === null) return ''
18
+ return String(value).trim().toLowerCase().replace(/^\[/, '').replace(/\]$/, '').split('%')[0]
19
+ }
20
+
5
21
  // Wrapper around the matter.js CommissioningController.
6
22
  // It mirrors the contract of hueEngine.mjs: an EventEmitter with a command queue,
7
23
  // emitting 'connected', 'disconnected', 'event' (attribute changes), 'matterEvent' (cluster events),
@@ -54,15 +70,90 @@ class classMatter extends EventEmitter {
54
70
  _loadApi = async () => {
55
71
  if (this._api !== null) return this._api
56
72
  const { Environment, StorageService, Logger, LogLevel } = await import('@matter/main')
73
+ const { Seconds } = await import('@matter/general')
57
74
  const { CommissioningController, NodeStates } = await import('@project-chip/matter.js')
58
75
  const { ManualPairingCodeCodec, QrPairingCodeCodec, NodeId } = await import('@matter/main/types')
59
- const { GeneralCommissioning } = await import('@matter/main/clusters')
76
+ const { BasicInformation, GeneralCommissioning } = await import('@matter/main/clusters')
60
77
  this._api = {
61
- Environment, StorageService, Logger, LogLevel, CommissioningController, NodeStates, ManualPairingCodeCodec, QrPairingCodeCodec, NodeId, GeneralCommissioning
78
+ Environment, StorageService, Logger, LogLevel, Seconds, CommissioningController, NodeStates, ManualPairingCodeCodec, QrPairingCodeCodec, NodeId, BasicInformation, GeneralCommissioning
62
79
  }
63
80
  return this._api
64
81
  }
65
82
 
83
+ _resolveTargetHosts = async (_targetHost) => {
84
+ const host = normalizeHostForCompare(_targetHost)
85
+ if (host === '') return []
86
+ const ret = new Set([host])
87
+ try {
88
+ const entries = await dns.lookup(host, { all: true })
89
+ entries.forEach((entry) => {
90
+ const address = normalizeHostForCompare(entry.address)
91
+ if (address !== '') ret.add(address)
92
+ })
93
+ } catch (error) { /* Host may already be an IP or only resolvable by Matter/mDNS. */ }
94
+ return Array.from(ret)
95
+ }
96
+
97
+ _deviceHasTargetHost = (device, targetHosts) => {
98
+ if (!Array.isArray(targetHosts) || targetHosts.length === 0) return false
99
+ try {
100
+ return (device.addresses || []).some((address) => targetHosts.includes(normalizeHostForCompare(address.ip)))
101
+ } catch (error) {
102
+ return false
103
+ }
104
+ }
105
+
106
+ _discoverCommissionableDeviceAtHost = async (identifierData, discoveryCapabilities, targetHost) => {
107
+ const targetHosts = await this._resolveTargetHosts(targetHost)
108
+ if (targetHosts.length === 0) return undefined
109
+ const devices = await this.controller.discoverCommissionableDevices(
110
+ identifierData,
111
+ discoveryCapabilities,
112
+ undefined,
113
+ this._api.Seconds(20)
114
+ )
115
+ return devices.find((device) => this._deviceHasTargetHost(device, targetHosts))
116
+ }
117
+
118
+ _buildKnownAddressDiscoveryAttempts = (identifierData, discoveryCapabilities, targetHost) => {
119
+ const host = normalizeHostForCompare(targetHost)
120
+ if (host === '') return []
121
+ // Matter devices normally listen for commissioning on UDP/5540. Shelly's HTTP RPC
122
+ // gives us the device IP, not its mDNS service port, so try the standard PASE port
123
+ // before falling back to mDNS-filtered discovery.
124
+ return [{
125
+ identifierData,
126
+ discoveryCapabilities,
127
+ knownAddress: {
128
+ ip: host,
129
+ port: 5540,
130
+ type: 'udp'
131
+ }
132
+ }, {
133
+ identifierData,
134
+ discoveryCapabilities,
135
+ knownAddress: {
136
+ ip: host,
137
+ port: 5540
138
+ }
139
+ }]
140
+ }
141
+
142
+ _allocateCommissionNodeId = () => {
143
+ const commissioned = new Set()
144
+ try {
145
+ this.controller.getCommissionedNodes().forEach((nodeId) => commissioned.add(nodeId.toString()))
146
+ } catch (error) { /* empty */ }
147
+ try {
148
+ if (this.controller.nodeId !== undefined) commissioned.add(this.controller.nodeId.toString())
149
+ } catch (error) { /* empty */ }
150
+ for (let index = 0; index < 100; index++) {
151
+ const nodeId = this._api.NodeId.randomOperationalNodeId(this.controller.crypto)
152
+ if (!commissioned.has(nodeId.toString())) return nodeId
153
+ }
154
+ throw new Error('Unable to allocate a free Matter Node ID for commissioning')
155
+ }
156
+
66
157
  nodeStateToString = (state) => {
67
158
  try {
68
159
  const { NodeStates } = this._api
@@ -164,13 +255,14 @@ class classMatter extends EventEmitter {
164
255
  }
165
256
 
166
257
  // Commission (pair) a new device using an 11-digit manual pairing code or a QR code string (MT:...)
167
- commission = async (_pairingCode) => {
258
+ commission = async (_pairingCode, _options = {}) => {
168
259
  if (this.controller === null) throw new Error('Matter controller not started')
169
260
  const api = await this._loadApi()
170
261
  const code = (_pairingCode || '').toString().trim()
171
262
  if (code === '') throw new Error('Empty pairing code')
172
263
  let passcode
173
264
  let identifierData
265
+ const discoveryCapabilities = { onIpNetwork: true }
174
266
  if (code.toUpperCase().startsWith('MT:')) {
175
267
  const qr = api.QrPairingCodeCodec.decode(code)[0]
176
268
  passcode = qr.passcode
@@ -180,16 +272,65 @@ class classMatter extends EventEmitter {
180
272
  passcode = manual.passcode
181
273
  identifierData = { shortDiscriminator: manual.shortDiscriminator }
182
274
  }
183
- const nodeId = await this.controller.commissionNode({
184
- commissioning: {
185
- regulatoryLocation: api.GeneralCommissioning.RegulatoryLocationType.IndoorOutdoor
186
- },
187
- discovery: {
275
+ let nodeId
276
+ try {
277
+ const commissioningNodeId = this._allocateCommissionNodeId()
278
+ this._log('info', `classMatter: commission: using Node ID ${commissioningNodeId.toString()} for new device`)
279
+ let discovery = {
188
280
  identifierData,
189
- discoveryCapabilities: { onIpNetwork: true }
190
- },
191
- passcode
192
- }, { connectNodeAfterCommissioning: false })
281
+ discoveryCapabilities
282
+ }
283
+ const targetHost = (_options?.targetHost || '').toString().trim()
284
+ if (targetHost !== '') {
285
+ const commissionableDevice = await this._discoverCommissionableDeviceAtHost(identifierData, discoveryCapabilities, targetHost)
286
+ discovery = commissionableDevice !== undefined
287
+ ? {
288
+ commissionableDevice,
289
+ discoveryCapabilities
290
+ }
291
+ : undefined
292
+ }
293
+ const discoveryAttempts = discovery !== undefined
294
+ ? [discovery]
295
+ : this._buildKnownAddressDiscoveryAttempts(identifierData, discoveryCapabilities, targetHost)
296
+ let lastError
297
+ for (const discoveryAttempt of discoveryAttempts) {
298
+ try {
299
+ nodeId = await this.controller.commissionNode({
300
+ commissioning: {
301
+ nodeId: commissioningNodeId,
302
+ regulatoryLocation: api.GeneralCommissioning.RegulatoryLocationType.IndoorOutdoor
303
+ },
304
+ discovery: discoveryAttempt,
305
+ passcode
306
+ }, { connectNodeAfterCommissioning: false })
307
+ lastError = undefined
308
+ break
309
+ } catch (error) {
310
+ lastError = error
311
+ }
312
+ }
313
+ if (nodeId === undefined) {
314
+ if (targetHost !== '' && discoveryAttempts.length > 0) {
315
+ throw new Error(`No commissionable Matter device matching this pairing code could be commissioned at ${targetHost}. Direct PASE on UDP/5540 and mDNS discovery both failed.${lastError ? ` Last error: ${lastError.message || lastError}` : ''}`)
316
+ }
317
+ throw lastError || new Error('Matter commissioning failed')
318
+ }
319
+ } catch (error) {
320
+ // The 11-digit MANUAL code carries only the 4-bit short discriminator, so several
321
+ // identical devices (e.g. same-model Shelly) look alike to discovery: it can reach
322
+ // the WRONG one — typically the first, already-commissioned device — and fail with a
323
+ // "node ID already commissioned" conflict. Point the user to the QR code.
324
+ const msg = String((error && error.message) || error)
325
+ const isManual = !code.toUpperCase().startsWith('MT:')
326
+ if (/already commissioned|can not be reused/i.test(msg)) {
327
+ throw new Error(`${msg} — the commissioner reached a device that is ALREADY paired to this controller.${isManual ? ' The MANUAL code can reach the wrong identical device (short discriminator): use the QR code (MT:...) to target the exact one.' : ''} If you really mean this device, factory-reset it (or unpair that node) and try again. Pair one device at a time.`)
328
+ }
329
+ if (isManual) {
330
+ throw new Error(`${msg} — tip: the MANUAL code can't tell identical devices apart (short discriminator). Use the QR code (MT:...) for a precise match, pair one device at a time, and factory-reset the device if it is already commissioned elsewhere.`)
331
+ }
332
+ throw error
333
+ }
193
334
  try {
194
335
  await this._attachNode(nodeId)
195
336
  } catch (error) {
@@ -234,7 +375,7 @@ class classMatter extends EventEmitter {
234
375
  try {
235
376
  const key = detail.nodeId.toString()
236
377
  const node = this.pairedNodes.get(key)
237
- let basicInfo = detail.basicInformationData || {}
378
+ let basicInfo = detail.basicInformationData || detail.deviceData?.basicInformation || {}
238
379
  try {
239
380
  if (node !== undefined && node.basicInformation !== undefined) basicInfo = node.basicInformation
240
381
  } catch (error) { /* empty */ }
@@ -242,9 +383,21 @@ class classMatter extends EventEmitter {
242
383
  try {
243
384
  if (node !== undefined) connectionState = this.nodeStateToString(node.connectionState)
244
385
  } catch (error) { /* empty */ }
386
+ // Name: prefer the user-assigned label; otherwise fall back to the model and
387
+ // append a short unique suffix so identical devices (e.g. several Shelly of the
388
+ // same model) are distinguishable. Shelly expose their MAC in the serial number,
389
+ // so its last 4 hex chars are effectively the "last 4 of the MAC" other apps show.
390
+ const userLabel = basicInfo.nodeLabel || basicInfo.productLabel
391
+ const baseName = userLabel || basicInfo.productName || detail.advertisedName || `Node ${key}`
392
+ let displayName = baseName
393
+ if (!userLabel) {
394
+ const idSource = basicInfo.serialNumber || basicInfo.uniqueId || detail.advertisedName || key
395
+ const suffix = shortHexSuffix(idSource)
396
+ if (suffix && !baseName.toUpperCase().includes(suffix)) displayName = `${baseName} (${suffix})`
397
+ }
245
398
  retArray.push({
246
399
  nodeId: key,
247
- name: basicInfo.nodeLabel || basicInfo.productLabel || basicInfo.productName || detail.advertisedName || `Node ${key}`,
400
+ name: displayName,
248
401
  vendorName: basicInfo.vendorName || '',
249
402
  productName: basicInfo.productName || '',
250
403
  serialNumber: basicInfo.serialNumber || '',
@@ -399,6 +552,24 @@ class classMatter extends EventEmitter {
399
552
  }
400
553
  }
401
554
 
555
+ renameNode = async (_nodeIdString, _label) => {
556
+ const label = (_label || '').toString().trim()
557
+ if (label === '') throw new Error('Empty Matter device name')
558
+ if (label.length > 64) throw new Error('Matter device name is too long')
559
+ const node = this.pairedNodes.get(_nodeIdString)
560
+ if (node === undefined) throw new Error(`Matter node ${_nodeIdString} unknown or not yet connected`)
561
+ let clusterClient
562
+ try {
563
+ if (typeof node.getRootClusterClient === 'function') clusterClient = node.getRootClusterClient(this._api.BasicInformation)
564
+ } catch (error) { /* empty */ }
565
+ if (clusterClient === undefined) clusterClient = this._findClusterClient(node, 0, 40) // BasicInformation on the root endpoint
566
+ if (clusterClient === undefined) throw new Error(`BasicInformation cluster not found on node ${_nodeIdString}`)
567
+ const attribute = clusterClient.attributes.nodeLabel
568
+ if (attribute === undefined) throw new Error(`nodeLabel attribute not found on node ${_nodeIdString}`)
569
+ await attribute.set(label)
570
+ return label
571
+ }
572
+
402
573
  _enqueueCommand = (command) => {
403
574
  if (!command) return false
404
575
  if (!Array.isArray(this.commandQueue)) this.commandQueue = []
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable max-len */
2
2
  // Value conversions between KNX (decoded DPT payloads) and Matter clusters.
3
- // Used by knxUltimateMatterDevice to translate group address payloads into Matter
3
+ // Used by the Matter controller nodes to translate group address payloads into Matter
4
4
  // commands/attribute-writes and Matter attribute reports into KNX payloads.
5
5
 
6
6
  // Matter cluster IDs
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "5.2.4",
6
+ "version": "6.0.0",
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/",
@@ -55,6 +55,7 @@
55
55
  "knxUltimate-config": "/nodes/knxUltimate-config.js",
56
56
  "hueConfig": "/nodes/hue-config.js",
57
57
  "knxUltimateHueLight": "/nodes/knxUltimateHueLight.js",
58
+ "knxUltimateMatterControllerDevice": "/nodes/knxUltimateMatterControllerDevice.js",
58
59
  "knxUltimateHueButton": "/nodes/knxUltimateHueButton.js",
59
60
  "knxUltimateHueMotion": "/nodes/knxUltimateHueMotion.js",
60
61
  "knxUltimateHueAreaMotion": "/nodes/knxUltimateHueAreaMotion.js",
@@ -71,7 +72,6 @@
71
72
  "knxUltimateHueHumiditySensor": "/nodes/knxUltimateHueHumiditySensor.js",
72
73
  "knxUltimateHueCameraMotion": "/nodes/knxUltimateHueCameraMotion.js",
73
74
  "matter-config": "/nodes/matter-config.js",
74
- "knxUltimateMatterDevice": "/nodes/knxUltimateMatterDevice.js",
75
75
  "matterbridge-config": "/nodes/matterbridge-config.js",
76
76
  "knxUltimateMatterBridge": "/nodes/knxUltimateMatterBridge.js"
77
77
  }
@@ -82,12 +82,22 @@
82
82
  },
83
83
  "keywords": [
84
84
  "node-red",
85
+ "node-red-contrib",
85
86
  "KNX",
87
+ "knx-secure",
86
88
  "ETS",
87
89
  "eib",
88
90
  "konnex",
89
91
  "IOT",
90
- "philips hue"
92
+ "philips hue",
93
+ "hue",
94
+ "matter",
95
+ "mqtt",
96
+ "home-assistant",
97
+ "building-automation",
98
+ "home-automation",
99
+ "smarthome",
100
+ "routing"
91
101
  ],
92
102
  "author": "Massimo Saccani (Supergiovane)",
93
103
  "license": "MIT",
@@ -97,6 +107,14 @@
97
107
  "prepublishOnly": "npm run build",
98
108
  "test": "DEBUG_KNX_HUE_TEST=0 npm run test:unit && node scripts/check-node-load.js",
99
109
  "test:unit": "mocha \"test/**/*.test.js\"",
110
+ "avviaquesto-itest:light": "node test/integration/hueLiveTest.mjs",
111
+ "avviaquesto-itest:matterlight": "node test/integration/matterLiveTest.mjs",
112
+ "avviaquesto-itest:nodered-knx": "node test/integration/nodeRedLiveTest.mjs",
113
+ "avviaquesto-itest:nodered-matter": "node test/integration/nodeRedLiveTest.mjs --matter",
114
+ "avviaquesto-itest:knxbus": "node test/integration/knxLightTest.js",
115
+ "avviaquesto-itest:buildflow": "node test/integration/buildTestFlow.js",
116
+ "avviaquesto-itest:serve": "node test/integration/serveFlow.mjs",
117
+ "lint:fix": "standard --fix",
100
118
  "lint-fix": "standard --fix",
101
119
  "knx-ai:dev": "vite --config ui/knxUltimateAI-vue/vite.config.mjs",
102
120
  "knx-ai:build": "vite build --config ui/knxUltimateAI-vue/vite.config.mjs",
@@ -1,9 +1,9 @@
1
1
  ; (function (root) {
2
- const KNXSendSnippets = [
3
- {
4
- id: 'status-ga-check',
5
- title: 'Status GA check',
6
- code: `// @ts-nocheck
2
+ const KNXSendSnippets = [
3
+ {
4
+ id: 'status-ga-check',
5
+ title: 'Status GA check',
6
+ code: `// @ts-nocheck
7
7
  // Replace '' with the real status group address.
8
8
  const statusGA = await getGAValue('','1.001');
9
9
  if (msg.payload !== statusGA){ // " !==" means " not equal"
@@ -13,21 +13,21 @@ if (msg.payload !== statusGA){ // " !==" means " not equal"
13
13
  node.status({fill:"grey",shape:"dot",text:"Not sent"});
14
14
  return;
15
15
  }`
16
- },
17
- {
18
- id: 'toggle-value-no-check',
19
- title: 'Toggle value (without status check)',
20
- code: `// @ts-nocheck
16
+ },
17
+ {
18
+ id: 'toggle-value-no-check',
19
+ title: 'Toggle value (without status check)',
20
+ code: `// @ts-nocheck
21
21
  // After 5000 milliseconds, toggle.
22
22
  setTimeout(function() {
23
23
  toggle();
24
24
  }, 5000);
25
25
  return msg;`
26
- },
27
- {
28
- id: 'toggle-value-with-check',
29
- title: 'Toggle value (with status check)',
30
- code: `// @ts-nocheck
26
+ },
27
+ {
28
+ id: 'toggle-value-with-check',
29
+ title: 'Toggle value (with status check)',
30
+ code: `// @ts-nocheck
31
31
  // Get the current value of the status GA. insert the actual STATUS GA here.
32
32
  let prevValue = await getGAValue('InsertHereTheStatusGA', '1.001')
33
33
  if (prevValue === null)
@@ -39,31 +39,31 @@ if (prevValue === null)
39
39
  // Otherwise, toggles the value
40
40
  self(!prevValue);
41
41
  }`
42
- },
43
- {
44
- id: 'send-false-delay',
45
- title: 'Send false after 5 secs',
46
- code: `// @ts-nocheck
42
+ },
43
+ {
44
+ id: 'send-false-delay',
45
+ title: 'Send false after 5 secs',
46
+ code: `// @ts-nocheck
47
47
  // After 5000 milliseconds, send false.
48
48
  setTimeout(function () {
49
49
  self(false);
50
50
  }, 5000);
51
51
  return msg;`
52
- },
53
- {
54
- id: 'send-20-percent-other-ga',
55
- title: 'Send 20% to another GA',
56
- code: `// @ts-nocheck
52
+ },
53
+ {
54
+ id: 'send-20-percent-other-ga',
55
+ title: 'Send 20% to another GA',
56
+ code: `// @ts-nocheck
57
57
  // Send 20% to the GA 1/0/1 having DPT 5.001
58
58
  if (msg.payload === true){
59
59
  setGAValue('1/0/1',20,'5.001')
60
60
  }
61
61
  return msg;`
62
- },
63
- {
64
- id: 'motion-activated-light',
65
- title: 'Turn on light on motion, auto off',
66
- code: `// @ts-nocheck
62
+ },
63
+ {
64
+ id: 'motion-activated-light',
65
+ title: 'Turn on light on motion, auto off',
66
+ code: `// @ts-nocheck
67
67
  // This snippet expects a motion sensor boolean on msg.payload.
68
68
  // When motion is detected turn the light on, otherwise schedule an auto-off.
69
69
  if (msg.payload === true) {
@@ -79,11 +79,11 @@ if (msg.payload === true) {
79
79
  context.set('autoOffTimer', timer);
80
80
  }
81
81
  return;`
82
- },
83
- {
84
- id: 'window-hvac-standby',
85
- title: 'HVAC standby when window open',
86
- code: `// @ts-nocheck
82
+ },
83
+ {
84
+ id: 'window-hvac-standby',
85
+ title: 'HVAC standby when window open',
86
+ code: `// @ts-nocheck
87
87
  // msg.payload should contain the window contact state (true = open).
88
88
  // If the window is open, set the HVAC to standby, otherwise restore comfort.
89
89
  const hvacGa = ''; // Replace with your HVAC GA.
@@ -98,11 +98,11 @@ if (msg.payload === true) {
98
98
  node.status({fill: 'green', shape: 'dot', text: 'HVAC comfort'});
99
99
  }
100
100
  return;`
101
- },
102
- {
103
- id: 'night-door-alert',
104
- title: 'Night door alert',
105
- code: `// @ts-nocheck
101
+ },
102
+ {
103
+ id: 'night-door-alert',
104
+ title: 'Night door alert',
105
+ code: `// @ts-nocheck
106
106
  // Send a KNX notification GA if a door opens between 22:00 and 06:00.
107
107
  const now = new Date();
108
108
  const hour = now.getHours();
@@ -112,11 +112,11 @@ if (msg.payload === true && (hour >= 22 || hour < 6)) {
112
112
  node.status({fill: 'red', shape: 'ring', text: 'Door alert sent'});
113
113
  }
114
114
  return;`
115
- },
116
- {
117
- id: 'bedtime-all-off',
118
- title: 'Bedtime all off',
119
- code: `// @ts-nocheck
115
+ },
116
+ {
117
+ id: 'bedtime-all-off',
118
+ title: 'Bedtime all off',
119
+ code: `// @ts-nocheck
120
120
  // Turn off a list of lights when a bedtime command is received.
121
121
  const lights = [
122
122
  '', // Replace with GA of the first light
@@ -133,13 +133,13 @@ if (msg.payload === 'bedtime') {
133
133
  return;
134
134
  }
135
135
  return msg;`
136
- }
137
- ]
138
-
139
- if (root) {
140
- root.KNXSendSnippets = KNXSendSnippets
141
- }
142
- if (typeof module !== 'undefined' && module.exports) {
143
- module.exports = KNXSendSnippets
144
136
  }
137
+ ]
138
+
139
+ if (root) {
140
+ root.KNXSendSnippets = KNXSendSnippets
141
+ }
142
+ if (typeof module !== 'undefined' && module.exports) {
143
+ module.exports = KNXSendSnippets
144
+ }
145
145
  })(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this))
@@ -1,3 +1,6 @@
1
+ /* global window, document, MutationObserver, $, jQuery */
2
+ /* eslint-disable camelcase */
3
+
1
4
  // 31/03/2020 Search Helper
2
5
  function htmlUtilsfullCSVSearch (sourceText, searchString) {
3
6
  let aSearchWords = []
@@ -23,7 +26,7 @@ function htmlUtilsfullCSVSearch (sourceText, searchString) {
23
26
  for (let index = 0; index < aSearchWords.length; index++) {
24
27
  if (sourceText.toLowerCase().indexOf(aSearchWords[index]) > -1) i += 1
25
28
  }
26
- return i == aSearchWords.length
29
+ return i === aSearchWords.length
27
30
  }
28
31
 
29
32
  // 2025-09 Secure KNX helpers for GA autocompletes
@@ -112,7 +115,6 @@ function KNX_makeSelectSearchable ($select) {
112
115
 
113
116
  function syncFromSelect ($input) {
114
117
  try {
115
- const val = $select.val()
116
118
  const txt = ($select.find('option:selected').text()) || ''
117
119
  if ($input) { $input.val(txt) }
118
120
  // refresh source
@@ -196,10 +198,10 @@ function KNX_makeSelectSearchable ($select) {
196
198
  } catch (e) {}
197
199
  })()
198
200
 
199
- window.KNX_makeSelectSearchable = KNX_makeSelectSearchable;
201
+ window.KNX_makeSelectSearchable = KNX_makeSelectSearchable
200
202
 
201
203
  // Ensure autocomplete lists open immediately on focus/click
202
- (function ($) {
204
+ ;(function ($) {
203
205
  if (!$.ui || !$.ui.autocomplete) return
204
206
  const _create = $.ui.autocomplete.prototype._create
205
207
  $.ui.autocomplete.prototype._create = function () {