node-red-contrib-knx-ultimate 4.1.35 → 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,13 @@
|
|
|
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
|
+
|
|
9
16
|
**Version 4.1.35** - March 2026<br/>
|
|
10
17
|
|
|
11
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/>
|
package/nodes/knxUltimateAI.js
CHANGED
|
@@ -1140,6 +1140,7 @@ module.exports = function (RED) {
|
|
|
1140
1140
|
node._anomalyLifecycle = new Map()
|
|
1141
1141
|
node._gaRateSeries = new Map()
|
|
1142
1142
|
node._gaLabelCsvCache = { ref: null, map: {} }
|
|
1143
|
+
node._busConnectionWatchTimer = null
|
|
1143
1144
|
node._busConnectionState = (node.serverKNX && typeof node.serverKNX.linkStatus === 'string')
|
|
1144
1145
|
? String(node.serverKNX.linkStatus).toLowerCase()
|
|
1145
1146
|
: 'unknown'
|
|
@@ -1150,6 +1151,7 @@ module.exports = function (RED) {
|
|
|
1150
1151
|
startedAtMs: nowMs(),
|
|
1151
1152
|
endedAtMs: 0
|
|
1152
1153
|
}]
|
|
1154
|
+
node._busConnectionWindowSec = 12 * 60 * 60
|
|
1153
1155
|
|
|
1154
1156
|
// Register runtime instance for sidebar visibility
|
|
1155
1157
|
aiRuntimeNodes.set(node.id, node)
|
|
@@ -2313,7 +2315,7 @@ module.exports = function (RED) {
|
|
|
2313
2315
|
const appendBusConnectionTimeline = ({ state, atMs }) => {
|
|
2314
2316
|
const nextState = state === 'connected' ? 'connected' : 'disconnected'
|
|
2315
2317
|
const ts = Number.isFinite(Number(atMs)) && Number(atMs) > 0 ? Number(atMs) : nowMs()
|
|
2316
|
-
const keepMs = Math.max(60, Number(node.
|
|
2318
|
+
const keepMs = Math.max(12 * 60 * 60, Number(node._busConnectionWindowSec || 0)) * 2000
|
|
2317
2319
|
if (!Array.isArray(node._busConnectionTimeline) || node._busConnectionTimeline.length === 0) {
|
|
2318
2320
|
node._busConnectionTimeline = [{ state: nextState, startedAtMs: ts, endedAtMs: 0 }]
|
|
2319
2321
|
return
|
|
@@ -2348,7 +2350,7 @@ module.exports = function (RED) {
|
|
|
2348
2350
|
}
|
|
2349
2351
|
|
|
2350
2352
|
const buildBusConnectionSummary = (now) => {
|
|
2351
|
-
const windowSec = Math.max(
|
|
2353
|
+
const windowSec = Math.max(12 * 60 * 60, Number(node._busConnectionWindowSec || 0))
|
|
2352
2354
|
const windowMs = windowSec * 1000
|
|
2353
2355
|
const windowStartMs = now - windowMs
|
|
2354
2356
|
const timeline = Array.isArray(node._busConnectionTimeline) ? node._busConnectionTimeline : []
|
|
@@ -2424,7 +2426,10 @@ module.exports = function (RED) {
|
|
|
2424
2426
|
if (/^Connected\./i.test(statusText)) nextState = 'connected'
|
|
2425
2427
|
if (/^Disconnected\b/i.test(statusText)) nextState = 'disconnected'
|
|
2426
2428
|
if (nextState === '') return
|
|
2429
|
+
applyBusConnectionStateChange({ nextState, statusText })
|
|
2430
|
+
}
|
|
2427
2431
|
|
|
2432
|
+
const applyBusConnectionStateChange = ({ nextState, statusText }) => {
|
|
2428
2433
|
const previousState = node._busConnectionState || 'unknown'
|
|
2429
2434
|
if (previousState === nextState) return
|
|
2430
2435
|
node._busConnectionState = nextState
|
|
@@ -2455,6 +2460,17 @@ module.exports = function (RED) {
|
|
|
2455
2460
|
}
|
|
2456
2461
|
}
|
|
2457
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
|
+
|
|
2458
2474
|
const maybeEmitOverallAnomaly = (now) => {
|
|
2459
2475
|
const windowMs = Math.max(2, node.rateWindowSec) * 1000
|
|
2460
2476
|
const cutoff = now - windowMs
|
|
@@ -2706,6 +2722,7 @@ module.exports = function (RED) {
|
|
|
2706
2722
|
node.on('close', function (done) {
|
|
2707
2723
|
try {
|
|
2708
2724
|
if (node._timerEmit) clearInterval(node._timerEmit)
|
|
2725
|
+
if (node._busConnectionWatchTimer) clearInterval(node._busConnectionWatchTimer)
|
|
2709
2726
|
if (node._summaryRebuildTimer) {
|
|
2710
2727
|
clearTimeout(node._summaryRebuildTimer)
|
|
2711
2728
|
node._summaryRebuildTimer = null
|
|
@@ -2731,6 +2748,12 @@ module.exports = function (RED) {
|
|
|
2731
2748
|
}, Math.max(5, node.emitIntervalSec) * 1000)
|
|
2732
2749
|
}
|
|
2733
2750
|
|
|
2751
|
+
if (node._busConnectionWatchTimer) clearInterval(node._busConnectionWatchTimer)
|
|
2752
|
+
node._busConnectionWatchTimer = setInterval(() => {
|
|
2753
|
+
pollBusConnectionStatus()
|
|
2754
|
+
}, 1000)
|
|
2755
|
+
pollBusConnectionStatus()
|
|
2756
|
+
|
|
2734
2757
|
updateStatus({ fill: 'grey', shape: 'dot', text: 'AI ready' })
|
|
2735
2758
|
}
|
|
2736
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);
|
|
@@ -654,17 +654,36 @@
|
|
|
654
654
|
|
|
655
655
|
#flow-wrap {
|
|
656
656
|
position: relative;
|
|
657
|
-
min-height:
|
|
657
|
+
min-height: 280px;
|
|
658
|
+
height: 420px;
|
|
659
|
+
max-height: 1400px;
|
|
658
660
|
padding: 12px;
|
|
659
661
|
background: #ffffff;
|
|
662
|
+
overflow: auto;
|
|
663
|
+
resize: vertical;
|
|
660
664
|
}
|
|
661
665
|
|
|
662
666
|
#flow-svg {
|
|
663
667
|
width: 100%;
|
|
668
|
+
min-height: 420px;
|
|
664
669
|
height: 420px;
|
|
665
670
|
display: block;
|
|
666
671
|
}
|
|
667
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
|
+
|
|
668
687
|
.viz-empty {
|
|
669
688
|
position: absolute;
|
|
670
689
|
inset: 0;
|
|
@@ -1178,6 +1197,8 @@
|
|
|
1178
1197
|
</label>
|
|
1179
1198
|
<button type="button" id="flow-clear-ga">Clear</button>
|
|
1180
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>
|
|
1181
1202
|
</div>
|
|
1182
1203
|
<div class="flow-legend" id="flow-color-legend">
|
|
1183
1204
|
<span class="flow-legend-item"><span
|
|
@@ -1321,6 +1342,7 @@
|
|
|
1321
1342
|
const $flowGaSelect = document.getElementById('flow-ga-select');
|
|
1322
1343
|
const $flowClearGa = document.getElementById('flow-clear-ga');
|
|
1323
1344
|
const $flowResetLayout = document.getElementById('flow-reset-layout');
|
|
1345
|
+
const $flowResetSize = document.getElementById('flow-reset-size');
|
|
1324
1346
|
const $eventPieSvg = document.getElementById('event-pie-svg');
|
|
1325
1347
|
const $eventPieLegend = document.getElementById('event-pie-legend');
|
|
1326
1348
|
const $anomalyPieSvg = document.getElementById('anomaly-pie-svg');
|
|
@@ -1339,16 +1361,19 @@
|
|
|
1339
1361
|
let stateRequestInFlight = false;
|
|
1340
1362
|
let lastDashboardRawData = null;
|
|
1341
1363
|
let resizeHandle = null;
|
|
1364
|
+
let flowResizeObserver = null;
|
|
1342
1365
|
const flowFilterKey = 'knxUltimateAI:flowMapFilters';
|
|
1343
1366
|
let flowEdgeCache = new Map();
|
|
1344
1367
|
let flowNodeCache = new Map();
|
|
1345
1368
|
|
|
1346
1369
|
// Normalize persisted filter payloads to a safe, bounded shape.
|
|
1347
1370
|
const parseFlowFilters = (raw) => {
|
|
1348
|
-
const defaults = { maxGa: 14, selectedGa: [], gaOrder: [], layoutOrder: [], edgeOrder: [] };
|
|
1371
|
+
const defaults = { maxGa: 14, selectedGa: [], gaOrder: [], layoutOrder: [], edgeOrder: [], flowHeight: 420 };
|
|
1349
1372
|
if (!raw || typeof raw !== 'object') return defaults;
|
|
1350
1373
|
const maxGaNum = Number(raw.maxGa);
|
|
1351
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;
|
|
1352
1377
|
const selectedGa = Array.isArray(raw.selectedGa)
|
|
1353
1378
|
? Array.from(new Set(raw.selectedGa.map(x => String(x || '').trim()).filter(Boolean))).slice(0, 200)
|
|
1354
1379
|
: [];
|
|
@@ -1361,7 +1386,7 @@
|
|
|
1361
1386
|
const edgeOrder = Array.isArray(raw.edgeOrder)
|
|
1362
1387
|
? Array.from(new Set(raw.edgeOrder.map(x => String(x || '').trim()).filter(Boolean))).slice(0, 4000)
|
|
1363
1388
|
: [];
|
|
1364
|
-
return { maxGa, selectedGa, gaOrder, layoutOrder, edgeOrder };
|
|
1389
|
+
return { maxGa, selectedGa, gaOrder, layoutOrder, edgeOrder, flowHeight };
|
|
1365
1390
|
};
|
|
1366
1391
|
|
|
1367
1392
|
// Read flow filter settings from local storage.
|
|
@@ -1386,6 +1411,23 @@
|
|
|
1386
1411
|
|
|
1387
1412
|
let flowFilters = loadFlowFilters();
|
|
1388
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
|
+
|
|
1389
1431
|
// Restore the last selected KNX AI node.
|
|
1390
1432
|
const loadStoredNode = () => {
|
|
1391
1433
|
try {
|
|
@@ -1863,7 +1905,7 @@
|
|
|
1863
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));
|
|
1864
1906
|
if (s.busConnection && typeof s.busConnection === 'object') {
|
|
1865
1907
|
const bus = s.busConnection;
|
|
1866
|
-
lines.push('Bus connection: ' + String(bus.currentState || 'unknown') + ' | Connected: ' + Number(bus.connectedPct || 0) + '% | Disconnected: ' + Number(bus.disconnectedPct || 0) + '% over ' +
|
|
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));
|
|
1867
1909
|
}
|
|
1868
1910
|
|
|
1869
1911
|
if (Array.isArray(s.topGAs) && s.topGAs.length) {
|
|
@@ -3295,7 +3337,6 @@
|
|
|
3295
3337
|
const height = Math.max(baseHeight, requiredHeight);
|
|
3296
3338
|
$flowSvg.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
|
|
3297
3339
|
$flowSvg.style.height = `${height}px`;
|
|
3298
|
-
if ($flowWrap) $flowWrap.style.minHeight = `${height + 24}px`;
|
|
3299
3340
|
const usableW = Math.max(40, width - (padX * 2));
|
|
3300
3341
|
const usableH = Math.max(40, height - (padY * 2));
|
|
3301
3342
|
const stepX = cols > 1 ? (usableW / (cols - 1)) : 0;
|
|
@@ -3704,8 +3745,10 @@
|
|
|
3704
3745
|
|
|
3705
3746
|
// Hard reset for Flow Map: clear all local caches/ordering and rebuild from fresh backend data.
|
|
3706
3747
|
const resetFlowMapHard = async () => {
|
|
3707
|
-
|
|
3748
|
+
const preservedHeight = Number(flowFilters && flowFilters.flowHeight ? flowFilters.flowHeight : 420);
|
|
3749
|
+
flowFilters = parseFlowFilters({ flowHeight: preservedHeight });
|
|
3708
3750
|
saveFlowFilters(flowFilters);
|
|
3751
|
+
applyFlowWrapHeight();
|
|
3709
3752
|
flowEdgeCache = new Map();
|
|
3710
3753
|
flowNodeCache = new Map();
|
|
3711
3754
|
lastDashboardRawData = null;
|
|
@@ -3794,6 +3837,21 @@
|
|
|
3794
3837
|
}
|
|
3795
3838
|
});
|
|
3796
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
|
+
}
|
|
3797
3855
|
if ($chatPresetBtns.length) {
|
|
3798
3856
|
$chatPresetBtns.forEach((btn) => {
|
|
3799
3857
|
btn.addEventListener('click', () => {
|
|
@@ -3814,6 +3872,7 @@
|
|
|
3814
3872
|
|
|
3815
3873
|
// Bootstrap page state and start background polling.
|
|
3816
3874
|
applyTheme(currentTheme, { rerender: false });
|
|
3875
|
+
applyFlowWrapHeight();
|
|
3817
3876
|
$auto.checked = loadAuto();
|
|
3818
3877
|
fetchNodes(queryNodeId || loadStoredNode())
|
|
3819
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
|
+
}
|