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 +5 -0
- package/nodes/knxUltimateAI.js +169 -6
- package/nodes/plugins/knxUltimateAI-web-page.html +255 -0
- package/package.json +1 -1
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/>
|
package/nodes/knxUltimateAI.js
CHANGED
|
@@ -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
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
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.
|
|
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/",
|