node-red-contrib-knx-ultimate 4.0.24 → 4.0.25

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,10 @@
6
6
 
7
7
  # CHANGELOG
8
8
 
9
+ **Version 4.0.25** - November 2025<br/>
10
+ - KNX Device node editor: repositioned the "Show manual command button in editor" option directly under the input passthrough setting so related controls stay together.<br/>
11
+ - KNX Function helper: refreshed the send-side snippet library with ready-to-use home-automation templates (motion-triggered lighting, HVAC standby on window-open, night door alerts, bedtime all-off) and cleaned up placeholders for easier copy/paste.<br/>
12
+
9
13
  **Version 4.0.24** - October 2025<br/>
10
14
  - Hue config node: pairing now polls the bridge automatically and closes the dialog as soon as the link button is pressed, with a cancellable wait message and improved error feedback.<br/>
11
15
  - Hue config node: added "I ALREADY HAVE THE CREDENTIALS" button and restored bridge discovery so manual credentials can be entered immediately without running the registration flow.<br/>
@@ -185,6 +185,38 @@
185
185
  $("#tabs").tabs();
186
186
 
187
187
  // 15/09/2020 Supergiovane, set the help sample based on Datapoint
188
+ const knxFunctionHelperItems = [
189
+ {
190
+ id: 'getGAValue',
191
+ label: 'getGAValue(address, dpt?)',
192
+ aceValue: "getGAValue('1/1/1', '1.001')",
193
+ snippet: "getGAValue('${1:1/1/1}', '${2:1.001}')",
194
+ doc: 'Read the cached value of another group address. Provide the datapoint if the ETS import is not available.'
195
+ },
196
+ {
197
+ id: 'setGAValue',
198
+ label: 'setGAValue(address, value, dpt?)',
199
+ aceValue: "setGAValue('1/1/1', true)",
200
+ snippet: "setGAValue('${1:1/1/1}', ${2:true}, '${3:1.001}')",
201
+ doc: 'Send a value to any KNX group address. The datapoint is optional when the ETS file is imported.'
202
+ },
203
+ {
204
+ id: 'self',
205
+ label: 'self(value)',
206
+ aceValue: 'self(false)',
207
+ snippet: 'self(${1:false})',
208
+ doc: 'Set this node value and forward it to the KNX bus.'
209
+ },
210
+ {
211
+ id: 'toggle',
212
+ label: 'toggle()',
213
+ aceValue: 'toggle()',
214
+ snippet: 'toggle()',
215
+ doc: 'Invert this node value and write it to the KNX bus.'
216
+ }
217
+ ];
218
+ const globalScope = typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : {});
219
+
188
220
  function knxUltimateDptsGetHelp(_dpt, _forceClose) {
189
221
  const detailsContainer = $("#dptDetailsContainer")
190
222
  if (_forceClose === true) {
@@ -257,6 +289,56 @@
257
289
  editor.renderer.setShowGutter(false);
258
290
  }
259
291
  if (typeof editor.setShowPrintMargin === 'function') editor.setShowPrintMargin(false);
292
+ if (typeof ace !== 'undefined' && ace.require) {
293
+ try { ace.require('ace/ext/language_tools'); } catch (error) { }
294
+ }
295
+ if (typeof editor.setOptions === 'function') {
296
+ editor.setOptions({
297
+ enableBasicAutocompletion: true,
298
+ enableLiveAutocompletion: true
299
+ });
300
+ }
301
+ if (typeof editor.completers === 'undefined') {
302
+ editor.completers = [];
303
+ }
304
+ if (Array.isArray(editor.completers) && !editor._knxHelperCompleter) {
305
+ const aceCompletions = knxFunctionHelperItems.map(item => ({
306
+ caption: item.label,
307
+ value: item.aceValue,
308
+ snippet: item.snippet,
309
+ meta: 'KNX helper',
310
+ doc: item.doc
311
+ }));
312
+ const helperCompleter = {
313
+ getCompletions: function (_editor, _session, _pos, prefix, callback) {
314
+ const search = (prefix || '').toLowerCase();
315
+ const filtered = search
316
+ ? aceCompletions.filter(entry => entry.caption.toLowerCase().startsWith(search) || entry.value.toLowerCase().startsWith(search))
317
+ : aceCompletions;
318
+ callback(null, filtered.length ? filtered : aceCompletions);
319
+ },
320
+ getDocTooltip: function (item) {
321
+ if (!item || item.docHTML || !item.doc) return;
322
+ item.docHTML = '<b>' + item.caption + '</b><hr />' + item.doc;
323
+ }
324
+ };
325
+ editor.completers.push(helperCompleter);
326
+ editor._knxHelperCompleter = helperCompleter;
327
+ }
328
+ if (typeof monaco !== 'undefined' && !globalScope.knxFunctionMonacoCompletionProvider) {
329
+ try {
330
+ const suggestions = knxFunctionHelperItems.map(item => ({
331
+ label: item.label,
332
+ kind: monaco.languages.CompletionItemKind.Function,
333
+ insertText: item.snippet,
334
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
335
+ documentation: item.doc
336
+ }));
337
+ globalScope.knxFunctionMonacoCompletionProvider = monaco.languages.registerCompletionItemProvider('javascript', {
338
+ provideCompletionItems: () => ({ suggestions })
339
+ });
340
+ } catch (error) { }
341
+ }
260
342
  } catch (error) { }
261
343
  };
262
344
 
@@ -293,20 +375,104 @@
293
375
  } catch (error) { }
294
376
  };
295
377
 
378
+ const sanitizeAutocompleteValue = (raw) => {
379
+ if (typeof raw !== 'string') return '';
380
+ return raw.replace(/['"]/g, '').trim();
381
+ };
382
+ const replaceLiteralAroundCursorAce = (editor, text) => {
383
+ try {
384
+ const AceRange = (typeof ace !== 'undefined' && ace.require) ? ace.require('ace/range').Range : null;
385
+ if (!AceRange) return false;
386
+ const pos = editor.getCursorPosition();
387
+ const line = editor.session.getLine(pos.row) || '';
388
+ let left = pos.column - 1;
389
+ while (left >= 0 && line[left] !== "'") left--;
390
+ if (left < 0) return false;
391
+ let right = pos.column;
392
+ while (right < line.length && line[right] !== "'") right++;
393
+ if (right >= line.length) return false;
394
+ const newLiteral = "'" + text + "'";
395
+ const range = new AceRange(pos.row, left, pos.row, right + 1);
396
+ editor.session.replace(range, newLiteral);
397
+ editor.moveCursorTo(pos.row, left + newLiteral.length);
398
+ return true;
399
+ } catch (error) { }
400
+ return false;
401
+ };
402
+ const replaceLiteralAroundCursorMonaco = (editor, text) => {
403
+ try {
404
+ const position = editor.getPosition();
405
+ if (!position) return false;
406
+ const model = typeof editor.getModel === 'function' ? editor.getModel() : null;
407
+ if (!model) return false;
408
+ const lineContent = model.getLineContent(position.lineNumber) || '';
409
+ let leftIdx = position.column - 2;
410
+ if (leftIdx >= lineContent.length) leftIdx = lineContent.length - 1;
411
+ while (leftIdx >= 0 && lineContent[leftIdx] !== "'") leftIdx--;
412
+ if (leftIdx < 0) return false;
413
+ let rightIdx = position.column - 1;
414
+ if (rightIdx < leftIdx) rightIdx = leftIdx + 1;
415
+ while (rightIdx < lineContent.length && lineContent[rightIdx] !== "'") rightIdx++;
416
+ if (rightIdx >= lineContent.length) return false;
417
+ const newLiteral = "'" + text + "'";
418
+ const range = new monaco.Range(
419
+ position.lineNumber,
420
+ leftIdx + 1,
421
+ position.lineNumber,
422
+ rightIdx + 2
423
+ );
424
+ editor.executeEdits('knxInsertGA', [{ range, text: newLiteral, forceMoveMarkers: true }]);
425
+ editor.setPosition({ lineNumber: position.lineNumber, column: leftIdx + 1 + newLiteral.length });
426
+ return true;
427
+ } catch (error) { }
428
+ return false;
429
+ };
430
+
296
431
  const insertTextIntoEditor = (editor, text) => {
297
432
  if (!editor || !text) return;
298
433
  try {
299
434
  if (editor.session && typeof editor.session.insert === 'function') {
300
435
  editor.focus();
301
- editor.session.insert(editor.getCursorPosition(), text);
436
+ const selectionRange = editor.getSelection && typeof editor.getSelectionRange === 'function'
437
+ ? editor.getSelectionRange()
438
+ : null;
439
+ if (selectionRange && !selectionRange.isEmpty()) {
440
+ editor.session.replace(selectionRange, text);
441
+ return;
442
+ }
443
+ const replaced = replaceLiteralAroundCursorAce(editor, text);
444
+ if (!replaced) {
445
+ const pos = editor.getCursorPosition();
446
+ editor.session.insert(pos, text);
447
+ }
302
448
  return;
303
449
  }
304
450
  if (typeof editor.executeEdits === 'function' && typeof editor.getPosition === 'function' && typeof monaco !== 'undefined') {
451
+ const selection = typeof editor.getSelection === 'function' ? editor.getSelection() : null;
452
+ const hasSelection = selection && (selection.startLineNumber !== selection.endLineNumber || selection.startColumn !== selection.endColumn);
453
+ if (selection && hasSelection) {
454
+ const selectionRange = new monaco.Range(
455
+ selection.startLineNumber,
456
+ selection.startColumn,
457
+ selection.endLineNumber,
458
+ selection.endColumn
459
+ );
460
+ editor.executeEdits('knxInsertGA', [{ range: selectionRange, text, forceMoveMarkers: true }]);
461
+ const targetLine = selectionRange.startLineNumber;
462
+ const targetColumn = selectionRange.startColumn + text.length;
463
+ editor.setPosition({ lineNumber: targetLine, column: targetColumn });
464
+ editor.focus();
465
+ return;
466
+ }
305
467
  const position = editor.getPosition();
306
468
  if (!position) return;
307
- const range = new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column);
308
- editor.executeEdits('knxInsertGA', [{ range, text, forceMoveMarkers: true }]);
309
- editor.setPosition({ lineNumber: position.lineNumber, column: position.column + text.length });
469
+ let replaced = replaceLiteralAroundCursorMonaco(editor, text);
470
+ if (!replaced) {
471
+ const range = new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column);
472
+ const newColumn = position.column + text.length;
473
+ editor.executeEdits('knxInsertGA', [{ range, text, forceMoveMarkers: true }]);
474
+ editor.setPosition({ lineNumber: position.lineNumber, column: newColumn });
475
+ }
310
476
  editor.focus();
311
477
  }
312
478
  } catch (error) { }
@@ -329,14 +495,16 @@
329
495
  attachFocusHandlers(node.receiveMsgFromKNXCodeEditor);
330
496
 
331
497
  $("#btn-insert-knxFunctionGA").off('click').on('click', function () {
332
- const value = $("#node-input-knxFunctionHelperGAList").val();
333
- if (!value || value.trim() === '') {
498
+ const rawValue = $("#node-input-knxFunctionHelperGAList").val();
499
+ if (!rawValue || rawValue.trim() === '') {
334
500
  $("#node-input-knxFunctionHelperGAList").focus();
335
501
  return;
336
502
  }
503
+ const sanitizedValue = sanitizeAutocompleteValue(rawValue);
337
504
  const editor = node.activeCodeEditor || node.sendMsgToKNXCodeEditor || node.receiveMsgFromKNXCodeEditor;
338
505
  if (!editor) return;
339
- insertTextIntoEditor(editor, value);
506
+ if (!sanitizedValue) return;
507
+ insertTextIntoEditor(editor, sanitizedValue);
340
508
  });
341
509
 
342
510
  const configureSnippetPicker = (snippets, inputSelector, datalistSelector, applySnippet) => {
@@ -908,6 +1076,42 @@
908
1076
  <option value="yesownprop" data-i18n="knxUltimate.selectlists.passthrough_OwnProp"></option>
909
1077
  </select>
910
1078
  </div>
1079
+ <div class="form-row">
1080
+ <input type="checkbox" id="node-input-buttonEnabled"
1081
+ style="display:inline-block; width:auto; vertical-align:top;" />
1082
+ <label style="width:85%" for="node-input-buttonEnabled">
1083
+ <i class="fa fa-mouse-pointer"></i> <span
1084
+ data-i18n="knxUltimate.button.enable"></span>
1085
+ </label>
1086
+ </div>
1087
+ <div id="knx-button-options" style="display:none">
1088
+ <div class="form-row">
1089
+ <label style="width:180px" for="node-input-buttonMode">
1090
+ <i class="fa fa-bolt"></i> <span data-i18n="knxUltimate.button.mode"></span>
1091
+ </label>
1092
+ <select id="node-input-buttonMode" style="width:220px;">
1093
+ <option value="read" data-i18n="knxUltimate.button.mode_read"></option>
1094
+ <option value="toggle" data-i18n="knxUltimate.button.mode_toggle"></option>
1095
+ <option value="value" data-i18n="knxUltimate.button.mode_value"></option>
1096
+ </select>
1097
+ </div>
1098
+ <div class="form-row knx-button-toggle-row">
1099
+ <label style="width:180px" for="node-input-buttonToggleInitial">
1100
+ <i class="fa fa-refresh"></i> <span data-i18n="knxUltimate.button.toggleInitial"></span>
1101
+ </label>
1102
+ <select id="node-input-buttonToggleInitial" style="width:220px;">
1103
+ <option value="true" data-i18n="knxUltimate.button.toggleInitial_true"></option>
1104
+ <option value="false" data-i18n="knxUltimate.button.toggleInitial_false"></option>
1105
+ </select>
1106
+ </div>
1107
+ <div class="form-row knx-button-value-row">
1108
+ <label style="width:180px" for="node-input-buttonStaticValue">
1109
+ <i class="fa fa-pencil"></i> <span data-i18n="knxUltimate.button.value"></span>
1110
+ </label>
1111
+ <input type="text" id="node-input-buttonStaticValue" style="width:220px;"
1112
+ placeholder="42, true, {\"red\":255}" />
1113
+ </div>
1114
+ </div>
911
1115
  <hr>
912
1116
  <div class="form-row">
913
1117
  <dt>
@@ -988,42 +1192,6 @@
988
1192
  data-i18n="knxUltimate.properties.node-input-notifyreadrequest"></span>
989
1193
  </label>
990
1194
  </div>
991
- <div class="form-row">
992
- <input type="checkbox" id="node-input-buttonEnabled"
993
- style="display:inline-block; width:auto; vertical-align:top;" />
994
- <label style="width:85%" for="node-input-buttonEnabled">
995
- <i class="fa fa-mouse-pointer"></i> <span
996
- data-i18n="knxUltimate.button.enable"></span>
997
- </label>
998
- </div>
999
- <div id="knx-button-options" style="display:none">
1000
- <div class="form-row">
1001
- <label style="width:180px" for="node-input-buttonMode">
1002
- <i class="fa fa-bolt"></i> <span data-i18n="knxUltimate.button.mode"></span>
1003
- </label>
1004
- <select id="node-input-buttonMode" style="width:220px;">
1005
- <option value="read" data-i18n="knxUltimate.button.mode_read"></option>
1006
- <option value="toggle" data-i18n="knxUltimate.button.mode_toggle"></option>
1007
- <option value="value" data-i18n="knxUltimate.button.mode_value"></option>
1008
- </select>
1009
- </div>
1010
- <div class="form-row knx-button-toggle-row">
1011
- <label style="width:180px" for="node-input-buttonToggleInitial">
1012
- <i class="fa fa-refresh"></i> <span data-i18n="knxUltimate.button.toggleInitial"></span>
1013
- </label>
1014
- <select id="node-input-buttonToggleInitial" style="width:220px;">
1015
- <option value="true" data-i18n="knxUltimate.button.toggleInitial_true"></option>
1016
- <option value="false" data-i18n="knxUltimate.button.toggleInitial_false"></option>
1017
- </select>
1018
- </div>
1019
- <div class="form-row knx-button-value-row">
1020
- <label style="width:180px" for="node-input-buttonStaticValue">
1021
- <i class="fa fa-pencil"></i> <span data-i18n="knxUltimate.button.value"></span>
1022
- </label>
1023
- <input type="text" id="node-input-buttonStaticValue" style="width:220px;"
1024
- placeholder="42, true, {\"red\":255}" />
1025
- </div>
1026
- </div>
1027
1195
  <div id="divnotifyreadrequestautoreact">
1028
1196
  <dd>
1029
1197
  <div class="form-row">
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "4.0.24",
6
+ "version": "4.0.25",
7
7
  "description": "Control your KNX and KNX Secure intallation via Node-Red! A bunch of KNX nodes, with integrated Philips HUE control and ETS group address importer. Easy to use and highly configurable.",
8
8
  "dependencies": {
9
9
  "binary-parser": "2.2.1",
@@ -88,4 +88,4 @@
88
88
  "chai": "^4.3.10",
89
89
  "mocha": "^10.4.0"
90
90
  }
91
- }
91
+ }
@@ -4,8 +4,8 @@
4
4
  id: 'status-ga-check',
5
5
  title: 'Status GA check',
6
6
  code: `// @ts-nocheck
7
- // Replace 'x/x/x' with the real status group address.
8
- const statusGA = getGAValue('x/x/x','1.001');
7
+ // Replace '' with the real status group address.
8
+ const statusGA = getGAValue('','1.001');
9
9
  if (msg.payload !== statusGA){ // " !==" means " not equal"
10
10
  return msg;
11
11
  }else{
@@ -42,6 +42,80 @@ return msg;`
42
42
  if (msg.payload === true){
43
43
  setGAValue('1/0/1',20,'5.001')
44
44
  }
45
+ return msg;`
46
+ },
47
+ {
48
+ id: 'motion-activated-light',
49
+ title: 'Turn on light on motion, auto off',
50
+ code: `// @ts-nocheck
51
+ // This snippet expects a motion sensor boolean on msg.payload.
52
+ // When motion is detected turn the light on, otherwise schedule an auto-off.
53
+ if (msg.payload === true) {
54
+ // Turn on the light immediately.
55
+ setGAValue('', true, '1.001'); // Replace '' with your light GA.
56
+ // Cancel pending auto-off timers if any.
57
+ context.set('autoOffTimer', null);
58
+ } else {
59
+ // Schedule auto-off after 90 seconds of no motion.
60
+ const timer = setTimeout(() => {
61
+ setGAValue('', false, '1.001'); // Replace '' with your light GA.
62
+ }, 90000);
63
+ context.set('autoOffTimer', timer);
64
+ }
65
+ return;`
66
+ },
67
+ {
68
+ id: 'window-hvac-standby',
69
+ title: 'HVAC standby when window open',
70
+ code: `// @ts-nocheck
71
+ // msg.payload should contain the window contact state (true = open).
72
+ // If the window is open, set the HVAC to standby, otherwise restore comfort.
73
+ const hvacGa = ''; // Replace with your HVAC GA.
74
+ const standbyValue = 'standby';
75
+ const comfortValue = 'comfort';
76
+
77
+ if (msg.payload === true) {
78
+ setGAValue(hvacGa, standbyValue, '20.102'); // HVAC mode DPT.
79
+ node.status({fill: 'yellow', shape: 'dot', text: 'HVAC standby'});
80
+ } else {
81
+ setGAValue(hvacGa, comfortValue, '20.102');
82
+ node.status({fill: 'green', shape: 'dot', text: 'HVAC comfort'});
83
+ }
84
+ return;`
85
+ },
86
+ {
87
+ id: 'night-door-alert',
88
+ title: 'Night door alert',
89
+ code: `// @ts-nocheck
90
+ // Send a KNX notification GA if a door opens between 22:00 and 06:00.
91
+ const now = new Date();
92
+ const hour = now.getHours();
93
+
94
+ if (msg.payload === true && (hour >= 22 || hour < 6)) {
95
+ setGAValue('', true, '1.001'); // Replace '' with your alert GA.
96
+ node.status({fill: 'red', shape: 'ring', text: 'Door alert sent'});
97
+ }
98
+ return;`
99
+ },
100
+ {
101
+ id: 'bedtime-all-off',
102
+ title: 'Bedtime all off',
103
+ code: `// @ts-nocheck
104
+ // Turn off a list of lights when a bedtime command is received.
105
+ const lights = [
106
+ '', // Replace with GA of the first light
107
+ '' // Replace with GA of the second light
108
+ ];
109
+
110
+ if (msg.payload === 'bedtime') {
111
+ lights.forEach(ga => {
112
+ if (ga) {
113
+ setGAValue(ga, false, '1.001');
114
+ }
115
+ });
116
+ node.status({fill: 'blue', shape: 'dot', text: 'All lights off'});
117
+ return;
118
+ }
45
119
  return msg;`
46
120
  }
47
121
  ]