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, ETS group address importer, KNX AI for diagnosticsand KNX routing between interfaces. Easy to use and highly configurable.
936 lines (894 loc) • 84.3 kB
HTML
<script type="text/javascript" src="resources/node-red-contrib-knx-ultimate/htmlUtils.js"></script>
<script type="text/javascript">
const optionalNumberValidator = function (value) {
return (value === '' || value === null || typeof value === 'undefined') ? true : RED.validators.number()(value)
}
RED.nodes.registerType('knxUltimate-config', {
category: 'config',
defaults: {
host: { value: "224.0.23.12", required: false },
port: { value: 3671, required: false, 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" },
enableFlowBubbles: { value: false },
statusUpdateThrottle: { value: "0" },
statusDateTimeFormat: { value: "legacy" },
statusDateTimeCustom: { value: "DD MMM HH:mm" },
statusDateTimeLocale: { value: "" },
serialPortPath: { value: "/dev/ttyAMA0" },
serialBaudRate: { value: 19200, validate: optionalNumberValidator },
serialDataBits: { value: 8 },
serialStopBits: { value: 1 },
serialParity: { value: "even" },
serialRtscts: { value: false },
serialDtr: { value: true },
serialTimeout: { value: 1200, validate: optionalNumberValidator },
isKBERRY: { value: true }
},
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);
}
// ################################################################
const isSerialProtocolSelected = () => {
try {
return $("#node-config-input-hostProtocol").val() === 'SerialFT12';
} catch (e) {
return false;
}
};
let previousIpHostValue = typeof node.host === 'string' ? node.host : '';
try {
const initialSerialPath = (typeof node.serialPortPath === 'undefined' || node.serialPortPath === null)
? (isSerialProtocolSelected() ? (node.host || '') : '')
: node.serialPortPath;
$("#node-config-input-serialPortPath").val(initialSerialPath || '');
if ((node.hostProtocol === 'SerialFT12' || node.hostProtocol === 'serialft12') && initialSerialPath) {
$("#node-config-input-host").val(initialSerialPath);
}
} catch (e) { }
applySerialDefaults();
function syncSerialPortPathFromHost() {
try {
const hostValue = String($("#node-config-input-host").val() || '').trim();
$("#node-config-input-serialPortPath").val(hostValue);
} catch (e) { }
}
function applySerialDefaults() {
$("#node-config-input-serialBaudRate").val(typeof node.serialBaudRate === 'undefined' ? 19200 : node.serialBaudRate);
$("#node-config-input-serialDataBits").val(typeof node.serialDataBits === 'undefined' ? '8' : String(node.serialDataBits));
$("#node-config-input-serialStopBits").val(typeof node.serialStopBits === 'undefined' ? '1' : String(node.serialStopBits));
$("#node-config-input-serialParity").val(typeof node.serialParity === 'undefined' ? 'even' : String(node.serialParity));
$("#node-config-input-serialRtscts").prop('checked', (typeof node.serialRtscts === 'undefined') ? false : !!node.serialRtscts);
$("#node-config-input-serialDtr").prop('checked', (typeof node.serialDtr === 'undefined') ? true : !!node.serialDtr);
$("#node-config-input-serialTimeout").val(typeof node.serialTimeout === 'undefined' ? 1200 : node.serialTimeout);
try {
let isKBERRY = node.isKBERRY;
if (typeof isKBERRY === 'undefined' || isKBERRY === null || isKBERRY === '') {
isKBERRY = true;
} else if (typeof isKBERRY === 'string') {
isKBERRY = isKBERRY.toLowerCase() === 'true';
} else {
isKBERRY = !!isKBERRY;
}
$("#node-config-input-isKBERRY").val(isKBERRY ? 'true' : 'false');
} catch (e) { }
}
const syncStatusDateTimeRows = () => {
try {
const mode = $("#node-config-input-statusDateTimeFormat").val();
const showCustom = mode === 'custom';
$("#knxUltimate-statusDateTimeCustom-row").toggle(showCustom);
$("#knxUltimate-statusDateTimeLocale-row").toggle(showCustom);
} catch (e) { }
}
$("#node-config-input-statusDateTimeFormat").on("change", syncStatusDateTimeRows);
syncStatusDateTimeRows();
function updateProtocolSections() {
const isSerial = isSerialProtocolSelected();
$("#rowPort").toggle(!isSerial);
$("#rowEthernetInterface").toggle(!isSerial);
if (isSerial) {
$("#divKNXEthInterfaceManuallyInput").hide();
} else {
try { updateEthernetManualVisibility($("#node-config-input-KNXEthInterface").val()); } catch (e) { }
}
$("#serialAdvancedContainer").toggle(isSerial);
if (isSerial) {
syncSerialPortPathFromHost();
}
}
// 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 {
if (isSerialProtocolSelected()) return;
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();
}
function updateSecureModeAvailability() {
try {
const hostProtocol = $("#node-config-input-hostProtocol").val();
const $mode = $("#node-config-input-secureCredentialsMode");
if (!$mode || $mode.length === 0) return;
if (hostProtocol === 'SerialFT12') {
// Secure credential source is only meaningful for IP transports.
// When using Serial FT1.2 keep the selector disabled to simplify the UI.
if ($mode.val() !== 'keyring') {
$mode.val('keyring');
}
$mode.prop('disabled', true);
} else {
$mode.prop('disabled', false);
}
} catch (e) { }
}
// 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 isSerial = (hostProtocol === 'SerialFT12');
const mode = getSecureCredentialsMode();
const keyringMode = (mode === 'keyring');
const manualMode = (mode === 'manual' || mode === 'combined');
const showTunnelIA = isSecure && !isMulticast && keyringMode && !isSerial;
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.
// For Serial FT1.2 these fields are not applicable (they apply only to IP tunnelling),
// so keep them hidden when a serial protocol is selected.
const showManualTunnelFields = isSecure && manualMode && !isSerial;
$("#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');
const isSerial = (hostProtocol === 'SerialFT12');
if (isSecure && isMulticast && !isSerial) {
$("#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 + Serial ports + Refresh capability
let hostEntries = []
function listGateways() {
return new Promise((resolve) => {
$.getJSON("knxUltimateDiscoverKNXGateways", { _: new Date().getTime() }, (data) => {
resolve(Array.isArray(data) ? data : [])
}).fail(() => resolve([]))
})
}
function listSerialPorts() {
return new Promise((resolve) => {
$.getJSON("knxUltimateSerialInterfaces", { _: new Date().getTime() }, (data) => {
resolve(Array.isArray(data) ? data : [])
}).fail(() => resolve([]))
})
}
function createGatewayEntries(data) {
const entries = []
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'
entries.push({
label: `${name || ''} -> ${ip || ''}:${port || ''} phys addr:${addrLabel}`,
value: ip || '',
type: 'gateway',
gateway: {
ip,
port,
name,
address,
security,
proto,
isSecure
}
})
}
return entries
}
function createSerialEntries(data) {
const entries = []
data.forEach((port) => {
if (!port || !port.path) return
const info = []
if (port.manufacturer) info.push(port.manufacturer)
if (port.serialNumber) info.push(port.serialNumber)
if (port.vendorId && port.productId) info.push(`${port.vendorId}:${port.productId}`)
const descriptor = info.length ? info.join(' / ') : 'n/a'
entries.push({
label: `[Serial] ${port.path} -> ${descriptor}`,
value: port.path,
type: 'serial',
serial: port
})
})
return entries
}
function refreshHostEntries(openDropdown) {
const startedAt = Date.now()
$("#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)
Promise.all([listGateways(), listSerialPorts()]).then(([gateways, serials]) => {
const gatewayEntries = createGatewayEntries(gateways)
const serialEntries = createSerialEntries(serials)
hostEntries = gatewayEntries.concat(serialEntries)
const finalize = () => {
$("#interfaces-count").text(`${hostEntries.length} ` + node._('knxUltimate-config.properties.interfaces_found'))
try {
$("#node-config-input-host").autocomplete('option', 'source', hostEntries)
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)
}).catch(() => {
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: hostEntries,
select: function (event, ui) {
const entry = ui.item || {}
if (entry.type === 'serial') {
const info = entry.serial || {}
const path = info.path || entry.value || ''
const descriptorParts = []
if (info.manufacturer && info.manufacturer !== 'n/a') descriptorParts.push(info.manufacturer)
if (info.serialNumber && info.serialNumber !== 'n/a') descriptorParts.push(`#${info.serialNumber}`)
if (info.vendorId && info.productId) descriptorParts.push(`${info.vendorId}:${info.productId}`)
const descriptor = descriptorParts.length > 0 ? descriptorParts.join(' ') : path
$("#node-config-input-host").val(path)
$("#node-config-input-name").val(descriptor)
syncSerialPortPathFromHost()
try { updateSecureModeAvailability(); } catch (e) { }
try {
const $sel = $("#node-config-input-hostProtocol")
$sel.val('SerialFT12')
$sel.prop('disabled', true)
} catch (e) { }
applySerialDefaults()
updateProtocolSections()
updateTunnelIAVisibility()
updatePhysAddrVisibility()
updateSecureMulticastHint()
blinkBackgroundArray(["#node-config-input-host", "#node-config-input-name"])
return false
}
const gateway = entry.gateway || {}
$("#node-config-input-host").val(gateway.ip || '')
$("#node-config-input-port").val(gateway.port || '')
$("#node-config-input-name").val(gateway.name || "")
try { updateSecureModeAvailability(); } catch (e) { }
try {
const $sel = $("#node-config-input-hostProtocol")
if (gateway.proto) {
$sel.val(gateway.proto)
$sel.prop('disabled', true)
}
} catch (e) { }
updateProtocolSections()
updateTunnelIAVisibility()
updatePhysAddrVisibility()
updateSecureMulticastHint()
const address = gateway.address
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'
}
let rnd = 200 + Math.floor(Math.random() * 55)
try {
const gwLast = parseInt(gwIaParts[2])
if (!isNaN(gwLast) && gwLast >= 200 && gwLast <= 254 && rnd === gwLast) {
rnd = (gwLast < 254 ? gwLast + 1 : 200)
}
} catch (e) { }
$("#node-config-input-physAddr").val(`${prefix}.${rnd}`)
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) {
const entry = ui.item || {}
if (entry.type === 'serial') {
$("#node-config-input-host").val(entry.serial?.path || entry.value || '')
} else {
$("#node-config-input-host").val(entry.gateway?.ip || entry.value || '')
}
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) {
if (item.type === 'serial') {
return $("<li>")
.append(`<div style="color:#005a9c;"><b>[Serial]</b> ${item.serial?.path || item.value || ''} -> ${item.label.replace('[Serial] ', '')}</div>`)
.appendTo(ul)
}
const gw = item.gateway || {}
const colorStyle = gw.isSecure ? 'color: green;' : ''
const proto = gw.proto || ''
const addrLabel = (typeof gw.address === 'string' && gw.address !== '' && gw.address !== 'undefined') ? gw.address : 'n/a'
return $("<li>")
.append(`<div style="${colorStyle}"><b>${gw.ip || ''}:${gw.port || ''}</b> [${proto}] -> ${gw.name || ''} - ${node._('knxUltimate-config.properties.address')}:${addrLabel}${gw.isSecure ? ' (' + node._('knxUltimate-config.properties.secure_knx_label') + ')' : ''}</div>`)
.appendTo(ul)
}
$("#node-config-input-serialPortPath").on('input', function () {
syncSerialPortPathFromHost()
});
try { updateProtocolSections(); } catch (e) { }
// Initial discovery
refreshHostEntries(false);
// Refresh button handler
try {
$("#refresh-interfaces").on('click', function (e) {
try { e.preventDefault(); e.stopPropagation(); } catch (_) { }
refreshHostEntries(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 () {
updateProtocolSections();
updateTunnelIAVisibility();
updatePhysAddrVisibility();
updateSecureMulticastHint();
const isSerial = isSerialProtocolSelected();
if (isSerial) {
applySerialDefaults();
syncSerialPortPathFromHost();
return;
}
try {
const ipNow = String($("#node-config-input-host").val() || '').trim();
if (!$("#node-config-input-physAddr").val()) {
const gwEntry = hostEntries.find((entry) => entry.type === 'gateway' && entry.gateway && entry.gateway.ip === ipNow);
suggestPhysAddrFromGatewayIA(gwEntry ? gwEntry.gateway.address : null);
}
} 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;
if (isSerialProtocolSelected()) 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 () {
if (isSerialProtocolSelected()) {
syncSerialPortPathFromHost();
return;
}
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