node-red-contrib-knx-ultimate 4.1.34 → 4.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 CHANGED
@@ -6,6 +6,18 @@
6
6
 
7
7
  # CHANGELOG
8
8
 
9
+ **Version 4.2.1** - March 2026<br/>
10
+
11
+ - FIX: **KNX Hue Light** brightness writes are now applied even when the Hue light is OFF, without forcing the light to switch on.<br/>
12
+ - FIX: **KNX Hue Light** grouped light handling: when a `grouped_light` is OFF and receives a brightness write, the node now presets supported member `light` resources first, so the brightness is ready when the group is turned on later.<br/>
13
+ - TEST: extended **KNX Hue Light** coverage for brightness writes on OFF lights and OFF grouped lights.<br/>
14
+ - UPDATE: KNXUltimate engine bumped to 5.4.0<br/>
15
+
16
+ **Version 4.1.35** - March 2026<br/>
17
+
18
+ - 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/>
19
+ - 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/>
20
+
9
21
  **Version 4.1.34** - March 2026<br/>
10
22
 
11
23
  - 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,18 @@ 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._busConnectionWatchTimer = null
1144
+ node._busConnectionState = (node.serverKNX && typeof node.serverKNX.linkStatus === 'string')
1145
+ ? String(node.serverKNX.linkStatus).toLowerCase()
1146
+ : 'unknown'
1147
+ node._busConnectionHadConnected = node._busConnectionState === 'connected'
1148
+ node._busConnectionPendingRestore = false
1149
+ node._busConnectionTimeline = [{
1150
+ state: node._busConnectionState === 'connected' ? 'connected' : 'disconnected',
1151
+ startedAtMs: nowMs(),
1152
+ endedAtMs: 0
1153
+ }]
1154
+ node._busConnectionWindowSec = 12 * 60 * 60
1142
1155
 
1143
1156
  // Register runtime instance for sidebar visibility
1144
1157
  aiRuntimeNodes.set(node.id, node)
@@ -1509,12 +1522,12 @@ module.exports = function (RED) {
1509
1522
  const buildGARateSeries = ({ now, topGAs = [], patterns = [], anomalyLifecycle = [] } = {}) => {
1510
1523
  pruneGARateSeries(now)
1511
1524
  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)) })
1525
+ ; (topGAs || []).forEach(x => { if (x && x.ga) candidates.add(String(x.ga)) })
1526
+ ; (patterns || []).forEach(p => {
1527
+ if (p && p.from) candidates.add(String(p.from))
1528
+ if (p && p.to) candidates.add(String(p.to))
1529
+ })
1530
+ ; (anomalyLifecycle || []).forEach(a => { if (a && a.ga) candidates.add(String(a.ga)) })
1518
1531
 
1519
1532
  if (!candidates.size) {
1520
1533
  const recent = Array.from(node._gaRateSeries.values())
@@ -1651,6 +1664,7 @@ module.exports = function (RED) {
1651
1664
  if (!ga) return
1652
1665
  anomalyByGA[ga] = (anomalyByGA[ga] || 0) + Math.max(1, Number(a && a.count ? a.count : 1))
1653
1666
  })
1667
+ const busConnection = buildBusConnectionSummary(now)
1654
1668
  const graphTelemetry = buildGraphTelemetry({ now, patterns, flowKnownGASet })
1655
1669
  const gaRateSeries = buildGARateSeries({
1656
1670
  now,
@@ -2116,6 +2130,7 @@ module.exports = function (RED) {
2116
2130
  edgeByEvent: graphTelemetry.edgeByEvent,
2117
2131
  hotEdgesDelta: graphTelemetry.hotEdgesDelta,
2118
2132
  flowMapTopology,
2133
+ busConnection,
2119
2134
  anomalyLifecycle,
2120
2135
  gaRateSeries,
2121
2136
  graph: {
@@ -2297,6 +2312,165 @@ module.exports = function (RED) {
2297
2312
  } catch (error) { /* empty */ }
2298
2313
  }
2299
2314
 
2315
+ const appendBusConnectionTimeline = ({ state, atMs }) => {
2316
+ const nextState = state === 'connected' ? 'connected' : 'disconnected'
2317
+ const ts = Number.isFinite(Number(atMs)) && Number(atMs) > 0 ? Number(atMs) : nowMs()
2318
+ const keepMs = Math.max(12 * 60 * 60, Number(node._busConnectionWindowSec || 0)) * 2000
2319
+ if (!Array.isArray(node._busConnectionTimeline) || node._busConnectionTimeline.length === 0) {
2320
+ node._busConnectionTimeline = [{ state: nextState, startedAtMs: ts, endedAtMs: 0 }]
2321
+ return
2322
+ }
2323
+
2324
+ const timeline = node._busConnectionTimeline
2325
+ const lastIdx = timeline.length - 1
2326
+ const last = timeline[lastIdx] || null
2327
+ if (last && last.state === nextState) {
2328
+ if (last.endedAtMs && last.endedAtMs >= ts) last.endedAtMs = 0
2329
+ timeline[lastIdx] = last
2330
+ } else {
2331
+ if (last) {
2332
+ last.endedAtMs = ts
2333
+ timeline[lastIdx] = last
2334
+ }
2335
+ timeline.push({ state: nextState, startedAtMs: ts, endedAtMs: 0 })
2336
+ }
2337
+
2338
+ while (timeline.length > 1) {
2339
+ const first = timeline[0]
2340
+ const firstEnd = Number(first && first.endedAtMs ? first.endedAtMs : 0)
2341
+ if (!firstEnd || (ts - firstEnd) <= keepMs) break
2342
+ timeline.shift()
2343
+ }
2344
+
2345
+ if (timeline.length > 240) {
2346
+ node._busConnectionTimeline = timeline.slice(timeline.length - 240)
2347
+ } else {
2348
+ node._busConnectionTimeline = timeline
2349
+ }
2350
+ }
2351
+
2352
+ const buildBusConnectionSummary = (now) => {
2353
+ const windowSec = Math.max(12 * 60 * 60, Number(node._busConnectionWindowSec || 0))
2354
+ const windowMs = windowSec * 1000
2355
+ const windowStartMs = now - windowMs
2356
+ const timeline = Array.isArray(node._busConnectionTimeline) ? node._busConnectionTimeline : []
2357
+ const segments = []
2358
+ let connectedMs = 0
2359
+ let disconnectedMs = 0
2360
+
2361
+ for (let i = 0; i < timeline.length; i++) {
2362
+ const item = timeline[i] || {}
2363
+ const state = item.state === 'connected' ? 'connected' : 'disconnected'
2364
+ const startedAtMs = Number(item.startedAtMs || 0)
2365
+ const endedAtMs = Number(item.endedAtMs || 0) > 0 ? Number(item.endedAtMs) : now
2366
+ if (!Number.isFinite(startedAtMs) || startedAtMs <= 0) continue
2367
+ const effectiveStartMs = Math.max(startedAtMs, windowStartMs)
2368
+ const effectiveEndMs = Math.min(endedAtMs, now)
2369
+ if (effectiveEndMs <= effectiveStartMs) continue
2370
+ const durationMs = effectiveEndMs - effectiveStartMs
2371
+ if (state === 'connected') connectedMs += durationMs
2372
+ else disconnectedMs += durationMs
2373
+ segments.push({
2374
+ state,
2375
+ startedAt: new Date(effectiveStartMs).toISOString(),
2376
+ endedAt: new Date(effectiveEndMs).toISOString(),
2377
+ durationSec: roundTo(durationMs / 1000, 1),
2378
+ ratioStart: roundTo((effectiveStartMs - windowStartMs) / windowMs, 4),
2379
+ ratioWidth: roundTo(durationMs / windowMs, 4),
2380
+ active: Number(item.endedAtMs || 0) <= 0
2381
+ })
2382
+ }
2383
+
2384
+ const knownMs = connectedMs + disconnectedMs
2385
+ return {
2386
+ currentState: node._busConnectionState === 'connected' ? 'connected' : 'disconnected',
2387
+ windowSec,
2388
+ windowStartAt: new Date(windowStartMs).toISOString(),
2389
+ windowEndAt: new Date(now).toISOString(),
2390
+ connectedSec: roundTo(connectedMs / 1000, 1),
2391
+ disconnectedSec: roundTo(disconnectedMs / 1000, 1),
2392
+ connectedPct: roundTo((connectedMs / windowMs) * 100, 2),
2393
+ disconnectedPct: roundTo((disconnectedMs / windowMs) * 100, 2),
2394
+ knownCoveragePct: roundTo((knownMs / windowMs) * 100, 2),
2395
+ segments
2396
+ }
2397
+ }
2398
+
2399
+ const emitBusConnectionEvent = ({ type, previousState, currentState, statusText }) => {
2400
+ const payload = {
2401
+ type,
2402
+ event: currentState,
2403
+ previousState: previousState || 'unknown',
2404
+ currentState,
2405
+ gatewayId: node.serverKNX ? node.serverKNX.id : '',
2406
+ gatewayName: (node.serverKNX && node.serverKNX.name) ? node.serverKNX.name : '',
2407
+ statusText: String(statusText || '').trim(),
2408
+ at: new Date().toISOString()
2409
+ }
2410
+ recordAnomaly(payload)
2411
+ node.send([null, {
2412
+ topic: node.outputtopic,
2413
+ payload,
2414
+ knxAi: {
2415
+ type: 'anomaly',
2416
+ event: type,
2417
+ gatewayId: payload.gatewayId,
2418
+ gatewayName: payload.gatewayName
2419
+ }
2420
+ }, null])
2421
+ }
2422
+
2423
+ const trackBusConnectionStatus = ({ text }) => {
2424
+ const statusText = String(text || '').trim()
2425
+ let nextState = ''
2426
+ if (/^Connected\./i.test(statusText)) nextState = 'connected'
2427
+ if (/^Disconnected\b/i.test(statusText)) nextState = 'disconnected'
2428
+ if (nextState === '') return
2429
+ applyBusConnectionStateChange({ nextState, statusText })
2430
+ }
2431
+
2432
+ const applyBusConnectionStateChange = ({ nextState, statusText }) => {
2433
+ const previousState = node._busConnectionState || 'unknown'
2434
+ if (previousState === nextState) return
2435
+ node._busConnectionState = nextState
2436
+ appendBusConnectionTimeline({ state: nextState, atMs: nowMs() })
2437
+
2438
+ if (nextState === 'connected') {
2439
+ if (node._busConnectionPendingRestore) {
2440
+ emitBusConnectionEvent({
2441
+ type: 'bus_connection_restored',
2442
+ previousState,
2443
+ currentState: nextState,
2444
+ statusText
2445
+ })
2446
+ node._busConnectionPendingRestore = false
2447
+ }
2448
+ node._busConnectionHadConnected = true
2449
+ return
2450
+ }
2451
+
2452
+ if (node._busConnectionHadConnected) {
2453
+ emitBusConnectionEvent({
2454
+ type: 'bus_connection_lost',
2455
+ previousState,
2456
+ currentState: nextState,
2457
+ statusText
2458
+ })
2459
+ node._busConnectionPendingRestore = true
2460
+ }
2461
+ }
2462
+
2463
+ const pollBusConnectionStatus = () => {
2464
+ try {
2465
+ const raw = String((node.serverKNX && node.serverKNX.linkStatus) ? node.serverKNX.linkStatus : '').trim().toLowerCase()
2466
+ if (raw !== 'connected' && raw !== 'disconnected') return
2467
+ applyBusConnectionStateChange({
2468
+ nextState: raw,
2469
+ statusText: `Polled gateway state: ${raw}`
2470
+ })
2471
+ } catch (error) { /* ignore */ }
2472
+ }
2473
+
2300
2474
  const maybeEmitOverallAnomaly = (now) => {
2301
2475
  const windowMs = Math.max(2, node.rateWindowSec) * 1000
2302
2476
  const cutoff = now - windowMs
@@ -2419,6 +2593,11 @@ module.exports = function (RED) {
2419
2593
  node._transitionRecent = []
2420
2594
  node._anomalyLifecycle = new Map()
2421
2595
  node._gaRateSeries = new Map()
2596
+ node._busConnectionTimeline = [{
2597
+ state: node._busConnectionState === 'connected' ? 'connected' : 'disconnected',
2598
+ startedAtMs: nowMs(),
2599
+ endedAtMs: 0
2600
+ }]
2422
2601
  node._lastSummary = null
2423
2602
  node._lastSummaryAt = 0
2424
2603
  if (node._summaryRebuildTimer) {
@@ -2543,6 +2722,7 @@ module.exports = function (RED) {
2543
2722
  node.on('close', function (done) {
2544
2723
  try {
2545
2724
  if (node._timerEmit) clearInterval(node._timerEmit)
2725
+ if (node._busConnectionWatchTimer) clearInterval(node._busConnectionWatchTimer)
2546
2726
  if (node._summaryRebuildTimer) {
2547
2727
  clearTimeout(node._summaryRebuildTimer)
2548
2728
  node._summaryRebuildTimer = null
@@ -2568,6 +2748,12 @@ module.exports = function (RED) {
2568
2748
  }, Math.max(5, node.emitIntervalSec) * 1000)
2569
2749
  }
2570
2750
 
2751
+ if (node._busConnectionWatchTimer) clearInterval(node._busConnectionWatchTimer)
2752
+ node._busConnectionWatchTimer = setInterval(() => {
2753
+ pollBusConnectionStatus()
2754
+ }, 1000)
2755
+ pollBusConnectionStatus()
2756
+
2571
2757
  updateStatus({ fill: 'grey', shape: 'dot', text: 'AI ready' })
2572
2758
  }
2573
2759
 
@@ -160,7 +160,7 @@ module.exports = function (RED) {
160
160
  const defaultOperation = node.isGrouped_light === false ? "setLight" : "setGroupedLight";
161
161
  const isGroupedLightOff = node.isGrouped_light === true && node.currentHUEDevice?.on?.on === false;
162
162
  const stateKeys = _state && typeof _state === "object" ? Object.keys(_state) : [];
163
- const presetKeys = ["color", "color_temperature", "gradient"];
163
+ const presetKeys = ["dimming", "color", "color_temperature", "gradient"];
164
164
  const actionableKeys = ["dimming", ...presetKeys];
165
165
  const hasActionablePayload = stateKeys.some((key) => actionableKeys.includes(key));
166
166
  const mustPresetGroupedLightChildren = isGroupedLightOff
@@ -534,14 +534,12 @@ module.exports = function (RED) {
534
534
  break;
535
535
  case config.GALightBrightness:
536
536
  msg.payload = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(config.dptLightBrightness));
537
+ if (typeof msg.payload !== "number" || Number.isNaN(msg.payload)) throw new Error("Invalid KNX brightness payload");
537
538
  state = { dimming: { brightness: msg.payload } };
538
- if (node.currentHUEDevice === undefined) {
539
- // Grouped light
540
- state.on = { on: msg.payload > 0 };
541
- } else {
542
- // Light
543
- if (node.currentHUEDevice.on.on === false && msg.payload > 0) state.on = { on: true };
544
- if (node.currentHUEDevice.on.on === true && msg.payload === 0) state.on = { on: false };
539
+ if (msg.payload === 0) {
540
+ state.on = { on: false };
541
+ } else if (node.currentHUEDevice !== undefined && node.currentHUEDevice.on?.on === true) {
542
+ state.on = { on: true };
545
543
  }
546
544
  node.syncCurrentHUEDeviceFromKNXState(state); // Starting from v 4.1.31
547
545
  node.writeHueState(state);
@@ -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);
@@ -525,17 +654,36 @@
525
654
 
526
655
  #flow-wrap {
527
656
  position: relative;
528
- min-height: 420px;
657
+ min-height: 280px;
658
+ height: 420px;
659
+ max-height: 1400px;
529
660
  padding: 12px;
530
661
  background: #ffffff;
662
+ overflow: auto;
663
+ resize: vertical;
531
664
  }
532
665
 
533
666
  #flow-svg {
534
667
  width: 100%;
668
+ min-height: 420px;
535
669
  height: 420px;
536
670
  display: block;
537
671
  }
538
672
 
673
+ .flow-resize-tip {
674
+ margin-left: auto;
675
+ color: #766f95;
676
+ font-size: 11px;
677
+ font-weight: 700;
678
+ }
679
+
680
+ @media (max-width: 900px) {
681
+ .flow-resize-tip {
682
+ width: 100%;
683
+ margin-left: 0;
684
+ }
685
+ }
686
+
539
687
  .viz-empty {
540
688
  position: absolute;
541
689
  inset: 0;
@@ -1049,6 +1197,8 @@
1049
1197
  </label>
1050
1198
  <button type="button" id="flow-clear-ga">Clear</button>
1051
1199
  <button type="button" id="flow-reset-layout">Reset Layout</button>
1200
+ <button type="button" id="flow-reset-size">Reset Height</button>
1201
+ <span class="flow-resize-tip">Resize from the bottom-right corner</span>
1052
1202
  </div>
1053
1203
  <div class="flow-legend" id="flow-color-legend">
1054
1204
  <span class="flow-legend-item"><span
@@ -1129,6 +1279,10 @@
1129
1279
  <h3>Anomalies</h3>
1130
1280
  <div id="anomalies"></div>
1131
1281
  </div>
1282
+ <div class="panel panel-wide">
1283
+ <h3>Bus Connection Persistence</h3>
1284
+ <div id="bus-connection"></div>
1285
+ </div>
1132
1286
  </div>
1133
1287
  <div class="panel" id="ask-panel">
1134
1288
  <h3>Ask</h3>
@@ -1178,6 +1332,7 @@
1178
1332
  const $themeBtns = Array.from(document.querySelectorAll('.knx-ai-theme-btn'));
1179
1333
  const $summary = document.getElementById('summary');
1180
1334
  const $anomalies = document.getElementById('anomalies');
1335
+ const $busConnection = document.getElementById('bus-connection');
1181
1336
  const $flowSvg = document.getElementById('flow-svg');
1182
1337
  const $flowWrap = document.getElementById('flow-wrap');
1183
1338
  const $flowEmpty = document.getElementById('flow-empty');
@@ -1187,6 +1342,7 @@
1187
1342
  const $flowGaSelect = document.getElementById('flow-ga-select');
1188
1343
  const $flowClearGa = document.getElementById('flow-clear-ga');
1189
1344
  const $flowResetLayout = document.getElementById('flow-reset-layout');
1345
+ const $flowResetSize = document.getElementById('flow-reset-size');
1190
1346
  const $eventPieSvg = document.getElementById('event-pie-svg');
1191
1347
  const $eventPieLegend = document.getElementById('event-pie-legend');
1192
1348
  const $anomalyPieSvg = document.getElementById('anomaly-pie-svg');
@@ -1205,16 +1361,19 @@
1205
1361
  let stateRequestInFlight = false;
1206
1362
  let lastDashboardRawData = null;
1207
1363
  let resizeHandle = null;
1364
+ let flowResizeObserver = null;
1208
1365
  const flowFilterKey = 'knxUltimateAI:flowMapFilters';
1209
1366
  let flowEdgeCache = new Map();
1210
1367
  let flowNodeCache = new Map();
1211
1368
 
1212
1369
  // Normalize persisted filter payloads to a safe, bounded shape.
1213
1370
  const parseFlowFilters = (raw) => {
1214
- const defaults = { maxGa: 14, selectedGa: [], gaOrder: [], layoutOrder: [], edgeOrder: [] };
1371
+ const defaults = { maxGa: 14, selectedGa: [], gaOrder: [], layoutOrder: [], edgeOrder: [], flowHeight: 420 };
1215
1372
  if (!raw || typeof raw !== 'object') return defaults;
1216
1373
  const maxGaNum = Number(raw.maxGa);
1217
1374
  const maxGa = Number.isFinite(maxGaNum) ? Math.max(4, Math.min(60, Math.round(maxGaNum))) : defaults.maxGa;
1375
+ const flowHeightNum = Number(raw.flowHeight);
1376
+ const flowHeight = Number.isFinite(flowHeightNum) ? Math.max(280, Math.min(1400, Math.round(flowHeightNum))) : defaults.flowHeight;
1218
1377
  const selectedGa = Array.isArray(raw.selectedGa)
1219
1378
  ? Array.from(new Set(raw.selectedGa.map(x => String(x || '').trim()).filter(Boolean))).slice(0, 200)
1220
1379
  : [];
@@ -1227,7 +1386,7 @@
1227
1386
  const edgeOrder = Array.isArray(raw.edgeOrder)
1228
1387
  ? Array.from(new Set(raw.edgeOrder.map(x => String(x || '').trim()).filter(Boolean))).slice(0, 4000)
1229
1388
  : [];
1230
- return { maxGa, selectedGa, gaOrder, layoutOrder, edgeOrder };
1389
+ return { maxGa, selectedGa, gaOrder, layoutOrder, edgeOrder, flowHeight };
1231
1390
  };
1232
1391
 
1233
1392
  // Read flow filter settings from local storage.
@@ -1252,6 +1411,23 @@
1252
1411
 
1253
1412
  let flowFilters = loadFlowFilters();
1254
1413
 
1414
+ const applyFlowWrapHeight = () => {
1415
+ if (!$flowWrap) return;
1416
+ const desiredHeight = Number(flowFilters && flowFilters.flowHeight ? flowFilters.flowHeight : 420);
1417
+ const nextHeight = Number.isFinite(desiredHeight) ? Math.max(280, Math.min(1400, Math.round(desiredHeight))) : 420;
1418
+ $flowWrap.style.height = `${nextHeight}px`;
1419
+ };
1420
+
1421
+ const storeFlowWrapHeight = () => {
1422
+ if (!$flowWrap) return;
1423
+ const measuredHeight = Math.round(Number($flowWrap.clientHeight || 0));
1424
+ if (!Number.isFinite(measuredHeight) || measuredHeight <= 0) return;
1425
+ const nextHeight = Math.max(280, Math.min(1400, measuredHeight));
1426
+ if (Number(flowFilters.flowHeight || 0) === nextHeight) return;
1427
+ flowFilters.flowHeight = nextHeight;
1428
+ saveFlowFilters(flowFilters);
1429
+ };
1430
+
1255
1431
  // Restore the last selected KNX AI node.
1256
1432
  const loadStoredNode = () => {
1257
1433
  try {
@@ -1727,6 +1903,10 @@
1727
1903
  const win = (s.meta && s.meta.analysisWindowSec) ? s.meta.analysisWindowSec : '';
1728
1904
  lines.push('Analysis window: ' + win + 's');
1729
1905
  lines.push('Telegrams: ' + (c.telegrams || 0) + ' | Rate: ' + (c.overallRatePerSec || 0) + '/s | Echoed: ' + (c.echoed || 0) + ' | Repeat: ' + (c.repeated || 0) + ' | Unknown DPT: ' + (c.unknownDpt || 0));
1906
+ if (s.busConnection && typeof s.busConnection === 'object') {
1907
+ const bus = s.busConnection;
1908
+ lines.push('Bus connection: ' + String(bus.currentState || 'unknown') + ' | Connected: ' + Number(bus.connectedPct || 0) + '% | Disconnected: ' + Number(bus.disconnectedPct || 0) + '% over last ' + formatDurationCompact(bus.windowSec || 0));
1909
+ }
1730
1910
 
1731
1911
  if (Array.isArray(s.topGAs) && s.topGAs.length) {
1732
1912
  lines.push('');
@@ -1755,6 +1935,122 @@
1755
1935
  return lines.join('\n');
1756
1936
  };
1757
1937
 
1938
+ const clamp01 = (value) => {
1939
+ const n = Number(value);
1940
+ if (!Number.isFinite(n)) return 0;
1941
+ if (n <= 0) return 0;
1942
+ if (n >= 1) return 1;
1943
+ return n;
1944
+ };
1945
+
1946
+ const formatClockLabel = (value) => {
1947
+ const ts = Number(value);
1948
+ if (!Number.isFinite(ts) || ts <= 0) return '--:--';
1949
+ const date = new Date(ts);
1950
+ if (Number.isNaN(date.getTime())) return '--:--';
1951
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1952
+ };
1953
+
1954
+ const formatDurationCompact = (seconds) => {
1955
+ const totalSec = Math.max(0, Math.round(Number(seconds) || 0));
1956
+ const days = Math.floor(totalSec / 86400);
1957
+ const hours = Math.floor((totalSec % 86400) / 3600);
1958
+ const mins = Math.floor((totalSec % 3600) / 60);
1959
+ const secs = totalSec % 60;
1960
+ const parts = [];
1961
+ if (days > 0) parts.push(days + 'd');
1962
+ if (hours > 0) parts.push(hours + 'h');
1963
+ if (mins > 0) parts.push(mins + 'm');
1964
+ if (secs > 0 || parts.length === 0) parts.push(secs + 's');
1965
+ return parts.slice(0, 2).join(' ');
1966
+ };
1967
+
1968
+ const renderBusConnection = (data) => {
1969
+ $busConnection.innerHTML = '';
1970
+ const summary = data && data.summary ? data.summary : {};
1971
+ const bus = summary && summary.busConnection && typeof summary.busConnection === 'object'
1972
+ ? summary.busConnection
1973
+ : null;
1974
+
1975
+ if (!bus || !Array.isArray(bus.segments) || bus.segments.length === 0) {
1976
+ const empty = document.createElement('div');
1977
+ empty.className = 'bus-conn-empty';
1978
+ empty.textContent = 'Waiting for connection persistence data...';
1979
+ $busConnection.appendChild(empty);
1980
+ return;
1981
+ }
1982
+
1983
+ const windowStartMs = new Date(String(bus.windowStartAt || '')).getTime();
1984
+ const windowEndMs = new Date(String(bus.windowEndAt || '')).getTime();
1985
+ const windowMs = Math.max(1, windowEndMs - windowStartMs);
1986
+ const midMs = windowStartMs + Math.round(windowMs / 2);
1987
+
1988
+ const header = document.createElement('div');
1989
+ header.className = 'bus-conn-header';
1990
+
1991
+ const title = document.createElement('div');
1992
+ title.className = 'bus-conn-title';
1993
+ title.textContent = 'Last ' + formatDurationCompact(bus.windowSec || 0) + ' of KNX bus availability';
1994
+ header.appendChild(title);
1995
+
1996
+ const badges = document.createElement('div');
1997
+ badges.className = 'bus-conn-badges';
1998
+
1999
+ const makePill = (label, value, cls) => {
2000
+ const pill = document.createElement('span');
2001
+ pill.className = 'bus-conn-pill ' + cls;
2002
+ pill.textContent = label + ': ' + value;
2003
+ return pill;
2004
+ };
2005
+
2006
+ badges.appendChild(makePill('Current', String(bus.currentState || 'unknown'), bus.currentState === 'connected' ? 'bus-conn-pill-connected' : 'bus-conn-pill-disconnected'));
2007
+ badges.appendChild(makePill('Connected', formatDurationCompact(bus.connectedSec || 0), 'bus-conn-pill-connected'));
2008
+ badges.appendChild(makePill('Disconnected', formatDurationCompact(bus.disconnectedSec || 0), 'bus-conn-pill-disconnected'));
2009
+ badges.appendChild(makePill('Coverage', Number(bus.knownCoveragePct || 0) + '%', 'bus-conn-pill-muted'));
2010
+ header.appendChild(badges);
2011
+ $busConnection.appendChild(header);
2012
+
2013
+ const track = document.createElement('div');
2014
+ track.className = 'bus-conn-track';
2015
+
2016
+ bus.segments.forEach((segment) => {
2017
+ const startRatio = clamp01(segment && segment.ratioStart);
2018
+ const widthRatio = clamp01(segment && segment.ratioWidth);
2019
+ if (widthRatio <= 0) return;
2020
+ const bar = document.createElement('div');
2021
+ bar.className = 'bus-conn-segment ' + (segment && segment.state === 'connected'
2022
+ ? 'bus-conn-segment-connected'
2023
+ : 'bus-conn-segment-disconnected');
2024
+ bar.style.left = (startRatio * 100).toFixed(3) + '%';
2025
+ bar.style.width = Math.max(widthRatio * 100, 0.4).toFixed(3) + '%';
2026
+ bar.title = (segment.state === 'connected' ? 'Connected' : 'Disconnected')
2027
+ + ' | ' + formatClockLabel(new Date(String(segment.startedAt || '')).getTime())
2028
+ + ' -> ' + formatClockLabel(new Date(String(segment.endedAt || '')).getTime())
2029
+ + ' | ' + formatDurationCompact(segment.durationSec || 0);
2030
+ track.appendChild(bar);
2031
+ });
2032
+
2033
+ $busConnection.appendChild(track);
2034
+
2035
+ const scale = document.createElement('div');
2036
+ scale.className = 'bus-conn-scale';
2037
+ const left = document.createElement('span');
2038
+ left.textContent = formatClockLabel(windowStartMs);
2039
+ const mid = document.createElement('span');
2040
+ mid.textContent = formatClockLabel(midMs);
2041
+ const right = document.createElement('span');
2042
+ right.textContent = 'Now';
2043
+ scale.appendChild(left);
2044
+ scale.appendChild(mid);
2045
+ scale.appendChild(right);
2046
+ $busConnection.appendChild(scale);
2047
+
2048
+ const note = document.createElement('div');
2049
+ note.className = 'bus-conn-note';
2050
+ note.textContent = 'Green shows time connected to the KNX bus. Red shows downtime inside the selected history window.';
2051
+ $busConnection.appendChild(note);
2052
+ };
2053
+
1758
2054
  const buildAnalysisActivityContext = (rawData, dashboardData) => {
1759
2055
  const summary = rawData && rawData.summary ? rawData.summary : {};
1760
2056
  const telemetryWindowSec = Number(
@@ -3041,7 +3337,6 @@
3041
3337
  const height = Math.max(baseHeight, requiredHeight);
3042
3338
  $flowSvg.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
3043
3339
  $flowSvg.style.height = `${height}px`;
3044
- if ($flowWrap) $flowWrap.style.minHeight = `${height + 24}px`;
3045
3340
  const usableW = Math.max(40, width - (padX * 2));
3046
3341
  const usableH = Math.max(40, height - (padY * 2));
3047
3342
  const stepX = cols > 1 ? (usableW / (cols - 1)) : 0;
@@ -3317,6 +3612,7 @@
3317
3612
  const dashboard = buildDashboardData(data || {});
3318
3613
  const activityContext = buildAnalysisActivityContext(data || {}, dashboard);
3319
3614
  updateFlowGaOptions(dashboard.nodes || []);
3615
+ renderBusConnection(data || {});
3320
3616
  const flowGraph = applyFlowFilters(dashboard);
3321
3617
  renderFlowMap(flowGraph);
3322
3618
  renderPie($eventPieSvg, $eventPieLegend, dashboard.eventEntries, 'Event');
@@ -3449,8 +3745,10 @@
3449
3745
 
3450
3746
  // Hard reset for Flow Map: clear all local caches/ordering and rebuild from fresh backend data.
3451
3747
  const resetFlowMapHard = async () => {
3452
- flowFilters = parseFlowFilters(null);
3748
+ const preservedHeight = Number(flowFilters && flowFilters.flowHeight ? flowFilters.flowHeight : 420);
3749
+ flowFilters = parseFlowFilters({ flowHeight: preservedHeight });
3453
3750
  saveFlowFilters(flowFilters);
3751
+ applyFlowWrapHeight();
3454
3752
  flowEdgeCache = new Map();
3455
3753
  flowNodeCache = new Map();
3456
3754
  lastDashboardRawData = null;
@@ -3539,6 +3837,21 @@
3539
3837
  }
3540
3838
  });
3541
3839
  }
3840
+ if ($flowResetSize) {
3841
+ $flowResetSize.addEventListener('click', () => {
3842
+ flowFilters.flowHeight = 420;
3843
+ saveFlowFilters(flowFilters);
3844
+ applyFlowWrapHeight();
3845
+ storeFlowWrapHeight();
3846
+ if (lastDashboardRawData) renderDashboard(lastDashboardRawData);
3847
+ });
3848
+ }
3849
+ if ($flowWrap && typeof window.ResizeObserver === 'function') {
3850
+ flowResizeObserver = new window.ResizeObserver(() => {
3851
+ storeFlowWrapHeight();
3852
+ });
3853
+ flowResizeObserver.observe($flowWrap);
3854
+ }
3542
3855
  if ($chatPresetBtns.length) {
3543
3856
  $chatPresetBtns.forEach((btn) => {
3544
3857
  btn.addEventListener('click', () => {
@@ -3559,6 +3872,7 @@
3559
3872
 
3560
3873
  // Bootstrap page state and start background polling.
3561
3874
  applyTheme(currentTheme, { rerender: false });
3875
+ applyFlowWrapHeight();
3562
3876
  $auto.checked = loadAuto();
3563
3877
  fetchNodes(queryNodeId || loadStoredNode())
3564
3878
  .then(() => resetFlowMapHard())
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.2.1",
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/",
@@ -18,7 +18,7 @@
18
18
  "dependencies": {
19
19
  "dns-sync": "0.2.1",
20
20
  "js-yaml": "4.1.1",
21
- "knxultimate": "5.2.11",
21
+ "knxultimate": "5.4.0",
22
22
  "lodash": "4.17.21",
23
23
  "node-color-log": "12.0.1",
24
24
  "ping": "0.4.4",
@@ -104,4 +104,4 @@
104
104
  "mocha": "^10.4.0",
105
105
  "marked": "^14.1.0"
106
106
  }
107
- }
107
+ }