/** * Meshcore Mode * Handles connection, live SSE streaming, message feed, map, telemetry, * repeater management, contacts, and traceroute visualization. */ const MeshCore = (function () { // ── State ────────────────────────────────────────────────────────────── let _transport = 'serial'; let _eventSource = null; let _map = null; let _markers = {}; let _telemetryChart = null; let _connected = false; let _nodeCount = 0; let _msgCount = 0; // ── Init / Destroy ───────────────────────────────────────────────────── function init() { _loadPorts(); _checkStatus(); _initMap(); } function destroy() { if (_eventSource) { _eventSource.close(); _eventSource = null; } if (_map) { _map.remove(); _map = null; _markers = {}; } if (_telemetryChart) { _telemetryChart.destroy(); _telemetryChart = null; } _connected = false; _nodeCount = 0; _msgCount = 0; } function invalidateMap() { if (_map) _map.invalidateSize(); } // ── Status ───────────────────────────────────────────────────────────── async function _checkStatus() { try { const r = await fetch('/meshcore/status'); const d = await r.json(); _updateStatusUI(d.state || 'disconnected', d.message); } catch (e) { /* ignore */ } } function _updateStatusUI(state, message) { const dot = document.getElementById('meshcoreStatusDot'); const txt = document.getElementById('meshcoreStatusText'); const connectBtn = document.getElementById('meshcoreConnectBtn'); const disconnectBtn = document.getElementById('meshcoreDisconnectBtn'); if (!dot) return; dot.className = 'meshcore-status-dot ' + state; const labels = { connected: 'Connected', connecting: 'Connecting…', error: 'Error', disconnected: 'Disconnected', unavailable: 'Not available' }; txt.textContent = message || labels[state] || state; _connected = state === 'connected'; if (connectBtn) connectBtn.disabled = state === 'connecting' || _connected; if (disconnectBtn) disconnectBtn.disabled = state !== 'connecting' && !_connected; if (_connected && !_eventSource) _startSSE(); if (!_connected && _eventSource) { _eventSource.close(); _eventSource = null; } } // ── Transport selector ───────────────────────────────────────────────── function selectTransport(t) { _transport = t; document.querySelectorAll('.meshcore-transport-tab').forEach(el => { el.classList.toggle('active', el.dataset.transport === t); }); document.getElementById('meshcoreSerialConfig').style.display = t === 'serial' ? '' : 'none'; document.getElementById('meshcoreTcpConfig').style.display = t === 'tcp' ? '' : 'none'; document.getElementById('meshcoreBleConfig').style.display = t === 'ble' ? '' : 'none'; } // ── Connect / Disconnect ─────────────────────────────────────────────── async function connect() { let body = { transport: _transport }; if (_transport === 'serial') { body.port = document.getElementById('meshcorePortSelect').value || null; } else if (_transport === 'tcp') { body.host = document.getElementById('meshcoreTcpHost').value; body.port = parseInt(document.getElementById('meshcoreTcpPort').value, 10); } else if (_transport === 'ble') { body.address = document.getElementById('meshcoreBleSelect').value || null; } try { _updateStatusUI('connecting'); await fetch('/meshcore/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); _pollUntilConnected(0); } catch (e) { _updateStatusUI('error', 'Connection failed'); console.error('Connect failed:', e); } } function _pollUntilConnected(attempts) { if (_connected) return; if (attempts > 45) { // Backend retry window (5+15+45s) has elapsed — give up _updateStatusUI('error', 'Connection timed out'); return; } setTimeout(async () => { await _checkStatus(); if (!_connected) _pollUntilConnected(attempts + 1); }, 2000); } async function disconnect() { try { await fetch('/meshcore/disconnect', { method: 'POST' }); _updateStatusUI('disconnected'); } catch (e) { console.error('Disconnect failed:', e); } } // ── Port / BLE discovery ─────────────────────────────────────────────── async function _loadPorts() { try { const r = await fetch('/meshcore/ports'); const d = await r.json(); const sel = document.getElementById('meshcorePortSelect'); if (!sel) return; const current = sel.value; sel.innerHTML = ''; (d.ports || []).forEach(p => { const o = document.createElement('option'); o.value = p; o.textContent = p; if (p === current) o.selected = true; sel.appendChild(o); }); } catch (e) { /* ignore */ } } async function scanBle() { const btn = document.querySelector('[onclick="MeshCore.scanBle()"]'); const sel = document.getElementById('meshcoreBleSelect'); if (btn) { btn.textContent = 'Scanning…'; btn.disabled = true; } if (sel) sel.innerHTML = ''; try { const r = await fetch('/meshcore/ble/scan'); const d = await r.json(); if (!sel) return; const devices = d.devices || []; if (!devices.length) { sel.innerHTML = ''; return; } sel.innerHTML = ''; devices.forEach(dev => { const o = document.createElement('option'); o.value = dev.address; o.textContent = `${dev.name || 'Unknown'} (${dev.address})${dev.rssi ? ' · ' + dev.rssi + ' dBm' : ''}`; sel.appendChild(o); }); if (devices.length === 1) sel.value = devices[0].address; } catch (e) { if (sel) sel.innerHTML = ''; console.error('BLE scan failed:', e); } finally { if (btn) { btn.textContent = 'Scan'; btn.disabled = false; } } } // ── SSE Stream ───────────────────────────────────────────────────────── function _startSSE() { if (_eventSource) _eventSource.close(); _eventSource = new EventSource('/meshcore/stream'); _eventSource.onmessage = (e) => { try { const event = JSON.parse(e.data); _routeEvent(event); } catch (err) { /* ignore malformed */ } }; _eventSource.onerror = () => { setTimeout(_checkStatus, 2000); }; } function _routeEvent(event) { switch (event.type) { case 'status': _updateStatusUI(event.data.state, event.data.message); break; case 'message': _appendMessage(event.data); break; case 'node': _updateNode(event.data); break; case 'telemetry': _storeTelemetry(event.data); break; case 'traceroute': _showTraceroute(event.data); break; } } // ── Messages ─────────────────────────────────────────────────────────── function _appendMessage(msg) { const feed = document.getElementById('meshcoreMessageFeed'); if (!feed) return; const placeholder = feed.querySelector('div[style*="padding:24px"]'); if (placeholder) placeholder.remove(); const el = document.createElement('div'); el.className = 'meshcore-message' + (msg.is_direct ? ' direct' : '') + (msg.pending ? ' pending' : ''); el.dataset.msgId = msg.id; const ts = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : ''; const snr = msg.snr !== null && msg.snr !== undefined ? ` · ${msg.snr} dB` : ''; el.innerHTML = `
`; feed.appendChild(el); feed.scrollTop = feed.scrollHeight; _msgCount++; const mc = document.getElementById('meshcoreMsgCount'); if (mc) mc.textContent = _msgCount; } async function sendMessage() { const input = document.getElementById('meshcoreComposeInput'); const recipientSel = document.getElementById('meshcoreRecipientSelect'); const text = input ? input.value.trim() : ''; if (!text) return; const recipient_id = recipientSel ? recipientSel.value : 'BROADCAST'; const tempId = 'pending-' + Date.now(); _appendMessage({ id: tempId, sender_id: 'Me', recipient_id, text, timestamp: new Date().toISOString(), is_direct: recipient_id !== 'BROADCAST', snr: null, pending: true }); if (input) input.value = ''; try { const r = await fetch('/meshcore/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, recipient_id }), }); if (!r.ok) { const d = await r.json(); _removePending(tempId); alert(d.error || 'Send failed'); } } catch (e) { _removePending(tempId); console.error('Send failed:', e); } } function _removePending(id) { const el = document.querySelector(`[data-msg-id="${id}"]`); if (el) el.remove(); } // ── Nodes ────────────────────────────────────────────────────────────── function _updateNode(node) { _updateNodeSidebar(node); _updateMapMarker(node); _updateRepeaterTable(node); _updateTelemetryNodeSelect(node); _updateRecipientSelect(node); } function _updateNodeSidebar(node) { const list = document.getElementById('meshcoreNodeList'); if (!list) return; let el = document.getElementById('meshcore-node-' + node.node_id); if (!el) { el = document.createElement('div'); el.className = 'meshcore-node-item'; el.id = 'meshcore-node-' + node.node_id; const empty = list.querySelector('.meshcore-empty'); if (empty) empty.remove(); list.appendChild(el); _nodeCount++; const nc = document.getElementById('meshcoreNodeCount'); if (nc) nc.textContent = _nodeCount; } const hops = node.hops_away !== null ? `${node.hops_away}h` : '?'; const snr = node.snr !== null ? `${node.snr}dB` : ''; el.innerHTML = `