node-red-contrib-knx-ultimate 4.1.18 → 4.1.21
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 +14 -2
- package/examples/KNX Multi Routing - KNXIP Server.json +133 -0
- package/nodes/knxUltimateAI.js +131 -85
- package/nodes/knxUltimateMultiRouting.html +75 -3
- package/nodes/knxUltimateMultiRouting.js +390 -28
- package/nodes/knxUltimateRouterFilter.html +1 -1
- package/nodes/knxUltimateRouterFilter.js +215 -9
- package/nodes/locales/de/knxUltimateMultiRouting.html +16 -0
- package/nodes/locales/de/knxUltimateMultiRouting.json +15 -1
- package/nodes/locales/de/knxUltimateRouterFilter.html +39 -1
- package/nodes/locales/en/knxUltimateMultiRouting.html +16 -0
- package/nodes/locales/en/knxUltimateMultiRouting.json +15 -1
- package/nodes/locales/en/knxUltimateRouterFilter.html +39 -1
- package/nodes/locales/es/knxUltimateMultiRouting.html +16 -0
- package/nodes/locales/es/knxUltimateMultiRouting.json +15 -1
- package/nodes/locales/es/knxUltimateRouterFilter.html +39 -1
- package/nodes/locales/fr/knxUltimateMultiRouting.html +16 -0
- package/nodes/locales/fr/knxUltimateMultiRouting.json +15 -1
- package/nodes/locales/fr/knxUltimateRouterFilter.html +39 -1
- package/nodes/locales/it/knxUltimateMultiRouting.html +16 -0
- package/nodes/locales/it/knxUltimateMultiRouting.json +15 -1
- package/nodes/locales/it/knxUltimateRouterFilter.html +39 -1
- package/nodes/locales/zh-CN/knxUltimateMultiRouting.html +16 -0
- package/nodes/locales/zh-CN/knxUltimateMultiRouting.json +15 -1
- package/nodes/locales/zh-CN/knxUltimateRouterFilter.html +39 -1
- package/package.json +2 -2
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
// KNX Multi Routing - interconnect multiple KNX Ultimate gateways via Node-RED flows
|
|
2
2
|
const loggerClass = require('./utils/sysLogger')
|
|
3
|
+
const os = require('os')
|
|
4
|
+
|
|
5
|
+
const toBoolean = (value, fallback) => {
|
|
6
|
+
if (value === undefined || value === null) return fallback
|
|
7
|
+
if (typeof value === 'boolean') return value
|
|
8
|
+
if (typeof value === 'number') return value !== 0
|
|
9
|
+
const s = String(value).trim().toLowerCase()
|
|
10
|
+
if (s === 'true' || s === '1' || s === 'yes' || s === 'on') return true
|
|
11
|
+
if (s === 'false' || s === '0' || s === 'no' || s === 'off') return false
|
|
12
|
+
return fallback
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const safeNumber = (value, fallback) => {
|
|
16
|
+
const n = Number(value)
|
|
17
|
+
return Number.isFinite(n) ? n : fallback
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const normalizeHex = (value) => {
|
|
21
|
+
if (value === undefined || value === null) return ''
|
|
22
|
+
const s = String(value).trim()
|
|
23
|
+
if (s === '') return ''
|
|
24
|
+
return s.replace(/^0x/i, '').replace(/[^0-9a-fA-F]/g, '')
|
|
25
|
+
}
|
|
3
26
|
|
|
4
27
|
const bufferFromMaybe = (value) => {
|
|
5
28
|
if (value === undefined || value === null) return null
|
|
@@ -16,13 +39,42 @@ const bufferFromMaybe = (value) => {
|
|
|
16
39
|
return null
|
|
17
40
|
}
|
|
18
41
|
|
|
42
|
+
const CEMI_L_DATA_REQ = 0x11
|
|
43
|
+
const CEMI_L_DATA_IND = 0x29
|
|
44
|
+
|
|
45
|
+
const isWildcardHost = (host) => {
|
|
46
|
+
const h = host === undefined || host === null ? '' : String(host).trim()
|
|
47
|
+
return h === '' || h === '0.0.0.0' || h === '::' || h === '::0'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const guessAdvertiseHost = (listenHost) => {
|
|
51
|
+
if (listenHost && !isWildcardHost(listenHost)) return String(listenHost)
|
|
52
|
+
try {
|
|
53
|
+
const ifaces = os.networkInterfaces()
|
|
54
|
+
for (const entries of Object.values(ifaces)) {
|
|
55
|
+
for (const entry of entries || []) {
|
|
56
|
+
if (entry.family === 'IPv4' && !entry.internal) return entry.address
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch (e) { /* ignore */ }
|
|
60
|
+
return '127.0.0.1'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let _knxultimateCache = null
|
|
64
|
+
const getKnxultimate = () => {
|
|
65
|
+
if (_knxultimateCache) return _knxultimateCache
|
|
66
|
+
_knxultimateCache = require('knxultimate')
|
|
67
|
+
return _knxultimateCache
|
|
68
|
+
}
|
|
69
|
+
|
|
19
70
|
module.exports = function (RED) {
|
|
20
71
|
function knxUltimateMultiRouting (config) {
|
|
21
72
|
RED.nodes.createNode(this, config)
|
|
22
73
|
const node = this
|
|
23
74
|
|
|
24
|
-
node.
|
|
25
|
-
|
|
75
|
+
node.mode = config.mode || 'gateway' // 'gateway' | 'server'
|
|
76
|
+
node.serverKNX = (config.server && RED.nodes.getNode(config.server)) || undefined
|
|
77
|
+
if (node.mode !== 'server' && node.serverKNX === undefined) {
|
|
26
78
|
node.status({ fill: 'red', shape: 'dot', text: '[THE GATEWAY NODE HAS BEEN DISABLED]' })
|
|
27
79
|
return
|
|
28
80
|
}
|
|
@@ -48,14 +100,30 @@ module.exports = function (RED) {
|
|
|
48
100
|
// Forwarding controls (input -> KNX bus)
|
|
49
101
|
// Basic loop protection: drop messages already tagged as originating from this same gateway.
|
|
50
102
|
node.dropIfSameGateway = config.dropIfSameGateway !== undefined ? (config.dropIfSameGateway === true || config.dropIfSameGateway === 'true') : true
|
|
103
|
+
node.respectRoutingCounter = toBoolean(config.respectRoutingCounter, true)
|
|
104
|
+
node.decrementRoutingCounter = toBoolean(config.decrementRoutingCounter, false)
|
|
105
|
+
|
|
106
|
+
// KNX/IP tunneling server (optional)
|
|
107
|
+
node.tunnelServer = null
|
|
108
|
+
node.tunnelSessions = new Set()
|
|
109
|
+
node.tunnelGatewayId = ''
|
|
110
|
+
node.tunnelAssignedIndividualAddress = ''
|
|
111
|
+
node.tunnelAdvertiseHostTimer = null
|
|
112
|
+
node.tunnelStatusRefreshTimer = null
|
|
113
|
+
node.tunnelRxCount = 0
|
|
114
|
+
node.tunnelLastRxAt = 0
|
|
51
115
|
|
|
52
116
|
const pushStatus = (status) => {
|
|
53
117
|
if (!status) return
|
|
54
118
|
const provider = node.serverKNX
|
|
55
|
-
|
|
56
|
-
provider.applyStatusUpdate
|
|
57
|
-
|
|
58
|
-
|
|
119
|
+
try {
|
|
120
|
+
if (provider && typeof provider.applyStatusUpdate === 'function') {
|
|
121
|
+
provider.applyStatusUpdate(node, status)
|
|
122
|
+
} else {
|
|
123
|
+
node.status(status)
|
|
124
|
+
}
|
|
125
|
+
} catch (error) {
|
|
126
|
+
try { node.status(status) } catch (e2) { /* ignore */ }
|
|
59
127
|
}
|
|
60
128
|
}
|
|
61
129
|
|
|
@@ -67,7 +135,7 @@ module.exports = function (RED) {
|
|
|
67
135
|
// Used to call the status update from the config node.
|
|
68
136
|
node.setNodeStatus = ({ fill, shape, text, payload, GA, dpt, devicename }) => {
|
|
69
137
|
try {
|
|
70
|
-
if (node.serverKNX === null) { updateStatus({ fill: 'red', shape: 'dot', text: '[NO GATEWAY SELECTED]' }); return }
|
|
138
|
+
if ((node.mode !== 'server') && (node.serverKNX === null || node.serverKNX === undefined)) { updateStatus({ fill: 'red', shape: 'dot', text: '[NO GATEWAY SELECTED]' }); return }
|
|
71
139
|
const dDate = new Date()
|
|
72
140
|
const ts = (node.serverKNX && typeof node.serverKNX.formatStatusTimestamp === 'function')
|
|
73
141
|
? node.serverKNX.formatStatusTimestamp(dDate)
|
|
@@ -85,15 +153,90 @@ module.exports = function (RED) {
|
|
|
85
153
|
node.sysLogger = new loggerClass({ loglevel: baseLogLevel, setPrefix: node.type + ' <' + (node.name || node.id || '') + '>' })
|
|
86
154
|
} catch (error) { /* empty */ }
|
|
87
155
|
|
|
156
|
+
const localGatewayIds = () => {
|
|
157
|
+
const ids = new Set()
|
|
158
|
+
if (node.serverKNX && node.serverKNX.id) ids.add(String(node.serverKNX.id))
|
|
159
|
+
if (node.tunnelGatewayId) ids.add(String(node.tunnelGatewayId))
|
|
160
|
+
return ids
|
|
161
|
+
}
|
|
162
|
+
|
|
88
163
|
// Called by knxUltimate-config.js to deliver bus telegrams (raw APDU + addresses)
|
|
89
164
|
node.handleSend = (msg) => {
|
|
90
165
|
try {
|
|
91
|
-
|
|
166
|
+
const processed = applyRoutingCounterOnOutboundMsg(msg)
|
|
167
|
+
if (!processed) return
|
|
168
|
+
node.send(processed)
|
|
92
169
|
} catch (error) {
|
|
93
170
|
node.sysLogger?.error(`knxUltimateMultiRouting: output error: ${error.message}`)
|
|
94
171
|
}
|
|
95
172
|
}
|
|
96
173
|
|
|
174
|
+
const tryParseCemiHex = (cemiHex) => {
|
|
175
|
+
const clean = normalizeHex(cemiHex)
|
|
176
|
+
if (!clean || clean.length % 2 !== 0) return null
|
|
177
|
+
let KNXTunnelingRequest
|
|
178
|
+
try { ({ KNXTunnelingRequest } = getKnxultimate()) } catch (e) { return null }
|
|
179
|
+
try { return KNXTunnelingRequest.parseCEMIMessage(Buffer.from(clean, 'hex'), 0) } catch (e) { return null }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const getHopCountFromCemiHex = (cemiHex) => {
|
|
183
|
+
const cemi = tryParseCemiHex(cemiHex)
|
|
184
|
+
const hop = cemi && cemi.control ? Number(cemi.control.hopCount) : NaN
|
|
185
|
+
return Number.isFinite(hop) ? hop : null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const decrementHopCountInCemiHex = (cemiHex) => {
|
|
189
|
+
const cemi = tryParseCemiHex(cemiHex)
|
|
190
|
+
if (!cemi || !cemi.control) return null
|
|
191
|
+
const hop = Number(cemi.control.hopCount)
|
|
192
|
+
if (!Number.isFinite(hop)) return null
|
|
193
|
+
if (hop <= 0) return { oldHopCount: hop, newHopCount: hop, cemiHex: cemi.toBuffer().toString('hex') }
|
|
194
|
+
const newHop = hop - 1
|
|
195
|
+
cemi.control.hopCount = newHop
|
|
196
|
+
return { oldHopCount: hop, newHopCount: newHop, cemiHex: cemi.toBuffer().toString('hex') }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const applyRoutingCounterOnOutboundMsg = (msg) => {
|
|
200
|
+
if (!msg) return msg
|
|
201
|
+
const p = msg.payload !== undefined ? msg.payload : msg
|
|
202
|
+
const k = (p && p.knx) ? p.knx : (msg && msg.knx ? msg.knx : null)
|
|
203
|
+
if (!k || typeof k !== 'object') return msg
|
|
204
|
+
const cemiHex = (k.cemi && (k.cemi.hex || k.cemi)) ? (k.cemi.hex || k.cemi) : ''
|
|
205
|
+
if (!cemiHex) return msg
|
|
206
|
+
|
|
207
|
+
const hopCount = getHopCountFromCemiHex(cemiHex)
|
|
208
|
+
if (hopCount !== null) {
|
|
209
|
+
try { k.routingCounter = hopCount } catch (e) { /* ignore */ }
|
|
210
|
+
try {
|
|
211
|
+
if (k.cemi && typeof k.cemi === 'object') k.cemi.hopCount = hopCount
|
|
212
|
+
} catch (e) { /* ignore */ }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (node.respectRoutingCounter && hopCount === 0) {
|
|
216
|
+
node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Dropped (routing counter 0)', payload: k.event || '', GA: k.destination || '', dpt: '', devicename: k.source || '' })
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!node.decrementRoutingCounter) return msg
|
|
221
|
+
if (hopCount === null) return msg
|
|
222
|
+
|
|
223
|
+
const dec = decrementHopCountInCemiHex(cemiHex)
|
|
224
|
+
if (!dec) return msg
|
|
225
|
+
|
|
226
|
+
if (node.respectRoutingCounter && dec.newHopCount === 0) {
|
|
227
|
+
node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Dropped (routing counter 0)', payload: k.event || '', GA: k.destination || '', dpt: '', devicename: k.source || '' })
|
|
228
|
+
return null
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
if (k.cemi && typeof k.cemi === 'object' && 'hex' in k.cemi) k.cemi.hex = dec.cemiHex
|
|
233
|
+
else k.cemi = dec.cemiHex
|
|
234
|
+
} catch (e) { /* ignore */ }
|
|
235
|
+
try { k.routingCounter = dec.newHopCount } catch (e) { /* ignore */ }
|
|
236
|
+
try { if (k.cemi && typeof k.cemi === 'object') k.cemi.hopCount = dec.newHopCount } catch (e) { /* ignore */ }
|
|
237
|
+
return msg
|
|
238
|
+
}
|
|
239
|
+
|
|
97
240
|
const parseIncoming = (msg) => {
|
|
98
241
|
const p = (msg && msg.payload !== undefined) ? msg.payload : msg
|
|
99
242
|
const k = (p && p.knx) ? p.knx : (msg && msg.knx ? msg.knx : p)
|
|
@@ -105,22 +248,68 @@ module.exports = function (RED) {
|
|
|
105
248
|
const apdu = k.apdu || {}
|
|
106
249
|
const apduData = bufferFromMaybe(apdu.data !== undefined ? apdu.data : (k.rawValue !== undefined ? k.rawValue : k.apduData))
|
|
107
250
|
const bitlength = Number(apdu.bitlength !== undefined ? apdu.bitlength : (k.bitlength !== undefined ? k.bitlength : (apduData ? apduData.length * 8 : 0)))
|
|
251
|
+
const cemiHex = (k.cemi && (k.cemi.hex || k.cemi)) ? (k.cemi.hex || k.cemi) : ''
|
|
252
|
+
const hopCount = cemiHex ? getHopCountFromCemiHex(cemiHex) : null
|
|
108
253
|
|
|
109
254
|
const routing = (p && p.knxMultiRouting) ? p.knxMultiRouting : (msg && msg.knxMultiRouting ? msg.knxMultiRouting : null)
|
|
110
255
|
const originGatewayId = routing && routing.gateway && routing.gateway.id ? String(routing.gateway.id) : ''
|
|
111
|
-
return { event, destination, source, apduData, bitlength, originGatewayId }
|
|
256
|
+
return { event, destination, source, apduData, bitlength, originGatewayId, cemiHex, hopCount }
|
|
112
257
|
}
|
|
113
258
|
|
|
114
|
-
const
|
|
259
|
+
const canForwardToGateway = (parsed) => {
|
|
115
260
|
if (!parsed) return false
|
|
116
261
|
if (!parsed.destination || typeof parsed.destination !== 'string') return false
|
|
117
262
|
if (!node.serverKNX || node.serverKNX.linkStatus !== 'connected' || !node.serverKNX.knxConnection) return false
|
|
118
|
-
if (node.dropIfSameGateway && parsed.originGatewayId &&
|
|
263
|
+
if (node.dropIfSameGateway && parsed.originGatewayId && localGatewayIds().has(String(parsed.originGatewayId))) return false
|
|
264
|
+
if (node.respectRoutingCounter && parsed.hopCount === 0) return false
|
|
119
265
|
return true
|
|
120
266
|
}
|
|
121
267
|
|
|
122
268
|
const forwardToBus = (parsed) => {
|
|
123
269
|
const client = node.serverKNX.knxConnection
|
|
270
|
+
if (parsed.cemiHex) {
|
|
271
|
+
const cemi = tryParseCemiHex(parsed.cemiHex)
|
|
272
|
+
if (cemi && cemi.control) {
|
|
273
|
+
// Apply routing-counter checks while forwarding to the BUS.
|
|
274
|
+
const hop = Number(cemi.control.hopCount)
|
|
275
|
+
if (Number.isFinite(hop)) {
|
|
276
|
+
if (node.respectRoutingCounter && hop === 0) return
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const isSerial = typeof client.isSerialTransport === 'function' ? client.isSerialTransport() : false
|
|
280
|
+
const hostProtocol = client && client._options && client._options.hostProtocol ? String(client._options.hostProtocol) : ''
|
|
281
|
+
let KNXProtocol
|
|
282
|
+
try { ({ KNXProtocol } = getKnxultimate()) } catch (e) { KNXProtocol = null }
|
|
283
|
+
if (!KNXProtocol || typeof client.send !== 'function') {
|
|
284
|
+
// fall back to legacy methods below
|
|
285
|
+
} else if (hostProtocol === 'Multicast' || isSerial) {
|
|
286
|
+
cemi.msgCode = hostProtocol === 'Multicast' ? CEMI_L_DATA_IND : CEMI_L_DATA_REQ
|
|
287
|
+
const knxPacketRequest = KNXProtocol.newKNXRoutingIndication(cemi)
|
|
288
|
+
const expected = (typeof client.getSeqNumber === 'function') ? client.getSeqNumber() : 0
|
|
289
|
+
client.send(knxPacketRequest, undefined, false, expected)
|
|
290
|
+
return
|
|
291
|
+
} else {
|
|
292
|
+
// Tunneling
|
|
293
|
+
cemi.msgCode = CEMI_L_DATA_REQ
|
|
294
|
+
try {
|
|
295
|
+
if (hostProtocol === 'TunnelTCP') cemi.control.ack = 0
|
|
296
|
+
else cemi.control.ack = (client._options && client._options.suppress_ack_ldatareq) ? 0 : 1
|
|
297
|
+
} catch (e) { /* ignore */ }
|
|
298
|
+
|
|
299
|
+
const seqNum = (hostProtocol === 'TunnelTCP' && typeof client.secureIncTunnelSeq === 'function')
|
|
300
|
+
? client.secureIncTunnelSeq()
|
|
301
|
+
: (typeof client.incSeqNumber === 'function' ? client.incSeqNumber() : 0)
|
|
302
|
+
|
|
303
|
+
const ch = (client.channelID !== undefined && client.channelID !== null) ? client.channelID : (client._channelID || 0)
|
|
304
|
+
const knxPacketRequest = KNXProtocol.newKNXTunnelingRequest(ch, seqNum, cemi)
|
|
305
|
+
const wantsAck = !(client._options && client._options.suppress_ack_ldatareq)
|
|
306
|
+
client.send(knxPacketRequest, wantsAck ? knxPacketRequest : undefined, false, seqNum)
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Legacy: re-create telegram from event+APDU (routing counter will be reset by the library).
|
|
124
313
|
const ga = parsed.destination
|
|
125
314
|
if (parsed.event === 'GroupValue_Write') {
|
|
126
315
|
if (!parsed.apduData) return
|
|
@@ -141,20 +330,177 @@ module.exports = function (RED) {
|
|
|
141
330
|
}
|
|
142
331
|
}
|
|
143
332
|
|
|
333
|
+
const canForwardToTunnel = (parsed) => {
|
|
334
|
+
if (!parsed) return false
|
|
335
|
+
if (!node.tunnelServer) return false
|
|
336
|
+
if (!parsed.cemiHex) return false
|
|
337
|
+
if (node.dropIfSameGateway && parsed.originGatewayId && localGatewayIds().has(String(parsed.originGatewayId))) return false
|
|
338
|
+
if (node.respectRoutingCounter && parsed.hopCount === 0) return false
|
|
339
|
+
return true
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const forwardToTunnel = (parsed) => {
|
|
343
|
+
const clean = normalizeHex(parsed.cemiHex)
|
|
344
|
+
if (!clean || clean.length % 2 !== 0) return
|
|
345
|
+
let KNXTunnelingRequest
|
|
346
|
+
try { ({ KNXTunnelingRequest } = getKnxultimate()) } catch (e) { throw new Error('knxultimate KNXTunnelingRequest not available') }
|
|
347
|
+
const cemi = KNXTunnelingRequest.parseCEMIMessage(Buffer.from(clean, 'hex'), 0)
|
|
348
|
+
node.tunnelServer.injectCemi(cemi)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const updateTunnelStatus = (extraText) => {
|
|
352
|
+
if (node.mode !== 'server') return
|
|
353
|
+
const addr = node.tunnelServer && typeof node.tunnelServer.address === 'object' ? node.tunnelServer.address : null
|
|
354
|
+
const host = addr ? addr.host : (config.tunnelListenHost || '0.0.0.0')
|
|
355
|
+
const port = addr ? addr.port : safeNumber(config.tunnelListenPort, 3671)
|
|
356
|
+
const sessionsFromServer = node.tunnelServer && node.tunnelServer.sessions && typeof node.tunnelServer.sessions.size === 'number'
|
|
357
|
+
? node.tunnelServer.sessions.size
|
|
358
|
+
: null
|
|
359
|
+
const sessionsFromEvents = node.tunnelSessions ? node.tunnelSessions.size : 0
|
|
360
|
+
const sessions = sessionsFromServer !== null ? sessionsFromServer : sessionsFromEvents
|
|
361
|
+
const adv = (node.tunnelServer && node.tunnelServer.options && node.tunnelServer.options.advertiseHost) ? node.tunnelServer.options.advertiseHost : ''
|
|
362
|
+
const tail = extraText ? ` ${extraText}` : ''
|
|
363
|
+
const advText = adv ? ` adv:${adv}` : ''
|
|
364
|
+
const rxText = node.tunnelRxCount ? ` rx:${node.tunnelRxCount}` : ''
|
|
365
|
+
updateStatus({ fill: 'green', shape: 'dot', text: `Tunnel ${host}:${port}${advText} sessions:${sessions}${rxText}${tail}` })
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const startTunnelServerIfNeeded = () => {
|
|
369
|
+
if (node.mode !== 'server') return
|
|
370
|
+
let KNXIPTunnelServer
|
|
371
|
+
try { ({ KNXIPTunnelServer } = require('knxultimate')) } catch (e) {
|
|
372
|
+
updateStatus({ fill: 'red', shape: 'dot', text: 'KNX/IP Server: knxultimate missing KNXIPTunnelServer' })
|
|
373
|
+
node.error('KNX/IP Server mode requires knxultimate with KNXIPTunnelServer exported.')
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
node.tunnelGatewayId = (config.tunnelGatewayId && String(config.tunnelGatewayId).trim()) || String(node.id)
|
|
378
|
+
node.tunnelAssignedIndividualAddress = (config.tunnelAssignedIndividualAddress && String(config.tunnelAssignedIndividualAddress).trim()) || '15.15.255'
|
|
379
|
+
|
|
380
|
+
const listenHost = (config.tunnelListenHost && String(config.tunnelListenHost).trim()) || '0.0.0.0'
|
|
381
|
+
const listenPort = safeNumber(config.tunnelListenPort, 3671)
|
|
382
|
+
const advertiseHostConfigured = (config.tunnelAdvertiseHost && String(config.tunnelAdvertiseHost).trim()) || ''
|
|
383
|
+
const advertiseHost = advertiseHostConfigured || guessAdvertiseHost(listenHost)
|
|
384
|
+
const maxSessions = Math.max(1, safeNumber(config.tunnelMaxSessions, 1))
|
|
385
|
+
|
|
386
|
+
const loglevel = (node.serverKNX && node.serverKNX.loglevel) ? node.serverKNX.loglevel : 'error'
|
|
387
|
+
|
|
388
|
+
node.tunnelServer = new KNXIPTunnelServer({
|
|
389
|
+
listenHost,
|
|
390
|
+
listenPort,
|
|
391
|
+
advertiseHost,
|
|
392
|
+
assignedIndividualAddress: node.tunnelAssignedIndividualAddress,
|
|
393
|
+
maxSessions,
|
|
394
|
+
loglevel
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
// If the OS network was not ready at boot, periodically refresh auto-advertise host.
|
|
398
|
+
// This affects only new client connections (CONNECT_RESPONSE payload).
|
|
399
|
+
if (!advertiseHostConfigured) {
|
|
400
|
+
const refreshAdvertiseHost = () => {
|
|
401
|
+
try {
|
|
402
|
+
if (!node.tunnelServer || !node.tunnelServer.options) return
|
|
403
|
+
const next = guessAdvertiseHost(listenHost)
|
|
404
|
+
const cur = node.tunnelServer.options.advertiseHost
|
|
405
|
+
if (next && cur !== next) {
|
|
406
|
+
node.tunnelServer.options.advertiseHost = next
|
|
407
|
+
updateTunnelStatus('(adv host updated)')
|
|
408
|
+
}
|
|
409
|
+
} catch (e) { /* ignore */ }
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
node.tunnelAdvertiseHostTimer = setInterval(refreshAdvertiseHost, 10000)
|
|
413
|
+
if (node.tunnelAdvertiseHostTimer && typeof node.tunnelAdvertiseHostTimer.unref === 'function') node.tunnelAdvertiseHostTimer.unref()
|
|
414
|
+
} catch (e) { /* ignore */ }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
node.tunnelServer.on('error', (err) => {
|
|
418
|
+
updateStatus({ fill: 'red', shape: 'dot', text: `Tunnel error: ${err.message}` })
|
|
419
|
+
node.error(err)
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
node.tunnelServer.on('listening', () => {
|
|
423
|
+
updateTunnelStatus('')
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
node.tunnelServer.on('sessionUp', (s) => {
|
|
427
|
+
try { node.tunnelSessions.add(s.channelId) } catch (e) { /* ignore */ }
|
|
428
|
+
updateTunnelStatus('client connected')
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
node.tunnelServer.on('sessionDown', (s) => {
|
|
432
|
+
try { if (s && s.channelId) node.tunnelSessions.delete(s.channelId) } catch (e) { /* ignore */ }
|
|
433
|
+
updateTunnelStatus('client disconnected')
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
node.tunnelServer.on('rawTelegram', (knx, info) => {
|
|
437
|
+
try {
|
|
438
|
+
node.tunnelRxCount = (node.tunnelRxCount || 0) + 1
|
|
439
|
+
node.tunnelLastRxAt = Date.now()
|
|
440
|
+
const msg = {
|
|
441
|
+
topic: node.outputtopic || knx.destination,
|
|
442
|
+
payload: {
|
|
443
|
+
knx,
|
|
444
|
+
knxMultiRouting: {
|
|
445
|
+
gateway: { id: node.tunnelGatewayId, name: node.name || '', physAddr: node.tunnelAssignedIndividualAddress || '' },
|
|
446
|
+
receivedAt: Date.now(),
|
|
447
|
+
tunnel: { channelId: info && info.channelId ? info.channelId : undefined }
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const processed = applyRoutingCounterOnOutboundMsg(msg)
|
|
452
|
+
if (!processed) return
|
|
453
|
+
node.send(processed)
|
|
454
|
+
} catch (error) {
|
|
455
|
+
node.sysLogger?.error(`knxUltimateMultiRouting: tunnel rawTelegram output error: ${error.message}`)
|
|
456
|
+
}
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
// Periodic refresh to surface session count even if Node-RED doesn't show transient updates.
|
|
460
|
+
try {
|
|
461
|
+
node.tunnelStatusRefreshTimer = setInterval(() => updateTunnelStatus(''), 5000)
|
|
462
|
+
if (node.tunnelStatusRefreshTimer && typeof node.tunnelStatusRefreshTimer.unref === 'function') node.tunnelStatusRefreshTimer.unref()
|
|
463
|
+
} catch (e) { /* ignore */ }
|
|
464
|
+
|
|
465
|
+
updateStatus({ fill: 'grey', shape: 'dot', text: 'Starting KNX/IP Server...' })
|
|
466
|
+
Promise.resolve()
|
|
467
|
+
.then(() => node.tunnelServer.start())
|
|
468
|
+
.then(() => updateTunnelStatus(''))
|
|
469
|
+
.catch((err) => {
|
|
470
|
+
updateStatus({ fill: 'red', shape: 'dot', text: `Tunnel start failed: ${err.message}` })
|
|
471
|
+
node.error(err)
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
|
|
144
475
|
node.on('input', function (msg) {
|
|
145
476
|
try {
|
|
146
477
|
const parsed = parseIncoming(msg)
|
|
147
|
-
if (
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
478
|
+
if (node.respectRoutingCounter && parsed && parsed.hopCount === 0) {
|
|
479
|
+
node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Dropped (routing counter 0)', payload: parsed.event || '', GA: parsed.destination, dpt: '', devicename: parsed.source || '' })
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (node.decrementRoutingCounter && parsed && parsed.cemiHex && parsed.hopCount !== null && parsed.hopCount > 0) {
|
|
484
|
+
const dec = decrementHopCountInCemiHex(parsed.cemiHex)
|
|
485
|
+
if (dec) {
|
|
486
|
+
if (node.respectRoutingCounter && dec.newHopCount === 0) {
|
|
487
|
+
node.setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Dropped (routing counter 0)', payload: parsed.event || '', GA: parsed.destination, dpt: '', devicename: parsed.source || '' })
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
parsed.cemiHex = dec.cemiHex
|
|
491
|
+
parsed.hopCount = dec.newHopCount
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (node.mode === 'server') {
|
|
496
|
+
if (!canForwardToTunnel(parsed)) return
|
|
497
|
+
forwardToTunnel(parsed)
|
|
498
|
+
} else {
|
|
499
|
+
if (!canForwardToGateway(parsed)) return
|
|
500
|
+
forwardToBus(parsed)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
node.setNodeStatus({ fill: 'green', shape: 'dot', text: 'Forwarded', payload: parsed.event || '', GA: parsed.destination, dpt: '', devicename: parsed.source || '' })
|
|
158
504
|
} catch (error) {
|
|
159
505
|
node.setNodeStatus({ fill: 'red', shape: 'dot', text: `Forward error: ${error.message || error}`, payload: '', GA: '', dpt: '', devicename: '' })
|
|
160
506
|
node.error(error)
|
|
@@ -162,19 +508,35 @@ module.exports = function (RED) {
|
|
|
162
508
|
})
|
|
163
509
|
|
|
164
510
|
node.on('close', function (done) {
|
|
165
|
-
|
|
166
|
-
node.serverKNX
|
|
511
|
+
const shutdown = async () => {
|
|
512
|
+
if (node.serverKNX) {
|
|
513
|
+
try { node.serverKNX.removeClient(node) } catch (e) { /* ignore */ }
|
|
514
|
+
}
|
|
515
|
+
if (node.tunnelAdvertiseHostTimer) {
|
|
516
|
+
try { clearInterval(node.tunnelAdvertiseHostTimer) } catch (e) { /* ignore */ }
|
|
517
|
+
node.tunnelAdvertiseHostTimer = null
|
|
518
|
+
}
|
|
519
|
+
if (node.tunnelStatusRefreshTimer) {
|
|
520
|
+
try { clearInterval(node.tunnelStatusRefreshTimer) } catch (e) { /* ignore */ }
|
|
521
|
+
node.tunnelStatusRefreshTimer = null
|
|
522
|
+
}
|
|
523
|
+
if (node.tunnelServer) {
|
|
524
|
+
try { await node.tunnelServer.stop() } catch (e) { /* ignore */ }
|
|
525
|
+
node.tunnelServer = null
|
|
526
|
+
}
|
|
167
527
|
}
|
|
168
|
-
done()
|
|
528
|
+
Promise.resolve(shutdown()).then(() => done()).catch(() => done())
|
|
169
529
|
})
|
|
170
530
|
|
|
171
531
|
// On each deploy, unsubscribe+resubscribe
|
|
172
532
|
if (node.serverKNX) {
|
|
173
|
-
node.serverKNX.removeClient(node)
|
|
174
|
-
node.serverKNX.addClient(node)
|
|
533
|
+
try { node.serverKNX.removeClient(node) } catch (e) { /* ignore */ }
|
|
534
|
+
try { node.serverKNX.addClient(node) } catch (e) { /* ignore */ }
|
|
175
535
|
}
|
|
176
536
|
|
|
177
|
-
|
|
537
|
+
startTunnelServerIfNeeded()
|
|
538
|
+
|
|
539
|
+
if (node.mode !== 'server') updateStatus({ fill: 'grey', shape: 'dot', text: 'Routing ready' })
|
|
178
540
|
}
|
|
179
541
|
|
|
180
542
|
RED.nodes.registerType('knxUltimateMultiRouting', knxUltimateMultiRouting)
|
|
@@ -131,4 +131,4 @@
|
|
|
131
131
|
<label for="node-input-srcRewriteRules"><i class="fa fa-random"></i> <span data-i18n="knxUltimateRouterFilter.properties.srcRewriteRules"></span></label>
|
|
132
132
|
<textarea style="width:100%; height:90px;" id="node-input-srcRewriteRules" data-i18n="[placeholder]knxUltimateRouterFilter.placeholder.srcRewriteRules"></textarea>
|
|
133
133
|
</div>
|
|
134
|
-
</script>
|
|
134
|
+
</script>
|