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.
- package/CHANGELOG.md +21 -8
- package/README.md +10 -3
- package/img/readmemain.png +0 -0
- package/nodes/commonFunctions.js +40 -3
- package/nodes/knxUltimate-config.js +379 -335
- package/nodes/knxUltimate.html +8 -9
- package/nodes/knxUltimate.js +5 -5
- package/nodes/knxUltimateAI.js +1 -1
- package/nodes/knxUltimateHueLight.html +3 -3
- package/nodes/knxUltimateHueLight.js +1 -1
- package/nodes/knxUltimateMatterBridge.html +321 -247
- package/nodes/knxUltimateMatterBridge.js +48 -1
- package/nodes/knxUltimateMatterControllerDevice.html +2119 -0
- package/nodes/knxUltimateMatterControllerDevice.js +1701 -0
- package/nodes/locales/de/knxUltimateMatterBridge.html +6 -2
- package/nodes/locales/de/knxUltimateMatterBridge.json +14 -1
- package/nodes/locales/de/knxUltimateMatterControllerDevice.html +27 -0
- package/nodes/locales/de/knxUltimateMatterControllerDevice.json +97 -0
- package/nodes/locales/de/matter-config.json +5 -1
- package/nodes/locales/de/matterbridge-config.json +1 -0
- package/nodes/locales/en/knxUltimateMatterBridge.html +6 -2
- package/nodes/locales/en/knxUltimateMatterBridge.json +14 -1
- package/nodes/locales/en/knxUltimateMatterControllerDevice.html +27 -0
- package/nodes/locales/en/knxUltimateMatterControllerDevice.json +97 -0
- package/nodes/locales/en/matter-config.json +6 -1
- package/nodes/locales/en/matterbridge-config.json +1 -0
- package/nodes/locales/es/knxUltimateMatterBridge.html +6 -2
- package/nodes/locales/es/knxUltimateMatterBridge.json +14 -1
- package/nodes/locales/es/knxUltimateMatterControllerDevice.html +27 -0
- package/nodes/locales/es/knxUltimateMatterControllerDevice.json +97 -0
- package/nodes/locales/es/matter-config.json +5 -1
- package/nodes/locales/es/matterbridge-config.json +1 -0
- package/nodes/locales/fr/knxUltimateMatterBridge.html +6 -2
- package/nodes/locales/fr/knxUltimateMatterBridge.json +14 -1
- package/nodes/locales/fr/knxUltimateMatterControllerDevice.html +27 -0
- package/nodes/locales/fr/knxUltimateMatterControllerDevice.json +89 -0
- package/nodes/locales/fr/matter-config.json +5 -1
- package/nodes/locales/fr/matterbridge-config.json +1 -0
- package/nodes/locales/it/knxUltimateMatterBridge.html +6 -2
- package/nodes/locales/it/knxUltimateMatterBridge.json +14 -1
- package/nodes/locales/it/knxUltimateMatterControllerDevice.html +27 -0
- package/nodes/locales/it/knxUltimateMatterControllerDevice.json +97 -0
- package/nodes/locales/it/matter-config.json +6 -1
- package/nodes/locales/it/matterbridge-config.json +1 -0
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +6 -2
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +14 -1
- package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.html +27 -0
- package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.json +89 -0
- package/nodes/locales/zh-CN/matter-config.json +5 -1
- package/nodes/locales/zh-CN/matterbridge-config.json +1 -0
- package/nodes/matter-config.html +58 -6
- package/nodes/matter-config.js +7 -2
- package/nodes/matterbridge-config.html +1 -0
- package/nodes/utils/lightEngines/canonicalLight.js +75 -0
- package/nodes/utils/lightEngines/hueLightEngine.js +51 -0
- package/nodes/utils/lightEngines/index.js +25 -0
- package/nodes/utils/lightEngines/knxLightCommand.js +79 -0
- package/nodes/utils/lightEngines/lightEngine.js +46 -0
- package/nodes/utils/lightEngines/matterHueShim.js +59 -0
- package/nodes/utils/lightEngines/matterLightEngine.js +117 -0
- package/nodes/utils/matterEngine.mjs +185 -14
- package/nodes/utils/matterKnxConverter.js +1 -1
- package/package.json +21 -3
- package/resources/KNXSendSnippets.js +53 -53
- package/resources/htmlUtils.js +6 -4
- package/nodes/knxUltimateMatterDevice.html +0 -632
- package/nodes/knxUltimateMatterDevice.js +0 -308
- package/nodes/locales/de/knxUltimateMatterDevice.html +0 -56
- package/nodes/locales/de/knxUltimateMatterDevice.json +0 -109
- package/nodes/locales/en/knxUltimateMatterDevice.html +0 -56
- package/nodes/locales/en/knxUltimateMatterDevice.json +0 -109
- package/nodes/locales/es/knxUltimateMatterDevice.html +0 -56
- package/nodes/locales/es/knxUltimateMatterDevice.json +0 -109
- package/nodes/locales/fr/knxUltimateMatterDevice.html +0 -56
- package/nodes/locales/fr/knxUltimateMatterDevice.json +0 -109
- package/nodes/locales/it/knxUltimateMatterDevice.html +0 -56
- package/nodes/locales/it/knxUltimateMatterDevice.json +0 -109
- package/nodes/locales/zh-CN/knxUltimateMatterDevice.html +0 -56
- 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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
|
|
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:
|
|
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
|
|
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": "
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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))
|
package/resources/htmlUtils.js
CHANGED
|
@@ -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
|
|
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 () {
|