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, and 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