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.
537 lines (491 loc) • 29.9 kB
HTML
<script type="text/javascript">
(function () {
RED.plugins.registerPlugin("knxUltimateAI-sidebar-plugin", {
onadd: function () {
const tabId = "knxUltimateAITab";
const actionId = "knxUltimateAI:show";
if (!document.getElementById('knx-ai-sidebar-style')) {
$('<style>', {
id: 'knx-ai-sidebar-style',
text: `
#knx-ai-toolbar { padding: 6px 6px !important; display:flex; align-items:center; gap:6px; flex-wrap: wrap; border-bottom: 1px solid rgba(0,0,0,0.06); }
#knx-ai-toolbar select { min-width: 180px; }
#knx-ai-toolbar .knx-ai-spacer { margin-left: auto; }
#knx-ai-status { font-size: 0.8em; color: var(--red-ui-text-color-secondary, #666); }
#knx-ai-body { padding: 6px; overflow:auto; }
.knx-ai-section { margin: 0 0 10px; }
.knx-ai-section h3 { margin: 8px 0 6px; font-size: 1em; font-weight: 600; }
#knx-ai-summary { font-family: Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; line-height: 1.35; white-space: pre-wrap; word-break: break-word; background: #fff; border: 1px solid rgba(0,0,0,0.08); border-radius: 4px; padding: 6px; }
#knx-ai-anomalies { font-size: 0.85em; }
.knx-ai-anomaly { padding: 4px 6px; border-left: 3px solid #ff6f00; background: #fff3e0; margin: 0 0 4px; border-radius: 3px; }
.knx-ai-anomaly .meta { color: #666; font-size: 0.8em; }
#knx-ai-chat { border: 1px solid rgba(0,0,0,0.08); border-radius: 4px; background:#fff; }
#knx-ai-chat-log { max-height: 280px; overflow:auto; padding: 6px; font-size: 0.9em; }
.knx-ai-chat-msg { margin: 0 0 6px; padding: 6px; border-radius: 4px; }
.knx-ai-chat-user { background: rgba(33,150,243,0.08); border-left: 3px solid #2196f3; }
.knx-ai-chat-assistant { background: rgba(76,175,80,0.08); border-left: 3px solid #4caf50; }
.knx-ai-chat-error { background: rgba(244,67,54,0.08); border-left: 3px solid #f44336; }
.knx-ai-chat-pending { background: rgba(96,125,139,0.08); border-left: 3px solid #607d8b; color: #37474f; display:flex; align-items:center; gap:8px; }
.knx-ai-chat-pending i { color: #607d8b; }
.knx-ai-chat-msg p { margin: 0 0 6px; }
.knx-ai-chat-msg p:last-child { margin-bottom: 0; }
.knx-ai-chat-msg pre { margin: 6px 0; padding: 6px; background: rgba(0,0,0,0.04); border-radius: 4px; overflow: auto; }
.knx-ai-chat-msg code { font-family: Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
.knx-ai-chat-msg h1, .knx-ai-chat-msg h2, .knx-ai-chat-msg h3 { margin: 8px 0 6px; font-size: 1em; }
.knx-ai-chat-msg ul { margin: 6px 0 6px 18px; padding: 0; }
.knx-ai-chat-msg .knx-ai-md-table-wrap { margin: 6px 0; overflow-x: auto; }
.knx-ai-chat-msg table { border-collapse: collapse; width: 100%; min-width: 520px; }
.knx-ai-chat-msg th, .knx-ai-chat-msg td { border: 1px solid rgba(0,0,0,0.12); padding: 4px 6px; }
.knx-ai-chat-msg th { background: rgba(0,0,0,0.03); font-weight: 600; }
.knx-ai-chat-msg tbody tr:nth-child(even) { background: rgba(0,0,0,0.02); }
#knx-ai-chat-input { display:flex; gap:6px; padding: 6px; border-top: 1px solid rgba(0,0,0,0.06); }
#knx-ai-chat-input input { flex: 1 1 auto; }
`
}).appendTo('head');
}
RED.actions.add(actionId, function () {
RED.sidebar.show(tabId);
});
if (RED.sidebar.containsTab(tabId)) {
RED.sidebar.removeTab(tabId);
}
const container = $('<div>').css({ display: 'flex', flexDirection: 'column', height: '100%' });
const toolbar = $('<div>').attr('id', 'knx-ai-toolbar').appendTo(container);
const nodeSelect = $('<select>').attr('id', 'knx-ai-node-select').appendTo(toolbar);
const refreshNodesBtn = $('<button type="button" class="red-ui-button"><i class="fa fa-refresh"></i> Refresh Node List</button>').appendTo(toolbar);
const refreshStateBtn = $('<button type="button" class="red-ui-button"><i class="fa fa-repeat"></i> Refresh Summary</button>').appendTo(toolbar);
const autoLabel = $('<label style="display:flex;align-items:center;gap:4px; margin:0 0 0 6px; font-size:0.9em;"></label>').appendTo(toolbar);
const autoChk = $('<input type="checkbox">').appendTo(autoLabel);
$('<span>').text('Auto').appendTo(autoLabel);
$('<div class="knx-ai-spacer"></div>').appendTo(toolbar);
const statusEl = $('<div>').attr('id', 'knx-ai-status').text('Ready').appendTo(toolbar);
const body = $('<div>').attr('id', 'knx-ai-body').appendTo(container);
const emptyNotice = $('<div>').css({ color: '#888', fontStyle: 'italic' }).text('No KNX AI nodes found.').appendTo(body);
const summarySection = $('<div class="knx-ai-section"></div>').appendTo(body);
$('<h3>').text('Summary').appendTo(summarySection);
const summaryPre = $('<pre>').attr('id', 'knx-ai-summary').text('').appendTo(summarySection);
const anomaliesSection = $('<div class="knx-ai-section"></div>').appendTo(body);
$('<h3>').text('Anomalies').appendTo(anomaliesSection);
const anomaliesWrap = $('<div>').attr('id', 'knx-ai-anomalies').appendTo(anomaliesSection);
const chatSection = $('<div class="knx-ai-section"></div>').appendTo(body);
$('<h3>').text('Ask').appendTo(chatSection);
const chatBox = $('<div>').attr('id', 'knx-ai-chat').appendTo(chatSection);
const chatLog = $('<div>').attr('id', 'knx-ai-chat-log').appendTo(chatBox);
const chatInputRow = $('<div>').attr('id', 'knx-ai-chat-input').appendTo(chatBox);
const chatInput = $('<input type="text" placeholder="Ask a question about KNX traffic…">').appendTo(chatInputRow);
const chatSendBtn = $('<button type="button" class="red-ui-button"><i class="fa fa-paper-plane"></i> Send</button>').appendTo(chatInputRow);
const storageKey = 'knxUltimateAI:selectedNodeId';
const autoKey = 'knxUltimateAI:autoRefresh';
const loadStoredNode = () => {
try { return window.localStorage ? window.localStorage.getItem(storageKey) : ''; } catch (e) { return ''; }
};
const storeNode = (val) => {
try { if (window.localStorage) window.localStorage.setItem(storageKey, val || ''); } catch (e) { }
};
const loadAuto = () => {
try { return window.localStorage ? window.localStorage.getItem(autoKey) === 'true' : false; } catch (e) { return false; }
};
const storeAuto = (val) => {
try { if (window.localStorage) window.localStorage.setItem(autoKey, val ? 'true' : 'false'); } catch (e) { }
};
let active = false;
let pollTimer = null;
let nodesTimer = null;
let nodesCache = [];
let lastStateNodeId = '';
let pendingChatEl = null;
const setStatus = (text) => {
statusEl.text(text || '');
};
const setEnabled = (enabled) => {
refreshNodesBtn.prop('disabled', !enabled);
refreshStateBtn.prop('disabled', !enabled);
nodeSelect.prop('disabled', !enabled);
chatInput.prop('disabled', !enabled);
chatSendBtn.prop('disabled', !enabled);
};
const renderAnomalies = (items) => {
anomaliesWrap.empty();
if (!items || !items.length) {
$('<div>').css({ color: '#888', fontStyle: 'italic' }).text('No anomalies.').appendTo(anomaliesWrap);
return;
}
items.slice().reverse().slice(0, 30).forEach((entry) => {
const payload = entry && entry.payload ? entry.payload : {};
const $row = $('<div class="knx-ai-anomaly"></div>').appendTo(anomaliesWrap);
$('<div>').text((payload.type || 'anomaly') + (payload.ga ? (' · ' + payload.ga) : '')).appendTo($row);
$('<div class="meta"></div>').text(entry.at || '').appendTo($row);
$('<div>').css({ fontFamily: 'Menlo, Consolas, monospace', fontSize: '12px', whiteSpace: 'pre-wrap' })
.text(JSON.stringify(payload, null, 2))
.appendTo($row);
});
};
const normalizeChatText = (value) => {
if (value === undefined || value === null) return '';
if (typeof value === 'string') return value;
try { return JSON.stringify(value, null, 2); } catch (e) { return String(value); }
};
const appendChat = (kind, text) => {
const cls = kind === 'user' ? 'knx-ai-chat-user' : (kind === 'assistant' ? 'knx-ai-chat-assistant' : 'knx-ai-chat-error');
const $msg = $('<div class="knx-ai-chat-msg"></div>').addClass(cls).appendTo(chatLog);
if (kind === 'assistant') {
const raw = normalizeChatText(text);
const trimmed = raw.trim();
$msg.html(trimmed ? renderMarkdownToHtml(raw) : renderMarkdownToHtml('(risposta vuota)'));
} else {
$msg.text(normalizeChatText(text));
}
try { chatLog.scrollTop(chatLog[0].scrollHeight); } catch (e) { }
};
const showChatPending = () => {
if (pendingChatEl) return;
pendingChatEl = $('<div class="knx-ai-chat-msg knx-ai-chat-pending"></div>').appendTo(chatLog);
$('<i class="fa fa-spinner fa-spin"></i>').appendTo(pendingChatEl);
$('<span>').text('Sto pensando…').appendTo(pendingChatEl);
try { chatLog.scrollTop(chatLog[0].scrollHeight); } catch (e) { }
};
const hideChatPending = () => {
if (!pendingChatEl) return;
try { pendingChatEl.remove(); } catch (e) { }
pendingChatEl = null;
};
const escapeHtml = (value) => {
const s = String(value || '');
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
const basicMarkdownToHtml = (md) => {
// Minimal markdown renderer (safe: starts from escaped text)
const lines = String(md || '').split(/\r?\n/);
const sanitizeHref = (href) => {
const h = String(href || '').trim();
if (/^javascript:/i.test(h)) return '#';
return h;
};
const renderInline = (text) => {
let out = String(text || '');
out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>');
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (m, label, href) => {
const safeHref = sanitizeHref(href);
return '<a href="' + safeHref + '" target="_blank" rel="noopener noreferrer">' + label + '</a>';
});
return out;
};
const isTableSeparatorLine = (line) => {
const s = String(line || '').trim();
if (!s.includes('-')) return false;
// Typical pipe-table separator: |---:|---|:---:| etc
return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(s);
};
const parsePipeRow = (line) => {
let s = String(line || '').trim();
if (s.startsWith('|')) s = s.slice(1);
if (s.endsWith('|')) s = s.slice(0, -1);
return s.split('|').map(c => String(c || '').trim());
};
const parseAlignments = (sepLine) => {
const cols = parsePipeRow(sepLine);
return cols.map((c) => {
const cell = String(c || '').trim();
const left = cell.startsWith(':');
const right = cell.endsWith(':');
if (left && right) return 'center';
if (right) return 'right';
return 'left';
});
};
let html = '';
let inCode = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/^```/.test(line.trim())) {
inCode = !inCode;
html += inCode ? '<pre><code>' : '</code></pre>';
continue;
}
if (inCode) {
html += line + '\n';
continue;
}
if (/^###\s+/.test(line)) { html += '<h3>' + line.replace(/^###\s+/, '') + '</h3>'; continue; }
if (/^##\s+/.test(line)) { html += '<h2>' + line.replace(/^##\s+/, '') + '</h2>'; continue; }
if (/^#\s+/.test(line)) { html += '<h1>' + line.replace(/^#\s+/, '') + '</h1>'; continue; }
// Pipe table support (GFM-like)
if (line.includes('|') && i + 1 < lines.length && isTableSeparatorLine(lines[i + 1])) {
const headerCells = parsePipeRow(line);
const aligns = parseAlignments(lines[i + 1]);
const rows = [];
i += 2;
while (i < lines.length) {
const rowLine = lines[i];
if (!rowLine || rowLine.trim() === '') break;
if (!rowLine.includes('|')) break;
if (/^```/.test(rowLine.trim())) break;
const cells = parsePipeRow(rowLine);
rows.push(cells);
i++;
}
i -= 1;
const colCount = Math.max(headerCells.length, aligns.length, ...(rows.map(r => r.length)));
html += '<div class="knx-ai-md-table-wrap"><table><thead><tr>';
for (let c = 0; c < colCount; c++) {
const a = aligns[c] || 'left';
const label = headerCells[c] !== undefined ? headerCells[c] : '';
html += '<th style="text-align:' + a + ';">' + renderInline(label) + '</th>';
}
html += '</tr></thead><tbody>';
for (const r of rows) {
html += '<tr>';
for (let c = 0; c < colCount; c++) {
const a = aligns[c] || 'left';
const cell = r[c] !== undefined ? r[c] : '';
html += '<td style="text-align:' + a + ';">' + renderInline(cell) + '</td>';
}
html += '</tr>';
}
html += '</tbody></table></div>';
continue;
}
const isUnordered = /^\s*[-*•·]\s+/.test(line);
const isOrdered = /^\s*\d+\.\s+/.test(line);
if (isUnordered || isOrdered) {
// naive list: accumulate consecutive list lines (unordered/ordered)
const listTag = isOrdered ? 'ol' : 'ul';
const itemRe = isOrdered ? /^\s*\d+\.\s+/ : /^\s*[-*•·]\s+/;
html += '<' + listTag + '>';
while (i < lines.length && itemRe.test(lines[i])) {
const item = lines[i].replace(itemRe, '');
html += '<li>' + renderInline(item) + '</li>';
i++;
}
i--;
html += '</' + listTag + '>';
continue;
}
if (line.trim() === '') { html += '<br>'; continue; }
html += '<p>' + renderInline(line) + '</p>';
}
if (inCode) html += '</code></pre>';
return html;
};
const renderMarkdownToHtml = (markdown) => {
// Prevent raw HTML injection by escaping input first.
const safeMd = escapeHtml(markdown || '');
try {
if (window.marked && typeof window.marked.parse === 'function') {
return window.marked.parse(safeMd, { mangle: false, headerIds: false });
}
} catch (e) { }
return basicMarkdownToHtml(safeMd);
};
const formatSummaryText = (data) => {
const nodeInfo = data && data.node ? data.node : {};
const s = data && data.summary ? data.summary : null;
if (!s) return 'Nessun dato disponibile.';
const lines = [];
const headerBits = [];
if (nodeInfo.name) headerBits.push(nodeInfo.name);
if (nodeInfo.gatewayName) headerBits.push('Gateway: ' + nodeInfo.gatewayName);
if (s.meta && s.meta.generatedAt) headerBits.push('Aggiornato: ' + s.meta.generatedAt);
if (headerBits.length) lines.push(headerBits.join(' · '));
lines.push('');
const c = s.counters || {};
const win = (s.meta && s.meta.analysisWindowSec) ? s.meta.analysisWindowSec : '';
lines.push(`Finestra analisi: ${win}s`);
lines.push(`Telegrammi: ${c.telegrams ?? 0} · Rate: ${(c.overallRatePerSec ?? 0)}/s · Echoed: ${c.echoed ?? 0} · DPT sconosciuti: ${c.unknownDpt ?? 0}`);
if (Array.isArray(s.topGAs) && s.topGAs.length) {
lines.push('');
lines.push('Top Group Address:');
s.topGAs.slice(0, 20).forEach((x, idx) => {
lines.push(`${idx + 1}. ${x.ga} (${x.count})`);
});
}
if (s.byEvent && Object.keys(s.byEvent).length) {
lines.push('');
lines.push('Eventi:');
Object.keys(s.byEvent).sort().forEach((k) => {
lines.push(`- ${k}: ${s.byEvent[k]}`);
});
}
if (Array.isArray(s.patterns) && s.patterns.length) {
lines.push('');
lines.push('Pattern (sequenze ricorrenti):');
s.patterns.slice(0, 15).forEach((p) => {
lines.push(`- ${p.from} → ${p.to} (${p.count} volte entro ${p.withinMs}ms)`);
});
}
return lines.join('\n');
};
const populateNodes = (nodes, preferredId) => {
nodesCache = Array.isArray(nodes) ? nodes : [];
nodeSelect.empty();
if (!nodesCache.length) {
emptyNotice.show();
setEnabled(false);
return;
}
emptyNotice.hide();
setEnabled(true);
nodesCache.forEach((n) => {
const label = (n.name || 'KNX AI') + (n.gatewayName ? (' · ' + n.gatewayName) : '');
$('<option>').attr('value', n.id).text(label).appendTo(nodeSelect);
});
const stored = preferredId || loadStoredNode();
const exists = stored && nodesCache.find(n => n.id === stored);
const selected = exists ? stored : (nodesCache[0] ? nodesCache[0].id : '');
if (selected) nodeSelect.val(selected);
storeNode(selected);
};
const fetchNodes = () => {
setStatus('Loading nodes…');
const currentSelected = nodeSelect.val() || loadStoredNode() || '';
return $.getJSON('knxUltimateAI/sidebar/nodes')
.done((data) => {
populateNodes(data && data.nodes ? data.nodes : [], currentSelected);
setStatus('Ready');
})
.fail((xhr) => {
setEnabled(false);
let err = 'Failed to load nodes';
try { if (xhr && xhr.responseJSON && xhr.responseJSON.error) err = xhr.responseJSON.error; } catch (e) { }
setStatus(err);
});
};
const fetchState = (opts) => {
const nodeId = nodeSelect.val() || '';
if (!nodeId) return;
const fresh = opts && opts.fresh ? 1 : 0;
lastStateNodeId = nodeId;
setStatus('Loading…');
return $.getJSON('knxUltimateAI/sidebar/state?nodeId=' + encodeURIComponent(nodeId) + '&fresh=' + fresh)
.done((data) => {
summaryPre.text(formatSummaryText(data));
renderAnomalies(data && data.anomalies ? data.anomalies : []);
const llmEnabled = data && data.node ? !!data.node.llmEnabled : false;
if (!llmEnabled) {
chatInput.prop('disabled', true);
chatSendBtn.prop('disabled', true);
chatInput.attr('placeholder', 'LLM disabled in node config');
} else {
chatInput.prop('disabled', false);
chatSendBtn.prop('disabled', false);
chatInput.attr('placeholder', 'Ask a question about KNX traffic…');
}
setStatus('Ready');
})
.fail((xhr) => {
let err = 'Failed to load state';
try { if (xhr && xhr.responseJSON && xhr.responseJSON.error) err = xhr.responseJSON.error; } catch (e) { }
setStatus(err);
});
};
const sendAsk = () => {
const nodeId = nodeSelect.val() || '';
const q = (chatInput.val() || '').trim();
if (!nodeId || !q) return;
chatInput.val('');
appendChat('user', q);
setStatus('Asking…');
chatSendBtn.prop('disabled', true);
showChatPending();
return $.ajax({
url: 'knxUltimateAI/sidebar/ask',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ nodeId: nodeId, question: q })
})
.done((data) => {
const answer = (data && data.answer !== undefined) ? data.answer : '';
hideChatPending();
appendChat('assistant', answer);
setStatus('Ready');
})
.fail((xhr) => {
let err = 'Ask failed';
try { if (xhr && xhr.responseJSON && xhr.responseJSON.error) err = xhr.responseJSON.error; } catch (e) { }
hideChatPending();
appendChat('error', err);
setStatus('Ready');
})
.always(() => {
hideChatPending();
chatSendBtn.prop('disabled', false);
});
};
const startPolling = () => {
if (pollTimer) return;
pollTimer = setInterval(() => {
if (!active) return;
if (!autoChk.is(':checked')) return;
fetchState({ fresh: false });
}, 2000);
if (!nodesTimer) {
nodesTimer = setInterval(() => {
if (!active) return;
fetchNodes();
}, 5000);
}
};
const stopPolling = () => {
if (pollTimer) clearInterval(pollTimer);
pollTimer = null;
if (nodesTimer) clearInterval(nodesTimer);
nodesTimer = null;
};
refreshNodesBtn.on('click', function () {
fetchNodes().done(() => fetchState({ fresh: false }));
});
refreshStateBtn.on('click', function () {
fetchState({ fresh: true });
});
nodeSelect.on('change', function () {
const nodeId = nodeSelect.val() || '';
storeNode(nodeId);
chatLog.empty();
fetchState({ fresh: false });
});
autoChk.on('change', function () {
storeAuto($(this).is(':checked'));
});
chatSendBtn.on('click', function (evt) {
evt.preventDefault();
sendAsk();
});
chatInput.on('keydown', function (evt) {
if (evt.key === 'Enter') {
evt.preventDefault();
sendAsk();
}
});
// init persisted state
autoChk.prop('checked', loadAuto());
RED.sidebar.addTab({
id: tabId,
label: "KNX AI",
name: "KNX AI",
iconClass: "fa fa-magic",
content: container,
action: actionId,
onchange: function (show) {
active = !!show;
if (active) {
setEnabled(true);
fetchNodes().done(() => fetchState({ fresh: false }));
startPolling();
} else {
stopPolling();
}
}
});
// Auto-refresh node list once at plugin load (so the tab is ready immediately)
try { fetchNodes(); } catch (e) { }
}
});
})();
</script>