// MeshDD-Bot – Paket-Log const MAX_ROWS = 300; const UNKNOWN_TYPE = '__unknown__'; let ws = null; let nodes = {}; // node_id -> {long_name, short_name, ...} let channels = {}; // ch_index -> channel name string let myNodeId = null; let paused = false; let activeFilter = 'all'; let pendingRows = []; let filterFrom = ''; let filterTo = ''; let filterChannel = ''; let filterHops = ''; let searchText = ''; const pktBody = document.getElementById('pktBody'); const pktCount = document.getElementById('pktCount'); const pktFilterBar = document.getElementById('pktFilterBar'); const pktPauseBtn = document.getElementById('pktPauseBtn'); const pktClearBtn = document.getElementById('pktClearBtn'); // ── Portnum config ───────────────────────────────────────────── const PORTNUM_CFG = { TEXT_MESSAGE_APP: { label: 'Text', color: 'info' }, POSITION_APP: { label: 'Position', color: 'success' }, NODEINFO_APP: { label: 'NodeInfo', color: 'primary' }, TELEMETRY_APP: { label: 'Telemetry', color: 'warning' }, ROUTING_APP: { label: 'Routing', color: 'secondary' }, ADMIN_APP: { label: 'Admin', color: 'danger' }, TRACEROUTE_APP: { label: 'Traceroute', color: 'purple' }, NEIGHBORINFO_APP: { label: 'Neighbor', color: 'teal' }, RANGE_TEST_APP: { label: 'RangeTest', color: 'orange' }, [UNKNOWN_TYPE]: { label: '?', color: 'secondary' }, }; const knownTypes = new Set(); function typeKey(portnum) { return portnum || UNKNOWN_TYPE; } function typeCfg(key) { return PORTNUM_CFG[key] || { label: key.replace(/_APP$/, ''), color: 'secondary' }; } // ── Helpers ──────────────────────────────────────────────────── function nodeName(id) { if (!id) return '—'; const n = nodes[id]; if (n) { const name = n.short_name || n.long_name; if (name) return escapeHtml(name); } return escapeHtml(id); } function nodeTitle(id) { if (!id) return ''; const n = nodes[id]; if (n && (n.long_name || n.short_name)) { return `title="${escapeHtml(n.long_name || n.short_name)} (${escapeHtml(id)})"`; } return ''; } function fmtTime(ts) { if (!ts) return '—'; const d = new Date(ts * 1000); return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`; } function fmtTo(toId) { const broadcast = ['4294967295', '^all', 'ffffffff', '4294967295']; if (!toId || broadcast.includes(String(toId))) { return 'Alle'; } return `${nodeName(toId)}`; } function fmtChannel(ch) { if (ch == null) return ''; const name = channels[ch]; if (name) return `${escapeHtml(name)}`; return `${ch}`; } // Colors that need dark text on their background const _LIGHT_BG = new Set(['warning', 'yellow', 'lime']); function portnumBadge(portnum) { const key = typeKey(portnum); const cfg = typeCfg(key); const txtCls = _LIGHT_BG.has(cfg.color) ? 'text-dark' : 'text-white'; return `${escapeHtml(cfg.label)}`; } function isSuppressed(pkt) { if (pkt.portnum !== 'TELEMETRY_APP') return false; if (myNodeId && pkt.from_id === myNodeId) return true; const n = nodes[pkt.from_id]; return !!(n && n.short_name === 'FTLW'); } function fmtPayload(portnum, payloadStr) { let p = {}; try { p = JSON.parse(payloadStr || '{}'); } catch { return ''; } if (portnum === 'TEXT_MESSAGE_APP' && p.text) return `${escapeHtml(p.text)}`; if (portnum === 'POSITION_APP' && p.lat != null) { const parts = [`${p.lat.toFixed(5)}, ${p.lon.toFixed(5)}`]; if (p.alt != null && p.alt !== 0) parts.push(`${p.alt} m`); if (p.speed != null && p.speed !== 0) parts.push(`${p.speed} km/h`); if (p.sats != null) parts.push(`${p.sats} Sat`); return `${parts.join(' · ')}`; } if (portnum === 'TELEMETRY_APP') { const parts = []; if (p.battery != null) parts.push(`🔋 ${p.battery}%`); if (p.voltage != null) parts.push(`${p.voltage.toFixed(2)} V`); if (p.ch_util != null) parts.push(`CH ${p.ch_util.toFixed(1)}%`); if (p.air_util != null) parts.push(`TX ${p.air_util.toFixed(1)}%`); if (p.temp != null) parts.push(`🌡 ${p.temp.toFixed(1)} °C`); if (p.humidity != null) parts.push(`💧 ${p.humidity.toFixed(0)}%`); if (p.pressure != null) parts.push(`${p.pressure.toFixed(0)} hPa`); return parts.length ? `${parts.join(' · ')}` : ''; } if (portnum === 'NODEINFO_APP' && (p.long_name || p.short_name)) { let s = escapeHtml(p.long_name || ''); if (p.short_name) s += ` [${escapeHtml(p.short_name)}]`; if (p.hw_model) s += ` ${escapeHtml(p.hw_model)}`; return `${s}`; } if (portnum === 'ROUTING_APP' && p.error && p.error !== 'NONE') return `${escapeHtml(p.error)}`; if (portnum === 'TRACEROUTE_APP' && p.hops != null) return `${p.hops} Hop${p.hops !== 1 ? 's' : ''}`; if (portnum === 'NEIGHBORINFO_APP' && p.count != null) return `${p.count} Nachbar${p.count !== 1 ? 'n' : ''}`; return ''; } function fmtSnr(v) { if (v == null) return ''; const cls = v >= 5 ? 'text-success' : v >= 0 ? 'text-warning' : 'text-danger'; return `${v > 0 ? '+' : ''}${v}`; } function fmtRssi(v) { if (v == null) return ''; const cls = v >= -100 ? 'text-success' : v >= -115 ? 'text-warning' : 'text-danger'; return `${v}`; } function fmtHops(limit, start) { if (start == null || limit == null) return ''; return `${start - limit}/${start}`; } // ── Filter bar ───────────────────────────────────────────────── function renderFilterBar() { const types = ['all', ...Array.from(knownTypes).sort((a, b) => { // UNKNOWN_TYPE always last if (a === UNKNOWN_TYPE) return 1; if (b === UNKNOWN_TYPE) return -1; return a.localeCompare(b); })]; pktFilterBar.innerHTML = types.map(t => { const active = t === activeFilter; if (t === 'all') { return ``; } const cfg = typeCfg(t); return ``; }).join(''); } pktFilterBar.addEventListener('click', e => { const btn = e.target.closest('.pkt-filter-btn'); if (!btn) return; activeFilter = btn.dataset.type; renderFilterBar(); applyFilter(); }); function rowVisible(row) { const typeOk = activeFilter === 'all' || row.dataset.type === activeFilter; const fromOk = !filterFrom || (row.dataset.from || '').includes(filterFrom); const toOk = !filterTo || (row.dataset.to || '').includes(filterTo); const chOk = !filterChannel || row.dataset.channel === filterChannel; const hopsOk = filterHops === '' || ( row.dataset.hops !== '' && parseInt(row.dataset.hops, 10) <= parseInt(filterHops, 10) ); const searchOk = !searchText || (row.dataset.search || '').includes(searchText); return typeOk && fromOk && toOk && chOk && hopsOk && searchOk; } function applyFilter() { pktBody.querySelectorAll('tr[data-type]').forEach(row => { row.classList.toggle('d-none', !rowVisible(row)); }); const hasFilter = filterFrom || filterTo || filterChannel || filterHops !== '' || searchText; document.getElementById('fClearBtn').classList.toggle('d-none', !hasFilter); } function fillChannelSelect() { const sel = document.getElementById('fChSelect'); if (!sel) return; const prev = sel.value; while (sel.options.length > 1) sel.remove(1); Object.entries(channels).sort(([a], [b]) => Number(a) - Number(b)).forEach(([idx, name]) => { const opt = document.createElement('option'); opt.value = String(idx); opt.textContent = name ? `${idx}: ${name}` : String(idx); sel.appendChild(opt); }); sel.value = prev; } // ── Row rendering ────────────────────────────────────────────── function buildSearchData(pkt, key) { const fromName = `${pkt.from_id || ''} ${nodes[pkt.from_id]?.long_name || ''} ${nodes[pkt.from_id]?.short_name || ''}`.toLowerCase().trim(); const isBcast = !pkt.to_id || ['4294967295', 'ffffffff'].includes(String(pkt.to_id)); const toName = isBcast ? 'alle broadcast' : `${pkt.to_id} ${nodes[pkt.to_id]?.long_name || ''} ${nodes[pkt.to_id]?.short_name || ''}`.toLowerCase().trim(); const chName = `${pkt.channel ?? ''} ${channels[pkt.channel] || channels[Number(pkt.channel)] || ''}`.toLowerCase().trim(); let payloadTxt = ''; try { const p = JSON.parse(pkt.payload || '{}'); payloadTxt = Object.values(p).filter(v => typeof v === 'string' || typeof v === 'number').join(' '); } catch {} return { from: fromName, to: toName, search: `${fromName} ${toName} ${typeCfg(key).label.toLowerCase()} ${chName} ${payloadTxt}`.toLowerCase(), }; } function buildRow(pkt) { const key = typeKey(pkt.portnum); const sd = buildSearchData(pkt, key); const tr = document.createElement('tr'); tr.dataset.type = key; tr.dataset.from = sd.from; tr.dataset.to = sd.to; tr.dataset.channel = String(pkt.channel ?? ''); tr.dataset.hops = (pkt.hop_start != null && pkt.hop_limit != null) ? String(pkt.hop_start - pkt.hop_limit) : ''; tr.dataset.search = sd.search; if (!rowVisible(tr)) tr.classList.add('d-none'); tr.innerHTML = `${fmtTime(pkt.timestamp)}` + `${nodeName(pkt.from_id)}` + `${fmtTo(pkt.to_id)}` + `${portnumBadge(pkt.portnum)}` + `${fmtChannel(pkt.channel)}` + `${fmtSnr(pkt.snr)}` + `${fmtRssi(pkt.rssi)}` + `${fmtHops(pkt.hop_limit, pkt.hop_start)}` + `${fmtPayload(pkt.portnum, pkt.payload)}`; return tr; } function addRow(pkt, prepend = true) { if (isSuppressed(pkt)) return; knownTypes.add(typeKey(pkt.portnum)); const row = buildRow(pkt); if (prepend) { pktBody.prepend(row); while (pktBody.children.length > MAX_ROWS) pktBody.removeChild(pktBody.lastChild); } else { pktBody.appendChild(row); } updateCount(); } function updateCount() { pktCount.textContent = `${pktBody.children.length} Einträge`; } // ── Pause / Clear ────────────────────────────────────────────── pktPauseBtn.addEventListener('click', () => { paused = !paused; pktPauseBtn.querySelector('i').className = paused ? 'bi bi-play-fill' : 'bi bi-pause-fill'; pktPauseBtn.title = paused ? 'Weiter' : 'Pause'; if (!paused && pendingRows.length) { pendingRows.forEach(p => addRow(p, true)); pendingRows = []; renderFilterBar(); } }); pktClearBtn.addEventListener('click', () => { pktBody.innerHTML = ''; pendingRows = []; updateCount(); }); // ── WebSocket ────────────────────────────────────────────────── function connectWs() { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; ws = new WebSocket(`${proto}://${location.host}/ws`); ws.onopen = () => { document.getElementById('statusDot').classList.add('connected'); document.getElementById('statusText').textContent = 'Verbunden'; }; ws.onclose = () => { document.getElementById('statusDot').classList.remove('connected'); document.getElementById('statusText').textContent = 'Getrennt'; setTimeout(connectWs, 3000); }; ws.onmessage = e => handleMsg(JSON.parse(e.data)); } function handleMsg(msg) { switch (msg.type) { case 'initial': (msg.data || []).forEach(n => { if (n.node_id) nodes[n.node_id] = n; }); break; case 'node_update': if (msg.data?.node_id) nodes[msg.data.node_id] = msg.data; break; case 'channels': channels = msg.data || {}; fillChannelSelect(); break; case 'my_node_id': myNodeId = msg.data; break; case 'initial_packets': pktBody.innerHTML = ''; (msg.data || []).forEach(p => { if (isSuppressed(p)) return; knownTypes.add(typeKey(p.portnum)); pktBody.appendChild(buildRow(p)); }); renderFilterBar(); updateCount(); break; case 'packet': if (paused) { pendingRows.push(msg.data); } else { addRow(msg.data, true); renderFilterBar(); } break; case 'bot_status': if (msg.data) { const dot = document.getElementById('statusDot'); const text = document.getElementById('statusText'); if (dot) dot.classList.toggle('connected', !!msg.data.connected); if (text) text.textContent = msg.data.connected ? 'Verbunden' : 'Getrennt'; } break; } } // ── Erweiterte Filter-Listener ────────────────────────────────── document.getElementById('fVon').addEventListener('input', e => { filterFrom = e.target.value.trim().toLowerCase(); applyFilter(); }); document.getElementById('fAn').addEventListener('input', e => { filterTo = e.target.value.trim().toLowerCase(); applyFilter(); }); document.getElementById('fChSelect').addEventListener('change', e => { filterChannel = e.target.value; applyFilter(); }); document.getElementById('fHops').addEventListener('input', e => { filterHops = e.target.value.trim(); applyFilter(); }); document.getElementById('fText').addEventListener('input', e => { searchText = e.target.value.trim().toLowerCase(); applyFilter(); }); document.getElementById('fClearBtn').addEventListener('click', () => { filterFrom = filterTo = filterChannel = searchText = ''; filterHops = ''; document.getElementById('fVon').value = ''; document.getElementById('fAn').value = ''; document.getElementById('fChSelect').value = ''; document.getElementById('fHops').value = ''; document.getElementById('fText').value = ''; applyFilter(); }); // ── Init ─────────────────────────────────────────────────────── initPage(); connectWs();