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.

923 lines (847 loc) 49.6 kB
<script type="text/javascript"> (function () { RED.plugins.registerPlugin("knxUltimateMonitor-sidebar-plugin", { onadd: function () { const tabId = "knxUltimateMonitorTab"; const actionId = "knxUltimateMonitor:show"; const debugTabId = "knxUltimateDebugTab"; const debugActionId = "knxUltimateDebug:show"; if (!document.getElementById('knx-monitor-style')) { $('<style>', { id: 'knx-monitor-style', text: ` #knx-monitor-table { font-size: 0.85em; table-layout: fixed; } #knx-monitor-table thead th { padding: 2px 4px; } #knx-monitor-table tbody td { padding: 2px 4px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; } #knx-monitor-table tbody tr:nth-child(even) { background-color: #fafafa; } #knx-monitor-table tbody tr.knx-monitor-highlight { background-color: #c9f5d4 !important; } #knx-monitor-toolbar { padding: 4px 6px !important; } #knx-monitor-toolbar select { margin-right: 4px; } #knx-monitor-empty { font-size: 0.85em; } #knx-monitor-table tbody td.knx-monitor-value-cell { display: flex; align-items: center; gap: 4px; height: 100%; min-height: 24px; } #knx-monitor-table tbody td.knx-monitor-value-cell span.knx-monitor-value-text { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } #knx-monitor-table tbody td.knx-monitor-value-cell button.knx-monitor-toggle { padding: 0 4px; min-width: 22px; min-height: 18px; font-size: 0.75em; line-height: 1; } #knx-monitor-table thead th { position: relative; } #knx-monitor-table thead th .knx-monitor-col-handle { position: absolute; top: 0; right: -3px; width: 6px; cursor: col-resize; user-select: none; height: 100%; } #knx-monitor-table thead th .knx-monitor-col-handle::after { content: ''; display: block; width: 1px; height: 100%; margin: 0 auto; background: rgba(0,0,0,0.2); } ` }).appendTo('head'); } if (!document.getElementById('knx-debug-style')) { $('<style>', { id: 'knx-debug-style', text: ` #knx-debug-toolbar { padding: 4px 6px !important; display: flex; align-items: center; gap: 6px; } #knx-debug-toolbar .knx-debug-toolbar-left { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; } #knx-debug-toolbar .knx-debug-toolbar-right { margin-left: auto; font-size: 0.8em; color: var(--red-ui-text-color-secondary, #666); } #knx-debug-toolbar button.red-ui-button { min-height: 24px; } #knx-debug-log-wrapper { flex: 1 1 auto; position: relative; background: #fff; border-top: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0; } #knx-debug-log { position: absolute; inset: 0; overflow: auto; padding: 6px; font-family: Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; line-height: 1.4; white-space: pre-wrap; word-break: break-word; background: #fff; color: #202020; } #knx-debug-log.empty::after { content: 'Nessuna riga di log disponibile al momento.'; color: var(--red-ui-text-color-secondary, #666); font-style: italic; } .knx-debug-line { padding: 2px 4px; border-left: 3px solid transparent; margin: 0 0 2px; } .knx-debug-line.level-error { border-left-color: #f44336; background-color: rgba(244, 67, 54, 0.1); color: #b71c1c; } .knx-debug-line.level-warn { border-left-color: #ff6f00; background-color: #fff3e0; color: #bf360c; } .knx-debug-line.level-info { border-left-color: #607d8b; background-color: rgba(96, 125, 139, 0.08); color: #37474f; } .knx-debug-line.level-debug { border-left-color: #2196f3; background-color: rgba(33, 150, 243, 0.08); color: #0d47a1; } .knx-debug-line.level-success { border-left-color: #4caf50; background-color: rgba(76, 175, 80, 0.08); color: #1b5e20; } .knx-debug-line.session-start { font-weight: 600; border-left-color: #9c27b0; background-color: rgba(156, 39, 176, 0.08); color: #4a148c; } .knx-debug-status { padding: 4px 6px; font-size: 0.85em; color: var(--red-ui-text-color-secondary, #666); border-top: 1px solid rgba(0,0,0,0.06); } ` }).appendTo('head'); } const getLocale = () => { const lang = (RED.settings && RED.settings.lang) ? RED.settings.lang : (navigator.language || navigator.userLanguage || 'en'); return (lang || 'en').toLowerCase(); }; const translations = { en: { tip: 'Tip: use the icon beside boolean values to toggle them on the KNX bus.', refresh: 'Refresh', auto: 'Auto', reorder: 'Reorder', search: 'Filter...', noData: 'No data available.', inferredDptHint: 'Values with * use an inferred datapoint from the payload.', colValue: 'Value', colName: 'Name', colUpdated: 'Updated' }, it: { tip: 'Suggerimento: usa l\'icona accanto ai valori booleani per inviarne il toggle sul bus KNX.', refresh: 'Aggiorna', auto: 'Auto', reorder: 'Riordina', search: 'Filtra...', noData: 'Nessun dato disponibile.', inferredDptHint: 'I valori con * usano un datapoint dedotto dal payload.', colValue: 'Valore', colName: 'Nome', colUpdated: 'Aggiornato' }, fr: { tip: 'Astuce : utilisez l\'icône à côté des valeurs booléennes pour les basculer sur le bus KNX.', refresh: 'Actualiser', auto: 'Auto', reorder: 'Réordonner', search: 'Filtrer...', noData: 'Aucune donnée disponible.', inferredDptHint: 'Les valeurs avec * utilisent un datapoint déduit du payload.', colValue: 'Valeur', colName: 'Nom', colUpdated: 'Mis à jour' }, es: { tip: 'Consejo: usa el icono junto a los valores booleanos para alternarlos en el bus KNX.', refresh: 'Actualizar', auto: 'Auto', reorder: 'Reordenar', search: 'Filtrar...', noData: 'No hay datos disponibles.', inferredDptHint: 'Los valores con * usan un datapoint inferido del payload.', colValue: 'Valor', colName: 'Nombre', colUpdated: 'Actualizado' }, de: { tip: 'Tipp: Verwenden Sie das Symbol neben booleschen Werten, um sie auf dem KNX-Bus umzuschalten.', refresh: 'Aktualisieren', auto: 'Auto', reorder: 'Neu ordnen', search: 'Filtern...', noData: 'Keine Daten verfügbar.', inferredDptHint: 'Werte mit * verwenden einen aus dem Payload abgeleiteten Datenpunkt.', colValue: 'Wert', colName: 'Name', colUpdated: 'Aktualisiert' }, 'zh-CN': { tip: '提示:使用布尔值旁边的图标,在 KNX 总线上切换它们。', refresh: '刷新', auto: '自动', reorder: '重新排序', search: '筛选…', noData: '暂无数据。', inferredDptHint: '带 * 的值使用从 payload 推断的数据点。', colValue: '值', colName: '名称', colUpdated: '更新时间' } }; const resolveMessage = (key) => { const locale = getLocale(); if (translations[locale] && translations[locale][key]) return translations[locale][key]; const short = locale.split('-')[0]; if (translations[short] && translations[short][key]) return translations[short][key]; return translations.en[key]; }; const columnDefs = [ { key: 'ga', label: 'GA', width: 50, minWidth: 40 }, { key: 'value', label: resolveMessage('colValue'), width: 100, minWidth: 70 }, { key: 'dpt', label: 'DPT', width: 50, minWidth: 40 }, { key: 'name', label: resolveMessage('colName'), width: 280, minWidth: 150 }, { key: 'updated', label: resolveMessage('colUpdated'), width: 160, minWidth: 120 } ]; const container = $("<div>").css({ height: "100%", display: "flex", flexDirection: "column" }); const header = $("<div>", { class: "red-ui-sidebar-header" }).appendTo(container); const toolbar = $("<div>").css({ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "4px 6px" }).attr('id', 'knx-monitor-toolbar').appendTo(header); const toolbarStack = $('<div>').css({ display: 'flex', flexDirection: 'column', gap: '6px' }).appendTo(toolbar); const rowTop = $('<div>').css({ display: 'flex', alignItems: 'center', gap: '8px', justifyContent: 'space-between' }).appendTo(toolbarStack); const topLeft = $('<div>').css({ display: 'flex', alignItems: 'center', gap: '6px' }).appendTo(rowTop); const statusSpan = $("<span>").css({ fontSize: "0.8em", color: "#555" }).appendTo(rowTop); const serverSelect = $("<select>").css({ minWidth: "200px" }).appendTo(topLeft); const refreshButton = $("<button>", { class: "red-ui-button" }).append($('<i>', { class: 'fa fa-refresh' })).append(` ${resolveMessage('refresh')}`).appendTo(topLeft); const rowBottom = $('<div>').css({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }).appendTo(toolbarStack); const bottomLeft = $('<div>').css({ display: 'flex', alignItems: 'center', gap: '12px' }).appendTo(rowBottom); const bottomRight = $('<div>').css({ display: 'flex', alignItems: 'center' }).appendTo(rowBottom); const autoLabel = $("<label>").css({ display: "flex", alignItems: "center", gap: "6px", fontSize: '0.9em' }).appendTo(bottomLeft); const autoCheckbox = $("<input>", { type: "checkbox", checked: true }).appendTo(autoLabel); autoLabel.append($('<span>').text(resolveMessage('auto'))); const reorderLabel = $("<label>").css({ display: "flex", alignItems: "center", gap: "6px", fontSize: '0.9em' }).appendTo(bottomLeft); const reorderCheckbox = $("<input>", { type: "checkbox", checked: true }).appendTo(reorderLabel); reorderLabel.append($('<span>').text(resolveMessage('reorder'))); const searchWrapper = $('<div>').css({ display: 'flex', alignItems: 'center', gap: '6px', border: '1px solid #c9c9c9', borderRadius: '4px', padding: '4px 8px', backgroundColor: '#fff', minWidth: '200px' }).appendTo(bottomRight); $('<i>', { class: 'fa fa-search', style: 'color:#666;font-size:0.9em;' }).appendTo(searchWrapper); const searchInput = $('<input>', { type: 'text', placeholder: resolveMessage('search') }).css({ border: 'none', outline: 'none', padding: 0, background: 'transparent', flex: '1 1 auto', fontSize: '0.9em', minWidth: '120px' }).appendTo(searchWrapper); const body = $("<div>", { class: "red-ui-sidebar-content" }).css({ flex: "1 1 auto", overflow: "auto", padding: "0 6px 4px" }).appendTo(container); const emptyNotice = $("<div>").css({ margin: "8px 4px", color: "#888" }).text(resolveMessage('noData')).attr('id', 'knx-monitor-empty').appendTo(body); const hintIcon = $('<div>').css({ fontSize: '0.8em', color: '#555', margin: '4px 4px 8px' }).text(resolveMessage('tip')).appendTo(body); const table = $("<table>").css({ width: "100%", borderCollapse: "collapse", display: "none" }).attr('id', 'knx-monitor-table').appendTo(body); const colGroup = $('<colgroup></colgroup>').appendTo(table); columnDefs.forEach(def => { $('<col>').attr('data-key', def.key).css('width', def.width + 'px').appendTo(colGroup); }); const thead = $("<thead>").appendTo(table); const headerRow = $("<tr>").appendTo(thead); columnDefs.forEach(def => { const cell = $("<th>").attr('data-key', def.key).css({ textAlign: "left", borderBottom: "1px solid #ccc", padding: "4px", fontWeight: "600" }).text(def.label); const handle = $('<span class="knx-monitor-col-handle"></span>'); cell.append(handle); headerRow.append(cell); }); const tbody = $("<tbody>").appendTo(table); const hint = $("<div>").css({ fontSize: "0.75em", color: "#777", margin: "6px 4px 0" }).text(resolveMessage('inferredDptHint')).appendTo(body); const initColumnResize = () => { let currentHandle = null; let startX = 0; let startWidth = 0; let colElement = null; let columnMeta = null; const onMouseMove = (evt) => { if (!currentHandle || !colElement || !columnMeta) return; const delta = evt.pageX - startX; const newWidth = Math.max(columnMeta.minWidth || 60, startWidth + delta); colElement.css('width', newWidth + 'px'); }; const onMouseUp = () => { if (!currentHandle) return; $(document).off('mousemove', onMouseMove); $(document).off('mouseup', onMouseUp); currentHandle = null; colElement = null; columnMeta = null; }; thead.find('.knx-monitor-col-handle').off('mousedown').on('mousedown', function (evt) { evt.preventDefault(); evt.stopPropagation(); currentHandle = $(this); const th = currentHandle.closest('th'); const key = th.data('key'); columnMeta = columnDefs.find(c => c.key === key); colElement = colGroup.find(`col[data-key="${key}"]`); startX = evt.pageX; startWidth = parseInt(colElement.width(), 10) || (columnMeta ? columnMeta.width : 120); $(document).on('mousemove', onMouseMove); $(document).on('mouseup', onMouseUp); }); }; const levelClassMap = { error: 'level-error', warn: 'level-warn', warning: 'level-warn', info: 'level-info', debug: 'level-debug', success: 'level-success' }; const DEBUG_REFRESH_INTERVAL = 1000; const debugAutoStorageKey = 'knxUltimateDebug:autoRefresh'; let debugLogWrapper = null; let debugLogContainer = null; let debugEntries = []; let debugActive = false; let debugLatestSeq = 0; let debugAutoRefreshHandle = null; let debugLimit = 5000; let debugStatusEl = null; let debugInfoCounter = null; let debugAutoCheckbox = null; let debugFetching = false; let debugPendingForceRefresh = false; let debugRenderedPlainText = ''; const loadDebugAutoPreference = () => { try { if (window.localStorage) { const stored = window.localStorage.getItem(debugAutoStorageKey); if (stored === 'true') return true; if (stored === 'false') return false; } } catch (error) { } return false; }; const storeDebugAutoPreference = (value) => { try { if (window.localStorage) { window.localStorage.setItem(debugAutoStorageKey, value ? 'true' : 'false'); } } catch (error) { } }; const setDebugStatus = (message) => { if (debugStatusEl) { debugStatusEl.text(message); } }; const ensureDebugLogContainer = () => { if (debugLogContainer) return; if (!debugLogWrapper) return; debugLogContainer = $('<div>', { id: 'knx-debug-log', class: 'empty' }).appendTo(debugLogWrapper); }; const formatDebugLine = (entry) => { const level = (entry.level || 'info').toUpperCase(); const prefix = entry.prefix ? `[${entry.prefix}] ` : ''; let timestampText = ''; if (typeof entry.timestamp === 'number') { const date = new Date(entry.timestamp); if (!Number.isNaN(date.getTime())) { timestampText = date.toLocaleString(); } } if (!timestampText && entry.isoTimestamp) { timestampText = entry.isoTimestamp; } const message = entry.message || ''; return `${timestampText} [${level}] ${prefix}${message}`.trim(); }; const isLogNearBottom = () => { if (!debugLogContainer || !debugLogContainer[0]) return true; const el = debugLogContainer[0]; const gap = el.scrollHeight - el.scrollTop - el.clientHeight; return gap < 40; }; const renderDebugEntries = () => { ensureDebugLogContainer(); if (!debugLogContainer) return; const autoRefreshActive = !!(debugAutoCheckbox && debugAutoCheckbox.is(':checked')); const shouldStick = autoRefreshActive ? true : isLogNearBottom(); const lines = debugEntries.map(formatDebugLine); debugRenderedPlainText = lines.join('\n'); debugLogContainer.empty(); if (!lines.length) { debugLogContainer.addClass('empty'); return; } debugLogContainer.removeClass('empty'); const fragment = document.createDocumentFragment(); debugEntries.forEach((entry, index) => { const lineText = lines[index] || ''; const div = document.createElement('div'); div.className = 'knx-debug-line'; const level = (entry.level || 'info').toLowerCase(); const mapped = levelClassMap[level] || levelClassMap.info; div.classList.add(mapped); if (entry.sessionStart) div.classList.add('session-start'); div.textContent = lineText; fragment.appendChild(div); }); debugLogContainer[0].appendChild(fragment); if (shouldStick) { const el = debugLogContainer[0]; el.scrollTop = el.scrollHeight; } }; const updateDebugInfoCounter = (total) => { if (debugInfoCounter) { debugInfoCounter.text(`Rows: ${total}/${debugLimit}`); } }; const stopDebugAutoRefresh = () => { if (debugAutoRefreshHandle) { clearInterval(debugAutoRefreshHandle); debugAutoRefreshHandle = null; } }; const ensureDebugAutoRefresh = () => { if (debugAutoRefreshHandle) return; if (!debugAutoCheckbox || !debugAutoCheckbox.is(':checked')) return; debugAutoRefreshHandle = setInterval(() => { if (!debugAutoCheckbox || !debugAutoCheckbox.is(':checked')) { stopDebugAutoRefresh(); return; } if (!debugActive) return; if (debugFetching) return; fetchDebugLog({ triggeredByTimer: true, forceFull: true }); }, DEBUG_REFRESH_INTERVAL); }; const copyDebugToClipboard = () => { ensureDebugLogContainer(); if (!debugLogContainer) { setDebugStatus('Debug viewer non disponibile.'); return; } const text = debugRenderedPlainText || ''; if (!text || text.trim() === '') { setDebugStatus('Nessun testo da copiare.'); return; } const handleSuccess = () => { setDebugStatus('Log copiato negli appunti.'); try { RED.notify('KNX Debug log copiato negli appunti.', 'success'); } catch (error) { } }; const handleFailure = () => { setDebugStatus('Impossibile copiare il log.'); try { RED.notify('Impossibile copiare il KNX Debug log.', 'error'); } catch (error) { } }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(handleSuccess).catch(handleFailure); } else { const temp = $('<textarea>').css({ position: 'fixed', top: '-2000px', left: '-2000px' }).val(text).appendTo('body'); try { temp[0].select(); const ok = document.execCommand('copy'); temp.remove(); if (ok) handleSuccess(); else handleFailure(); } catch (error) { temp.remove(); handleFailure(); } } }; const fetchDebugLog = (options = {}) => { const { forceFull = false } = options; if (debugFetching) { if (forceFull) { debugPendingForceRefresh = true; } return; } debugFetching = true; const params = {}; if (!forceFull && debugLatestSeq > 0) { params.sinceSeq = debugLatestSeq; } else if (forceFull) { debugLatestSeq = 0; } const query = $.param(params); const url = query ? `knxUltimateDebugLog?${query}` : 'knxUltimateDebugLog'; if (forceFull) { setDebugStatus('Aggiornamento log...'); } $.getJSON(url, function (response) { if (!response || !Array.isArray(response.entries)) { setDebugStatus(response && response.error ? response.error : 'Impossibile leggere il log.'); return; } const incoming = response.entries; debugLimit = response.limit || debugLimit; if (forceFull || (!params.sinceSeq && debugEntries.length === 0)) { debugEntries = incoming.slice(); } else if (params.sinceSeq) { if (incoming.length) { debugEntries = debugEntries.concat(incoming); } } else { debugEntries = incoming.slice(); } if (debugEntries.length > debugLimit) { debugEntries = debugEntries.slice(debugEntries.length - debugLimit); } debugLatestSeq = response.latestSeq || debugLatestSeq; renderDebugEntries(); updateDebugInfoCounter(debugEntries.length); if (debugEntries.length === 0) { setDebugStatus('Nessuna riga di log disponibile al momento.'); } else { const now = new Date(); setDebugStatus(`Aggiornato alle ${now.toLocaleTimeString()}`); } }).fail(function () { setDebugStatus('Impossibile caricare il KNX Debug log.'); }).always(function () { debugFetching = false; if (debugActive && debugAutoCheckbox && debugAutoCheckbox.is(':checked')) { ensureDebugAutoRefresh(); } else { stopDebugAutoRefresh(); } if (debugPendingForceRefresh) { debugPendingForceRefresh = false; fetchDebugLog({ forceFull: true }); } }); }; const initDebugTab = () => { const container = $("<div>").css({ height: "100%", display: "flex", flexDirection: "column" }); const header = $("<div>", { class: "red-ui-sidebar-header" }).appendTo(container); const toolbar = $("<div>", { id: "knx-debug-toolbar" }).appendTo(header); const left = $("<div>", { class: "knx-debug-toolbar-left" }).appendTo(toolbar); const copyButton = $('<button>', { type: 'button', class: 'red-ui-button', title: 'Copy log to clipboard' }).html('<i class="fa fa-copy"></i>').appendTo(left); const refreshButton = $('<button>', { type: 'button', class: 'red-ui-button', title: 'Refresh log' }).html('<i class="fa fa-sync"></i>').appendTo(left); const autoWrapper = $('<label>').css({ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.9em' }).appendTo(left); debugAutoCheckbox = $('<input>', { type: 'checkbox' }).appendTo(autoWrapper); autoWrapper.append($('<span>').text('Auto')); debugAutoCheckbox.prop('checked', loadDebugAutoPreference()); const right = $("<div>", { class: "knx-debug-toolbar-right" }).appendTo(toolbar); debugInfoCounter = $('<span>').text('Rows: 0/5000').appendTo(right); debugLogWrapper = $('<div>', { id: 'knx-debug-log-wrapper' }).appendTo(container); ensureDebugLogContainer(); debugStatusEl = $('<div>', { class: 'knx-debug-status' }).text('Pronto.').appendTo(container); copyButton.on('click', function () { copyDebugToClipboard(); }); refreshButton.on('click', function () { fetchDebugLog({ forceFull: true }); }); debugAutoCheckbox.on('change', function () { const enabled = $(this).is(':checked'); storeDebugAutoPreference(enabled); if (enabled) { debugActive = true; fetchDebugLog({ forceFull: true }); ensureDebugAutoRefresh(); } else { stopDebugAutoRefresh(); } }); RED.actions.add(debugActionId, function () { RED.sidebar.show(debugTabId); }); if (RED.sidebar.containsTab(debugTabId)) { RED.sidebar.removeTab(debugTabId); } RED.sidebar.addTab({ id: debugTabId, label: "KNX Debug", name: "KNX Debug", iconClass: "fa fa-bug", content: container, action: debugActionId, onchange: function (show) { debugActive = !!show; if (debugActive) { ensureDebugLogContainer(); const shouldForceFull = debugEntries.length === 0; fetchDebugLog({ forceFull: shouldForceFull }); if (debugAutoCheckbox && debugAutoCheckbox.is(':checked')) { ensureDebugAutoRefresh(); } } else { stopDebugAutoRefresh(); } } }); }; RED.actions.add(actionId, function () { RED.sidebar.show(tabId); }); if (RED.sidebar.containsTab(tabId)) { RED.sidebar.removeTab(tabId); } initColumnResize(); let active = false; let timer = null; let currentServer = ""; let lastDataMap = new Map(); let reorderEnabled = true; const placeholderValue = ''; const disabledValue = 'NONE'; const listPlaceholderOption = () => $('<option>', { value: disabledValue }).text('-----'); const storageKey = 'knxUltimateMonitor:selectedGateway'; const loadStoredGateway = () => { try { if (window.localStorage) { return window.localStorage.getItem(storageKey); } } catch (err) { } return disabledValue; }; const storeGateway = (val) => { try { if (window.localStorage) { window.localStorage.setItem(storageKey, val || disabledValue); } } catch (err) { } }; let storedGateway = loadStoredGateway(); let lastItems = []; let filterTerm = ''; const matchesFilter = (item) => { if (!filterTerm) return true; const term = filterTerm; if (item.ga && item.ga.toLowerCase().includes(term)) return true; if (item.devicename && item.devicename.toLowerCase().includes(term)) return true; if (item.valueText && item.valueText.toLowerCase().includes(term)) return true; return false; }; const renderRows = (items) => { lastItems = Array.isArray(items) ? items.slice() : []; const filteredItems = lastItems.filter(matchesFilter); tbody.empty(); if (!filteredItems.length) { table.hide(); emptyNotice.show(); return; } emptyNotice.hide(); table.show(); const highlightSet = new Set(); lastItems.forEach((item) => { const key = item.ga || ''; const stamp = item.updatedAt === undefined ? '' : String(item.updatedAt); const previousStamp = lastDataMap.get(key); if (stamp && stamp !== previousStamp) { highlightSet.add(key); } }); let sortedItems; if (reorderEnabled) { sortedItems = filteredItems.slice().sort((a, b) => { const aChanged = highlightSet.has(a.ga); const bChanged = highlightSet.has(b.ga); if (aChanged !== bChanged) return aChanged ? -1 : 1; if (aChanged && bChanged) { const aTime = Number(a.updatedAt || 0); const bTime = Number(b.updatedAt || 0); return bTime - aTime; } return (a.ga || '').localeCompare(b.ga || ''); }); } else { sortedItems = filteredItems.slice().sort((a, b) => (a.ga || '').localeCompare(b.ga || '')); } sortedItems.forEach((item) => { const row = $("<tr>"); const appendCell = (text, extra) => { const cell = $("<td>").text(text || ""); if (extra) cell.css(extra); row.append(cell); }; appendCell(item.ga || "", { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }); let valueDisplay = item.valueText || ''; const valueStyle = { fontWeight: '600' }; if (typeof item.value === 'boolean') { valueDisplay = item.value ? 'TRUE' : 'FALSE'; valueStyle.color = item.value ? '#0b5d1e' : '#c22929'; } else if (item.value !== null && item.value !== undefined && item.value !== '') { const numericValue = Number(item.value); if (!Number.isNaN(numericValue) && Number.isFinite(numericValue)) { valueDisplay = numericValue.toFixed(2); } } const appliedValueStyle = { ...valueStyle }; const textColor = appliedValueStyle.color; const textWeight = appliedValueStyle.fontWeight; delete appliedValueStyle.color; delete appliedValueStyle.fontWeight; const valueCell = $("<td>").addClass('knx-monitor-value-cell').css(appliedValueStyle); valueCell.css({ alignItems: 'center', height: '100%', maxWidth: 'none', width: '100%' }); const valueSpan = $("<span>").addClass('knx-monitor-value-text').text(valueDisplay); valueSpan.css({ color: textColor || '', fontWeight: textWeight || '', display: 'inline-flex', alignItems: 'center', height: '100%', flex: '0 0 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }); valueCell.append(valueSpan); if (typeof item.value === 'boolean') { const toggleBtn = $('<button>', { type: 'button', class: 'knx-monitor-toggle red-ui-button red-ui-button-small', title: 'Toggle value' }).append($('<i>', { class: 'fa fa-exchange' })); toggleBtn.css({ alignSelf: 'center', marginLeft: '4px' }); toggleBtn.on('click', function (evt) { evt.preventDefault(); evt.stopPropagation(); toggleValue(item.ga, toggleBtn); }); valueCell.append(toggleBtn); } row.append(valueCell); let dptLabel = item.dpt || ""; if (item.dptGuessed) { dptLabel = dptLabel ? dptLabel + " *" : "*"; } appendCell(dptLabel); appendCell(item.devicename || "", { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }); appendCell(item.updatedAt ? new Date(Number(item.updatedAt)).toLocaleString() : ""); if (highlightSet.has(item.ga)) { row.addClass('knx-monitor-highlight'); setTimeout(() => { row.removeClass('knx-monitor-highlight'); }, 500); } tbody.append(row); }); const newMap = new Map(); lastItems.forEach((item) => { if (item.ga) newMap.set(item.ga, item.updatedAt === undefined ? '' : String(item.updatedAt)); }); lastDataMap = newMap; }; const setStatus = (text) => { statusSpan.text(text || ""); }; const fetchData = (force) => { if (!active && !force) return; const serverId = currentServer; if (!serverId || serverId === disabledValue) { renderRows([]); setStatus("Select a KNX gateway to view values."); return; } setStatus("Loading..."); refreshButton.prop("disabled", true); $.getJSON("knxUltimateMonitor", { serverId, _: Date.now() }, function (response) { const items = (response && Array.isArray(response.items)) ? response.items : []; renderRows(items); if (response && response.error && response.error === 'NO_SERVER') { setStatus("Gateway not found. Deploy and try again."); } else if (response && response.error) { setStatus(response.error); } else if (items.length === 0) { setStatus("No telegrams received yet."); } else { setStatus("Updated: " + new Date().toLocaleTimeString()); } }).fail(function (jqXHR) { let message = "Unable to load values."; try { if (jqXHR && jqXHR.responseJSON && jqXHR.responseJSON.error) { message = jqXHR.responseJSON.error; } } catch (err) { /* empty */ } renderRows([]); setStatus(message); }).always(function () { refreshButton.prop("disabled", false); }); }; const stopTimer = () => { if (timer) { clearInterval(timer); timer = null; } }; const startTimer = () => { stopTimer(); if (!autoCheckbox.prop("checked")) return; if (!currentServer || currentServer === disabledValue) return; timer = setInterval(function () { fetchData(false); }, 1000); fetchData(true); }; const populateServers = () => { const options = []; try { RED.nodes.eachConfig(function (configNode) { if (configNode.type === 'knxUltimate-config') { const label = configNode.name || configNode.host || configNode.id; options.push({ id: configNode.id, label }); } }); } catch (err) { /* empty */ } serverSelect.empty(); serverSelect.append(listPlaceholderOption()); if (options.length === 0) { serverSelect.prop('disabled', true); currentServer = disabledValue; renderRows([]); setStatus("No KNX gateways in the current flow."); return false; } serverSelect.prop('disabled', false); options.sort((a, b) => a.label.localeCompare(b.label)); options.forEach((item) => { serverSelect.append($('<option>', { value: item.id }).text(item.label)); }); const candidate = storedGateway && options.some(opt => opt.id === storedGateway) ? storedGateway : disabledValue; serverSelect.val(candidate); currentServer = candidate; storedGateway = candidate; if (currentServer === disabledValue) { renderRows([]); } return true; }; serverSelect.on('change', function () { currentServer = $(this).val(); storeGateway(currentServer); if (!currentServer || currentServer === disabledValue) { currentServer = disabledValue; renderRows([]); setStatus("Select a KNX gateway to view values."); return; } fetchData(true); setTimeout(() => refreshButton.trigger('click'), 1000); }); refreshButton.on('click', function (evt) { evt.preventDefault(); fetchData(true); }); autoCheckbox.on('change', function () { if (autoCheckbox.prop('checked')) { startTimer(); } else { stopTimer(); } }); reorderCheckbox.on('change', function () { reorderEnabled = reorderCheckbox.prop('checked'); if (reorderEnabled) fetchData(true); }); searchInput.on('input', function () { filterTerm = ($(this).val() || '').toString().trim().toLowerCase(); renderRows(lastItems); }); const toggleValue = (ga, button) => { if (!currentServer) { setStatus('Select a KNX gateway to view values.'); return; } if (!ga) return; if (reorderEnabled) { reorderEnabled = false; reorderCheckbox.prop('checked', false); } button.prop('disabled', true); $.post('knxUltimateMonitorToggle', { serverId: currentServer, ga }, function (response) { if (response && response.status === 'ok') { setStatus('Toggled ' + ga + '.'); fetchData(true); } else { const errMsg = (response && response.error) ? response.error : 'Toggle failed.'; setStatus(errMsg); } }).fail(function () { setStatus('Toggle request failed.'); }).always(function () { button.prop('disabled', false); }); }; const handleFlowChange = () => { const hadServers = populateServers(); if (active && hadServers) { if (autoCheckbox.prop('checked')) { startTimer(); } else { fetchData(true); } } }; RED.events.on('flows:started', handleFlowChange); RED.events.on('nodes:add', function (node) { if (node && node.type === 'knxUltimate-config') { handleFlowChange(); } }); RED.events.on('nodes:remove', function (node) { if (node && node.type === 'knxUltimate-config') { handleFlowChange(); } }); initDebugTab(); RED.sidebar.addTab({ id: tabId, label: "KNX Monitor", name: "KNX Monitor", iconClass: "fa fa-eye", content: container, action: actionId, onchange: function (show) { active = !!show; if (active) { const hasServers = populateServers(); if (!hasServers) { setStatus("No KNX gateways in the current flow."); stopTimer(); return; } if (!currentServer) { setStatus("Select a KNX gateway to view values."); } if (autoCheckbox.prop('checked')) { startTimer(); } else { fetchData(true); } } else { stopTimer(); } } }); setTimeout(() => { populateServers(); setStatus("Select a KNX gateway to view values."); if (autoCheckbox.prop('checked')) { startTimer(); } }, 300); } }); })(); </script>