node-red-contrib-knx-ultimate 5.0.4 → 5.2.2
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 +24 -0
- package/nodes/commonFunctions.js +93 -0
- package/nodes/icons/node-matter-icon.svg +12 -0
- package/nodes/knxUltimateMatterBridge.html +342 -0
- package/nodes/knxUltimateMatterBridge.js +313 -0
- package/nodes/knxUltimateMatterDevice.html +632 -0
- package/nodes/knxUltimateMatterDevice.js +308 -0
- package/nodes/locales/de/knxUltimateMatterBridge.html +53 -0
- package/nodes/locales/de/knxUltimateMatterBridge.json +83 -0
- package/nodes/locales/de/knxUltimateMatterDevice.html +56 -0
- package/nodes/locales/de/knxUltimateMatterDevice.json +109 -0
- package/nodes/locales/de/matter-config.html +25 -0
- package/nodes/locales/de/matter-config.json +26 -0
- package/nodes/locales/de/matterbridge-config.html +34 -0
- package/nodes/locales/de/matterbridge-config.json +20 -0
- package/nodes/locales/en/knxUltimateMatterBridge.html +53 -0
- package/nodes/locales/en/knxUltimateMatterBridge.json +83 -0
- package/nodes/locales/en/knxUltimateMatterDevice.html +56 -0
- package/nodes/locales/en/knxUltimateMatterDevice.json +109 -0
- package/nodes/locales/en/matter-config.html +25 -0
- package/nodes/locales/en/matter-config.json +26 -0
- package/nodes/locales/en/matterbridge-config.html +34 -0
- package/nodes/locales/en/matterbridge-config.json +20 -0
- package/nodes/locales/es/knxUltimateMatterBridge.html +53 -0
- package/nodes/locales/es/knxUltimateMatterBridge.json +83 -0
- package/nodes/locales/es/knxUltimateMatterDevice.html +56 -0
- package/nodes/locales/es/knxUltimateMatterDevice.json +109 -0
- package/nodes/locales/es/matter-config.html +25 -0
- package/nodes/locales/es/matter-config.json +26 -0
- package/nodes/locales/es/matterbridge-config.html +34 -0
- package/nodes/locales/es/matterbridge-config.json +20 -0
- package/nodes/locales/fr/knxUltimateMatterBridge.html +53 -0
- package/nodes/locales/fr/knxUltimateMatterBridge.json +83 -0
- package/nodes/locales/fr/knxUltimateMatterDevice.html +56 -0
- package/nodes/locales/fr/knxUltimateMatterDevice.json +109 -0
- package/nodes/locales/fr/matter-config.html +25 -0
- package/nodes/locales/fr/matter-config.json +26 -0
- package/nodes/locales/fr/matterbridge-config.html +34 -0
- package/nodes/locales/fr/matterbridge-config.json +20 -0
- package/nodes/locales/it/knxUltimateMatterBridge.html +53 -0
- package/nodes/locales/it/knxUltimateMatterBridge.json +83 -0
- package/nodes/locales/it/knxUltimateMatterDevice.html +56 -0
- package/nodes/locales/it/knxUltimateMatterDevice.json +109 -0
- package/nodes/locales/it/matter-config.html +25 -0
- package/nodes/locales/it/matter-config.json +26 -0
- package/nodes/locales/it/matterbridge-config.html +34 -0
- package/nodes/locales/it/matterbridge-config.json +20 -0
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +53 -0
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +83 -0
- package/nodes/locales/zh-CN/knxUltimateMatterDevice.html +56 -0
- package/nodes/locales/zh-CN/knxUltimateMatterDevice.json +109 -0
- package/nodes/locales/zh-CN/matter-config.html +25 -0
- package/nodes/locales/zh-CN/matter-config.json +26 -0
- package/nodes/locales/zh-CN/matterbridge-config.html +34 -0
- package/nodes/locales/zh-CN/matterbridge-config.json +20 -0
- package/nodes/matter-config.html +226 -0
- package/nodes/matter-config.js +242 -0
- package/nodes/matterbridge-config.html +121 -0
- package/nodes/matterbridge-config.js +220 -0
- package/nodes/utils/matterBridgeDeviceFactory.mjs +511 -0
- package/nodes/utils/matterBridgeEngine.mjs +290 -0
- package/nodes/utils/matterEngine.mjs +475 -0
- package/nodes/utils/matterKnxConverter.js +204 -0
- package/package.json +8 -2
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/* eslint-disable max-len */
|
|
2
|
+
import { EventEmitter } from 'events'
|
|
3
|
+
|
|
4
|
+
// Wrapper around a matter.js ServerNode acting as a Matter BRIDGE (Aggregator):
|
|
5
|
+
// external Matter controllers (Alexa, Google Home, Apple Home...) commission it once
|
|
6
|
+
// and see every configured KNX virtual device as a bridged Matter device.
|
|
7
|
+
// Mirrors the contract of matterEngine.mjs: an EventEmitter with
|
|
8
|
+
// 'online', 'offline', 'commissioned', 'decommissioned', 'fabricsChanged' and
|
|
9
|
+
// 'command' ({ deviceId, fn, value }) events.
|
|
10
|
+
class classMatterBridge extends EventEmitter {
|
|
11
|
+
constructor (_storagePath, _instanceId, _sysLogger, _options = {}) {
|
|
12
|
+
super()
|
|
13
|
+
this.storagePath = _storagePath
|
|
14
|
+
this.instanceId = _instanceId
|
|
15
|
+
this.sysLogger = _sysLogger
|
|
16
|
+
this.options = {
|
|
17
|
+
port: Number(_options.port) || 5540,
|
|
18
|
+
deviceName: (_options.deviceName || 'KNX-Ultimate Bridge').toString().slice(0, 32)
|
|
19
|
+
}
|
|
20
|
+
this.server = null
|
|
21
|
+
this.aggregator = null
|
|
22
|
+
this.endpoints = new Map() // deviceId -> Endpoint
|
|
23
|
+
this.deviceDefs = new Map() // deviceId -> definition
|
|
24
|
+
this.bridgeStatus = 'stopped' // stopped | running
|
|
25
|
+
this._api = null
|
|
26
|
+
this._factory = null
|
|
27
|
+
this._logThrottle = new Map()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_log = (level, message) => {
|
|
31
|
+
try {
|
|
32
|
+
const logger = this.sysLogger
|
|
33
|
+
if (logger && typeof logger[level] === 'function') return logger[level](message)
|
|
34
|
+
if (logger && typeof logger.log === 'function') return logger.log(message)
|
|
35
|
+
} catch (error) { /* empty */ }
|
|
36
|
+
if (level === 'error') console.error(message)
|
|
37
|
+
else if (level === 'warn') console.warn(message)
|
|
38
|
+
else console.log(message)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_logThrottled = (level, throttleKey, message, intervalMs = 30000) => {
|
|
42
|
+
const now = Date.now()
|
|
43
|
+
const entry = this._logThrottle.get(throttleKey) || { last: 0, suppressed: 0 }
|
|
44
|
+
if (this._logThrottle.size > 1000) this._logThrottle.clear()
|
|
45
|
+
if (entry.last === 0 || (now - entry.last) >= intervalMs) {
|
|
46
|
+
const suffix = entry.suppressed > 0 ? ` (suppressed ${entry.suppressed} similar)` : ''
|
|
47
|
+
entry.last = now
|
|
48
|
+
entry.suppressed = 0
|
|
49
|
+
this._logThrottle.set(throttleKey, entry)
|
|
50
|
+
this._log(level, `${message}${suffix}`)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
entry.suppressed += 1
|
|
54
|
+
this._logThrottle.set(throttleKey, entry)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_loadApi = async () => {
|
|
58
|
+
if (this._api !== null) return this._api
|
|
59
|
+
const { Environment, ServerNode, Endpoint, Logger, LogLevel, VendorId } = await import('@matter/main')
|
|
60
|
+
const { AggregatorEndpoint } = await import('@matter/main/endpoints/aggregator')
|
|
61
|
+
const { QrCode } = await import('@matter/main/types')
|
|
62
|
+
this._factory = await import('./matterBridgeDeviceFactory.mjs')
|
|
63
|
+
this._api = { Environment, ServerNode, Endpoint, Logger, LogLevel, VendorId, AggregatorEndpoint, QrCode }
|
|
64
|
+
return this._api
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Starts the bridge and exposes the given virtual devices.
|
|
68
|
+
// _deviceDefs: [{ id, type, name }]
|
|
69
|
+
start = async (_deviceDefs = []) => {
|
|
70
|
+
const api = await this._loadApi()
|
|
71
|
+
try {
|
|
72
|
+
api.Logger.level = api.LogLevel.ERROR
|
|
73
|
+
} catch (error) { /* empty */ }
|
|
74
|
+
const environment = api.Environment.default
|
|
75
|
+
// Same storage root as the Matter controller engine: instances are separated by their id.
|
|
76
|
+
environment.vars.set('storage.path', this.storagePath)
|
|
77
|
+
|
|
78
|
+
this.server = await api.ServerNode.create({
|
|
79
|
+
id: this.instanceId,
|
|
80
|
+
network: { port: this.options.port },
|
|
81
|
+
productDescription: {
|
|
82
|
+
name: this.options.deviceName,
|
|
83
|
+
deviceType: api.AggregatorEndpoint.deviceType
|
|
84
|
+
},
|
|
85
|
+
basicInformation: {
|
|
86
|
+
vendorName: 'KNX-Ultimate',
|
|
87
|
+
vendorId: api.VendorId(0xFFF1), // Test vendor ID
|
|
88
|
+
nodeLabel: this.options.deviceName,
|
|
89
|
+
productName: this.options.deviceName,
|
|
90
|
+
productLabel: this.options.deviceName,
|
|
91
|
+
productId: 0x8000,
|
|
92
|
+
serialNumber: `knxu-${this.instanceId}`.slice(0, 32),
|
|
93
|
+
uniqueId: this.instanceId.slice(-32)
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
this.aggregator = new api.Endpoint(api.AggregatorEndpoint, { id: 'aggregator' })
|
|
98
|
+
await this.server.add(this.aggregator)
|
|
99
|
+
|
|
100
|
+
for (let index = 0; index < _deviceDefs.length; index++) {
|
|
101
|
+
const def = _deviceDefs[index]
|
|
102
|
+
try {
|
|
103
|
+
await this._addDeviceEndpoint(def)
|
|
104
|
+
} catch (error) {
|
|
105
|
+
this._log('error', `classMatterBridge: cannot add device "${def?.name}" (${def?.type}): ${error.message}`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Commissioning lifecycle events
|
|
110
|
+
try {
|
|
111
|
+
this.server.events.commissioning.commissioned.on(() => {
|
|
112
|
+
try { this.emit('commissioned') } catch (error) { /* empty */ }
|
|
113
|
+
})
|
|
114
|
+
this.server.events.commissioning.decommissioned.on(() => {
|
|
115
|
+
try { this.emit('decommissioned') } catch (error) { /* empty */ }
|
|
116
|
+
})
|
|
117
|
+
this.server.events.commissioning.fabricsChanged.on((fabricIndex, action) => {
|
|
118
|
+
try { this.emit('fabricsChanged', Number(fabricIndex), action) } catch (error) { /* empty */ }
|
|
119
|
+
})
|
|
120
|
+
} catch (error) {
|
|
121
|
+
this._log('warn', `classMatterBridge: cannot hook commissioning events: ${error.message}`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
await this.server.start()
|
|
125
|
+
this.bridgeStatus = 'running'
|
|
126
|
+
this.emit('online')
|
|
127
|
+
this._log('info', `classMatterBridge: bridge "${this.options.deviceName}" started on port ${this.options.port} with ${this.endpoints.size} device(s)`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
_addDeviceEndpoint = async (def) => {
|
|
131
|
+
if (!def || !def.id || !def.type) throw new Error('Invalid device definition')
|
|
132
|
+
const endpoint = this._factory.createBridgedEndpoint(def, `knxu-${this.instanceId.slice(-8)}`, (command) => {
|
|
133
|
+
try {
|
|
134
|
+
this.emit('command', command)
|
|
135
|
+
} catch (error) {
|
|
136
|
+
this._logThrottled('error', 'bridge:command', `classMatterBridge: command handler error: ${error.message}`)
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
await this.aggregator.add(endpoint)
|
|
140
|
+
this.endpoints.set(def.id, endpoint)
|
|
141
|
+
this.deviceDefs.set(def.id, def)
|
|
142
|
+
return endpoint
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Removes a bridged device endpoint from the running server.
|
|
146
|
+
removeDeviceEndpoint = async (_deviceId) => {
|
|
147
|
+
const endpoint = this.endpoints.get(_deviceId)
|
|
148
|
+
if (endpoint === undefined) return false
|
|
149
|
+
try {
|
|
150
|
+
await endpoint.close()
|
|
151
|
+
} catch (error) {
|
|
152
|
+
this._log('warn', `classMatterBridge: removeDeviceEndpoint ${_deviceId}: ${error.message}`)
|
|
153
|
+
}
|
|
154
|
+
this.endpoints.delete(_deviceId)
|
|
155
|
+
this.deviceDefs.delete(_deviceId)
|
|
156
|
+
return true
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Reconciles the exposed devices with a new definition list WITHOUT restarting the server:
|
|
160
|
+
// paired controllers receive the endpoint changes live (PartsList subscription reports),
|
|
161
|
+
// while a full restart would resume the session without re-reading the structure.
|
|
162
|
+
reconcileDevices = async (_deviceDefs = []) => {
|
|
163
|
+
if (this.server === null) throw new Error('Matter bridge not started')
|
|
164
|
+
const newById = new Map(_deviceDefs.filter((d) => d && d.id).map((d) => [d.id, d]))
|
|
165
|
+
// Remove deleted devices; recreate the ones whose type/behavior changed
|
|
166
|
+
const currentIds = Array.from(this.endpoints.keys())
|
|
167
|
+
for (let index = 0; index < currentIds.length; index++) {
|
|
168
|
+
const id = currentIds[index]
|
|
169
|
+
const newDef = newById.get(id)
|
|
170
|
+
const oldDef = this.deviceDefs.get(id)
|
|
171
|
+
const needsRecreate = newDef !== undefined && oldDef !== undefined &&
|
|
172
|
+
(newDef.type !== oldDef.type || (newDef.invertPosition === true) !== (oldDef.invertPosition === true))
|
|
173
|
+
if (newDef === undefined || needsRecreate) {
|
|
174
|
+
await this.removeDeviceEndpoint(id)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// Add new devices and apply renames on the existing ones
|
|
178
|
+
for (const def of newById.values()) {
|
|
179
|
+
if (!this.endpoints.has(def.id)) {
|
|
180
|
+
try {
|
|
181
|
+
await this._addDeviceEndpoint(def)
|
|
182
|
+
} catch (error) {
|
|
183
|
+
this._log('error', `classMatterBridge: reconcile cannot add "${def.name}" (${def.type}): ${error.message}`)
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
const oldDef = this.deviceDefs.get(def.id)
|
|
187
|
+
if ((oldDef.name || '') !== (def.name || '')) {
|
|
188
|
+
const name = (def.name || def.type).toString().slice(0, 32)
|
|
189
|
+
try {
|
|
190
|
+
await this.endpoints.get(def.id).set({
|
|
191
|
+
bridgedDeviceBasicInformation: { nodeLabel: name, productName: name, productLabel: name }
|
|
192
|
+
})
|
|
193
|
+
} catch (error) {
|
|
194
|
+
this._log('warn', `classMatterBridge: reconcile rename ${def.id}: ${error.message}`)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
this.deviceDefs.set(def.id, def)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
this._log('info', `classMatterBridge: devices reconciled, now exposing ${this.endpoints.size} device(s)`)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// KNX -> Matter: applies a KNX status value to the Matter attribute of a virtual device.
|
|
204
|
+
setDeviceState = async (_deviceId, _fn, _knxValue) => {
|
|
205
|
+
try {
|
|
206
|
+
const endpoint = this.endpoints.get(_deviceId)
|
|
207
|
+
const def = this.deviceDefs.get(_deviceId)
|
|
208
|
+
if (endpoint === undefined || def === undefined) return false
|
|
209
|
+
const patch = this._factory.knxValueToMatterPatch(def, _fn, _knxValue)
|
|
210
|
+
if (patch === undefined) return false
|
|
211
|
+
await endpoint.set(patch)
|
|
212
|
+
return true
|
|
213
|
+
} catch (error) {
|
|
214
|
+
this._logThrottled('warn', `bridge:set:${_deviceId}:${_fn}`, `classMatterBridge: setDeviceState ${_deviceId}/${_fn}: ${error.message}`)
|
|
215
|
+
return false
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Marks a device as reachable/unreachable on the Matter side
|
|
220
|
+
setDeviceReachable = async (_deviceId, _reachable) => {
|
|
221
|
+
try {
|
|
222
|
+
const endpoint = this.endpoints.get(_deviceId)
|
|
223
|
+
if (endpoint === undefined) return false
|
|
224
|
+
await endpoint.set({ bridgedDeviceBasicInformation: { reachable: _reachable === true } })
|
|
225
|
+
return true
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return false
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Pairing info for the editor: QR code (text + ASCII art), manual code, commissioned fabrics.
|
|
232
|
+
getPairingInfo = () => {
|
|
233
|
+
if (this.server === null) return { running: false }
|
|
234
|
+
try {
|
|
235
|
+
const commissioningState = this.server.state.commissioning
|
|
236
|
+
const fabrics = []
|
|
237
|
+
try {
|
|
238
|
+
Object.values(commissioningState.fabrics || {}).forEach((fabric) => {
|
|
239
|
+
fabrics.push({
|
|
240
|
+
label: fabric.label || '',
|
|
241
|
+
vendorId: Number(fabric.rootVendorId !== undefined ? fabric.rootVendorId : (fabric.vendorId || 0)),
|
|
242
|
+
fabricIndex: Number(fabric.fabricIndex || 0)
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
} catch (error) { /* empty */ }
|
|
246
|
+
const info = {
|
|
247
|
+
running: this.bridgeStatus === 'running',
|
|
248
|
+
commissioned: commissioningState.commissioned === true,
|
|
249
|
+
fabrics,
|
|
250
|
+
deviceCount: this.endpoints.size,
|
|
251
|
+
port: this.options.port
|
|
252
|
+
}
|
|
253
|
+
if (commissioningState.commissioned !== true && commissioningState.pairingCodes !== undefined) {
|
|
254
|
+
info.manualPairingCode = commissioningState.pairingCodes.manualPairingCode
|
|
255
|
+
info.qrPairingCode = commissioningState.pairingCodes.qrPairingCode
|
|
256
|
+
try {
|
|
257
|
+
info.qrAscii = this._api.QrCode.get(commissioningState.pairingCodes.qrPairingCode)
|
|
258
|
+
} catch (error) { /* empty */ }
|
|
259
|
+
}
|
|
260
|
+
return info
|
|
261
|
+
} catch (error) {
|
|
262
|
+
this._log('warn', `classMatterBridge: getPairingInfo: ${error.message}`)
|
|
263
|
+
return { running: this.bridgeStatus === 'running', error: error.message }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Factory reset: removes all fabrics and commissioning data. The bridge restarts advertising for pairing.
|
|
268
|
+
factoryReset = async () => {
|
|
269
|
+
if (this.server === null) throw new Error('Matter bridge not started')
|
|
270
|
+
await this.server.erase()
|
|
271
|
+
this.emit('decommissioned')
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
close = async () => {
|
|
275
|
+
this.bridgeStatus = 'stopped'
|
|
276
|
+
this._logThrottle.clear()
|
|
277
|
+
try {
|
|
278
|
+
if (this.server !== null) await this.server.close()
|
|
279
|
+
} catch (error) {
|
|
280
|
+
this._log('warn', `classMatterBridge: close: ${error.message}`)
|
|
281
|
+
}
|
|
282
|
+
this.server = null
|
|
283
|
+
this.aggregator = null
|
|
284
|
+
this.endpoints.clear()
|
|
285
|
+
this.deviceDefs.clear()
|
|
286
|
+
this.emit('offline')
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export { classMatterBridge }
|