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 +12 -0
- package/nodes/knxUltimateAI.js +192 -6
- package/nodes/knxUltimateHueLight.js +6 -8
- package/nodes/plugins/knxUltimateAI-web-page.html +319 -5
- package/package.json +3 -3
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/>
|
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,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
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
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 (
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
+
}
|