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.
@@ -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.serverKNX = RED.nodes.getNode(config.server) || undefined
25
- if (node.serverKNX === undefined) {
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
- if (provider && typeof provider.applyStatusUpdate === 'function') {
56
- provider.applyStatusUpdate(node, status)
57
- } else {
58
- node.status(status)
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
- node.send(msg)
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 canForward = (parsed) => {
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 && node.serverKNX && node.serverKNX.id && parsed.originGatewayId === node.serverKNX.id) return false
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 (!canForward(parsed)) return
148
- forwardToBus(parsed)
149
- node.setNodeStatus({
150
- fill: 'green',
151
- shape: 'dot',
152
- text: 'Forwarded',
153
- payload: parsed.event || '',
154
- GA: parsed.destination,
155
- dpt: '',
156
- devicename: parsed.source || ''
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
- if (node.serverKNX) {
166
- node.serverKNX.removeClient(node)
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
- updateStatus({ fill: 'grey', shape: 'dot', text: 'Routing ready' })
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>