node-red-contrib-knx-ultimate 5.0.4 → 5.2.1
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 -0
- package/nodes/commonFunctions.js +93 -0
- package/nodes/icons/node-matter-icon.svg +12 -0
- package/nodes/knxUltimateMatterBridge.html +429 -0
- package/nodes/knxUltimateMatterBridge.js +456 -0
- package/nodes/knxUltimateMatterDevice.html +586 -0
- package/nodes/knxUltimateMatterDevice.js +308 -0
- package/nodes/locales/de/knxUltimateMatterBridge.html +64 -0
- package/nodes/locales/de/knxUltimateMatterBridge.json +80 -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/en/knxUltimateMatterBridge.html +64 -0
- package/nodes/locales/en/knxUltimateMatterBridge.json +80 -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/es/knxUltimateMatterBridge.html +64 -0
- package/nodes/locales/es/knxUltimateMatterBridge.json +80 -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/fr/knxUltimateMatterBridge.html +64 -0
- package/nodes/locales/fr/knxUltimateMatterBridge.json +80 -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/it/knxUltimateMatterBridge.html +64 -0
- package/nodes/locales/it/knxUltimateMatterBridge.json +80 -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/zh-CN/knxUltimateMatterBridge.html +64 -0
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +80 -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/matter-config.html +226 -0
- package/nodes/matter-config.js +242 -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 +7 -2
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const dptlib = require('knxultimate').dptlib
|
|
3
|
+
|
|
4
|
+
// The running Matter servers, keyed by node id. Kept OUTSIDE the node lifecycle:
|
|
5
|
+
// a re-deploy must NOT restart the Matter server, otherwise the paired controllers
|
|
6
|
+
// resume their session without re-reading the structure and never see device changes.
|
|
7
|
+
// The devices are reconciled live instead; the server is closed only when the node
|
|
8
|
+
// is deleted/disabled or Node-RED shuts down.
|
|
9
|
+
const runningBridges = new Map()
|
|
10
|
+
|
|
11
|
+
// Exposes KNX group addresses as Matter devices (bridge/aggregator):
|
|
12
|
+
// external Matter controllers (Alexa, Google Home, Apple Home...) commission this
|
|
13
|
+
// bridge once and control/see every configured KNX virtual device.
|
|
14
|
+
module.exports = function (RED) {
|
|
15
|
+
function knxUltimateMatterBridge (config) {
|
|
16
|
+
RED.nodes.createNode(this, config)
|
|
17
|
+
const node = this
|
|
18
|
+
node.serverKNX = RED.nodes.getNode(config.server) || undefined
|
|
19
|
+
|
|
20
|
+
node.topic = node.name
|
|
21
|
+
node.name = config.name === undefined || config.name === '' ? 'Matter Bridge (BETA)' : config.name
|
|
22
|
+
node.dpt = ''
|
|
23
|
+
node.notifyreadrequest = false
|
|
24
|
+
node.notifyreadrequestalsorespondtobus = 'false'
|
|
25
|
+
node.notifyreadrequestalsorespondtobusdefaultvalueifnotinitialized = ''
|
|
26
|
+
node.notifyresponse = false
|
|
27
|
+
node.notifywrite = true
|
|
28
|
+
node.initialread = false
|
|
29
|
+
node.listenallga = true // Don't remove
|
|
30
|
+
node.outputtype = 'write'
|
|
31
|
+
node.outputRBE = 'false'
|
|
32
|
+
node.inputRBE = 'false'
|
|
33
|
+
node.currentPayload = ''
|
|
34
|
+
node.passthrough = 'no'
|
|
35
|
+
node.formatmultiplyvalue = 1
|
|
36
|
+
node.formatnegativevalue = 'leave'
|
|
37
|
+
node.formatdecimalsvalue = 2
|
|
38
|
+
|
|
39
|
+
node.bridgePort = Number(config.bridgePort) || 5540
|
|
40
|
+
node.bridgeDeviceName = config.bridgeDeviceName || 'KNX-Ultimate Bridge'
|
|
41
|
+
node.readStatusAtStartup = config.readStatusAtStartup === undefined || config.readStatusAtStartup === 'yes'
|
|
42
|
+
node.enableNodePINS = config.enableNodePINS === 'yes'
|
|
43
|
+
node.inputs = node.enableNodePINS ? 1 : 0
|
|
44
|
+
node.outputs = node.enableNodePINS ? 1 : 0
|
|
45
|
+
node.devices = []
|
|
46
|
+
try {
|
|
47
|
+
node.devices = Array.isArray(config.devices) ? config.devices : JSON.parse(config.devices || '[]')
|
|
48
|
+
} catch (error) {
|
|
49
|
+
node.devices = []
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
node.matterBridge = null
|
|
53
|
+
node.matterInstanceId = `knxultimate-bridge-${node.id.replace(/[^a-zA-Z0-9]/g, '')}`
|
|
54
|
+
node.matterStoragePath = path.join(RED.settings.userDir || '.', 'knxultimatestorage', 'matter')
|
|
55
|
+
let startupTimer = null
|
|
56
|
+
let initialReadTimer = null
|
|
57
|
+
let closing = false
|
|
58
|
+
|
|
59
|
+
// The functions of each device type, with their GA/DPT config fields.
|
|
60
|
+
// direction 'command' = Matter -> KNX (write to the bus), 'status' = KNX -> Matter.
|
|
61
|
+
const DEVICE_FUNCTIONS = {
|
|
62
|
+
onofflight: [
|
|
63
|
+
{ fn: 'onoff', direction: 'command', ga: 'gaOnOff', dpt: 'dptOnOff', fallbackDpt: '1.001' },
|
|
64
|
+
{ fn: 'onoff', direction: 'status', ga: 'gaOnOffStatus', dpt: 'dptOnOffStatus', fallbackDpt: '1.001' }
|
|
65
|
+
],
|
|
66
|
+
onoffplug: [
|
|
67
|
+
{ fn: 'onoff', direction: 'command', ga: 'gaOnOff', dpt: 'dptOnOff', fallbackDpt: '1.001' },
|
|
68
|
+
{ fn: 'onoff', direction: 'status', ga: 'gaOnOffStatus', dpt: 'dptOnOffStatus', fallbackDpt: '1.001' }
|
|
69
|
+
],
|
|
70
|
+
dimmablelight: [
|
|
71
|
+
{ fn: 'onoff', direction: 'command', ga: 'gaOnOff', dpt: 'dptOnOff', fallbackDpt: '1.001' },
|
|
72
|
+
{ fn: 'onoff', direction: 'status', ga: 'gaOnOffStatus', dpt: 'dptOnOffStatus', fallbackDpt: '1.001' },
|
|
73
|
+
{ fn: 'level', direction: 'command', ga: 'gaLevel', dpt: 'dptLevel', fallbackDpt: '5.001' },
|
|
74
|
+
{ fn: 'level', direction: 'status', ga: 'gaLevelStatus', dpt: 'dptLevelStatus', fallbackDpt: '5.001' }
|
|
75
|
+
],
|
|
76
|
+
rgblight: [
|
|
77
|
+
{ fn: 'onoff', direction: 'command', ga: 'gaOnOff', dpt: 'dptOnOff', fallbackDpt: '1.001' },
|
|
78
|
+
{ fn: 'onoff', direction: 'status', ga: 'gaOnOffStatus', dpt: 'dptOnOffStatus', fallbackDpt: '1.001' },
|
|
79
|
+
{ fn: 'level', direction: 'command', ga: 'gaLevel', dpt: 'dptLevel', fallbackDpt: '5.001' },
|
|
80
|
+
{ fn: 'level', direction: 'status', ga: 'gaLevelStatus', dpt: 'dptLevelStatus', fallbackDpt: '5.001' },
|
|
81
|
+
{ fn: 'rgb', direction: 'command', ga: 'gaRGB', dpt: 'dptRGB', fallbackDpt: '232.600' },
|
|
82
|
+
{ fn: 'rgb', direction: 'status', ga: 'gaRGBStatus', dpt: 'dptRGBStatus', fallbackDpt: '232.600' }
|
|
83
|
+
],
|
|
84
|
+
colortemperaturelight: [
|
|
85
|
+
{ fn: 'onoff', direction: 'command', ga: 'gaOnOff', dpt: 'dptOnOff', fallbackDpt: '1.001' },
|
|
86
|
+
{ fn: 'onoff', direction: 'status', ga: 'gaOnOffStatus', dpt: 'dptOnOffStatus', fallbackDpt: '1.001' },
|
|
87
|
+
{ fn: 'level', direction: 'command', ga: 'gaLevel', dpt: 'dptLevel', fallbackDpt: '5.001' },
|
|
88
|
+
{ fn: 'level', direction: 'status', ga: 'gaLevelStatus', dpt: 'dptLevelStatus', fallbackDpt: '5.001' },
|
|
89
|
+
{ fn: 'colortemp', direction: 'command', ga: 'gaCT', dpt: 'dptCT', fallbackDpt: '7.600' },
|
|
90
|
+
{ fn: 'colortemp', direction: 'status', ga: 'gaCTStatus', dpt: 'dptCTStatus', fallbackDpt: '7.600' }
|
|
91
|
+
],
|
|
92
|
+
temperaturesensor: [{ fn: 'temperature', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '9.001' }],
|
|
93
|
+
humiditysensor: [{ fn: 'humidity', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '9.007' }],
|
|
94
|
+
lightsensor: [{ fn: 'illuminance', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '9.004' }],
|
|
95
|
+
occupancysensor: [{ fn: 'occupancy', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '1.011' }],
|
|
96
|
+
contactsensor: [{ fn: 'contact', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '1.002' }],
|
|
97
|
+
windowcovering: [
|
|
98
|
+
{ fn: 'updown', direction: 'command', ga: 'gaUpDown', dpt: 'dptUpDown', fallbackDpt: '1.008' },
|
|
99
|
+
{ fn: 'stop', direction: 'command', ga: 'gaStop', dpt: 'dptStop', fallbackDpt: '1.017' },
|
|
100
|
+
{ fn: 'position', direction: 'command', ga: 'gaPosition', dpt: 'dptPosition', fallbackDpt: '5.001' },
|
|
101
|
+
{ fn: 'position', direction: 'status', ga: 'gaPositionStatus', dpt: 'dptPositionStatus', fallbackDpt: '5.001' }
|
|
102
|
+
],
|
|
103
|
+
thermostat: [
|
|
104
|
+
{ fn: 'currenttemp', direction: 'status', ga: 'gaCurrentTemp', dpt: 'dptCurrentTemp', fallbackDpt: '9.001' },
|
|
105
|
+
{ fn: 'setpoint', direction: 'command', ga: 'gaSetpoint', dpt: 'dptSetpoint', fallbackDpt: '9.001' },
|
|
106
|
+
{ fn: 'setpoint', direction: 'status', ga: 'gaSetpointStatus', dpt: 'dptSetpointStatus', fallbackDpt: '9.001' }
|
|
107
|
+
],
|
|
108
|
+
smokecoalarm: [
|
|
109
|
+
{ fn: 'smoke', direction: 'status', ga: 'gaSmoke', dpt: 'dptSmoke', fallbackDpt: '1.005' },
|
|
110
|
+
{ fn: 'co', direction: 'status', ga: 'gaCO', dpt: 'dptCO', fallbackDpt: '1.005' }
|
|
111
|
+
],
|
|
112
|
+
waterleakdetector: [{ fn: 'leak', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '1.005' }],
|
|
113
|
+
airqualitysensor: [{ fn: 'co2', direction: 'status', ga: 'gaStatus', dpt: 'dptStatus', fallbackDpt: '9.008' }],
|
|
114
|
+
fan: [
|
|
115
|
+
{ fn: 'fanspeed', direction: 'command', ga: 'gaFanSpeed', dpt: 'dptFanSpeed', fallbackDpt: '5.001' },
|
|
116
|
+
{ fn: 'fanspeed', direction: 'status', ga: 'gaFanSpeedStatus', dpt: 'dptFanSpeedStatus', fallbackDpt: '5.001' }
|
|
117
|
+
],
|
|
118
|
+
robotvacuum: [] // Flow-only device: talks to the flow through the node PINs
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const formatTs = (date) => {
|
|
122
|
+
const d = date instanceof Date ? date : new Date(date)
|
|
123
|
+
const provider = node.serverKNX
|
|
124
|
+
if (provider && typeof provider.formatStatusTimestamp === 'function') return provider.formatStatusTimestamp(d)
|
|
125
|
+
return `${d.getDate()}, ${d.toLocaleTimeString()}`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const pushStatus = (status) => {
|
|
129
|
+
if (!status) return
|
|
130
|
+
try {
|
|
131
|
+
const provider = node.serverKNX
|
|
132
|
+
if (provider && typeof provider.applyStatusUpdate === 'function') {
|
|
133
|
+
provider.applyStatusUpdate(node, status)
|
|
134
|
+
} else {
|
|
135
|
+
node.status(status)
|
|
136
|
+
}
|
|
137
|
+
} catch (error) { /* empty */ }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
node.setNodeStatus = ({ fill, shape, text, payload }) => {
|
|
141
|
+
try {
|
|
142
|
+
if (payload === undefined) payload = ''
|
|
143
|
+
payload = typeof payload === 'object' ? JSON.stringify(payload) : payload.toString()
|
|
144
|
+
pushStatus({ fill, shape, text: `${text} ${payload} (${formatTs(new Date())})` })
|
|
145
|
+
} catch (error) { /* empty */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const safeSendToKNX = (telegram, context = 'write') => {
|
|
149
|
+
try {
|
|
150
|
+
if (!node.serverKNX || typeof node.serverKNX.sendKNXTelegramToKNXEngine !== 'function') {
|
|
151
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: `KNX server missing (${context})` })
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
node.serverKNX.sendKNXTelegramToKNXEngine({ ...telegram, nodecallerid: node.id })
|
|
155
|
+
} catch (error) {
|
|
156
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: `KNX send error ${error.message}` })
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Precompute the routing tables:
|
|
161
|
+
// statusRoutes: GA -> [{ deviceId, fn, dpt }] (KNX -> Matter)
|
|
162
|
+
// commandRoutes: deviceId + fn -> { ga, dpt } (Matter -> KNX)
|
|
163
|
+
const statusRoutes = new Map()
|
|
164
|
+
const commandRoutes = new Map()
|
|
165
|
+
// Reads a device config value, also recovering the corrupted keys written by early
|
|
166
|
+
// 5.2.0 editors (jQuery UI polluted the key with " ui-autocomplete-input").
|
|
167
|
+
const devConfigValue = (dev, key) => {
|
|
168
|
+
if (dev[key] !== undefined) return (dev[key] || '').trim()
|
|
169
|
+
const corrupted = dev[`${key} ui-autocomplete-input`]
|
|
170
|
+
return (corrupted || '').trim()
|
|
171
|
+
}
|
|
172
|
+
node.devices.forEach((dev) => {
|
|
173
|
+
const functions = DEVICE_FUNCTIONS[dev.type] || []
|
|
174
|
+
functions.forEach((fnDef) => {
|
|
175
|
+
const ga = devConfigValue(dev, fnDef.ga)
|
|
176
|
+
if (ga === '') return
|
|
177
|
+
const dpt = devConfigValue(dev, fnDef.dpt) || fnDef.fallbackDpt
|
|
178
|
+
if (fnDef.direction === 'status') {
|
|
179
|
+
if (!statusRoutes.has(ga)) statusRoutes.set(ga, [])
|
|
180
|
+
statusRoutes.get(ga).push({ deviceId: dev.id, fn: fnDef.fn, dpt })
|
|
181
|
+
} else {
|
|
182
|
+
commandRoutes.set(`${dev.id}#${fnDef.fn}`, { ga, dpt })
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const updateBridgeStatus = () => {
|
|
188
|
+
try {
|
|
189
|
+
if (node.matterBridge === null) {
|
|
190
|
+
node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Matter bridge starting...' })
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
const info = node.matterBridge.getPairingInfo()
|
|
194
|
+
if (!info.running) {
|
|
195
|
+
node.setNodeStatus({ fill: 'red', shape: 'ring', text: 'Matter bridge stopped' })
|
|
196
|
+
} else if (info.commissioned) {
|
|
197
|
+
node.setNodeStatus({ fill: 'green', shape: 'dot', text: `Bridge online, paired with ${info.fabrics.length} controller(s), ${info.deviceCount} device(s), ${commandRoutes.size} cmd GA, ${statusRoutes.size} status GA` })
|
|
198
|
+
} else {
|
|
199
|
+
node.setNodeStatus({ fill: 'yellow', shape: 'ring', text: `Awaiting pairing. Code ${info.manualPairingCode || ''}, ${info.deviceCount} device(s)` })
|
|
200
|
+
}
|
|
201
|
+
} catch (error) { /* empty */ }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Ask the KNX bus for the current values of all status GAs, so the Matter
|
|
205
|
+
// attributes are populated before a controller reads them.
|
|
206
|
+
const doInitialRead = () => {
|
|
207
|
+
if (!node.readStatusAtStartup) return
|
|
208
|
+
if (!node.serverKNX) return // Flow-only bridge (no KNX gateway): nothing to read
|
|
209
|
+
try {
|
|
210
|
+
let delay = 0
|
|
211
|
+
statusRoutes.forEach((routes, ga) => {
|
|
212
|
+
delay += 200
|
|
213
|
+
setTimeout(() => {
|
|
214
|
+
safeSendToKNX({ grpaddr: ga, payload: '', dpt: '', outputtype: 'read' }, 'read')
|
|
215
|
+
}, delay)
|
|
216
|
+
})
|
|
217
|
+
} catch (error) {
|
|
218
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: `Initial read error ${error.message}` })
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const bindEngineEvents = (engine) => {
|
|
223
|
+
// Matter -> KNX: a controller (Alexa...) sent a command to a bridged device
|
|
224
|
+
engine.on('command', (command) => {
|
|
225
|
+
try {
|
|
226
|
+
const dev = node.devices.find((d) => d.id === command.deviceId)
|
|
227
|
+
// Forward every controller command to the flow, when the pins are enabled
|
|
228
|
+
if (node.enableNodePINS) {
|
|
229
|
+
try {
|
|
230
|
+
node.send({
|
|
231
|
+
topic: dev !== undefined ? dev.name : command.deviceId,
|
|
232
|
+
payload: command.value,
|
|
233
|
+
device: dev !== undefined ? { id: dev.id, type: dev.type, name: dev.name } : { id: command.deviceId },
|
|
234
|
+
matter: command
|
|
235
|
+
})
|
|
236
|
+
} catch (error) { /* empty */ }
|
|
237
|
+
}
|
|
238
|
+
const route = commandRoutes.get(`${command.deviceId}#${command.fn}`)
|
|
239
|
+
if (route === undefined) {
|
|
240
|
+
if (node.enableNodePINS) return // Flow-only device: the flow already got the command
|
|
241
|
+
// Make the misconfiguration visible instead of silently dropping the command
|
|
242
|
+
node.setNodeStatus({
|
|
243
|
+
fill: 'yellow',
|
|
244
|
+
shape: 'ring',
|
|
245
|
+
text: `No command GA configured for "${dev !== undefined ? dev.name : command.deviceId}" (${command.fn}): open the node, check the GA fields and re-deploy`,
|
|
246
|
+
payload: ''
|
|
247
|
+
})
|
|
248
|
+
RED.log.warn(`knxUltimateMatterBridge: Matter command dropped, no GA route for ${command.deviceId}#${command.fn}`)
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
if (!node.serverKNX) {
|
|
252
|
+
// Flow-only bridge: with the PINs enabled the flow already received the command
|
|
253
|
+
if (node.enableNodePINS) return
|
|
254
|
+
node.setNodeStatus({
|
|
255
|
+
fill: 'yellow',
|
|
256
|
+
shape: 'ring',
|
|
257
|
+
text: `No KNX gateway configured: enable the node PINs to handle "${dev !== undefined ? dev.name : command.deviceId}" from the flow`,
|
|
258
|
+
payload: ''
|
|
259
|
+
})
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
safeSendToKNX({
|
|
263
|
+
grpaddr: route.ga,
|
|
264
|
+
payload: command.value,
|
|
265
|
+
dpt: route.dpt,
|
|
266
|
+
outputtype: 'write'
|
|
267
|
+
}, 'write')
|
|
268
|
+
node.setNodeStatus({
|
|
269
|
+
fill: 'green',
|
|
270
|
+
shape: 'dot',
|
|
271
|
+
text: `Matter->KNX ${dev !== undefined ? dev.name : command.deviceId} ${command.fn}`,
|
|
272
|
+
payload: command.value
|
|
273
|
+
})
|
|
274
|
+
} catch (error) {
|
|
275
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: `Matter->KNX error ${error.message}` })
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
engine.on('commissioned', () => updateBridgeStatus())
|
|
280
|
+
engine.on('decommissioned', () => updateBridgeStatus())
|
|
281
|
+
engine.on('fabricsChanged', () => updateBridgeStatus())
|
|
282
|
+
engine.on('online', () => {
|
|
283
|
+
updateBridgeStatus()
|
|
284
|
+
if (initialReadTimer !== null) clearTimeout(initialReadTimer)
|
|
285
|
+
initialReadTimer = setTimeout(() => doInitialRead(), 5000)
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const buildEngineDefs = () => node.devices
|
|
290
|
+
.filter((dev) => dev.id && dev.type && DEVICE_FUNCTIONS[dev.type] !== undefined)
|
|
291
|
+
.map((dev) => ({ id: dev.id, type: dev.type, name: dev.name, invertPosition: dev.invertPosition === true }))
|
|
292
|
+
|
|
293
|
+
const startBridge = async () => {
|
|
294
|
+
try {
|
|
295
|
+
const { classMatterBridge } = await import('./utils/matterBridgeEngine.mjs')
|
|
296
|
+
const engineDefs = buildEngineDefs()
|
|
297
|
+
const existing = runningBridges.get(node.id)
|
|
298
|
+
|
|
299
|
+
if (existing !== undefined && existing.bridgeStatus === 'running' &&
|
|
300
|
+
Number(existing.options.port) === node.bridgePort &&
|
|
301
|
+
existing.options.deviceName === node.bridgeDeviceName) {
|
|
302
|
+
// Re-deploy: reuse the running Matter server and reconcile the devices live,
|
|
303
|
+
// so the paired controllers (Alexa & Co.) pick up the changes immediately.
|
|
304
|
+
existing.removeAllListeners()
|
|
305
|
+
node.matterBridge = existing
|
|
306
|
+
bindEngineEvents(existing)
|
|
307
|
+
await existing.reconcileDevices(engineDefs)
|
|
308
|
+
updateBridgeStatus()
|
|
309
|
+
if (initialReadTimer !== null) clearTimeout(initialReadTimer)
|
|
310
|
+
initialReadTimer = setTimeout(() => doInitialRead(), 5000)
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (existing !== undefined) {
|
|
315
|
+
// Port or bridge name changed: a full restart is unavoidable
|
|
316
|
+
try {
|
|
317
|
+
existing.removeAllListeners()
|
|
318
|
+
await existing.close()
|
|
319
|
+
} catch (error) { /* empty */ }
|
|
320
|
+
runningBridges.delete(node.id)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const engine = new classMatterBridge(node.matterStoragePath, node.matterInstanceId, {
|
|
324
|
+
info: (m) => RED.log.info(m),
|
|
325
|
+
warn: (m) => RED.log.warn(m),
|
|
326
|
+
error: (m) => RED.log.error(m),
|
|
327
|
+
debug: (m) => RED.log.debug(m)
|
|
328
|
+
}, { port: node.bridgePort, deviceName: node.bridgeDeviceName })
|
|
329
|
+
runningBridges.set(node.id, engine)
|
|
330
|
+
node.matterBridge = engine
|
|
331
|
+
bindEngineEvents(engine)
|
|
332
|
+
await engine.start(engineDefs)
|
|
333
|
+
updateBridgeStatus()
|
|
334
|
+
} catch (error) {
|
|
335
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: `Matter bridge start error: ${error.message}` })
|
|
336
|
+
RED.log.error(`knxUltimateMatterBridge: start error: ${error.message}`)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// KNX -> Matter: telegrams from the bus update the Matter attributes
|
|
341
|
+
node.handleSend = (msg) => {
|
|
342
|
+
try {
|
|
343
|
+
if (!msg || !msg.knx) return
|
|
344
|
+
if (msg.knx.event === 'GroupValue_Read') return
|
|
345
|
+
const routes = statusRoutes.get(msg.knx.destination)
|
|
346
|
+
if (routes === undefined || node.matterBridge === null) return
|
|
347
|
+
routes.forEach((route) => {
|
|
348
|
+
try {
|
|
349
|
+
const value = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(route.dpt))
|
|
350
|
+
if (value === undefined || value === null) return
|
|
351
|
+
node.matterBridge.setDeviceState(route.deviceId, route.fn, value)
|
|
352
|
+
const dev = node.devices.find((d) => d.id === route.deviceId)
|
|
353
|
+
node.setNodeStatus({
|
|
354
|
+
fill: 'blue',
|
|
355
|
+
shape: 'dot',
|
|
356
|
+
text: `KNX->Matter ${dev !== undefined ? dev.name : route.deviceId} ${route.fn}`,
|
|
357
|
+
payload: value
|
|
358
|
+
})
|
|
359
|
+
} catch (error) {
|
|
360
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: `KNX->Matter error ${error.message}` })
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
} catch (error) {
|
|
364
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: `KNX->Matter error ${error.message}` })
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Called by the admin endpoints (editor UI)
|
|
369
|
+
node.getBridgePairingInfo = () => {
|
|
370
|
+
if (node.matterBridge === null) return { running: false }
|
|
371
|
+
return node.matterBridge.getPairingInfo()
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
node.factoryResetBridge = async () => {
|
|
375
|
+
if (node.matterBridge === null) throw new Error('Matter bridge not started')
|
|
376
|
+
await node.matterBridge.factoryReset()
|
|
377
|
+
updateBridgeStatus()
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (node.serverKNX) {
|
|
381
|
+
try {
|
|
382
|
+
node.serverKNX.removeClient(node)
|
|
383
|
+
node.serverKNX.addClient(node)
|
|
384
|
+
} catch (error) {
|
|
385
|
+
RED.log.error(`knxUltimateMatterBridge: register KNX client error ${error.message}`)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Flow input pin: updates the Matter state of a virtual device without going through the KNX bus.
|
|
390
|
+
// msg.payload = { device: 'Kitchen light' (name or id), function: 'onoff'|'level'|'position'|'temperature'|..., value: ... }
|
|
391
|
+
node.on('input', (msg, send, done) => {
|
|
392
|
+
if (!node.enableNodePINS) {
|
|
393
|
+
if (done) done()
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
const payload = msg.payload || {}
|
|
398
|
+
const deviceKey = (payload.device !== undefined ? payload.device : msg.topic || '').toString().trim()
|
|
399
|
+
const fn = (payload.function || payload.fn || '').toString().trim()
|
|
400
|
+
if (deviceKey === '' || fn === '' || payload.value === undefined) {
|
|
401
|
+
throw new Error('msg.payload must be { device, function, value }')
|
|
402
|
+
}
|
|
403
|
+
const dev = node.devices.find((d) => d.id === deviceKey || (d.name || '').toLowerCase() === deviceKey.toLowerCase())
|
|
404
|
+
if (dev === undefined) throw new Error(`Unknown bridge device "${deviceKey}"`)
|
|
405
|
+
if (node.matterBridge === null) throw new Error('Matter bridge not started yet')
|
|
406
|
+
node.matterBridge.setDeviceState(dev.id, fn, payload.value)
|
|
407
|
+
node.setNodeStatus({ fill: 'green', shape: 'dot', text: `Flow->Matter ${dev.name} ${fn}`, payload: payload.value })
|
|
408
|
+
if (done) done()
|
|
409
|
+
} catch (error) {
|
|
410
|
+
node.setNodeStatus({ fill: 'red', shape: 'dot', text: `Flow error ${error.message}`, payload: '' })
|
|
411
|
+
if (done) done(error)
|
|
412
|
+
}
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Matter bridge starting in 5s...' })
|
|
416
|
+
startupTimer = setTimeout(() => {
|
|
417
|
+
(async () => {
|
|
418
|
+
if (closing) return
|
|
419
|
+
try {
|
|
420
|
+
await startBridge()
|
|
421
|
+
} catch (error) {
|
|
422
|
+
RED.log.error(`knxUltimateMatterBridge: startup error: ${error.message}`)
|
|
423
|
+
}
|
|
424
|
+
})()
|
|
425
|
+
}, 5000)
|
|
426
|
+
|
|
427
|
+
// On re-deploy (removed === false) the Matter server stays alive: the new node
|
|
428
|
+
// instance will reuse it and reconcile the devices, so paired controllers never
|
|
429
|
+
// lose the session. The server is closed only when the node is deleted/disabled.
|
|
430
|
+
node.on('close', (removed, done) => {
|
|
431
|
+
closing = true
|
|
432
|
+
try {
|
|
433
|
+
if (startupTimer !== null) clearTimeout(startupTimer)
|
|
434
|
+
if (initialReadTimer !== null) clearTimeout(initialReadTimer)
|
|
435
|
+
} catch (error) { /* empty */ }
|
|
436
|
+
try {
|
|
437
|
+
if (node.serverKNX) node.serverKNX.removeClient(node)
|
|
438
|
+
} catch (error) { /* empty */ }
|
|
439
|
+
try {
|
|
440
|
+
if (node.matterBridge !== null) node.matterBridge.removeAllListeners()
|
|
441
|
+
} catch (error) { /* empty */ }
|
|
442
|
+
(async () => {
|
|
443
|
+
try {
|
|
444
|
+
if (removed === true && node.matterBridge !== null) {
|
|
445
|
+
await node.matterBridge.close()
|
|
446
|
+
runningBridges.delete(node.id)
|
|
447
|
+
}
|
|
448
|
+
} catch (error) { /* empty */ }
|
|
449
|
+
node.matterBridge = null
|
|
450
|
+
done()
|
|
451
|
+
})()
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
RED.nodes.registerType('knxUltimateMatterBridge', knxUltimateMatterBridge)
|
|
456
|
+
}
|