node-red-contrib-knx-ultimate 7.0.0 → 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 +10 -0
- package/README.md +1 -1
- package/nodes/knxUltimate-config.js +38 -38
- package/nodes/knxUltimate.html +45 -62
- package/nodes/knxUltimate.js +1 -1
- package/package.json +1 -1
- package/resources/knxFunctionAutocomplete.js +223 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,16 @@
|
|
|
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
|
+
|
|
14
|
+
**Version 7.0.1** - September 2026<br/>
|
|
15
|
+
|
|
16
|
+
KNX Device: fixed a wrong status text, when the node receives a READ request and responds with the last payload received.</br>
|
|
17
|
+
KNX Device: the input msg now supports **msg.event = "GroupValue_Read"** as an additional method besides msg.readstatus = true, to issue a READ command to the bus.</br>
|
|
18
|
+
|
|
9
19
|
**Version 7.0.0** - September 2026<br/>
|
|
10
20
|
|
|
11
21
|
- **Major release — focused KNX package**: KNX Ultimate 7 starts the new package boundary. Conversational AI, supervised home learning, shared memory and multi-platform chat now belong to the standalone `node-red-contrib-cerebrum-ultimate`; this package remains the optional KNX/ETS integration. Existing deployed flows that contain the hidden legacy runtime types continue to load, but new instances can no longer be added from the palette.<br/>
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
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, diagnostics, virtual devices, and powerful automation nodes. Build professional, reliable, and scalable smart home and building automation projects with minimal effort.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
|
|
12
12
|
|
|
13
13
|
The former bundled AI assistant has moved to the standalone [Cerebrum Ultimate](https://github.com/Supergiovane/node-red-contrib-cerebrum-ultimate) package. Cerebrum Ultimate now provides the conversational, learning and supervised home-intelligence features, while using KNX Ultimate as an optional compatible integration. Hidden legacy runtime types remain in this package only so existing Node-RED flows continue to load; new installations should add the Cerebrum node from the standalone package.
|
|
14
14
|
|
|
@@ -1529,9 +1529,9 @@ module.exports = (RED) => {
|
|
|
1529
1529
|
}
|
|
1530
1530
|
_input.handleSend(msg)
|
|
1531
1531
|
} else if (_input.hasOwnProperty('isSceneController')) { // 19/03/2020 in the middle of coronavirus. Whole italy is red zone, closed down. Scene Controller implementation
|
|
1532
|
-
|
|
1532
|
+
// 12/08/2020 Check wether is a learn (save) command or a activate (play) command.
|
|
1533
1533
|
if (_dest === _input.topic || _dest === _input.topicSave) {
|
|
1534
|
-
|
|
1534
|
+
// Prepare the two messages to be evaluated directly into the Scene Controller node.
|
|
1535
1535
|
new Promise((resolve) => {
|
|
1536
1536
|
if (_dest === _input.topic) {
|
|
1537
1537
|
try {
|
|
@@ -1568,16 +1568,16 @@ module.exports = (RED) => {
|
|
|
1568
1568
|
} catch (error) { }
|
|
1569
1569
|
}
|
|
1570
1570
|
resolve(true) // fulfilled
|
|
1571
|
-
|
|
1571
|
+
// reject("error"); // rejected
|
|
1572
1572
|
})
|
|
1573
1573
|
.then(function () { })
|
|
1574
1574
|
.catch(function () { })
|
|
1575
1575
|
} else {
|
|
1576
|
-
|
|
1576
|
+
// 19/03/2020 Check and Update value if the input is part of a scene controller
|
|
1577
1577
|
new Promise((resolve) => {
|
|
1578
|
-
|
|
1578
|
+
// Check and update the values of each device in the scene and update the rule array accordingly.
|
|
1579
1579
|
for (let i = 0; i < _input.rules.length; i++) {
|
|
1580
|
-
|
|
1580
|
+
// rule is { topic: rowRuleTopic, devicename: rowRuleDeviceName, dpt:rowRuleDPT, send: rowRuleSend}
|
|
1581
1581
|
const oDevice = _input.rules[i]
|
|
1582
1582
|
if (typeof oDevice !== 'undefined' && oDevice.topic == _dest) {
|
|
1583
1583
|
const msg = buildInputMessage({
|
|
@@ -1606,23 +1606,23 @@ module.exports = (RED) => {
|
|
|
1606
1606
|
}
|
|
1607
1607
|
}
|
|
1608
1608
|
resolve(true) // fulfilled
|
|
1609
|
-
|
|
1609
|
+
// reject("error"); // rejected
|
|
1610
1610
|
})
|
|
1611
1611
|
.then(function () { })
|
|
1612
1612
|
.catch(function () { })
|
|
1613
1613
|
}
|
|
1614
1614
|
} else if (_input.hasOwnProperty('isLogger')) {
|
|
1615
|
-
|
|
1616
|
-
|
|
1615
|
+
// 26/03/2020 Coronavirus is slightly decreasing the affected numer of people. Logger Node
|
|
1616
|
+
// 24/03/2021 Logger Node, i'll pass cemiETS
|
|
1617
1617
|
if (_cemiETS !== undefined) {
|
|
1618
|
-
|
|
1618
|
+
// new Promise((resolve, reject) => {
|
|
1619
1619
|
_input.handleSend(_cemiETS)
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1620
|
+
// resolve(true); // fulfilled
|
|
1621
|
+
// reject("error"); // rejected
|
|
1622
|
+
// }).then(function () { }).catch(function () { });
|
|
1623
1623
|
}
|
|
1624
1624
|
} else if (_input.listenallga === true && universalNodeAcceptsGA(_input, _dest)) {
|
|
1625
|
-
|
|
1625
|
+
// 25/10/2019 TRY TO AUTO DECODE IF Group address not found in the CSV
|
|
1626
1626
|
const msg = buildInputMessage({
|
|
1627
1627
|
_srcGA: _src,
|
|
1628
1628
|
_destGA: _dest,
|
|
@@ -1645,8 +1645,8 @@ module.exports = (RED) => {
|
|
|
1645
1645
|
_input.handleSend(msg)
|
|
1646
1646
|
} else if (_input.topic == _dest) {
|
|
1647
1647
|
if (_input.hasOwnProperty('isWatchDog')) {
|
|
1648
|
-
|
|
1649
|
-
|
|
1648
|
+
// 04/02/2020 Watchdog implementation
|
|
1649
|
+
// Is a watchdog node
|
|
1650
1650
|
} else {
|
|
1651
1651
|
const msg = buildInputMessage({
|
|
1652
1652
|
_srcGA: _src,
|
|
@@ -1721,14 +1721,14 @@ module.exports = (RED) => {
|
|
|
1721
1721
|
_input.handleSend(msg)
|
|
1722
1722
|
} else
|
|
1723
1723
|
if (_input.hasOwnProperty('isLogger')) {
|
|
1724
|
-
|
|
1725
|
-
|
|
1724
|
+
// 26/03/2020 Coronavirus is slightly decreasing the affected numer of people. Logger Node
|
|
1725
|
+
// 24/03/2021 Logger Node, i'll pass cemiETS
|
|
1726
1726
|
if (_cemiETS !== undefined) {
|
|
1727
|
-
|
|
1727
|
+
// new Promise((resolve, reject) => {
|
|
1728
1728
|
_input.handleSend(_cemiETS)
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1729
|
+
// resolve(true); // fulfilled
|
|
1730
|
+
// reject("error"); // rejected
|
|
1731
|
+
// }).then(function () { }).catch(function () { });
|
|
1732
1732
|
}
|
|
1733
1733
|
} else if (_input.listenallga === true && universalNodeAcceptsGA(_input, _dest)) {
|
|
1734
1734
|
const msg = buildInputMessage({
|
|
@@ -1752,9 +1752,9 @@ module.exports = (RED) => {
|
|
|
1752
1752
|
})
|
|
1753
1753
|
_input.handleSend(msg)
|
|
1754
1754
|
} else if (_input.topic === _dest) {
|
|
1755
|
-
|
|
1755
|
+
// 04/02/2020 Watchdog implementation
|
|
1756
1756
|
if (_input.hasOwnProperty('isWatchDog')) {
|
|
1757
|
-
|
|
1757
|
+
// Is a watchdog node
|
|
1758
1758
|
_input.watchDogTimerReset()
|
|
1759
1759
|
} else {
|
|
1760
1760
|
const msg = buildInputMessage({
|
|
@@ -1827,18 +1827,18 @@ module.exports = (RED) => {
|
|
|
1827
1827
|
_input.handleSend(msg)
|
|
1828
1828
|
} else
|
|
1829
1829
|
if (_input.hasOwnProperty('isLogger')) {
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1830
|
+
// 26/03/2020 Coronavirus is slightly decreasing the affected numer of people. Logger Node
|
|
1831
|
+
// node.sysLogger?.info("BANANA isLogger", _evt, _src, _dest, _rawValue, _cemiETS);
|
|
1832
|
+
// 24/03/2021 Logger Node, i'll pass cemiETS
|
|
1833
1833
|
if (_cemiETS !== undefined) {
|
|
1834
|
-
|
|
1834
|
+
// new Promise((resolve, reject) => {
|
|
1835
1835
|
_input.handleSend(_cemiETS)
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1836
|
+
// resolve(true); // fulfilled
|
|
1837
|
+
// reject("error"); // rejected
|
|
1838
|
+
// }).then(function () { }).catch(function () { });
|
|
1839
1839
|
}
|
|
1840
1840
|
} else if (_input.listenallga === true && universalNodeAcceptsGA(_input, _dest)) {
|
|
1841
|
-
|
|
1841
|
+
// Read Request
|
|
1842
1842
|
const msg = buildInputMessage({
|
|
1843
1843
|
_srcGA: _src,
|
|
1844
1844
|
_destGA: _dest,
|
|
@@ -1860,11 +1860,11 @@ module.exports = (RED) => {
|
|
|
1860
1860
|
})
|
|
1861
1861
|
_input.handleSend(msg)
|
|
1862
1862
|
} else if (_input.topic === _dest) {
|
|
1863
|
-
|
|
1863
|
+
// 04/02/2020 Watchdog implementation
|
|
1864
1864
|
if (_input.hasOwnProperty('isWatchDog')) {
|
|
1865
|
-
|
|
1865
|
+
// Is a watchdog node
|
|
1866
1866
|
} else {
|
|
1867
|
-
|
|
1867
|
+
// Read Request
|
|
1868
1868
|
const msg = buildInputMessage({
|
|
1869
1869
|
_srcGA: _src,
|
|
1870
1870
|
_destGA: _dest,
|
|
@@ -1881,7 +1881,7 @@ module.exports = (RED) => {
|
|
|
1881
1881
|
// 24/09/2019 Autorespond to BUS
|
|
1882
1882
|
if (_input.hasOwnProperty('notifyreadrequestalsorespondtobus') && _input.notifyreadrequestalsorespondtobus === true) {
|
|
1883
1883
|
if (typeof _input.currentPayload === 'undefined' || _input.currentPayload === '' || _input.currentPayload === null) {
|
|
1884
|
-
|
|
1884
|
+
// 14/08/2021 Added || input.currentPayload === null
|
|
1885
1885
|
node.sendKNXTelegramToKNXEngine({
|
|
1886
1886
|
grpaddr: _dest,
|
|
1887
1887
|
payload: _input.notifyreadrequestalsorespondtobusdefaultvalueifnotinitialized,
|
|
@@ -1909,8 +1909,8 @@ module.exports = (RED) => {
|
|
|
1909
1909
|
_input.setNodeStatus({
|
|
1910
1910
|
fill: 'blue',
|
|
1911
1911
|
shape: 'ring',
|
|
1912
|
-
text: 'Read & Autorespond
|
|
1913
|
-
payload: _input.
|
|
1912
|
+
text: 'Read & Autorespond',
|
|
1913
|
+
payload: _input.currentPayload,
|
|
1914
1914
|
GA: _input.topic,
|
|
1915
1915
|
dpt: msg.knx.dpt,
|
|
1916
1916
|
devicename: ''
|
package/nodes/knxUltimate.html
CHANGED
|
@@ -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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1121
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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/nodes/knxUltimate.js
CHANGED
|
@@ -756,7 +756,7 @@ module.exports = function (RED) {
|
|
|
756
756
|
// #endregion
|
|
757
757
|
|
|
758
758
|
// 25/07/2019 if payload is read or the Telegram type is set to "read", do a read, otherwise, write to the bus
|
|
759
|
-
if ((msg.hasOwnProperty('readstatus') && msg.readstatus === true) || node.outputtype === 'read') {
|
|
759
|
+
if ((msg.hasOwnProperty('readstatus') && msg.readstatus === true) || node.outputtype === 'read' || (msg.hasOwnProperty('event') && msg.event === 'GroupValue_Read')) {
|
|
760
760
|
// READ: Send a Read request to the bus
|
|
761
761
|
let grpaddr = ''
|
|
762
762
|
if (node.listenallga == false) {
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"engines": {
|
|
4
4
|
"node": ">=20.18.1"
|
|
5
5
|
},
|
|
6
|
-
"version": "7.0.
|
|
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/",
|
|
@@ -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
|
+
}))
|