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/>
@@ -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.historyWindowSec || 60)) * 4000
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(5, Number(node.historyWindowSec || 60))
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 (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);
@@ -654,17 +654,36 @@
654
654
 
655
655
  #flow-wrap {
656
656
  position: relative;
657
- min-height: 420px;
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 ' + Number(bus.windowSec || 0) + 's');
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
- flowFilters = parseFlowFilters(null);
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.35",
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
+ }