node-red-contrib-knx-ultimate
Version:
Control your KNX and KNX Secure intallation via Node-Red! A bunch of KNX nodes, with integrated Philips HUE control, ETS group address importer, KNX AI for diagnosticsand KNX routing between interfaces. Easy to use and highly configurable.
923 lines (847 loc) • 49.6 kB
HTML
<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>