diff --git a/static/js/modes/meshcore.js b/static/js/modes/meshcore.js new file mode 100644 index 0000000..0ddad3b --- /dev/null +++ b/static/js/modes/meshcore.js @@ -0,0 +1,471 @@ +/** + * 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 = {}; // node_id → L.marker + let _telemetryChart = null; + let _connected = false; + + // ── 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; + } + + 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 = !_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 { + await fetch('/meshcore/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + _updateStatusUI('connecting'); + } catch (e) { console.error('Connect failed:', e); } + } + + 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() { + try { + const r = await fetch('/meshcore/ble/scan'); + const d = await r.json(); + const sel = document.getElementById('meshcoreBleSelect'); + if (!sel) return; + sel.innerHTML = ''; + (d.devices || []).forEach(dev => { + const o = document.createElement('option'); + o.value = dev.address; + o.textContent = `${dev.name || 'Unknown'} (${dev.address}) RSSI ${dev.rssi}`; + sel.appendChild(o); + }); + } catch (e) { console.error('BLE scan failed:', e); } + } + + // ── 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; + } + + 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; + list.innerHTML = ''; + list.appendChild(el); + } + const hops = node.hops_away !== null ? `${node.hops_away}h` : '?'; + const snr = node.snr !== null ? `${node.snr}dB` : ''; + el.innerHTML = ` + +