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