node-red-contrib-knx-ultimate 4.1.34 → 4.1.35

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 CHANGED
@@ -6,6 +6,11 @@
6
6
 
7
7
  # CHANGELOG
8
8
 
9
+ **Version 4.1.35** - March 2026<br/>
10
+
11
+ - NEW: **KNX AI** anomalies output now always emits dedicated bus connection events when the KNX gateway connection is lost and when it is restored.<br/>
12
+ - NEW: **KNX AI Web Dashboard** added a bus connection persistence section with a green/red time bar showing connected/disconnected periods over the history window.<br/>
13
+
9
14
  **Version 4.1.34** - March 2026<br/>
10
15
 
11
16
  - FIX: **KNX Hue Light** grouped light handling: when a `grouped_light` is OFF and receives color / color temperature / gradient commands, the node now presets supported member `light` resources first and only then turns the group ON, so all lamps can inherit the requested state more reliably.<br/>
@@ -1106,6 +1106,7 @@ module.exports = function (RED) {
1106
1106
  node.setNodeStatus = ({ fill, shape, text, payload, GA, dpt, devicename }) => {
1107
1107
  try {
1108
1108
  if (node.serverKNX === null) { updateStatus({ fill: 'red', shape: 'dot', text: '[NO GATEWAY SELECTED]' }); return }
1109
+ trackBusConnectionStatus({ text })
1109
1110
  const dDate = new Date()
1110
1111
  const ts = (node.serverKNX && typeof node.serverKNX.formatStatusTimestamp === 'function')
1111
1112
  ? node.serverKNX.formatStatusTimestamp(dDate)
@@ -1139,6 +1140,16 @@ module.exports = function (RED) {
1139
1140
  node._anomalyLifecycle = new Map()
1140
1141
  node._gaRateSeries = new Map()
1141
1142
  node._gaLabelCsvCache = { ref: null, map: {} }
1143
+ node._busConnectionState = (node.serverKNX && typeof node.serverKNX.linkStatus === 'string')
1144
+ ? String(node.serverKNX.linkStatus).toLowerCase()
1145
+ : 'unknown'
1146
+ node._busConnectionHadConnected = node._busConnectionState === 'connected'
1147
+ node._busConnectionPendingRestore = false
1148
+ node._busConnectionTimeline = [{
1149
+ state: node._busConnectionState === 'connected' ? 'connected' : 'disconnected',
1150
+ startedAtMs: nowMs(),
1151
+ endedAtMs: 0
1152
+ }]
1142
1153
 
1143
1154
  // Register runtime instance for sidebar visibility
1144
1155
  aiRuntimeNodes.set(node.id, node)
@@ -1509,12 +1520,12 @@ module.exports = function (RED) {
1509
1520
  const buildGARateSeries = ({ now, topGAs = [], patterns = [], anomalyLifecycle = [] } = {}) => {
1510
1521
  pruneGARateSeries(now)
1511
1522
  const candidates = new Set()
1512
- ;(topGAs || []).forEach(x => { if (x && x.ga) candidates.add(String(x.ga)) })
1513
- ;(patterns || []).forEach(p => {
1514
- if (p && p.from) candidates.add(String(p.from))
1515
- if (p && p.to) candidates.add(String(p.to))
1516
- })
1517
- ;(anomalyLifecycle || []).forEach(a => { if (a && a.ga) candidates.add(String(a.ga)) })
1523
+ ; (topGAs || []).forEach(x => { if (x && x.ga) candidates.add(String(x.ga)) })
1524
+ ; (patterns || []).forEach(p => {
1525
+ if (p && p.from) candidates.add(String(p.from))
1526
+ if (p && p.to) candidates.add(String(p.to))
1527
+ })
1528
+ ; (anomalyLifecycle || []).forEach(a => { if (a && a.ga) candidates.add(String(a.ga)) })
1518
1529
 
1519
1530
  if (!candidates.size) {
1520
1531
  const recent = Array.from(node._gaRateSeries.values())
@@ -1651,6 +1662,7 @@ module.exports = function (RED) {
1651
1662
  if (!ga) return
1652
1663
  anomalyByGA[ga] = (anomalyByGA[ga] || 0) + Math.max(1, Number(a && a.count ? a.count : 1))
1653
1664
  })
1665
+ const busConnection = buildBusConnectionSummary(now)
1654
1666
  const graphTelemetry = buildGraphTelemetry({ now, patterns, flowKnownGASet })
1655
1667
  const gaRateSeries = buildGARateSeries({
1656
1668
  now,
@@ -2116,6 +2128,7 @@ module.exports = function (RED) {
2116
2128
  edgeByEvent: graphTelemetry.edgeByEvent,
2117
2129
  hotEdgesDelta: graphTelemetry.hotEdgesDelta,
2118
2130
  flowMapTopology,
2131
+ busConnection,
2119
2132
  anomalyLifecycle,
2120
2133
  gaRateSeries,
2121
2134
  graph: {
@@ -2297,6 +2310,151 @@ module.exports = function (RED) {
2297
2310
  } catch (error) { /* empty */ }
2298
2311
  }
2299
2312
 
2313
+ const appendBusConnectionTimeline = ({ state, atMs }) => {
2314
+ const nextState = state === 'connected' ? 'connected' : 'disconnected'
2315
+ const ts = Number.isFinite(Number(atMs)) && Number(atMs) > 0 ? Number(atMs) : nowMs()
2316
+ const keepMs = Math.max(60, Number(node.historyWindowSec || 60)) * 4000
2317
+ if (!Array.isArray(node._busConnectionTimeline) || node._busConnectionTimeline.length === 0) {
2318
+ node._busConnectionTimeline = [{ state: nextState, startedAtMs: ts, endedAtMs: 0 }]
2319
+ return
2320
+ }
2321
+
2322
+ const timeline = node._busConnectionTimeline
2323
+ const lastIdx = timeline.length - 1
2324
+ const last = timeline[lastIdx] || null
2325
+ if (last && last.state === nextState) {
2326
+ if (last.endedAtMs && last.endedAtMs >= ts) last.endedAtMs = 0
2327
+ timeline[lastIdx] = last
2328
+ } else {
2329
+ if (last) {
2330
+ last.endedAtMs = ts
2331
+ timeline[lastIdx] = last
2332
+ }
2333
+ timeline.push({ state: nextState, startedAtMs: ts, endedAtMs: 0 })
2334
+ }
2335
+
2336
+ while (timeline.length > 1) {
2337
+ const first = timeline[0]
2338
+ const firstEnd = Number(first && first.endedAtMs ? first.endedAtMs : 0)
2339
+ if (!firstEnd || (ts - firstEnd) <= keepMs) break
2340
+ timeline.shift()
2341
+ }
2342
+
2343
+ if (timeline.length > 240) {
2344
+ node._busConnectionTimeline = timeline.slice(timeline.length - 240)
2345
+ } else {
2346
+ node._busConnectionTimeline = timeline
2347
+ }
2348
+ }
2349
+
2350
+ const buildBusConnectionSummary = (now) => {
2351
+ const windowSec = Math.max(5, Number(node.historyWindowSec || 60))
2352
+ const windowMs = windowSec * 1000
2353
+ const windowStartMs = now - windowMs
2354
+ const timeline = Array.isArray(node._busConnectionTimeline) ? node._busConnectionTimeline : []
2355
+ const segments = []
2356
+ let connectedMs = 0
2357
+ let disconnectedMs = 0
2358
+
2359
+ for (let i = 0; i < timeline.length; i++) {
2360
+ const item = timeline[i] || {}
2361
+ const state = item.state === 'connected' ? 'connected' : 'disconnected'
2362
+ const startedAtMs = Number(item.startedAtMs || 0)
2363
+ const endedAtMs = Number(item.endedAtMs || 0) > 0 ? Number(item.endedAtMs) : now
2364
+ if (!Number.isFinite(startedAtMs) || startedAtMs <= 0) continue
2365
+ const effectiveStartMs = Math.max(startedAtMs, windowStartMs)
2366
+ const effectiveEndMs = Math.min(endedAtMs, now)
2367
+ if (effectiveEndMs <= effectiveStartMs) continue
2368
+ const durationMs = effectiveEndMs - effectiveStartMs
2369
+ if (state === 'connected') connectedMs += durationMs
2370
+ else disconnectedMs += durationMs
2371
+ segments.push({
2372
+ state,
2373
+ startedAt: new Date(effectiveStartMs).toISOString(),
2374
+ endedAt: new Date(effectiveEndMs).toISOString(),
2375
+ durationSec: roundTo(durationMs / 1000, 1),
2376
+ ratioStart: roundTo((effectiveStartMs - windowStartMs) / windowMs, 4),
2377
+ ratioWidth: roundTo(durationMs / windowMs, 4),
2378
+ active: Number(item.endedAtMs || 0) <= 0
2379
+ })
2380
+ }
2381
+
2382
+ const knownMs = connectedMs + disconnectedMs
2383
+ return {
2384
+ currentState: node._busConnectionState === 'connected' ? 'connected' : 'disconnected',
2385
+ windowSec,
2386
+ windowStartAt: new Date(windowStartMs).toISOString(),
2387
+ windowEndAt: new Date(now).toISOString(),
2388
+ connectedSec: roundTo(connectedMs / 1000, 1),
2389
+ disconnectedSec: roundTo(disconnectedMs / 1000, 1),
2390
+ connectedPct: roundTo((connectedMs / windowMs) * 100, 2),
2391
+ disconnectedPct: roundTo((disconnectedMs / windowMs) * 100, 2),
2392
+ knownCoveragePct: roundTo((knownMs / windowMs) * 100, 2),
2393
+ segments
2394
+ }
2395
+ }
2396
+
2397
+ const emitBusConnectionEvent = ({ type, previousState, currentState, statusText }) => {
2398
+ const payload = {
2399
+ type,
2400
+ event: currentState,
2401
+ previousState: previousState || 'unknown',
2402
+ currentState,
2403
+ gatewayId: node.serverKNX ? node.serverKNX.id : '',
2404
+ gatewayName: (node.serverKNX && node.serverKNX.name) ? node.serverKNX.name : '',
2405
+ statusText: String(statusText || '').trim(),
2406
+ at: new Date().toISOString()
2407
+ }
2408
+ recordAnomaly(payload)
2409
+ node.send([null, {
2410
+ topic: node.outputtopic,
2411
+ payload,
2412
+ knxAi: {
2413
+ type: 'anomaly',
2414
+ event: type,
2415
+ gatewayId: payload.gatewayId,
2416
+ gatewayName: payload.gatewayName
2417
+ }
2418
+ }, null])
2419
+ }
2420
+
2421
+ const trackBusConnectionStatus = ({ text }) => {
2422
+ const statusText = String(text || '').trim()
2423
+ let nextState = ''
2424
+ if (/^Connected\./i.test(statusText)) nextState = 'connected'
2425
+ if (/^Disconnected\b/i.test(statusText)) nextState = 'disconnected'
2426
+ if (nextState === '') return
2427
+
2428
+ const previousState = node._busConnectionState || 'unknown'
2429
+ if (previousState === nextState) return
2430
+ node._busConnectionState = nextState
2431
+ appendBusConnectionTimeline({ state: nextState, atMs: nowMs() })
2432
+
2433
+ if (nextState === 'connected') {
2434
+ if (node._busConnectionPendingRestore) {
2435
+ emitBusConnectionEvent({
2436
+ type: 'bus_connection_restored',
2437
+ previousState,
2438
+ currentState: nextState,
2439
+ statusText
2440
+ })
2441
+ node._busConnectionPendingRestore = false
2442
+ }
2443
+ node._busConnectionHadConnected = true
2444
+ return
2445
+ }
2446
+
2447
+ if (node._busConnectionHadConnected) {
2448
+ emitBusConnectionEvent({
2449
+ type: 'bus_connection_lost',
2450
+ previousState,
2451
+ currentState: nextState,
2452
+ statusText
2453
+ })
2454
+ node._busConnectionPendingRestore = true
2455
+ }
2456
+ }
2457
+
2300
2458
  const maybeEmitOverallAnomaly = (now) => {
2301
2459
  const windowMs = Math.max(2, node.rateWindowSec) * 1000
2302
2460
  const cutoff = now - windowMs
@@ -2419,6 +2577,11 @@ module.exports = function (RED) {
2419
2577
  node._transitionRecent = []
2420
2578
  node._anomalyLifecycle = new Map()
2421
2579
  node._gaRateSeries = new Map()
2580
+ node._busConnectionTimeline = [{
2581
+ state: node._busConnectionState === 'connected' ? 'connected' : 'disconnected',
2582
+ startedAtMs: nowMs(),
2583
+ endedAtMs: 0
2584
+ }]
2422
2585
  node._lastSummary = null
2423
2586
  node._lastSummaryAt = 0
2424
2587
  if (node._summaryRebuildTimer) {
@@ -196,6 +196,10 @@
196
196
  background: var(--knx-ai-soft);
197
197
  }
198
198
 
199
+ .panel-wide {
200
+ grid-column: 1 / -1;
201
+ }
202
+
199
203
  #summary {
200
204
  margin: 0;
201
205
  padding: 12px;
@@ -243,6 +247,131 @@
243
247
  font-size: 12px;
244
248
  }
245
249
 
250
+ #bus-connection {
251
+ padding: 12px;
252
+ background: #ffffff;
253
+ }
254
+
255
+ .bus-conn-empty {
256
+ min-height: 96px;
257
+ display: flex;
258
+ align-items: center;
259
+ color: var(--muted);
260
+ font-size: 13px;
261
+ font-weight: 600;
262
+ }
263
+
264
+ .bus-conn-header {
265
+ display: flex;
266
+ align-items: center;
267
+ justify-content: space-between;
268
+ gap: 10px;
269
+ flex-wrap: wrap;
270
+ margin-bottom: 10px;
271
+ }
272
+
273
+ .bus-conn-title {
274
+ font-size: 13px;
275
+ font-weight: 800;
276
+ color: #38315d;
277
+ }
278
+
279
+ .bus-conn-badges {
280
+ display: flex;
281
+ flex-wrap: wrap;
282
+ gap: 6px;
283
+ }
284
+
285
+ .bus-conn-pill {
286
+ display: inline-flex;
287
+ align-items: center;
288
+ gap: 5px;
289
+ padding: 4px 8px;
290
+ border-radius: 999px;
291
+ border: 1px solid var(--line);
292
+ background: #f7f4ff;
293
+ color: #463e6d;
294
+ font-size: 11px;
295
+ font-weight: 800;
296
+ letter-spacing: .01em;
297
+ }
298
+
299
+ .bus-conn-pill-connected {
300
+ background: var(--ok-bg);
301
+ border-color: var(--ok-border);
302
+ color: #2e7d46;
303
+ }
304
+
305
+ .bus-conn-pill-disconnected {
306
+ background: var(--err-bg);
307
+ border-color: var(--err-border);
308
+ color: #b13d46;
309
+ }
310
+
311
+ .bus-conn-pill-muted {
312
+ background: #f7f4ff;
313
+ border-color: #d7cffa;
314
+ color: #655f80;
315
+ }
316
+
317
+ .bus-conn-track {
318
+ position: relative;
319
+ height: 18px;
320
+ border-radius: 999px;
321
+ overflow: hidden;
322
+ border: 1px solid #ddd7f2;
323
+ background: linear-gradient(180deg, #f6f3ff 0%, #efebfb 100%);
324
+ }
325
+
326
+ .bus-conn-track::after {
327
+ content: "";
328
+ position: absolute;
329
+ inset: 0;
330
+ border-radius: inherit;
331
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
332
+ pointer-events: none;
333
+ }
334
+
335
+ .bus-conn-segment {
336
+ position: absolute;
337
+ top: 0;
338
+ bottom: 0;
339
+ min-width: 2px;
340
+ }
341
+
342
+ .bus-conn-segment-connected {
343
+ background: linear-gradient(90deg, #4ad06d 0%, #38c45f 100%);
344
+ }
345
+
346
+ .bus-conn-segment-disconnected {
347
+ background: linear-gradient(90deg, #eb6a74 0%, #d94b55 100%);
348
+ }
349
+
350
+ .bus-conn-scale {
351
+ display: grid;
352
+ grid-template-columns: 1fr 1fr 1fr;
353
+ gap: 8px;
354
+ margin-top: 8px;
355
+ color: var(--muted);
356
+ font-size: 12px;
357
+ font-weight: 700;
358
+ }
359
+
360
+ .bus-conn-scale span:nth-child(2) {
361
+ text-align: center;
362
+ }
363
+
364
+ .bus-conn-scale span:nth-child(3) {
365
+ text-align: right;
366
+ }
367
+
368
+ .bus-conn-note {
369
+ margin-top: 9px;
370
+ color: #6a6386;
371
+ font-size: 12px;
372
+ line-height: 1.35;
373
+ }
374
+
246
375
  #chat-wrap {
247
376
  margin: 10px;
248
377
  border: 1px solid var(--line);
@@ -1129,6 +1258,10 @@
1129
1258
  <h3>Anomalies</h3>
1130
1259
  <div id="anomalies"></div>
1131
1260
  </div>
1261
+ <div class="panel panel-wide">
1262
+ <h3>Bus Connection Persistence</h3>
1263
+ <div id="bus-connection"></div>
1264
+ </div>
1132
1265
  </div>
1133
1266
  <div class="panel" id="ask-panel">
1134
1267
  <h3>Ask</h3>
@@ -1178,6 +1311,7 @@
1178
1311
  const $themeBtns = Array.from(document.querySelectorAll('.knx-ai-theme-btn'));
1179
1312
  const $summary = document.getElementById('summary');
1180
1313
  const $anomalies = document.getElementById('anomalies');
1314
+ const $busConnection = document.getElementById('bus-connection');
1181
1315
  const $flowSvg = document.getElementById('flow-svg');
1182
1316
  const $flowWrap = document.getElementById('flow-wrap');
1183
1317
  const $flowEmpty = document.getElementById('flow-empty');
@@ -1727,6 +1861,10 @@
1727
1861
  const win = (s.meta && s.meta.analysisWindowSec) ? s.meta.analysisWindowSec : '';
1728
1862
  lines.push('Analysis window: ' + win + 's');
1729
1863
  lines.push('Telegrams: ' + (c.telegrams || 0) + ' | Rate: ' + (c.overallRatePerSec || 0) + '/s | Echoed: ' + (c.echoed || 0) + ' | Repeat: ' + (c.repeated || 0) + ' | Unknown DPT: ' + (c.unknownDpt || 0));
1864
+ if (s.busConnection && typeof s.busConnection === 'object') {
1865
+ const bus = s.busConnection;
1866
+ lines.push('Bus connection: ' + String(bus.currentState || 'unknown') + ' | Connected: ' + Number(bus.connectedPct || 0) + '% | Disconnected: ' + Number(bus.disconnectedPct || 0) + '% over ' + Number(bus.windowSec || 0) + 's');
1867
+ }
1730
1868
 
1731
1869
  if (Array.isArray(s.topGAs) && s.topGAs.length) {
1732
1870
  lines.push('');
@@ -1755,6 +1893,122 @@
1755
1893
  return lines.join('\n');
1756
1894
  };
1757
1895
 
1896
+ const clamp01 = (value) => {
1897
+ const n = Number(value);
1898
+ if (!Number.isFinite(n)) return 0;
1899
+ if (n <= 0) return 0;
1900
+ if (n >= 1) return 1;
1901
+ return n;
1902
+ };
1903
+
1904
+ const formatClockLabel = (value) => {
1905
+ const ts = Number(value);
1906
+ if (!Number.isFinite(ts) || ts <= 0) return '--:--';
1907
+ const date = new Date(ts);
1908
+ if (Number.isNaN(date.getTime())) return '--:--';
1909
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1910
+ };
1911
+
1912
+ const formatDurationCompact = (seconds) => {
1913
+ const totalSec = Math.max(0, Math.round(Number(seconds) || 0));
1914
+ const days = Math.floor(totalSec / 86400);
1915
+ const hours = Math.floor((totalSec % 86400) / 3600);
1916
+ const mins = Math.floor((totalSec % 3600) / 60);
1917
+ const secs = totalSec % 60;
1918
+ const parts = [];
1919
+ if (days > 0) parts.push(days + 'd');
1920
+ if (hours > 0) parts.push(hours + 'h');
1921
+ if (mins > 0) parts.push(mins + 'm');
1922
+ if (secs > 0 || parts.length === 0) parts.push(secs + 's');
1923
+ return parts.slice(0, 2).join(' ');
1924
+ };
1925
+
1926
+ const renderBusConnection = (data) => {
1927
+ $busConnection.innerHTML = '';
1928
+ const summary = data && data.summary ? data.summary : {};
1929
+ const bus = summary && summary.busConnection && typeof summary.busConnection === 'object'
1930
+ ? summary.busConnection
1931
+ : null;
1932
+
1933
+ if (!bus || !Array.isArray(bus.segments) || bus.segments.length === 0) {
1934
+ const empty = document.createElement('div');
1935
+ empty.className = 'bus-conn-empty';
1936
+ empty.textContent = 'Waiting for connection persistence data...';
1937
+ $busConnection.appendChild(empty);
1938
+ return;
1939
+ }
1940
+
1941
+ const windowStartMs = new Date(String(bus.windowStartAt || '')).getTime();
1942
+ const windowEndMs = new Date(String(bus.windowEndAt || '')).getTime();
1943
+ const windowMs = Math.max(1, windowEndMs - windowStartMs);
1944
+ const midMs = windowStartMs + Math.round(windowMs / 2);
1945
+
1946
+ const header = document.createElement('div');
1947
+ header.className = 'bus-conn-header';
1948
+
1949
+ const title = document.createElement('div');
1950
+ title.className = 'bus-conn-title';
1951
+ title.textContent = 'Last ' + formatDurationCompact(bus.windowSec || 0) + ' of KNX bus availability';
1952
+ header.appendChild(title);
1953
+
1954
+ const badges = document.createElement('div');
1955
+ badges.className = 'bus-conn-badges';
1956
+
1957
+ const makePill = (label, value, cls) => {
1958
+ const pill = document.createElement('span');
1959
+ pill.className = 'bus-conn-pill ' + cls;
1960
+ pill.textContent = label + ': ' + value;
1961
+ return pill;
1962
+ };
1963
+
1964
+ badges.appendChild(makePill('Current', String(bus.currentState || 'unknown'), bus.currentState === 'connected' ? 'bus-conn-pill-connected' : 'bus-conn-pill-disconnected'));
1965
+ badges.appendChild(makePill('Connected', formatDurationCompact(bus.connectedSec || 0), 'bus-conn-pill-connected'));
1966
+ badges.appendChild(makePill('Disconnected', formatDurationCompact(bus.disconnectedSec || 0), 'bus-conn-pill-disconnected'));
1967
+ badges.appendChild(makePill('Coverage', Number(bus.knownCoveragePct || 0) + '%', 'bus-conn-pill-muted'));
1968
+ header.appendChild(badges);
1969
+ $busConnection.appendChild(header);
1970
+
1971
+ const track = document.createElement('div');
1972
+ track.className = 'bus-conn-track';
1973
+
1974
+ bus.segments.forEach((segment) => {
1975
+ const startRatio = clamp01(segment && segment.ratioStart);
1976
+ const widthRatio = clamp01(segment && segment.ratioWidth);
1977
+ if (widthRatio <= 0) return;
1978
+ const bar = document.createElement('div');
1979
+ bar.className = 'bus-conn-segment ' + (segment && segment.state === 'connected'
1980
+ ? 'bus-conn-segment-connected'
1981
+ : 'bus-conn-segment-disconnected');
1982
+ bar.style.left = (startRatio * 100).toFixed(3) + '%';
1983
+ bar.style.width = Math.max(widthRatio * 100, 0.4).toFixed(3) + '%';
1984
+ bar.title = (segment.state === 'connected' ? 'Connected' : 'Disconnected')
1985
+ + ' | ' + formatClockLabel(new Date(String(segment.startedAt || '')).getTime())
1986
+ + ' -> ' + formatClockLabel(new Date(String(segment.endedAt || '')).getTime())
1987
+ + ' | ' + formatDurationCompact(segment.durationSec || 0);
1988
+ track.appendChild(bar);
1989
+ });
1990
+
1991
+ $busConnection.appendChild(track);
1992
+
1993
+ const scale = document.createElement('div');
1994
+ scale.className = 'bus-conn-scale';
1995
+ const left = document.createElement('span');
1996
+ left.textContent = formatClockLabel(windowStartMs);
1997
+ const mid = document.createElement('span');
1998
+ mid.textContent = formatClockLabel(midMs);
1999
+ const right = document.createElement('span');
2000
+ right.textContent = 'Now';
2001
+ scale.appendChild(left);
2002
+ scale.appendChild(mid);
2003
+ scale.appendChild(right);
2004
+ $busConnection.appendChild(scale);
2005
+
2006
+ const note = document.createElement('div');
2007
+ note.className = 'bus-conn-note';
2008
+ note.textContent = 'Green shows time connected to the KNX bus. Red shows downtime inside the selected history window.';
2009
+ $busConnection.appendChild(note);
2010
+ };
2011
+
1758
2012
  const buildAnalysisActivityContext = (rawData, dashboardData) => {
1759
2013
  const summary = rawData && rawData.summary ? rawData.summary : {};
1760
2014
  const telemetryWindowSec = Number(
@@ -3317,6 +3571,7 @@
3317
3571
  const dashboard = buildDashboardData(data || {});
3318
3572
  const activityContext = buildAnalysisActivityContext(data || {}, dashboard);
3319
3573
  updateFlowGaOptions(dashboard.nodes || []);
3574
+ renderBusConnection(data || {});
3320
3575
  const flowGraph = applyFlowFilters(dashboard);
3321
3576
  renderFlowMap(flowGraph);
3322
3577
  renderPie($eventPieSvg, $eventPieLegend, dashboard.eventEntries, 'Event');
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "4.1.34",
6
+ "version": "4.1.35",
7
7
  "description": "Control your KNX and KNX Secure intallation via Node-Red! A bunch of KNX nodes, with integrated Philips HUE control, ETS group address importer, and KNX routing between interfaces. Easy to use and highly configurable.",
8
8
  "files": [
9
9
  "nodes/",