node-red-contrib-knx-ultimate
Version:
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.
1,000 lines (934 loc) • 64.7 kB
HTML
<script type="text/javascript" src="resources/node-red-contrib-knx-ultimate/htmlUtils.js"></script>
<script type="text/javascript">
RED.nodes.registerType('knxUltimate-config', {
category: 'config',
defaults: {
host: { value: "224.0.23.12", required: true },
port: { value: 3671, required: true, validate: RED.validators.number() },
// the KNX physical address we'd like to use
physAddr: { value: "15.15.22", required: false },
hostProtocol: { value: "Auto", required: false }, // TunnelUDP/TunnelTCP/Multicast
// enable this option to suppress the acknowledge flag with outgoing L_Data.req requests. LoxOne needs this
suppressACKRequest: { value: false },
csv: { value: "", required: false },
KNXEthInterface: { value: "Auto" },
KNXEthInterfaceManuallyInput: { value: "" },
stopETSImportIfNoDatapoint: { value: "fake" },
loglevel: { value: "error" },
name: { value: "KNX Gateway" },
delaybetweentelegrams: { value: 25, required: false, validate: RED.validators.number() },
ignoreTelegramsWithRepeatedFlag: { value: false, required: false },
keyringFileXML: { value: "" },
knxSecureSelected: { value: false },
secureCredentialsMode: { value: "keyring" },
tunnelIASelection: { value: "Auto" },
tunnelIA: { value: "" },
tunnelInterfaceIndividualAddress: { value: "" },
tunnelUserPassword: { value: "" },
tunnelUserId: { value: "" },
autoReconnect: { value: "yes" },
statusUpdateThrottle: { value: "0" }
},
credentials: {
keyringFilePassword: { type: "password" }
},
oneditprepare: function () {
// Go to the help panel
try {
RED.sidebar.show("help");
} catch (error) { }
var node = this;
// TIMER BLINK ####################################################
let blinkStatus = 2;
let timerBlinkBackground;
function blinkBackgroundArray(_arrayElementIDwithHashAtTheBeginning) {
if (timerBlinkBackground !== undefined) clearInterval(timerBlinkBackground);
timerBlinkBackground = setInterval(() => {
for (let index = 0; index < _arrayElementIDwithHashAtTheBeginning.length; index++) {
const _elementIDwithHashAtTheBeginning = _arrayElementIDwithHashAtTheBeginning[index];
if (isEven(blinkStatus)) $(_elementIDwithHashAtTheBeginning).css("background-color", "lightgreen");
if (!isEven(blinkStatus)) $(_elementIDwithHashAtTheBeginning).css("background-color", "");
}
blinkStatus += 1;
if (blinkStatus >= 14) {
clearInterval(timerBlinkBackground);
blinkStatus = 2;
for (let index = 0; index < _arrayElementIDwithHashAtTheBeginning.length; index++) {
const _elementIDwithHashAtTheBeginning = _arrayElementIDwithHashAtTheBeginning[index];
$(_elementIDwithHashAtTheBeginning).css("background-color", "");
}
}
}, 100);
}
function isEven(n) {
return (n % 2 == 0);
}
// ################################################################
// 22/07/2021 Main tab
// Initialize tabs strictly from saved config, not from keyring presence
$("#tabsMain").tabs({
active: (node.knxSecureSelected === true || node.knxSecureSelected === 'true') ? 1 : 0,
activate: function (event, ui) {
node.knxSecureSelected = $(ui.newTab).index() === 1;
try { updateTunnelIAVisibility(); updatePhysAddrVisibility(); updateSecureMulticastHint(); enforceProtocolFromIP(); } catch (e) { }
}
});
// Ensure the correct tab is enforced after all UI init
try {
const initialActive = (node.knxSecureSelected === true || node.knxSecureSelected === 'true') ? 1 : 0;
setTimeout(() => { $("#tabsMain").tabs("option", "active", initialActive); updateTunnelIAVisibility(); updatePhysAddrVisibility(); updateSecureMulticastHint(); enforceProtocolFromIP(); }, 0);
} catch (e) { }
// Keyring ACE editor setup
try {
const initialKeyring = $("#node-config-input-keyringFileXML").val() || (node.keyringFileXML || '');
node._keyringEditor = RED.editor.createEditor({
id: 'node-config-input-keyringFileXML-editor',
mode: 'ace/mode/xml',
value: initialKeyring
});
try { node._keyringEditor.getSession().setUseWrapMode(true); } catch (e) { }
} catch (e) { }
// ETS CSV editor setup (plain text)
try {
const initialCSV = $("#node-config-input-csv").val() || '';
node._csvEditor = RED.editor.createEditor({
id: 'node-config-input-csv-editor',
mode: 'ace/mode/text',
value: initialCSV
});
try { node._csvEditor.getSession().setUseWrapMode(true); } catch (e) { }
} catch (e) { }
function getSecureCredentialsMode() {
try {
const sel = $("#node-config-input-secureCredentialsMode").val();
return (typeof sel === 'string' && sel.length > 0) ? sel : 'keyring';
} catch (e) {
return 'keyring';
}
}
function isSecureTabActive() {
try {
return $("#tabsMain").tabs("option", "active") === 1;
} catch (e) {
return !!(typeof node.knxSecureSelected !== 'undefined' ? node.knxSecureSelected : false);
}
}
function parseIPv4Address(str) {
if (typeof str !== 'string') return null;
const parts = str.trim().split('.');
if (parts.length !== 4) return null;
const octets = [];
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!/^\d+$/.test(part)) return null;
const value = Number(part);
if (value < 0 || value > 255) return null;
octets.push(value);
}
return octets;
}
function isMulticastIPv4(octets) {
if (!Array.isArray(octets) || octets.length !== 4) return false;
const first = octets[0];
return first >= 224 && first <= 239;
}
const NETMASK_BIT_LOOKUP = (() => {
const lookup = new Array(256);
for (let i = 0; i < 256; i++) {
let bits = 0;
let value = i;
while (value > 0) {
bits += value & 1;
value >>= 1;
}
lookup[i] = bits;
}
return lookup;
})();
function countNetmaskBits(maskOctets) {
if (!Array.isArray(maskOctets) || maskOctets.length !== 4) return 0;
let total = 0;
for (let i = 0; i < 4; i++) {
const oct = maskOctets[i];
if (!Number.isInteger(oct) || oct < 0 || oct > 255) return 0;
total += NETMASK_BIT_LOOKUP[oct];
}
return total;
}
function computeIPv4NetworkKey(ipOctets, maskOctets) {
if (!Array.isArray(ipOctets) || ipOctets.length !== 4 || !Array.isArray(maskOctets) || maskOctets.length !== 4) return null;
const parts = [];
for (let i = 0; i < 4; i++) {
const ipVal = ipOctets[i];
const maskVal = maskOctets[i];
if (!Number.isInteger(ipVal) || ipVal < 0 || ipVal > 255 || !Number.isInteger(maskVal) || maskVal < 0 || maskVal > 255) return null;
parts.push(ipVal & maskVal);
}
return parts.join('.');
}
function normalizeTunnelIAValue(value) {
if (typeof value !== 'string') return '';
const trimmed = value.trim();
return (trimmed && trimmed !== 'undefined') ? trimmed : '';
}
function setTunnelIAValue(value) {
const normalized = normalizeTunnelIAValue(value || '');
try { $("#node-config-input-tunnelIA").val(normalized); } catch (e) { }
try { $("#node-config-input-tunnelInterfaceIndividualAddress").val(normalized); } catch (e) { }
}
function syncTunnelIAFromManual() {
try { setTunnelIAValue($("#node-config-input-tunnelInterfaceIndividualAddress").val()); } catch (e) { }
}
function syncTunnelIAFromKeyring() {
try { setTunnelIAValue($("#node-config-input-tunnelIA").val()); } catch (e) { }
}
function enforceProtocolFromIP() {
try {
const ipTyped = String($("#node-config-input-host").val() || '').trim();
if (!ipTyped) return;
const octets = parseIPv4Address(ipTyped);
if (!octets || isMulticastIPv4(octets)) return;
const autoProto = isSecureTabActive() ? 'TunnelTCP' : 'TunnelUDP';
const $sel = $("#node-config-input-hostProtocol");
if ($sel.val() !== autoProto) {
$sel.val(autoProto);
updateTunnelIAVisibility();
updateSecureMulticastHint();
updatePhysAddrVisibility();
}
} catch (e) { }
}
function updateSecureCredentialMode() {
try {
const mode = getSecureCredentialsMode();
const showKeyringFields = (mode === 'keyring' || mode === 'combined');
const showKeyringIAControls = (mode === 'keyring');
$("#secureKeyringFields").toggle(showKeyringFields);
if (!showKeyringIAControls) {
$("#rowTunnelIASelection").hide();
$("#divTunnelIA").hide();
}
} catch (e) { }
updateTunnelIAVisibility();
enforceProtocolFromIP();
}
// Helper: show/hide Tunnel IA controls depending on mode
function updateTunnelIAVisibility() {
try {
const isSecure = (node.knxSecureSelected === true || node.knxSecureSelected === 'true');
const hostProtocol = $("#node-config-input-hostProtocol").val();
const ip = $("#node-config-input-host").val();
const isMulticast = (hostProtocol === 'Multicast') || (ip === '224.0.23.12');
const mode = getSecureCredentialsMode();
const keyringMode = (mode === 'keyring');
const manualMode = (mode === 'manual' || mode === 'combined');
const showTunnelIA = isSecure && !isMulticast && keyringMode;
if (showTunnelIA) {
$("#rowTunnelIASelection").show();
if ($("#node-config-input-tunnelIASelection").val() === 'Manual') {
$("#divTunnelIA").show();
} else {
$("#divTunnelIA").hide();
}
} else {
$("#rowTunnelIASelection").hide();
$("#divTunnelIA").hide();
}
// Allow manual/combined credentials to surface even before the user selects a unicast IP.
const showManualTunnelFields = isSecure && manualMode;
$("#rowTunnelInterfaceIndividualAddress").toggle(showManualTunnelFields);
$("#rowTunnelUserPassword").toggle(showManualTunnelFields);
$("#rowTunnelUserId").toggle(showManualTunnelFields);
if (!showManualTunnelFields) {
try {
$("#node-config-input-tunnelInterfaceIndividualAddress").removeClass('input-error');
} catch (e) { }
}
} catch (e) { }
}
// Helper: ensure Physical Address row is visible (required also in multicast)
function updatePhysAddrVisibility() {
try { $("#rowPhysAddr").show(); } catch (e) { }
}
// Helper: show contextual hint for Secure + Multicast (Data Secure senders requirement)
let isSecureMulticastHintOpen = false;
function updateSecureMulticastHint() {
try {
const isSecure = (node.knxSecureSelected === true || node.knxSecureSelected === 'true');
const hostProtocol = $("#node-config-input-hostProtocol").val();
const ip = $("#node-config-input-host").val();
const isMulticast = (hostProtocol === 'Multicast') || (ip === '224.0.23.12');
if (isSecure && isMulticast) {
$("#hintSecureMulticastToggle").show();
$("#hintSecureMulticast").toggle(isSecureMulticastHintOpen);
} else {
$("#hintSecureMulticastToggle").hide();
$("#hintSecureMulticast").hide();
isSecureMulticastHintOpen = false;
}
} catch (e) { }
}
// Toggle behavior for the hint
try {
$(document).on('click', '#hintSecureMulticastToggle', function () {
isSecureMulticastHintOpen = !isSecureMulticastHintOpen;
$("#hintSecureMulticast").toggle(isSecureMulticastHintOpen);
});
} catch (e) { }
// Helper: suggest a coherent physAddr from a gateway IA string like A.L.D
function suggestPhysAddrFromGatewayIA(gatewayIA) {
let prefix = '';
let gwIaParts = [];
if (typeof gatewayIA === 'string' && gatewayIA.includes('.')) {
gwIaParts = gatewayIA.split('.');
prefix = `${gwIaParts[0]}.${gwIaParts[1]}`;
} else {
const currentPhys = $("#node-config-input-physAddr").val() || '';
const m = String(currentPhys).trim().match(/^(\d{1,2})\.(\d{1,3})/);
prefix = m ? `${m[1]}.${m[2]}` : '15.15';
}
let rnd = 200 + Math.floor(Math.random() * 55); // 200..254
try {
const gwLast = parseInt(gwIaParts[2]);
if (!isNaN(gwLast) && gwLast >= 200 && gwLast <= 254 && rnd === gwLast) {
rnd = (gwLast < 254 ? gwLast + 1 : 200);
}
} catch (e) { }
const phys = `${prefix}.${rnd}`;
$("#node-config-input-physAddr").val(phys);
}
// Helper: enforce host protocol depending on Secure/Plain and IP
function enforceHostProtocol(isSecure, ip) {
try {
const $sel = $("#node-config-input-hostProtocol");
const isMulticast = (ip === '224.0.23.12');
const forced = isSecure
? (isMulticast ? 'Multicast' : 'TunnelTCP')
: (isMulticast ? 'Multicast' : 'TunnelUDP');
// Set value
$sel.val(forced);
// Force selection and prevent changes for both Secure and Plain.
$sel.prop('disabled', true);
// Reset options visibility so that UI remains consistent if user reselects
$sel.find("option").show().prop('disabled', false);
if (isSecure) {
// Keep only valid choices visible conceptually (info only)
$sel.find("option[value='TunnelUDP']").prop('disabled', true).hide();
$sel.find("option[value='Auto']").prop('disabled', true).hide();
} else {
// For plain, hide TunnelTCP which is not used
$sel.find("option[value='TunnelTCP']").prop('disabled', true).hide();
$sel.find("option[value='Auto']").prop('disabled', true).hide();
}
} catch (e) { }
updateTunnelIAVisibility();
updatePhysAddrVisibility();
updateSecureMulticastHint();
}
// List of IA from keyring and loader
let aTunnelIAs = [];
function populateTunnelIAList() {
try {
const mode = getSecureCredentialsMode();
const keyringMode = (mode === 'keyring');
if (!keyringMode) {
aTunnelIAs = [];
try {
const $ia = $("#node-config-input-tunnelIA");
if ($ia.data('ui-autocomplete')) {
$ia.autocomplete('option', 'source', aTunnelIAs);
}
} catch (e) { }
return;
}
const keyring = $("#node-config-input-keyringFileXML").val();
const pwd = $("#node-config-input-keyringFilePassword").val();
const params = { serverId: node.id };
if (typeof keyring === 'string' && keyring.trim() !== '') params.keyring = keyring.trim();
if (typeof pwd === 'string' && pwd.trim() !== '') params.pwd = pwd.trim();
$.getJSON('knxUltimateKeyringInterfaces', params, (data) => {
aTunnelIAs = [];
if (Array.isArray(data)) {
data.forEach(item => {
const ia = item.ia || '';
const userId = (item.userId !== undefined && item.userId !== null) ? item.userId : '';
if (ia) {
aTunnelIAs.push({ label: userId !== '' ? `${ia} (user ${userId})` : ia, value: ia });
}
});
}
try {
const $ia = $("#node-config-input-tunnelIA");
if ($ia.data('ui-autocomplete')) {
$ia.autocomplete('option', 'source', aTunnelIAs);
}
} catch (e) { }
});
} catch (error) { }
}
// Secure: Tunnel IA selection
try {
const storedTunnelIA = (() => {
const manualStored = normalizeTunnelIAValue(node.tunnelInterfaceIndividualAddress);
const keyringStored = normalizeTunnelIAValue(node.tunnelIA);
return manualStored || keyringStored;
})();
setTunnelIAValue(storedTunnelIA);
$("#node-config-input-tunnelIASelection").val(typeof node.tunnelIASelection === 'undefined' ? 'Auto' : node.tunnelIASelection);
$("#node-config-input-tunnelIA").on('change input', syncTunnelIAFromKeyring);
const toggleTunnelIA = () => {
if ($("#node-config-input-tunnelIASelection").val() === 'Manual') {
$("#divTunnelIA").show();
populateTunnelIAList();
} else {
$("#divTunnelIA").hide();
}
};
$("#node-config-input-tunnelIASelection").on('change', function () { toggleTunnelIA(); updateTunnelIAVisibility(); updateSecureMulticastHint(); });
toggleTunnelIA();
updateTunnelIAVisibility();
updateSecureMulticastHint();
updatePhysAddrVisibility();
// Also refresh IA list when user edits keyring/pwd
$("#node-config-input-keyringFileXML").on('change', function () { if ($("#node-config-input-tunnelIASelection").val() === 'Manual') populateTunnelIAList(); });
$("#node-config-input-keyringFilePassword").on('change', function () { if ($("#node-config-input-tunnelIASelection").val() === 'Manual') populateTunnelIAList(); });
// Autocomplete on IA input using the discovered IA list
try {
$("#node-config-input-tunnelIA").autocomplete({
minLength: 0,
source: function (request, response) { response(aTunnelIAs); },
focus: function (event, ui) { setTunnelIAValue(ui.item.value); return false; },
select: function (event, ui) { setTunnelIAValue(ui.item.value); return false; }
}).on('focus click', function () {
// Show dropdown on focus/click
$(this).autocomplete('search', '');
});
} catch (e) { }
} catch (error) { }
try {
$("#node-config-input-secureCredentialsMode").val(typeof node.secureCredentialsMode === 'undefined' ? 'keyring' : node.secureCredentialsMode);
$("#node-config-input-tunnelInterfaceIndividualAddress").on('change input', syncTunnelIAFromManual);
$("#node-config-input-tunnelUserPassword").val(typeof node.tunnelUserPassword === 'undefined' ? '' : node.tunnelUserPassword);
$("#node-config-input-tunnelUserId").val(typeof node.tunnelUserId === 'undefined' ? '' : node.tunnelUserId);
$("#node-config-input-secureCredentialsMode").on('change', function () {
updateSecureCredentialMode();
updateSecureMulticastHint();
});
updateSecureCredentialMode();
} catch (error) { }
// Autocomplete with KNX IP Interfaces + Refresh capability
let aKNXInterfaces = [];
function refreshKNXGateways(openDropdown) {
const startedAt = Date.now();
// UI: show spinner and disable host input while searching
$("#interfaces-count").html('<i class="fa fa-circle-o-notch fa-spin"></i> ' + node._('knxUltimate-config.properties.discovering'));
$("#node-config-input-host").prop('disabled', true);
aKNXInterfaces = [];
$.getJSON("knxUltimateDiscoverKNXGateways", { _: new Date().getTime() }, (data) => {
for (let index = 0; index < data.length; index++) {
const element = data[index];
const parts = String(element).split(':');
const ip = parts[0];
const port = parts[1];
const name = parts[2];
const address = parts[3];
const security = parts[4] || '';
const protoRaw = parts[5] || '';
const isSecure = security === 'Secure KNX';
const proto = (protoRaw === 'TCP') ? 'TunnelTCP' : (protoRaw === 'UDP' ? 'TunnelUDP' : 'Multicast');
const addrLabel = (typeof address === 'string' && address !== '' && address !== 'undefined') ? address : 'n/a';
aKNXInterfaces.push({
label: `${name || ''} -> ${ip || ''}:${port || ''} phys addr:${addrLabel}`,
value: element,
isSecure,
proto
});
}
const finalize = () => {
$("#interfaces-count").text(`${aKNXInterfaces.length} ` + node._('knxUltimate-config.properties.interfaces_found'));
try {
$("#node-config-input-host").autocomplete('option', 'source', aKNXInterfaces);
if (openDropdown === true) {
$("#node-config-input-host").autocomplete("search", "");
}
} catch (e) { }
$("#node-config-input-host").prop('disabled', false);
};
const elapsed = Date.now() - startedAt;
const wait = Math.max(0, 1000 - elapsed);
setTimeout(finalize, wait);
}).fail(function () {
const finalizeFail = () => {
$("#interfaces-count").text(node._('knxUltimate-config.properties.discovery_failed'));
$("#node-config-input-host").prop('disabled', false);
};
const elapsed = Date.now() - startedAt;
const wait = Math.max(0, 2000 - elapsed);
setTimeout(finalizeFail, wait);
});
}
// Initialize autocomplete once
$("#node-config-input-host").autocomplete({
minLength: 0,
source: aKNXInterfaces,
select: function (event, ui) {
const parts = String(ui.item.value).split(':');
const ip = parts[0];
const port = parts[1];
const name = parts[2];
const address = parts[3];
const security = parts[4] || '';
const protoRaw = parts[5] || '';
const isSecure = security === 'Secure KNX';
const proto = (protoRaw === 'TCP') ? 'TunnelTCP' : (protoRaw === 'UDP' ? 'TunnelUDP' : 'Multicast');
$("#node-config-input-host").val(ip);
$("#node-config-input-port").val(port);
$("#node-config-input-name").val(name || "");
if (isSecure) { try { $("#tabsMain").tabs("option", "active", 1); } catch (e) { } }
else { try { $("#tabsMain").tabs("option", "active", 0); } catch (e) { } }
// Set protocol coming from discovery and lock selection
try { const $sel = $("#node-config-input-hostProtocol"); $sel.val(proto); $sel.prop('disabled', true); } catch (e) { }
// Ensure UI visibility follows protocol choice
updateTunnelIAVisibility();
updatePhysAddrVisibility();
updateSecureMulticastHint();
// Suggest a physAddr coherent with gateway IA (area.line preserved)
let prefix = '';
let gwIaParts = [];
if (typeof address === 'string' && address.includes('.')) {
gwIaParts = address.split('.');
prefix = `${gwIaParts[0]}.${gwIaParts[1]}`;
} else {
const currentPhys = $("#node-config-input-physAddr").val() || '';
const m = String(currentPhys).trim().match(/^(\d{1,2})\.(\d{1,3})/);
prefix = m ? `${m[1]}.${m[2]}` : '15.15';
}
// pick a last byte in the safer range 200..254
let rnd = 200 + Math.floor(Math.random() * 55);
// avoid same last byte as gateway IA if known and within range
try {
const gwLast = parseInt(gwIaParts[2]);
if (!isNaN(gwLast) && gwLast >= 200 && gwLast <= 254 && rnd === gwLast) {
rnd = (gwLast < 254 ? gwLast + 1 : 200);
}
} catch (e) { }
const phys = `${prefix}.${rnd}`;
$("#node-config-input-physAddr").val(phys);
autoSelectEthernetInterfaceForHost({ force: true });
blinkBackgroundArray(["#node-config-input-host", "#node-config-input-port", "#node-config-input-name", "#node-config-input-physAddr"]);
return false;
},
focus: function (event, ui) {
$("#node-config-input-host").val(ui.item.value.split(':')[0]);
return false;
}
}).on("focus click", function () {
const $input = $(this);
const currentValue = $input.val();
$input.data('knx-restore-host', currentValue);
if (currentValue) {
$input.val('');
}
$input.autocomplete("search", "");
if (currentValue) {
setTimeout(() => {
if ($input.data('knx-restore-host') === currentValue) {
$input.val(currentValue);
}
}, 0);
}
}).on("blur", function () {
$(this).data('knx-restore-host', null);
}).autocomplete("instance")._renderItem = function (ul, item) {
const parts = String(item.value).split(':');
const ip = parts[0];
const port = parts[1];
const name = parts[2];
const address = parts[3];
const security = parts[4] || '';
const protoRaw = parts[5] || '';
const isSecure = security === 'Secure KNX';
const addrLabel = (typeof address === 'string' && address !== '' && address !== 'undefined') ? address : 'n/a';
const colorStyle = isSecure ? 'color: green;' : '';
return $("<li>")
.append(`<div style="${colorStyle}"><b>${ip || ''}:${port || ''}</b> [${protoRaw || ''}] -> ${name || ''} - ${node._('knxUltimate-config.properties.address')}:${addrLabel}${isSecure ? ' (' + node._('knxUltimate-config.properties.secure_knx_label') + ')' : ''}</div>`)
.appendTo(ul);
}
// Initial discovery
refreshKNXGateways(false);
// Refresh button handler
try {
$("#refresh-interfaces").on('click', function (e) {
try { e.preventDefault(); e.stopPropagation(); } catch (_) { }
refreshKNXGateways(false); // refresh silently, don't open dropdown
});
} catch (e) { }
// Track protocol changes to sync visibility and suggest physAddr when needed
try {
$("#node-config-input-hostProtocol").on('change', function () {
updateTunnelIAVisibility();
updatePhysAddrVisibility();
updateSecureMulticastHint();
try {
const ipNow = String($("#node-config-input-host").val() || '').trim();
if (!$("#node-config-input-physAddr").val()) {
let gwIA = null;
for (let i = 0; i < aKNXInterfaces.length; i++) {
const parts = String(aKNXInterfaces[i].value).split(':');
if (parts[0] === ipNow) { gwIA = parts[3] || null; break; }
}
suggestPhysAddrFromGatewayIA(gwIA);
}
} catch (e) { }
});
} catch (e) { }
const $knxEthInterfaceSelect = $("#node-config-input-KNXEthInterface");
const $knxEthInterfaceManualRow = $("#divKNXEthInterfaceManuallyInput");
const $knxEthInterfaceManualInput = $("#node-config-input-KNXEthInterfaceManuallyInput");
let ethernetInterfaces = [];
let suppressInterfaceSelectionChange = false;
let userLockedInterfaceSelection = false;
function updateEthernetManualVisibility(value) {
if (value === 'Manual') {
$knxEthInterfaceManualRow.show();
} else {
$knxEthInterfaceManualRow.hide();
}
}
function setEthernetInterfaceSelection(value, triggerChange) {
const finalValue = value || 'Auto';
if ($knxEthInterfaceSelect.val() === finalValue) {
if (triggerChange) {
suppressInterfaceSelectionChange = true;
$knxEthInterfaceSelect.trigger('change');
suppressInterfaceSelectionChange = false;
}
return;
}
suppressInterfaceSelectionChange = true;
$knxEthInterfaceSelect.val(finalValue);
if (triggerChange) {
$knxEthInterfaceSelect.trigger('change');
}
suppressInterfaceSelectionChange = false;
}
function findEthernetInterfaceMatch(ipOctets) {
if (!Array.isArray(ipOctets) || ethernetInterfaces.length === 0) return null;
let bestMatch = null;
let bestMaskBits = -1;
ethernetInterfaces.forEach((iface) => {
const entries = Array.isArray(iface.addresses) ? iface.addresses : [];
entries.forEach((entry) => {
if (!entry || entry.family !== 'IPv4') return;
const ifaceOctets = parseIPv4Address(entry.address);
const maskOctets = parseIPv4Address(entry.netmask || '');
if (!ifaceOctets || !maskOctets) return;
const ifaceNetwork = computeIPv4NetworkKey(ifaceOctets, maskOctets);
const hostNetwork = computeIPv4NetworkKey(ipOctets, maskOctets);
if (!ifaceNetwork || !hostNetwork || ifaceNetwork !== hostNetwork) return;
const maskBits = countNetmaskBits(maskOctets);
if (maskBits > bestMaskBits) {
bestMaskBits = maskBits;
bestMatch = iface;
}
});
});
return bestMatch;
}
function autoSelectEthernetInterfaceForHost(options = {}) {
const { force } = options;
if (!force && userLockedInterfaceSelection) return;
const hostValue = String($("#node-config-input-host").val() || '').trim();
const hostOctets = parseIPv4Address(hostValue);
if (!hostOctets || isMulticastIPv4(hostOctets)) return;
const match = findEthernetInterfaceMatch(hostOctets);
if (!match || !match.name) return;
if ($knxEthInterfaceSelect.val() === match.name) return;
setEthernetInterfaceSelection(match.name, true);
}
$knxEthInterfaceSelect.empty()
.append($("<option></option>")
.attr("value", "Auto")
.text(node._('knxUltimate-config.properties.iface_auto')))
.append($("<option></option>")
.attr("value", "Manual")
.text(node._('knxUltimate-config.properties.iface_manual')));
$knxEthInterfaceManualInput.val(typeof node.KNXEthInterfaceManuallyInput === "undefined" ? "" : node.KNXEthInterfaceManuallyInput);
$knxEthInterfaceSelect.on('change', function () {
const value = $knxEthInterfaceSelect.val();
updateEthernetManualVisibility(value);
if (!suppressInterfaceSelectionChange) {
userLockedInterfaceSelection = !!value && value !== 'Auto';
}
});
$.getJSON('knxUltimateETHInterfaces', (data) => {
ethernetInterfaces = Array.isArray(data) ? data.slice() : [];
ethernetInterfaces.sort((a, b) => {
const nameA = (a && a.name) ? String(a.name) : '';
const nameB = (b && b.name) ? String(b.name) : '';
return nameA.localeCompare(nameB);
});
$knxEthInterfaceSelect.find('option').filter(function () {
const val = $(this).val();
return val !== 'Auto' && val !== 'Manual';
}).remove();
ethernetInterfaces.forEach((iFace) => {
if (!iFace || !iFace.name) return;
const labelAddress = (typeof iFace.address === 'string' && iFace.address.length > 0) ? iFace.address : '';
const displayText = labelAddress ? `${iFace.name} (${labelAddress})` : iFace.name;
const $opt = $("<option></option>")
.attr("value", iFace.name)
.text(displayText);
$opt.data('iface', iFace);
$knxEthInterfaceSelect.append($opt);
});
const initialValue = (typeof node.KNXEthInterface === "undefined") ? "Auto" : node.KNXEthInterface;
userLockedInterfaceSelection = initialValue !== 'Auto';
setEthernetInterfaceSelection(initialValue, true);
if (!userLockedInterfaceSelection) {
autoSelectEthernetInterfaceForHost({ force: true });
}
});
// Abilita manualmente la lista dei protocolli quando l'utente modifica l'IP a mano
try {
$("#node-config-input-host").on('input', function () {
const isSecure = isSecureTabActive();
const $sel = $("#node-config-input-hostProtocol");
// Abilita select
$sel.prop('disabled', false);
// Mostra opzioni consentite in base a Secure/Plain
$sel.find('option').show().prop('disabled', false);
if (isSecure) {
$sel.find("option[value='TunnelUDP']").prop('disabled', true).hide();
$sel.find("option[value='Auto']").prop('disabled', true).hide();
} else {
$sel.find("option[value='TunnelTCP']").prop('disabled', true).hide();
$sel.find("option[value='Auto']").prop('disabled', true).hide();
}
// Sync visibility when user types a multicast/unicast IP
updateTunnelIAVisibility();
updatePhysAddrVisibility();
// Suggest physAddr when typing unicast IP and field is empty
try {
const ipTyped = String($(this).val() || '').trim();
if (ipTyped) {
let gwIA = null;
let proto = null;
for (let i = 0; i < aKNXInterfaces.length; i++) {
const parts = String(aKNXInterfaces[i].value).split(':');
if (parts[0] === ipTyped) {
gwIA = parts[3] || null;
const protoRaw = parts[5] || '';
proto = (protoRaw === 'TCP') ? 'TunnelTCP' : (protoRaw === 'UDP' ? 'TunnelUDP' : 'Multicast');
break;
}
}
// Suggest phys only if empty
if (!$("#node-config-input-physAddr").val()) {
suggestPhysAddrFromGatewayIA(gwIA);
}
// If discovery knows protocol, preselect it (keep select enabled for manual override)
if (proto) { $("#node-config-input-hostProtocol").val(proto); }
const octets = parseIPv4Address(ipTyped);
if (octets && !isMulticastIPv4(octets)) enforceProtocolFromIP();
}
} catch (e) { }
autoSelectEthernetInterfaceForHost();
});
} catch (e) { }
// 14/08/2021 Elimino il file delle persistenze di questo nodo
$.getJSON("deletePersistGAFile?serverId=" + node.id, (data) => { });
// 06/07/2023 Tabs
// *****************************
$("#tabs").tabs();
// *****************************
var sRetDebugText = "";
$("#getinfocam").click(function () {
sRetDebugText = "";
$("#divDebugText").show();
for (const [key, value] of Object.entries(node)) {
sRetDebugText += (`-> ${key}: ${value}\r`);
}
$("#debugText").val(sRetDebugText); // Store the config-node);
});
$("#getallgaused").click(function () {
sRetDebugText = "";
$("#divDebugText").show();
let aFound = [];
RED.nodes.eachNode(function (node) {
if (!aFound.includes(node.topic)) {
aFound.push(node.topic);
sRetDebugText += node.topic + "\r"
}
});
sRetDebugText = node._('knxUltimate-config.utility.copy_ga_router') + "\r" + sRetDebugText;
$("#debugText").val(sRetDebugText); // Store the config-node);
});
},
oneditcancel: function () {
try {
if (this._keyringEditor) { this._keyringEditor.destroy(); this._keyringEditor = null; }
if (this._csvEditor) { this._csvEditor.destroy(); this._csvEditor = null; }
} catch (e) { }
},
oneditsave: function () {
// Return to the info tab
try {
RED.sidebar.show("info");
} catch (error) { }
var node = this;
try {
if (node._keyringEditor) {
$("#node-config-input-keyringFileXML").val(node._keyringEditor.getValue());
node._keyringEditor.destroy();
node._keyringEditor = null;
}
if (node._csvEditor) {
$("#node-config-input-csv").val(node._csvEditor.getValue());
node._csvEditor.destroy();
node._csvEditor = null;
}
try {
$("#node-config-input-tunnelIA").val($("#node-config-input-tunnelInterfaceIndividualAddress").val());
} catch (e) { }
} catch (e) { }
// Check if the csv file contains errors
if (($("#node-config-input-csv").val() != 'undefined' && $("#node-config-input-csv").val() != "") || ($("#node-config-input-keyring").val() != 'undefined' && $("#node-config-input-keyring").val() != "")) {
var checkResult = node._("knxUltimate-config.ets.deploywithETS");
var myNotification = RED.notify(checkResult,
{
modal: true,
fixed: true,
type: 'info',
buttons: [
{
text: "OK",
click: function (e) {
myNotification.close();
}
}]
})
}
},
label: function () {
return typeof this.name === undefined ? (this.host + ":" + this.port) : this.name + " " + (this.host + ":" + this.port);
}
});
</script>
<style>
.ui-tabs {
background: transparent;
border: none;
}
.ui-tabs .ui-widget-header {
background: transparent;
border: none;
border-bottom: 1px solid #c0c0c0;
-moz-border-radius: 0px;
-webkit-border-radius: 0px;
border-radius: 0px;
}
.ui-tabs .ui-tabs-nav .ui-state-default {
background: transparent;
border: none;
}
.ui-tabs .ui-tabs-nav .ui-state-active {
background: transparent no-repeat bottom center;
border: none;
}
.ui-tabs .ui-tabs-nav .ui-state-default a {
color: #878787;
outline: none;
}
.ui-tabs .ui-tabs-nav .ui-state-active a {
color: #377e00;
border-bottom: 1px solid#377e00;
outline: none;
}
#knxUltimate-config-template .form-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
#knxUltimate-config-template .form-row>label {
flex: 0 0 230px;
display: flex;
align-items: center;
gap: 6px;
margin: 0;
}
#knxUltimate-config-template .form-row>label+input,
#knxUltimate-config-template .form-row>label+select,
#knxUltimate-config-template .form-row>label+textarea {
flex: 1 1 auto;
}
#knxUltimate-config-template .form-row.form-row-offset {
display: block;
margin-left: 230px;
}
#knxUltimate-config-template .form-row.form-row-checkbox {
align-items: flex-start;
}
#knxUltimate-config-template .form-row.form-row-checkbox input[type="checkbox"] {
margin-top: 4px;
}
#knxUltimate-config-template .form-row.form-row-checkbox label {
flex: 1 1 auto;
}
#knxUltimate-config-template #hintSecureMulticast,
#knxUltimate-config-template .form-row .form-tips {
margin-left: 230px;
}
#knxUltimate-config-template .form-row.form-row-no-label {
display: block;
}
#knxUltimate-config-template i.fa.fa-shield {
color: #0f5d16;
}
#node-config-input-keyringFileXML-editor .monaco-editor .minimap,
#node-config-input-csv-editor .monaco-editor .minimap {
display: none !important;
}
#node-config-input-keyringFileXML-editor .ace_gutter {
display: none !important;
}
#node-config-input-keyringFileXML-editor .ace_scroller {
left: 0 !important;
}
#knxUltimate-config-template .form-section-heading {
margin-bottom: 12px;
}
</style>
<script type="text/html" data-template-name="knxUltimate-config">
<div id="knxUltimate-config-template">
<div class="form-section-heading">
<b><span data-i18n="knxUltimate-config.properties.title"></span></b>
</div>
<div class="form-row">
<label for="node-config-input-name">
<i class="fa fa-tag"></i> <span data-i18n="knxUltimate-config.properties.node-config-input-name"></span></label>
<input type="text" id="node-config-input-name" style="width: 200px">
</div>
<div class="form-row">
<label for="node-config-input-host"><i class="fa fa-server"></i> <span data-i18n="knxUltimate-config.properties.host"></span></label>
<input type="text" id="node-config-input-host" style="width: 200px">
<i id="refresh-interfaces" class="fa fa-refresh" style="cursor:pointer; color:#377e00; margin-left:6px;" data-i18n="[title]knxUltimate-config.properties.refresh_interfaces"></i>
</div>
<div class="form-row form-row-offset">
<span id="interfaces-count" style="font-size: 12px; color:#377e00; margin-top: 2px;"></span>
</div>
<div class="form-row" id="rowPhysAddr">
<label for="node-config-input-physAddr">
<i class="fa fa-microchip"></i>
<span data-i18n="knxUltimate-config.advanced.knx_phy_addr"></span>
</label>
<input type="text" id="node-config-input-physAddr" style="width:100px">
<span id="hintSecureMulticastToggle" style="display:none; cursor:pointer; color:#c97f00; margin-left:10px;">
<i class="fa fa-exclamation-triangle"></i>
<span data-i18n="knxUltimate-config.properties.secure_multicast_physaddr_hint_toggle"></span>
</span>
</div>
<div id="hintSecureMulticast" class="form-tips" style="display:none; margin-top:6px;">
<i class="fa fa-shield"></i>
<span data-i