node-red-contrib-knx-ultimate 7.0.1 → 7.0.2

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,11 @@
6
6
 
7
7
  # CHANGELOG
8
8
 
9
+ **Version 7.0.2** - September 2026<br/>
10
+
11
+ - **KNX Device — group address autocomplete**: both input and output function editors now suggest imported ETS group addresses in the first argument of `getGAValue(...)` and `setGAValue(...)`. Search by address, device name or DPT in Monaco and Ace.<br/>
12
+ - **KNX Device — readable GA references**: selecting an address adds its full ETS name and hierarchy to a deduplicated comment list at the beginning of the code. Clicking **Done** removes unused addresses from each editor's list before saving; addresses mentioned only in comments do not count as used.<br/>
13
+
9
14
  **Version 7.0.1** - September 2026<br/>
10
15
 
11
16
  KNX Device: fixed a wrong status text, when the node receives a READ request and responds with the last payload received.</br>
@@ -25,6 +25,7 @@
25
25
  <script type="text/javascript" src="resources/node-red-contrib-knx-ultimate/htmlUtils.js"></script>
26
26
  <script type="text/javascript" src="resources/node-red-contrib-knx-ultimate/KNXSendSnippets.js"></script>
27
27
  <script type="text/javascript" src="resources/node-red-contrib-knx-ultimate/KNXReceiveSnippets.js"></script>
28
+ <script type="text/javascript" src="resources/node-red-contrib-knx-ultimate/knxFunctionAutocomplete.js"></script>
28
29
 
29
30
  <script type="text/javascript">
30
31
  RED.nodes.registerType('knxUltimate', {
@@ -407,6 +408,36 @@ return msg;`
407
408
  })
408
409
  }
409
410
 
411
+ let groupAddressRequest = null;
412
+ let groupAddressServerId = null;
413
+ let functionEditorsDisposed = false;
414
+ const functionEditorDisposables = [];
415
+ const loadFunctionGroupAddresses = () => {
416
+ const serverId = $("#node-input-server").val();
417
+ if (functionEditorsDisposed || !serverId || serverId === '_ADD_') return Promise.resolve([]);
418
+ if (!groupAddressRequest || groupAddressServerId !== serverId) {
419
+ groupAddressServerId = serverId;
420
+ groupAddressRequest = new Promise(resolve => {
421
+ $.getJSON("knxUltimatecsv?nodeID=" + encodeURIComponent(serverId))
422
+ .done(data => resolve(Array.isArray(data) ? data : []))
423
+ .fail(() => { groupAddressRequest = null; resolve([]); });
424
+ });
425
+ }
426
+ return groupAddressRequest.then(data => {
427
+ return !functionEditorsDisposed && $("#node-input-server").val() === serverId ? data : [];
428
+ });
429
+ };
430
+ node.disposeKnxFunctionEditors = () => {
431
+ functionEditorsDisposed = true;
432
+ functionEditorDisposables.forEach(disposable => disposable.dispose());
433
+ ['sampleEditor', 'sendMsgToKNXCodeEditor', 'receiveMsgFromKNXCodeEditor'].forEach(key => {
434
+ try { if (node[key]) node[key].destroy(); } catch (error) { }
435
+ delete node[key];
436
+ });
437
+ delete node.activeCodeEditor;
438
+ delete node.disposeKnxFunctionEditors;
439
+ };
440
+
410
441
  const applyEditorOptions = (editor) => {
411
442
  try {
412
443
  if (!editor) return;
@@ -425,47 +456,15 @@ return msg;`
425
456
  enableLiveAutocompletion: true
426
457
  });
427
458
  }
428
- if (typeof editor.completers === 'undefined') {
429
- editor.completers = [];
430
- }
431
- if (Array.isArray(editor.completers) && !editor._knxHelperCompleter) {
432
- const aceCompletions = knxFunctionHelperItems.map(item => ({
433
- caption: item.label,
434
- value: item.aceValue,
435
- snippet: item.snippet,
436
- meta: 'KNX helper',
437
- doc: item.doc
438
- }));
439
- const helperCompleter = {
440
- getCompletions: function (_editor, _session, _pos, prefix, callback) {
441
- const search = (prefix || '').toLowerCase();
442
- const filtered = search
443
- ? aceCompletions.filter(entry => entry.caption.toLowerCase().startsWith(search) || entry.value.toLowerCase().startsWith(search))
444
- : aceCompletions;
445
- callback(null, filtered.length ? filtered : aceCompletions);
446
- },
447
- getDocTooltip: function (item) {
448
- if (!item || item.docHTML || !item.doc) return;
449
- item.docHTML = '<b>' + item.caption + '</b><hr />' + item.doc;
450
- }
451
- };
452
- editor.completers.push(helperCompleter);
453
- editor._knxHelperCompleter = helperCompleter;
454
- }
455
- if (typeof monaco !== 'undefined' && !globalScope.knxFunctionMonacoCompletionProvider) {
459
+ functionEditorDisposables.push(globalScope.KNXUltimateFunctionAutocomplete.attach(editor, {
460
+ monaco: typeof monaco !== 'undefined' ? monaco : null,
461
+ ace: typeof ace !== 'undefined' ? ace : null,
462
+ loadGroupAddresses: loadFunctionGroupAddresses,
463
+ helperItems: knxFunctionHelperItems
464
+ }));
465
+ if (typeof monaco !== 'undefined' && !globalScope.knxFunctionMonacoGlobals) {
456
466
  try {
457
- const functionSuggestions = knxFunctionHelperItems.map(item => ({
458
- label: item.label,
459
- kind: monaco.languages.CompletionItemKind.Function,
460
- insertText: item.snippet,
461
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
462
- documentation: item.doc
463
- }));
464
-
465
- globalScope.knxFunctionMonacoCompletionProvider = monaco.languages.registerCompletionItemProvider('javascript', {
466
- provideCompletionItems: () => ({ suggestions: functionSuggestions })
467
- });
468
-
467
+ globalScope.knxFunctionMonacoGlobals = true;
469
468
  monaco.languages.typescript.javascriptDefaults.addExtraLib([
470
469
  '/** Read a KNX group address. This helper is async, so use await to get the real value. By default, if the value is not cached, a KNX read is sent. Pass false as third parameter to use cache-only mode. */',
471
470
  'declare function getGAValue(address: string, dptOrReadIfMissing?: string | boolean, readIfMissing?: boolean): Promise<any>;',
@@ -764,6 +763,7 @@ return msg;`
764
763
  })
765
764
 
766
765
  function checkUI() {
766
+ groupAddressRequest = null;
767
767
 
768
768
  // Backward compatibility
769
769
  if (node.outputRBE === true || $("#node-input-outputRBE").val() === true) {
@@ -1117,8 +1117,9 @@ return msg;`
1117
1117
  } catch (error) { }
1118
1118
 
1119
1119
  var node = this;
1120
- this.sendMsgToKNXCode = this.sendMsgToKNXCodeEditor.getValue();
1121
- this.receiveMsgFromKNXCode = this.receiveMsgFromKNXCodeEditor.getValue();
1120
+ const cleanFunctionCode = window.KNXUltimateFunctionAutocomplete.pruneUnusedGaComments;
1121
+ this.sendMsgToKNXCode = cleanFunctionCode(this.sendMsgToKNXCodeEditor.getValue());
1122
+ this.receiveMsgFromKNXCode = cleanFunctionCode(this.receiveMsgFromKNXCodeEditor.getValue());
1122
1123
  if ($("#node-input-setTopicType").val() === "listenAllGA") {
1123
1124
  this.listenallga = true;
1124
1125
  } else {
@@ -1150,16 +1151,7 @@ return msg;`
1150
1151
  // $("#divInputRBE").show()
1151
1152
  }
1152
1153
 
1153
- // 15/09/2020 Supergiovane, Detele the sample help editor
1154
- try {
1155
- node.sampleEditor.destroy();
1156
- delete node.sampleEditor;
1157
- node.sendMsgToKNXCodeEditor.destroy();
1158
- delete node.sendMsgToKNXCodeEditor;
1159
- node.receiveMsgFromKNXCodeEditor.destroy();
1160
- delete node.receiveMsgFromKNXCodeEditor;
1161
- //RED.editor.destroy(); // 23/01/2024 added
1162
- } catch (error) { }
1154
+ if (node.disposeKnxFunctionEditors) node.disposeKnxFunctionEditors();
1163
1155
 
1164
1156
  },
1165
1157
  oneditcancel: function () {
@@ -1168,16 +1160,7 @@ return msg;`
1168
1160
  RED.sidebar.show("info");
1169
1161
  } catch (error) { }
1170
1162
 
1171
- // 15/09/2020 Supergiovane, Detele the sample help editor
1172
- try {
1173
- node.sampleEditor.destroy();
1174
- delete node.sampleEditor;
1175
- node.sendMsgToKNXCodeEditor.destroy();
1176
- delete node.sendMsgToKNXCodeEditor;
1177
- node.receiveMsgFromKNXCodeEditor.destroy();
1178
- delete node.receiveMsgFromKNXCodeEditor;
1179
- RED.editor.destroy(); // 23/01/2024 added
1180
- } catch (error) { }
1163
+ if (this.disposeKnxFunctionEditors) this.disposeKnxFunctionEditors();
1181
1164
  }
1182
1165
  })
1183
1166
 
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "7.0.1",
6
+ "version": "7.0.2",
7
7
  "description": "KNX Ultimate is the most advanced KNX integration for Node-RED, providing secure KNX/IP communication, routing, ETS project import, Philips Hue, Matter Controller and Matter Bridge (control matter device via KNX and expose KNX GA via Matter), MQTT and Modbus adapters, diagnostics with AI, virtual devices, and powerful automation nodes. Build professional, reliable, and scalable smart home and building automation projects with minimal effort.",
8
8
  "files": [
9
9
  "nodes/",
@@ -152,4 +152,4 @@
152
152
  "vite": "^7.3.6",
153
153
  "vue": "^3.5.41"
154
154
  }
155
- }
155
+ }
@@ -0,0 +1,223 @@
1
+ (function (root, factory) {
2
+ const api = factory()
3
+ if (typeof module === 'object' && module.exports) module.exports = api
4
+ if (root) root.KNXUltimateFunctionAutocomplete = api
5
+ }(typeof window !== 'undefined' ? window : globalThis, function () {
6
+ const firstArgument = /(?:^|[^\w$.])(?:getGAValue|setGAValue)\s*\(\s*$/
7
+
8
+ // Scan strings and comments so helper names in examples/comments cannot trigger a GA edit.
9
+ function getContext (source, offset) {
10
+ let code = ''
11
+ let index = 0
12
+ while (index < offset) {
13
+ const char = source[index]
14
+ if (source.slice(index, index + 2) === '//') {
15
+ const end = source.indexOf('\n', index + 2)
16
+ if (end < 0 || end >= offset) return null
17
+ code += ' '
18
+ index = end
19
+ } else if (source.slice(index, index + 2) === '/*') {
20
+ const end = source.indexOf('*/', index + 2)
21
+ if (end < 0 || end + 2 > offset) return null
22
+ code += ' '
23
+ index = end + 2
24
+ } else if (char === "'" || char === '"' || char === '`') {
25
+ const start = index + 1
26
+ let end = start
27
+ while (end < source.length && source[end] !== char && source[end] !== '\n') {
28
+ if (source[end] === '\\') end++
29
+ end++
30
+ }
31
+ if (offset <= end) {
32
+ if (!firstArgument.test(code) || (char === '`' && source.slice(start, end).includes('${'))) return null
33
+ return { start, end, query: source.slice(start, offset), quote: char, closed: source[end] === char }
34
+ }
35
+ code += ' literal '
36
+ index = end + 1
37
+ // Template literals can span lines; skip the rest rather than treating their text as code.
38
+ if (char === '`' && source[end] !== char) {
39
+ while (index < source.length && source[index] !== '`') {
40
+ if (source[index] === '\\') index++
41
+ index++
42
+ }
43
+ if (index >= offset) return null
44
+ index++
45
+ }
46
+ } else {
47
+ code += char
48
+ index++
49
+ }
50
+ }
51
+ return firstArgument.test(code) ? { start: offset, end: offset, query: '', quote: '', closed: false } : null
52
+ }
53
+
54
+ function getEntries (data, query) {
55
+ const terms = query.toLowerCase().trim().split(/\s+/).filter(Boolean)
56
+ const seen = new Set()
57
+ return (Array.isArray(data) ? data : []).filter(entry => {
58
+ if (!entry || typeof entry.ga !== 'string' || !entry.ga || seen.has(entry.ga)) return false
59
+ seen.add(entry.ga)
60
+ return terms.every(term => `${entry.ga} ${entry.devicename || ''} ${entry.dpt || ''}`.toLowerCase().includes(term))
61
+ }).map(entry => ({
62
+ ga: entry.ga,
63
+ comment: '// ' + `${entry.ga} ${entry.devicename || ''}`.replace(/[\r\n\u2028\u2029]+/g, ' ').trim(),
64
+ label: `${entry.ga} # ${entry.devicename || ''} # ${entry.dpt || ''}`
65
+ }))
66
+ }
67
+
68
+ function replacement (context, ga) {
69
+ return context.quote ? ga + (context.closed ? '' : context.quote) : "'" + ga + "'"
70
+ }
71
+
72
+ function getGaHeader (source) {
73
+ const entries = []
74
+ let end = 0
75
+ let match
76
+ const line = /^[\t ]*\/\/[\t ]+(\d+(?:\/\d+){0,2})(?:[\t ]+[^\r\n]*)?(?:\r?\n|$)/
77
+ while ((match = source.slice(end).match(line))) {
78
+ entries.push({ ga: match[1], comment: match[0].trim(), start: end, end: end + match[0].length })
79
+ end += match[0].length
80
+ }
81
+ return { entries, end }
82
+ }
83
+
84
+ function getCommentEdit (source, entry) {
85
+ const header = getGaHeader(source)
86
+ const existing = header.entries.find(item => item.ga === entry.ga)
87
+ if (existing && existing.comment === entry.comment) return null
88
+ const eol = source.includes('\r\n') ? '\r\n' : '\n'
89
+ return { start: existing ? existing.start : header.end, end: existing ? existing.end : header.end, text: entry.comment + eol }
90
+ }
91
+
92
+ function pruneUnusedGaComments (source) {
93
+ const header = getGaHeader(source)
94
+ if (!header.entries.length) return source
95
+ const body = source.slice(header.end)
96
+ // Preserve quoted strings while removing disabled code and explanatory comments.
97
+ const code = body.replace(/"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|`(?:\\[\s\S]|[^`\\])*`|\/\/[^\r\n]*|\/\*[\s\S]*?(?:\*\/|$)/g, token => {
98
+ return token.startsWith('//') || token.startsWith('/*') ? ' ' : token
99
+ }).replace(/\\\//g, '/')
100
+ const seen = new Set()
101
+ const kept = header.entries.filter(entry => {
102
+ if (seen.has(entry.ga) || !new RegExp('(^|[^\\w/])' + entry.ga + '(?![\\w/])').test(code)) return false
103
+ seen.add(entry.ga)
104
+ return true
105
+ })
106
+ const eol = source.includes('\r\n') ? '\r\n' : '\n'
107
+ return kept.map(entry => entry.comment + eol).join('') + body
108
+ }
109
+
110
+ function attach (editor, { monaco, ace, loadGroupAddresses, helperItems }) {
111
+ if (monaco && typeof editor.getModel === 'function') {
112
+ editor.updateOptions({ quickSuggestions: { other: true, comments: false, strings: true }, suggestOnTriggerCharacters: true })
113
+ return monaco.languages.registerCompletionItemProvider('javascript', {
114
+ triggerCharacters: ['(', "'", '"', '`', '/'],
115
+ provideCompletionItems: async (model, position, _context, token) => {
116
+ if (model !== editor.getModel()) return { suggestions: [] }
117
+ const context = getContext(model.getValue(), model.getOffsetAt(position))
118
+ if (!context) {
119
+ const word = model.getWordUntilPosition(position)
120
+ return {
121
+ suggestions: helperItems.map(item => ({
122
+ label: item.label,
123
+ kind: monaco.languages.CompletionItemKind.Function,
124
+ insertText: item.snippet,
125
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
126
+ documentation: item.doc,
127
+ range: new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn)
128
+ }))
129
+ }
130
+ }
131
+ const entries = getEntries(await loadGroupAddresses(), context.query)
132
+ if ((token && token.isCancellationRequested) || model.isDisposed() || model !== editor.getModel()) return { suggestions: [] }
133
+ const start = model.getPositionAt(context.start)
134
+ const end = model.getPositionAt(context.end)
135
+ return {
136
+ incomplete: true,
137
+ suggestions: entries.map(entry => {
138
+ const comment = getCommentEdit(model.getValue(), entry)
139
+ const suggestion = {
140
+ label: entry.label,
141
+ kind: monaco.languages.CompletionItemKind.Value,
142
+ insertText: replacement(context, entry.ga),
143
+ // The list is already filtered by every search term, including ETS names.
144
+ filterText: context.query + ' ' + entry.label,
145
+ detail: 'KNX group address',
146
+ range: new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column)
147
+ }
148
+ if (comment) {
149
+ const position = model.getPositionAt(comment.start)
150
+ const end = model.getPositionAt(comment.end)
151
+ suggestion.additionalTextEdits = [{
152
+ range: new monaco.Range(position.lineNumber, position.column, end.lineNumber, end.column),
153
+ text: comment.text
154
+ }]
155
+ }
156
+ return suggestion
157
+ })
158
+ }
159
+ }
160
+ })
161
+ }
162
+
163
+ if (!ace || !ace.require) return { dispose () {} }
164
+ const Range = ace.require('ace/range').Range
165
+ const getAceContext = (session, position) => getContext(session.getValue(), session.getDocument().positionToIndex(position))
166
+ const helpers = {
167
+ getCompletions (_editor, session, position, prefix, callback) {
168
+ if (getAceContext(session, position)) return callback(null, [])
169
+ const search = (prefix || '').toLowerCase()
170
+ callback(null, helperItems.filter(item => item.id.toLowerCase().startsWith(search)).map(item => ({
171
+ caption: item.label, value: item.aceValue, snippet: item.snippet, meta: 'KNX helper', docText: item.doc
172
+ })))
173
+ }
174
+ }
175
+ const addresses = {
176
+ identifierRegexps: [/[a-zA-Z0-9_$\/\u00A2-\uFFFF]/],
177
+ getCompletions (_editor, session, position, _prefix, callback) {
178
+ const context = getAceContext(session, position)
179
+ if (!context) return callback(null, [])
180
+ loadGroupAddresses().then(data => callback(null, getEntries(data, context.query).map(entry => ({
181
+ caption: entry.label, value: entry.ga, comment: entry.comment, meta: 'KNX GA', score: 1000, completer: addresses
182
+ }))), () => callback(null, []))
183
+ },
184
+ insertMatch (target, item) {
185
+ const context = getAceContext(target.session, target.getCursorPosition())
186
+ if (!context) return
187
+ const doc = target.session.getDocument()
188
+ const start = doc.indexToPosition(context.start)
189
+ const end = doc.indexToPosition(context.end)
190
+ const text = replacement(context, item.value)
191
+ const comment = getCommentEdit(target.session.getValue(), { ga: item.value, comment: item.comment })
192
+ target.session.replace(new Range(start.row, start.column, end.row, end.column), text)
193
+ if (comment) {
194
+ const position = doc.indexToPosition(comment.start)
195
+ const end = doc.indexToPosition(comment.end)
196
+ target.session.replace(new Range(position.row, position.column, end.row, end.column), comment.text)
197
+ }
198
+ target.clearSelection()
199
+ target.moveCursorToPosition(doc.indexToPosition(context.start + text.length + (comment ? comment.text.length - (comment.end - comment.start) : 0)))
200
+ }
201
+ }
202
+ // Ace's default completer array is shared across editor instances.
203
+ editor.completers = (editor.completers || []).concat(helpers, addresses)
204
+ const afterExec = event => {
205
+ if (event.command.name !== 'insertstring' || !['(', "'", '"', '`', '/', ' '].includes(event.args)) return
206
+ if (!getAceContext(editor.session, editor.getCursorPosition())) return
207
+ const Autocomplete = ace.require('ace/autocomplete').Autocomplete
208
+ const completer = editor.completer || new Autocomplete()
209
+ editor.completer = completer
210
+ completer.autoInsert = false
211
+ completer.showPopup(editor)
212
+ }
213
+ editor.commands.on('afterExec', afterExec)
214
+ return {
215
+ dispose () {
216
+ editor.commands.off('afterExec', afterExec)
217
+ editor.completers = editor.completers.filter(item => item !== helpers && item !== addresses)
218
+ }
219
+ }
220
+ }
221
+
222
+ return { getContext, getEntries, attach, pruneUnusedGaComments }
223
+ }))