UNPKG

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