/** * Meshtastic Mode * Mesh network monitoring and configuration */ const Meshtastic = (function() { // State let isConnected = false; let eventSource = null; let messages = []; let channels = []; let nodeInfo = null; let uniqueNodes = new Set(); let currentFilter = ''; let editingChannelIndex = null; // Map state let meshMap = null; let meshMarkers = {}; // nodeId -> marker let localNodeId = null; /** * Initialize the Meshtastic mode */ function init() { initMap(); loadPorts(); checkStatus(); setupEventDelegation(); } /** * Setup event delegation for dynamically created elements */ function setupEventDelegation() { // Handle button clicks in Leaflet popups and elsewhere document.addEventListener('click', function(e) { const tracerouteBtn = e.target.closest('.mesh-traceroute-btn'); if (tracerouteBtn) { const nodeId = tracerouteBtn.dataset.nodeId; if (nodeId) { sendTraceroute(nodeId); } } const positionBtn = e.target.closest('.mesh-position-btn'); if (positionBtn) { const nodeId = positionBtn.dataset.nodeId; if (nodeId) { requestPosition(nodeId); } } const qrBtn = e.target.closest('.mesh-qr-btn'); if (qrBtn) { const channelIndex = qrBtn.dataset.channelIndex; if (channelIndex !== undefined) { showChannelQR(parseInt(channelIndex, 10)); } } }); } /** * Load available serial ports and populate dropdown */ async function loadPorts() { try { const response = await fetch('/meshtastic/ports'); const data = await response.json(); const select = document.getElementById('meshStripDevice'); if (!select) return; // Clear existing options except auto-detect select.innerHTML = ''; if (data.status === 'ok' && data.ports && data.ports.length > 0) { data.ports.forEach(port => { const option = document.createElement('option'); option.value = port; option.textContent = port; select.appendChild(option); }); // If multiple ports, select the first one by default to avoid auto-detect failure if (data.ports.length > 1) { select.value = data.ports[0]; showStatusMessage(`Multiple ports detected. Selected ${data.ports[0]}`, 'warning'); } } } catch (err) { console.error('Failed to load ports:', err); } } /** * Initialize the Leaflet map */ async function initMap() { if (meshMap) return; const mapContainer = document.getElementById('meshMap'); if (!mapContainer) return; // Default to center of US const defaultLat = 39.8283; const defaultLon = -98.5795; meshMap = L.map('meshMap').setView([defaultLat, defaultLon], 4); window.meshMap = meshMap; // Use settings manager for tile layer (allows runtime changes) if (typeof Settings !== 'undefined') { // Wait for settings to load from server before applying tiles await Settings.init(); Settings.createTileLayer().addTo(meshMap); Settings.registerMap(meshMap); } else { L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { attribution: '© OSM © CARTO', maxZoom: 19, subdomains: 'abcd', className: 'tile-layer-cyan' }).addTo(meshMap); } // Handle resize setTimeout(() => { if (meshMap) meshMap.invalidateSize(); }, 100); } /** * Check current connection status */ async function checkStatus() { try { const response = await fetch('/meshtastic/status'); const data = await response.json(); if (!data.available) { showStatusMessage('SDK not installed. Install with: pip install meshtastic', 'warning'); return; } if (data.running) { isConnected = true; updateConnectionUI(true, data.device, data.connection_type); if (data.node_info) { updateNodeInfo(data.node_info); localNodeId = data.node_info.num; } loadChannels(); loadMessages(); loadNodes(); startStream(); } } catch (err) { console.error('Failed to check Meshtastic status:', err); } } /** * Handle connection type change (serial vs TCP) */ function onConnectionTypeChange() { const connTypeSelect = document.getElementById('meshStripConnType'); const deviceSelect = document.getElementById('meshStripDevice'); const hostnameInput = document.getElementById('meshStripHostname'); if (!connTypeSelect) return; const connType = connTypeSelect.value; if (connType === 'tcp') { // Show hostname input, hide device select if (deviceSelect) deviceSelect.style.display = 'none'; if (hostnameInput) hostnameInput.style.display = 'block'; } else { // Show device select, hide hostname input if (deviceSelect) deviceSelect.style.display = 'block'; if (hostnameInput) hostnameInput.style.display = 'none'; } } /** * Start Meshtastic connection */ async function start() { // Get connection type const connTypeSelect = document.getElementById('meshStripConnType'); const connectionType = connTypeSelect?.value || 'serial'; // Get connection parameters based on type let device = null; let hostname = null; if (connectionType === 'tcp') { // TCP connection - get hostname const hostnameInput = document.getElementById('meshStripHostname'); hostname = hostnameInput?.value?.trim() || null; if (!hostname) { showStatusMessage('Please enter a hostname or IP address for TCP connection', 'error'); updateStatusIndicator('disconnected', 'Enter hostname'); return; } } else { // Serial connection - get device const stripDeviceSelect = document.getElementById('meshStripDevice'); const sidebarDeviceSelect = document.getElementById('meshDeviceSelect'); device = stripDeviceSelect?.value || sidebarDeviceSelect?.value || null; // Check if auto-detect is selected but multiple ports exist if (!device && stripDeviceSelect && stripDeviceSelect.options.length > 2) { // Multiple ports available - prompt user to select one showStatusMessage('Multiple ports detected. Please select a specific device from the dropdown.', 'warning'); updateStatusIndicator('disconnected', 'Select a device'); return; } } updateStatusIndicator('connecting', 'Connecting...'); // Update strip status const stripDot = document.getElementById('meshStripDot'); const stripStatus = document.getElementById('meshStripStatus'); if (stripDot) stripDot.className = 'mesh-strip-dot connecting'; if (stripStatus) stripStatus.textContent = 'Connecting...'; try { const requestBody = { connection_type: connectionType }; if (connectionType === 'tcp') { requestBody.hostname = hostname; } else if (device) { requestBody.device = device; } const response = await fetch('/meshtastic/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); const data = await response.json(); if (data.status === 'started' || data.status === 'already_running') { isConnected = true; updateConnectionUI(true, data.device, data.connection_type); if (data.node_info) { updateNodeInfo(data.node_info); localNodeId = data.node_info.num; } loadChannels(); loadNodes(); startStream(); const connLabel = data.connection_type === 'tcp' ? 'TCP' : 'Serial'; showNotification('Meshtastic', `Connected via ${connLabel}`); } else { updateStatusIndicator('disconnected', data.message || 'Connection failed'); showStatusMessage(data.message || 'Failed to connect', 'error'); } } catch (err) { console.error('Failed to start Meshtastic:', err); updateStatusIndicator('disconnected', 'Connection error'); showStatusMessage('Connection error: ' + err.message, 'error'); } } /** * Stop Meshtastic connection */ async function stop() { try { await fetch('/meshtastic/stop', { method: 'POST' }); isConnected = false; stopStream(); updateConnectionUI(false); showNotification('Meshtastic', 'Disconnected'); } catch (err) { console.error('Failed to stop Meshtastic:', err); } } /** * Update connection UI state */ function updateConnectionUI(connected, device, connectionType) { const connectBtn = document.getElementById('meshConnectBtn'); const disconnectBtn = document.getElementById('meshDisconnectBtn'); const nodeSection = document.getElementById('meshNodeSection'); const channelsSection = document.getElementById('meshChannelsSection'); const statsSection = document.getElementById('meshStatsSection'); const filterSection = document.getElementById('meshFilterSection'); const composeBox = document.getElementById('meshCompose'); // Strip controls const stripConnectBtn = document.getElementById('meshStripConnectBtn'); const stripDisconnectBtn = document.getElementById('meshStripDisconnectBtn'); const stripDot = document.getElementById('meshStripDot'); const stripStatus = document.getElementById('meshStripStatus'); if (connected) { const connLabel = connectionType === 'tcp' ? 'TCP' : 'Serial'; const statusText = device ? `${device} (${connLabel})` : `Connected (${connLabel})`; updateStatusIndicator('connected', statusText); if (connectBtn) connectBtn.style.display = 'none'; if (disconnectBtn) disconnectBtn.style.display = 'block'; if (nodeSection) nodeSection.style.display = 'block'; if (channelsSection) channelsSection.style.display = 'block'; if (statsSection) statsSection.style.display = 'block'; if (filterSection) filterSection.style.display = 'block'; if (composeBox) composeBox.style.display = 'block'; // Update strip if (stripConnectBtn) stripConnectBtn.style.display = 'none'; if (stripDisconnectBtn) stripDisconnectBtn.style.display = 'inline-block'; if (stripDot) { stripDot.className = 'mesh-strip-dot connected'; } if (stripStatus) stripStatus.textContent = statusText; } else { updateStatusIndicator('disconnected', 'Disconnected'); if (connectBtn) connectBtn.style.display = 'block'; if (disconnectBtn) disconnectBtn.style.display = 'none'; if (nodeSection) nodeSection.style.display = 'none'; if (channelsSection) channelsSection.style.display = 'none'; if (statsSection) statsSection.style.display = 'none'; if (filterSection) filterSection.style.display = 'none'; if (composeBox) composeBox.style.display = 'none'; // Reset strip if (stripConnectBtn) stripConnectBtn.style.display = 'inline-block'; if (stripDisconnectBtn) stripDisconnectBtn.style.display = 'none'; if (stripDot) { stripDot.className = 'mesh-strip-dot disconnected'; } if (stripStatus) stripStatus.textContent = 'Disconnected'; // Reset strip node info const stripNodeName = document.getElementById('meshStripNodeName'); const stripNodeId = document.getElementById('meshStripNodeId'); const stripModel = document.getElementById('meshStripModel'); if (stripNodeName) stripNodeName.textContent = '--'; if (stripNodeId) stripNodeId.textContent = '--'; if (stripModel) stripModel.textContent = '--'; } } /** * Update status indicator */ function updateStatusIndicator(status, text) { const dot = document.querySelector('.mesh-status-dot'); const textEl = document.getElementById('meshStatusText'); if (dot) { dot.classList.remove('connected', 'connecting', 'disconnected'); dot.classList.add(status); } if (textEl) { textEl.textContent = text; } } /** * Update node info display */ function updateNodeInfo(info) { nodeInfo = info; // Sidebar elements const nameEl = document.getElementById('meshNodeName'); const idEl = document.getElementById('meshNodeId'); const modelEl = document.getElementById('meshNodeModel'); const posRow = document.getElementById('meshNodePosRow'); const posEl = document.getElementById('meshNodePosition'); // Strip elements const stripNodeName = document.getElementById('meshStripNodeName'); const stripNodeId = document.getElementById('meshStripNodeId'); const stripModel = document.getElementById('meshStripModel'); const nodeName = info.long_name || info.short_name || '--'; const nodeId = info.user_id || formatNodeId(info.num) || '--'; const hwModel = info.hw_model || '--'; // Update sidebar if (nameEl) nameEl.textContent = nodeName; if (idEl) idEl.textContent = nodeId; if (modelEl) modelEl.textContent = hwModel; // Update strip if (stripNodeName) stripNodeName.textContent = nodeName; if (stripNodeId) stripNodeId.textContent = nodeId; if (stripModel) stripModel.textContent = hwModel; // Position is nested in the response const pos = info.position; if (pos && pos.latitude && pos.longitude) { if (posRow) posRow.style.display = 'flex'; if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`; } else { if (posRow) posRow.style.display = 'none'; } } /** * Load channels from device */ async function loadChannels() { try { const response = await fetch('/meshtastic/channels'); const data = await response.json(); if (data.status === 'ok') { channels = data.channels; renderChannels(); updateChannelFilter(); updateComposeChannels(); } } catch (err) { console.error('Failed to load channels:', err); } } /** * Render channel list */ function renderChannels() { const container = document.getElementById('meshChannelsList'); if (!container) return; if (channels.length === 0) { container.innerHTML = '

No channels configured

'; return; } container.innerHTML = channels.map(ch => { const isDisabled = !ch.name && ch.role === 'DISABLED'; const roleBadge = ch.role === 'PRIMARY' ? 'mesh-badge-primary' : 'mesh-badge-secondary'; const encBadge = ch.encrypted ? 'mesh-badge-encrypted' : 'mesh-badge-unencrypted'; const encText = ch.encrypted ? (ch.psk_length === 32 ? 'AES-256' : ch.psk_length === 16 ? 'AES-128' : 'ENCRYPTED') : 'NONE'; return `
${ch.index} ${ch.name || (isDisabled ? '(disabled)' : '(unnamed)')}
${ch.role || 'SECONDARY'} ${encText}
`; }).join(''); } /** * Refresh channels */ function refreshChannels() { loadChannels(); } /** * Open channel configuration modal */ function openChannelModal(index) { editingChannelIndex = index; const channel = channels.find(ch => ch.index === index); const modal = document.getElementById('meshChannelModal'); const indexEl = document.getElementById('meshModalChannelIndex'); const nameInput = document.getElementById('meshModalChannelName'); const pskFormat = document.getElementById('meshModalPskFormat'); if (indexEl) indexEl.textContent = index; if (nameInput) nameInput.value = channel?.name || ''; if (pskFormat) pskFormat.value = 'keep'; onPskFormatChange(); if (modal) modal.classList.add('show'); } /** * Close channel configuration modal */ function closeChannelModal() { const modal = document.getElementById('meshChannelModal'); if (modal) modal.classList.remove('show'); editingChannelIndex = null; } /** * Handle PSK format change */ function onPskFormatChange() { const format = document.getElementById('meshModalPskFormat')?.value; const inputContainer = document.getElementById('meshModalPskInputContainer'); const pskInput = document.getElementById('meshModalPskValue'); const warning = document.getElementById('meshModalPskWarning'); // Show input for formats that need a value const needsInput = ['simple', 'base64', 'hex'].includes(format); if (inputContainer) inputContainer.style.display = needsInput ? 'block' : 'none'; // Update placeholder based on format if (pskInput) { const placeholders = { 'simple': 'Enter passphrase...', 'base64': 'Enter base64 key...', 'hex': 'Enter hex key (0x...)...' }; pskInput.placeholder = placeholders[format] || ''; pskInput.value = ''; } // Show warning for default key if (warning) warning.style.display = format === 'default' ? 'block' : 'none'; } /** * Save channel configuration */ async function saveChannelConfig() { if (editingChannelIndex === null) return; const nameInput = document.getElementById('meshModalChannelName'); const pskFormat = document.getElementById('meshModalPskFormat')?.value; const pskValue = document.getElementById('meshModalPskValue')?.value; const body = {}; const name = nameInput?.value.trim(); if (name) body.name = name; // Build PSK value based on format if (pskFormat && pskFormat !== 'keep') { switch (pskFormat) { case 'none': body.psk = 'none'; break; case 'default': body.psk = 'default'; break; case 'random': body.psk = 'random'; break; case 'simple': if (pskValue) body.psk = 'simple:' + pskValue; break; case 'base64': if (pskValue) body.psk = 'base64:' + pskValue; break; case 'hex': if (pskValue) body.psk = pskValue.startsWith('0x') ? pskValue : '0x' + pskValue; break; } } if (Object.keys(body).length === 0) { closeChannelModal(); return; } try { const response = await fetch(`/meshtastic/channels/${editingChannelIndex}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await response.json(); if (data.status === 'ok') { showNotification('Meshtastic', 'Channel configured successfully'); closeChannelModal(); loadChannels(); } else { showStatusMessage(data.message || 'Failed to configure channel', 'error'); } } catch (err) { console.error('Failed to configure channel:', err); showStatusMessage('Error configuring channel: ' + err.message, 'error'); } } /** * Load message history */ async function loadMessages(limit) { try { let url = '/meshtastic/messages'; const params = new URLSearchParams(); if (limit) params.set('limit', limit); if (currentFilter) params.set('channel', currentFilter); if (params.toString()) url += '?' + params.toString(); const response = await fetch(url); const data = await response.json(); if (data.status === 'ok') { messages = data.messages; data.messages.forEach(msg => { if (msg.from) uniqueNodes.add(msg.from); }); updateStats(); renderMessages(); } } catch (err) { console.error('Failed to load messages:', err); } } /** * Load nodes and update map */ async function loadNodes() { try { const response = await fetch('/meshtastic/nodes'); const data = await response.json(); if (data.status === 'ok') { updateMapStats(data.count, data.with_position_count); // Update markers for all nodes with positions data.nodes.forEach(node => { // Track node in uniqueNodes set for stats if (node.num) uniqueNodes.add(node.num); if (node.has_position) { updateNodeMarker(node); } }); // Update stats to reflect loaded nodes updateStats(); // Fit map to show all nodes if we have any const nodesWithPos = data.nodes.filter(n => n.has_position); if (nodesWithPos.length > 0 && meshMap) { const bounds = nodesWithPos.map(n => [n.latitude, n.longitude]); if (bounds.length === 1) { meshMap.setView(bounds[0], 12); } else { meshMap.fitBounds(bounds, { padding: [50, 50] }); } } } } catch (err) { console.error('Failed to load nodes:', err); } } /** * Update or create a node marker on the map */ function updateNodeMarker(node) { if (!meshMap || !node.latitude || !node.longitude) return; const nodeId = node.id || `!${node.num.toString(16).padStart(8, '0')}`; const isLocal = node.num === localNodeId; // Determine if node is stale (no update in 30 minutes) let isStale = false; if (node.last_heard) { const lastHeard = new Date(node.last_heard); const now = new Date(); isStale = (now - lastHeard) > 30 * 60 * 1000; } // Create marker icon const markerClass = `mesh-node-marker ${isLocal ? 'local' : ''} ${isStale ? 'stale' : ''}`; const shortName = node.short_name || nodeId.slice(-4); const icon = L.divIcon({ className: 'mesh-marker-wrapper', html: `
${shortName.slice(0, 2).toUpperCase()}
`, iconSize: [32, 32], iconAnchor: [16, 16], popupAnchor: [0, -16] }); // Build telemetry section let telemetryHtml = ''; if (node.voltage !== null || node.channel_utilization !== null || node.air_util_tx !== null) { telemetryHtml += '
'; telemetryHtml += 'Device Telemetry
'; if (node.voltage !== null) { telemetryHtml += `Voltage: ${node.voltage.toFixed(2)}V
`; } if (node.channel_utilization !== null) { telemetryHtml += `Ch Util: ${node.channel_utilization.toFixed(1)}%
`; } if (node.air_util_tx !== null) { telemetryHtml += `Air TX: ${node.air_util_tx.toFixed(1)}%
`; } telemetryHtml += '
'; } // Build environment section let envHtml = ''; if (node.temperature !== null || node.humidity !== null || node.barometric_pressure !== null) { envHtml += '
'; envHtml += 'Environment
'; if (node.temperature !== null) { telemetryHtml += `Temp: ${node.temperature.toFixed(1)}°C
`; } if (node.humidity !== null) { envHtml += `Humidity: ${node.humidity.toFixed(1)}%
`; } if (node.barometric_pressure !== null) { envHtml += `Pressure: ${node.barometric_pressure.toFixed(1)} hPa
`; } envHtml += '
'; } // Build popup content with action buttons let actionButtons = ''; if (!isLocal) { actionButtons = `
`; } const popupContent = `
${node.long_name || shortName}
ID: ${nodeId}
Model: ${node.hw_model || 'Unknown'}
Position: ${node.latitude.toFixed(5)}, ${node.longitude.toFixed(5)}
${node.altitude ? `Altitude: ${node.altitude}m
` : ''} ${node.battery_level !== null ? `Battery: ${node.battery_level}%
` : ''} ${node.snr !== null ? `SNR: ${node.snr.toFixed(1)} dB
` : ''} ${node.last_heard ? `Last heard: ${new Date(node.last_heard).toLocaleTimeString()}
` : ''} ${telemetryHtml} ${envHtml} ${actionButtons}
`; // Update or create marker if (meshMarkers[nodeId]) { meshMarkers[nodeId].setLatLng([node.latitude, node.longitude]); meshMarkers[nodeId].setIcon(icon); meshMarkers[nodeId].setPopupContent(popupContent); } else { const marker = L.marker([node.latitude, node.longitude], { icon }) .bindPopup(popupContent) .addTo(meshMap); meshMarkers[nodeId] = marker; } } /** * Update map stats display */ function updateMapStats(total, withGps) { const totalEl = document.getElementById('meshMapNodeCount'); const gpsEl = document.getElementById('meshMapGpsCount'); if (totalEl) totalEl.textContent = total; if (gpsEl) gpsEl.textContent = withGps; } /** * Start SSE stream */ function startStream() { if (eventSource) { eventSource.close(); } eventSource = new EventSource('/meshtastic/stream'); eventSource.onmessage = (e) => { try { const data = JSON.parse(e.data); if (data.type === 'meshtastic') { handleMessage(data); } } catch (err) { console.error('Failed to parse SSE message:', err); } }; eventSource.onerror = () => { console.warn('Meshtastic SSE error, will reconnect...'); setTimeout(() => { if (isConnected) startStream(); }, 3000); }; } /** * Stop SSE stream */ function stopStream() { if (eventSource) { eventSource.close(); eventSource = null; } } /** * Handle incoming message */ function handleMessage(msg) { console.log('Received message:', msg); console.log('from_name:', msg.from_name, 'timestamp:', msg.timestamp, 'type:', typeof msg.timestamp); messages.push(msg); if (msg.from) uniqueNodes.add(msg.from); // Keep messages limited if (messages.length > 500) { messages.shift(); } updateStats(); // Only render if passes filter if (!currentFilter || msg.channel == currentFilter) { prependMessage(msg); } // Refresh nodes if we got position or nodeinfo data const portnum = msg.portnum || msg.app_type || ''; if (portnum.includes('POSITION') || portnum.includes('NODEINFO')) { // Debounce node refresh to avoid too many requests clearTimeout(handleMessage._nodeRefreshTimeout); handleMessage._nodeRefreshTimeout = setTimeout(() => { loadNodes(); }, 2000); } } /** * Update statistics display */ function updateStats() { // Sidebar stats const msgCountEl = document.getElementById('meshMsgCount'); const nodeCountEl = document.getElementById('meshNodeCount'); // Strip stats const stripMsgCount = document.getElementById('meshStripMsgCount'); const stripNodeCount = document.getElementById('meshStripNodeCount'); const msgCount = messages.length; const nodeCount = uniqueNodes.size; if (msgCountEl) msgCountEl.textContent = msgCount; if (nodeCountEl) nodeCountEl.textContent = nodeCount; if (stripMsgCount) stripMsgCount.textContent = msgCount; if (stripNodeCount) stripNodeCount.textContent = nodeCount; } /** * Render all messages */ function renderMessages() { const container = document.getElementById('meshMessagesGrid'); if (!container) return; const filtered = currentFilter ? messages.filter(m => m.channel == currentFilter) : messages; if (filtered.length === 0) { container.innerHTML = `

No messages received yet

`; return; } container.innerHTML = filtered .slice() .reverse() .map(msg => renderMessageCard(msg)) .join(''); } /** * Prepend a single message to the feed */ function prependMessage(msg) { const container = document.getElementById('meshMessagesGrid'); if (!container) return; // Remove empty state if present const empty = container.querySelector('.mesh-messages-empty'); if (empty) empty.remove(); const card = document.createElement('div'); card.innerHTML = renderMessageCard(msg); container.insertBefore(card.firstElementChild, container.firstChild); // Limit displayed messages while (container.children.length > 100) { container.lastElementChild.remove(); } } /** * Render a single message card */ function renderMessageCard(msg) { const typeClass = getMessageTypeClass(msg.app_type || msg.portnum); // Use name if available, fall back to ID const fromDisplay = msg.from_name || formatNodeId(msg.from); const toDisplay = msg.to === 'broadcast' || msg.to === '^all' ? '^all' : (msg.to_name || formatNodeId(msg.to)); const time = msg.timestamp ? new Date(msg.timestamp * 1000).toLocaleTimeString() : '--:--:--'; let body; if (msg.text) { body = `
${escapeHtml(msg.text)}
`; } else { body = `
[${msg.app_type || msg.portnum || 'UNKNOWN'}]
`; } let signalInfo = ''; if (msg.rssi != null || msg.snr != null) { const rssiHtml = msg.rssi != null ? `
RSSI${msg.rssi}dBm
` : ''; const snrClass = msg.snr != null ? (msg.snr < 0 ? 'bad' : msg.snr < 5 ? 'poor' : '') : ''; const snrHtml = msg.snr != null ? `
SNR${msg.snr.toFixed(1)}
` : ''; signalInfo = `
${rssiHtml}${snrHtml}
`; } // Handle pending/sent messages const isPending = msg._pending; const isFailed = msg._failed; const pendingClass = isPending ? 'pending' : (isFailed ? 'failed' : ''); const pendingAttr = isPending ? 'data-pending="true"' : ''; // Status indicator for sent messages let statusIndicator = ''; if (isPending) { statusIndicator = 'Sending...'; } else if (isFailed) { statusIndicator = 'Failed'; } return `
${fromDisplay} -> ${toDisplay} ${statusIndicator}
[CH${msg.channel !== undefined ? msg.channel : '?'}] ${time}
${body} ${signalInfo}
`; } /** * Get message type CSS class */ function getMessageTypeClass(appType) { if (!appType) return ''; const type = appType.toLowerCase(); if (type.includes('text')) return 'text-message'; if (type.includes('position')) return 'position-message'; if (type.includes('telemetry')) return 'telemetry-message'; if (type.includes('nodeinfo')) return 'nodeinfo-message'; return ''; } /** * Format node ID for display */ function formatNodeId(id) { if (!id) return '--'; if (typeof id === 'number') { return '!' + id.toString(16).padStart(8, '0'); } if (typeof id === 'string' && !id.startsWith('!') && !id.startsWith('^')) { // Try to format as hex if it's a numeric string const num = parseInt(id, 10); if (!isNaN(num)) { return '!' + num.toString(16).padStart(8, '0'); } } return id; } /** * Apply message filter */ function applyFilter() { // Read from either filter dropdown (sidebar or visuals header) const sidebarFilter = document.getElementById('meshChannelFilter'); const visualsFilter = document.getElementById('meshVisualsFilter'); // Use whichever one has a value, preferring the one that was just changed const value = sidebarFilter?.value || visualsFilter?.value || ''; currentFilter = value; // Sync both dropdowns if (sidebarFilter) sidebarFilter.value = value; if (visualsFilter) visualsFilter.value = value; renderMessages(); } /** * Update channel filter dropdowns */ function updateChannelFilter() { const selects = [ document.getElementById('meshChannelFilter'), document.getElementById('meshVisualsFilter') ]; selects.forEach(select => { if (!select) return; const currentValue = select.value; select.innerHTML = ''; channels.forEach(ch => { if (ch.name || ch.role === 'PRIMARY') { const option = document.createElement('option'); option.value = ch.index; option.textContent = `[${ch.index}] ${ch.name || 'Primary'}`; select.appendChild(option); } }); select.value = currentValue; }); } /** * Escape HTML for safe display */ function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Show status message */ function showStatusMessage(message, type) { if (typeof showNotification === 'function') { showNotification('Meshtastic', message); } else { console.log(`[Meshtastic ${type}] ${message}`); } } /** * Show help modal */ function showHelp() { let modal = document.getElementById('meshtasticHelpModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'meshtasticHelpModal'; modal.className = 'signal-details-modal'; document.body.appendChild(modal); } modal.innerHTML = `

About Meshtastic

What is Meshtastic?

Meshtastic is an open-source mesh networking platform for LoRa radios. It enables long-range, low-power communication between devices without requiring cellular or WiFi infrastructure. Messages hop through the mesh to reach their destination.

Supported Hardware

Common Meshtastic devices include Heltec LoRa32, LILYGO T-Beam, RAK WisBlock, and many others. Connect your device via USB to start monitoring the mesh.

Channel Encryption
  • None: Messages are unencrypted (not recommended)
  • Default: Uses a known public key (NOT SECURE)
  • Random: Generates a new AES-256 key
  • Passphrase: Derives a key from a passphrase
  • Base64/Hex: Use your own pre-shared key
Requirements

Install the Meshtastic Python SDK: pip install meshtastic

`; modal.classList.add('show'); } /** * Close help modal */ function closeHelp() { const modal = document.getElementById('meshtasticHelpModal'); if (modal) modal.classList.remove('show'); } /** * Handle keydown in compose input */ function handleComposeKeydown(event) { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendMessage(); } } /** * Send a message to the mesh */ async function sendMessage() { const textInput = document.getElementById('meshComposeText'); const channelSelect = document.getElementById('meshComposeChannel'); const toInput = document.getElementById('meshComposeTo'); const sendBtn = document.querySelector('.mesh-compose-send'); const text = textInput?.value.trim(); if (!text) return; const channel = parseInt(channelSelect?.value || '0', 10); const toValue = toInput?.value.trim(); // Convert empty or "^all" to null for broadcast const to = (toValue && toValue !== '^all') ? toValue : null; // Show sending state immediately if (sendBtn) { sendBtn.disabled = true; sendBtn.classList.add('sending'); } // Optimistically add message to feed immediately const localNodeName = nodeInfo?.short_name || nodeInfo?.long_name || null; const localNodeIdStr = nodeInfo ? formatNodeId(nodeInfo.num) : '!local'; const optimisticMsg = { type: 'meshtastic', from: localNodeIdStr, from_name: localNodeName, to: to || '^all', text: text, channel: channel, timestamp: Date.now() / 1000, portnum: 'TEXT_MESSAGE_APP', _pending: true // Mark as pending }; // Add to messages and render messages.push(optimisticMsg); prependMessage(optimisticMsg); // Clear input immediately for snappy feel const sentText = text; textInput.value = ''; updateCharCount(); try { console.log('Sending message:', { text: sentText, channel, to }); const response = await fetch('/meshtastic/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: sentText, channel, to: to || undefined }) }); console.log('Send response status:', response.status); if (!response.ok) { // HTTP error let errorMsg = `HTTP ${response.status}`; try { const errData = await response.json(); errorMsg = errData.message || errorMsg; } catch (e) { // Response wasn't JSON } throw new Error(errorMsg); } const data = await response.json(); console.log('Send response data:', data); if (data.status === 'sent') { // Mark optimistic message as confirmed optimisticMsg._pending = false; updatePendingMessage(optimisticMsg, false); } else { // Mark as failed optimisticMsg._failed = true; updatePendingMessage(optimisticMsg, true); if (typeof showNotification === 'function') { showNotification('Meshtastic', data.message || 'Failed to send'); } } } catch (err) { console.error('Failed to send message:', err); optimisticMsg._failed = true; updatePendingMessage(optimisticMsg, true); if (typeof showNotification === 'function') { showNotification('Meshtastic', 'Send error: ' + err.message); } } finally { if (sendBtn) { sendBtn.disabled = false; sendBtn.classList.remove('sending'); } textInput?.focus(); } } /** * Update a pending message's visual state */ function updatePendingMessage(msg, failed) { // Find the message card and update its state const cards = document.querySelectorAll('.mesh-message-card'); cards.forEach(card => { if (card.dataset.pending === 'true') { card.classList.remove('pending'); card.dataset.pending = 'false'; // Update the status indicator const statusEl = card.querySelector('.mesh-message-status'); if (statusEl) { if (failed) { statusEl.className = 'mesh-message-status failed'; statusEl.textContent = 'Failed'; } else { // Remove the status indicator on success statusEl.remove(); } } if (failed) { card.classList.add('failed'); } else { card.classList.add('sent'); // Remove sent indicator after a moment setTimeout(() => card.classList.remove('sent'), 2000); } } }); } /** * Update character count display */ function updateCharCount() { const input = document.getElementById('meshComposeText'); const counter = document.getElementById('meshComposeCount'); if (input && counter) { counter.textContent = input.value.length; } } /** * Update compose channel dropdown */ function updateComposeChannels() { const select = document.getElementById('meshComposeChannel'); if (!select) return; select.innerHTML = channels.map(ch => { if (ch.role === 'DISABLED') return ''; const name = ch.name || (ch.role === 'PRIMARY' ? 'Primary' : `CH ${ch.index}`); return ``; }).filter(Boolean).join(''); // Default to first channel (usually primary) if (channels.length > 0) { select.value = channels[0].index; } } // Public API /** * Toggle main sidebar collapsed state */ /** * Toggle the main application sidebar visibility */ function toggleSidebar() { const mainContent = document.querySelector('.main-content'); if (mainContent) { mainContent.classList.toggle('mesh-sidebar-hidden'); // Resize map after sidebar toggle setTimeout(() => { if (meshMap) meshMap.invalidateSize(); }, 100); } } /** * Toggle the Meshtastic options panel within the sidebar */ function toggleOptionsPanel() { const modePanel = document.getElementById('meshtasticMode'); const icon = document.getElementById('meshSidebarIcon'); if (modePanel) { modePanel.classList.toggle('mesh-sidebar-collapsed'); if (icon) { icon.textContent = modePanel.classList.contains('mesh-sidebar-collapsed') ? '▶' : '▼'; } } } /** * Send traceroute to a node */ async function sendTraceroute(destination) { if (!destination) return; // Show traceroute modal with loading state showTracerouteModal(destination, null, true); try { const response = await fetch('/meshtastic/traceroute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ destination, hop_limit: 7 }) }); const data = await response.json(); if (data.status === 'sent') { // Start polling for results pollTracerouteResults(destination); } else { showTracerouteModal(destination, { error: data.message || 'Failed to send traceroute' }, false); } } catch (err) { console.error('Traceroute error:', err); showTracerouteModal(destination, { error: err.message }, false); } } /** * Poll for traceroute results */ async function pollTracerouteResults(destination, attempts = 0) { const maxAttempts = 30; // 30 seconds timeout const pollInterval = 1000; if (attempts >= maxAttempts) { showTracerouteModal(destination, { error: 'Traceroute timeout - no response received' }, false); return; } try { const response = await fetch('/meshtastic/traceroute/results?limit=5'); const data = await response.json(); if (data.status === 'ok' && data.results) { // Find result matching our destination const result = data.results.find(r => r.destination_id === destination); if (result) { showTracerouteModal(destination, result, false); return; } } // Continue polling setTimeout(() => pollTracerouteResults(destination, attempts + 1), pollInterval); } catch (err) { console.error('Error polling traceroute:', err); setTimeout(() => pollTracerouteResults(destination, attempts + 1), pollInterval); } } /** * Show traceroute modal */ function showTracerouteModal(destination, result, loading) { let modal = document.getElementById('meshTracerouteModal'); if (!modal) return; const destEl = document.getElementById('meshTracerouteDest'); const contentEl = document.getElementById('meshTracerouteContent'); if (destEl) destEl.textContent = destination; if (loading) { contentEl.innerHTML = `

Waiting for traceroute response...

`; } else if (result && result.error) { contentEl.innerHTML = `

Error: ${escapeHtml(result.error)}

`; } else if (result) { contentEl.innerHTML = renderTracerouteVisualization(result); } modal.classList.add('show'); } /** * Close traceroute modal */ function closeTracerouteModal() { const modal = document.getElementById('meshTracerouteModal'); if (modal) modal.classList.remove('show'); } /** * Render traceroute visualization */ function renderTracerouteVisualization(result) { if (!result.route || result.route.length === 0) { if (result.route_back && result.route_back.length > 0) { // Only have return path - show it return renderRoutePath('Return Path', result.route_back, result.snr_back); } return '

Direct connection (no intermediate hops)

'; } let html = ''; // Forward route if (result.route && result.route.length > 0) { html += renderRoutePath('Forward Path', result.route, result.snr_towards); } // Return route if (result.route_back && result.route_back.length > 0) { html += renderRoutePath('Return Path', result.route_back, result.snr_back); } // Timestamp if (result.timestamp) { html += `
Completed: ${new Date(result.timestamp).toLocaleString()}
`; } return html; } /** * Render a single route path */ function renderRoutePath(label, route, snrValues) { let html = `
${label}
`; route.forEach((nodeId, index) => { // Look up node name if available const nodeName = lookupNodeName(nodeId) || nodeId.slice(-4); const snr = snrValues && snrValues[index] !== undefined ? snrValues[index] : null; const snrClass = snr !== null ? getSnrClass(snr) : ''; html += `
${escapeHtml(nodeName)}
${nodeId}
${snr !== null ? `
${snr.toFixed(1)} dB
` : ''}
`; // Add arrow between hops if (index < route.length - 1) { html += '
'; } }); html += '
'; return html; } /** * Get SNR quality class */ function getSnrClass(snr) { if (snr >= 10) return 'snr-good'; if (snr >= 0) return 'snr-ok'; if (snr >= -10) return 'snr-poor'; return 'snr-bad'; } /** * Look up node name from our tracked nodes */ function lookupNodeName(nodeId) { // This would ideally look up from our cached nodes // For now, return null to use ID return null; } /** * Request position from a specific node */ async function requestPosition(nodeId) { if (!nodeId) return; try { const response = await fetch('/meshtastic/position/request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ node_id: nodeId }) }); const data = await response.json(); if (data.status === 'sent') { showNotification('Meshtastic', `Position requested from ${nodeId}`); // Refresh nodes after a delay to get updated position setTimeout(loadNodes, 5000); } else { showStatusMessage(data.message || 'Failed to request position', 'error'); } } catch (err) { console.error('Position request error:', err); showStatusMessage('Error requesting position: ' + err.message, 'error'); } } /** * Check firmware version and show update status */ async function checkFirmware() { try { const response = await fetch('/meshtastic/firmware/check'); const data = await response.json(); if (data.status === 'ok') { showFirmwareModal(data); } else { showStatusMessage(data.message || 'Failed to check firmware', 'error'); } } catch (err) { console.error('Firmware check error:', err); showStatusMessage('Error checking firmware: ' + err.message, 'error'); } } /** * Show firmware information modal */ function showFirmwareModal(info) { let modal = document.getElementById('meshFirmwareModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'meshFirmwareModal'; modal.className = 'signal-details-modal'; document.body.appendChild(modal); } const updateBadge = info.update_available ? 'Update Available' : 'Up to Date'; modal.innerHTML = `

Firmware Information

Current Version

${info.current_version || 'Unknown'}

Latest Version

${info.latest_version || 'Unknown'} ${updateBadge}

${info.release_url ? ` ` : ''} ${info.error ? `

Note: ${info.error}

` : ''}
`; modal.classList.add('show'); } /** * Close firmware modal */ function closeFirmwareModal() { const modal = document.getElementById('meshFirmwareModal'); if (modal) modal.classList.remove('show'); } /** * Show QR code for a channel */ async function showChannelQR(channelIndex) { let modal = document.getElementById('meshQRModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'meshQRModal'; modal.className = 'signal-details-modal'; document.body.appendChild(modal); } const channel = channels.find(ch => ch.index === channelIndex); const channelName = channel?.name || `Channel ${channelIndex}`; // Show loading state modal.innerHTML = `

Channel QR Code

Generating QR code...

`; modal.classList.add('show'); try { const response = await fetch(`/meshtastic/channels/${channelIndex}/qr`); if (response.ok) { const blob = await response.blob(); const imageUrl = URL.createObjectURL(blob); modal.innerHTML = `

Channel QR Code

${escapeHtml(channelName)}

Channel QR Code

Scan with the Meshtastic app to join this channel

`; } else { const data = await response.json(); throw new Error(data.message || 'Failed to generate QR code'); } } catch (err) { console.error('QR generation error:', err); modal.innerHTML = `

Channel QR Code

Error: ${escapeHtml(err.message)}

Make sure the qrcode library is installed: pip install qrcode[pil]

`; } } /** * Close QR modal */ function closeQRModal() { const modal = document.getElementById('meshQRModal'); if (modal) modal.classList.remove('show'); } /** * Load and display telemetry history for a node */ async function showTelemetryChart(nodeId, hours = 24) { let modal = document.getElementById('meshTelemetryModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'meshTelemetryModal'; modal.className = 'signal-details-modal'; document.body.appendChild(modal); } // Show loading modal.innerHTML = `

Telemetry History

Loading telemetry data...

`; modal.classList.add('show'); try { const response = await fetch(`/meshtastic/telemetry/history?node_id=${encodeURIComponent(nodeId)}&hours=${hours}`); const data = await response.json(); if (data.status === 'ok') { renderTelemetryCharts(modal, nodeId, data.data, hours); } else { throw new Error(data.message || 'Failed to load telemetry'); } } catch (err) { console.error('Telemetry load error:', err); modal.querySelector('.signal-details-modal-body').innerHTML = `

Error: ${escapeHtml(err.message)}

`; } } /** * Render telemetry charts */ function renderTelemetryCharts(modal, nodeId, data, hours) { if (!data || data.length === 0) { modal.querySelector('.signal-details-modal-body').innerHTML = `

No telemetry data available for this node in the last ${hours} hours.

`; return; } // Build charts for available metrics let chartsHtml = `
Node: ${escapeHtml(nodeId)} ${data.length} data points
`; // Battery chart const batteryData = data.filter(p => p.battery_level !== null); if (batteryData.length > 0) { chartsHtml += renderSimpleChart('Battery Level', batteryData, 'battery_level', '%', 0, 100); } // Voltage chart const voltageData = data.filter(p => p.voltage !== null); if (voltageData.length > 0) { chartsHtml += renderSimpleChart('Voltage', voltageData, 'voltage', 'V', null, null); } // Temperature chart const tempData = data.filter(p => p.temperature !== null); if (tempData.length > 0) { chartsHtml += renderSimpleChart('Temperature', tempData, 'temperature', '°C', null, null); } // Humidity chart const humidityData = data.filter(p => p.humidity !== null); if (humidityData.length > 0) { chartsHtml += renderSimpleChart('Humidity', humidityData, 'humidity', '%', 0, 100); } modal.querySelector('.signal-details-modal-body').innerHTML = chartsHtml; } /** * Render a simple SVG line chart */ function renderSimpleChart(title, data, field, unit, minY, maxY) { if (data.length < 2) { return `
${title}

Not enough data points

`; } // Extract values const values = data.map(p => p[field]); const timestamps = data.map(p => new Date(p.timestamp)); // Calculate bounds const min = minY !== null ? minY : Math.min(...values) * 0.95; const max = maxY !== null ? maxY : Math.max(...values) * 1.05; const range = max - min || 1; // Chart dimensions const width = 500; const height = 100; const padding = { left: 40, right: 10, top: 10, bottom: 20 }; const chartWidth = width - padding.left - padding.right; const chartHeight = height - padding.top - padding.bottom; // Build path const points = values.map((v, i) => { const x = padding.left + (i / (values.length - 1)) * chartWidth; const y = padding.top + chartHeight - ((v - min) / range) * chartHeight; return `${x},${y}`; }); const pathD = 'M' + points.join(' L'); // Current value const currentValue = values[values.length - 1]; return `
${title} ${currentValue.toFixed(1)}${unit}
${max.toFixed(0)} ${min.toFixed(0)}
`; } /** * Close telemetry modal */ function closeTelemetryModal() { const modal = document.getElementById('meshTelemetryModal'); if (modal) modal.classList.remove('show'); } /** * Show network topology (neighbors) */ async function showNetworkTopology() { let modal = document.getElementById('meshNetworkModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'meshNetworkModal'; modal.className = 'signal-details-modal'; document.body.appendChild(modal); } // Show loading modal.innerHTML = `

Network Topology

Loading neighbor data...

`; modal.classList.add('show'); try { const response = await fetch('/meshtastic/neighbors'); const data = await response.json(); if (data.status === 'ok') { renderNetworkTopology(modal, data.neighbors); } else { throw new Error(data.message || 'Failed to load neighbors'); } } catch (err) { console.error('Network topology error:', err); modal.querySelector('.signal-details-modal-body').innerHTML = `

Error: ${escapeHtml(err.message)}

`; } } /** * Render network topology visualization */ function renderNetworkTopology(modal, neighbors) { if (!neighbors || Object.keys(neighbors).length === 0) { modal.querySelector('.signal-details-modal-body').innerHTML = `

No neighbor information available yet.
Neighbor data is collected from NEIGHBOR_INFO_APP packets.

`; return; } // Build a simple list view of neighbors let html = '
'; for (const [nodeId, neighborList] of Object.entries(neighbors)) { html += `
${escapeHtml(nodeId)} ${neighborList.length} neighbors
`; neighborList.forEach(neighbor => { const snrClass = getSnrClass(neighbor.snr); html += `
${escapeHtml(neighbor.neighbor_id)} ${neighbor.snr.toFixed(1)} dB
`; }); html += '
'; } html += '
'; modal.querySelector('.signal-details-modal-body').innerHTML = html; } /** * Close network modal */ function closeNetworkModal() { const modal = document.getElementById('meshNetworkModal'); if (modal) modal.classList.remove('show'); } /** * Show range test modal */ function showRangeTestModal() { let modal = document.getElementById('meshRangeTestModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'meshRangeTestModal'; modal.className = 'signal-details-modal'; document.body.appendChild(modal); } modal.innerHTML = `

Range Test

`; modal.classList.add('show'); } /** * Start range test */ async function startRangeTest() { const countInput = document.getElementById('rangeTestCount'); const intervalInput = document.getElementById('rangeTestInterval'); const startBtn = document.getElementById('rangeTestStartBtn'); const stopBtn = document.getElementById('rangeTestStopBtn'); const statusDiv = document.getElementById('rangeTestStatus'); const count = parseInt(countInput?.value || '10', 10); const interval = parseInt(intervalInput?.value || '5', 10); try { const response = await fetch('/meshtastic/range-test/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ count, interval }) }); const data = await response.json(); if (data.status === 'started') { if (startBtn) startBtn.style.display = 'none'; if (stopBtn) stopBtn.style.display = 'inline-block'; if (statusDiv) statusDiv.style.display = 'block'; showNotification('Meshtastic', `Range test started: ${count} packets`); // Poll for completion pollRangeTestStatus(); } else { showStatusMessage(data.message || 'Failed to start range test', 'error'); } } catch (err) { console.error('Range test error:', err); showStatusMessage('Error starting range test: ' + err.message, 'error'); } } /** * Stop range test */ async function stopRangeTest() { try { await fetch('/meshtastic/range-test/stop', { method: 'POST' }); resetRangeTestUI(); showNotification('Meshtastic', 'Range test stopped'); } catch (err) { console.error('Error stopping range test:', err); } } /** * Poll range test status */ async function pollRangeTestStatus() { try { const response = await fetch('/meshtastic/range-test/status'); const data = await response.json(); if (data.running) { setTimeout(pollRangeTestStatus, 1000); } else { resetRangeTestUI(); showNotification('Meshtastic', 'Range test complete'); } } catch (err) { console.error('Error polling range test:', err); resetRangeTestUI(); } } /** * Reset range test UI */ function resetRangeTestUI() { const startBtn = document.getElementById('rangeTestStartBtn'); const stopBtn = document.getElementById('rangeTestStopBtn'); const statusDiv = document.getElementById('rangeTestStatus'); if (startBtn) startBtn.style.display = 'inline-block'; if (stopBtn) stopBtn.style.display = 'none'; if (statusDiv) statusDiv.style.display = 'none'; } /** * Close range test modal */ function closeRangeTestModal() { const modal = document.getElementById('meshRangeTestModal'); if (modal) modal.classList.remove('show'); } /** * Show Store & Forward modal */ async function showStoreForwardModal() { let modal = document.getElementById('meshStoreForwardModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'meshStoreForwardModal'; modal.className = 'signal-details-modal'; document.body.appendChild(modal); } // Show loading state modal.innerHTML = `

Store & Forward

Checking for S&F router...

`; modal.classList.add('show'); try { const response = await fetch('/meshtastic/store-forward/status'); const data = await response.json(); if (data.available) { modal.querySelector('.signal-details-modal-body').innerHTML = `

✓ Store & Forward router found

Router: ${escapeHtml(data.router_name || data.router_id || 'Unknown')}

`; } else { modal.querySelector('.signal-details-modal-body').innerHTML = `

No Store & Forward router found on the mesh.

S&F requires a node with ROUTER role running the
Store & Forward module with history enabled.

`; } } catch (err) { console.error('S&F status error:', err); modal.querySelector('.signal-details-modal-body').innerHTML = `

Error: ${escapeHtml(err.message)}

`; } } /** * Request Store & Forward history */ async function requestStoreForward() { const select = document.getElementById('sfWindowMinutes'); const windowMinutes = parseInt(select?.value || '60', 10); try { const response = await fetch('/meshtastic/store-forward/request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ window_minutes: windowMinutes }) }); const data = await response.json(); if (data.status === 'sent') { showNotification('Meshtastic', `Requested ${windowMinutes} minutes of history`); closeStoreForwardModal(); } else { showStatusMessage(data.message || 'Failed to request S&F history', 'error'); } } catch (err) { console.error('S&F request error:', err); showStatusMessage('Error: ' + err.message, 'error'); } } /** * Close Store & Forward modal */ function closeStoreForwardModal() { const modal = document.getElementById('meshStoreForwardModal'); if (modal) modal.classList.remove('show'); } return { init, start, stop, onConnectionTypeChange, loadPorts, refreshChannels, openChannelModal, closeChannelModal, onPskFormatChange, saveChannelConfig, applyFilter, showHelp, closeHelp, sendMessage, updateCharCount, invalidateMap, handleComposeKeydown, toggleSidebar, toggleOptionsPanel, sendTraceroute, closeTracerouteModal, // New features requestPosition, checkFirmware, closeFirmwareModal, showChannelQR, closeQRModal, showTelemetryChart, closeTelemetryModal, showNetworkTopology, closeNetworkModal, // Range test showRangeTestModal, startRangeTest, stopRangeTest, closeRangeTestModal, // Store & Forward showStoreForwardModal, requestStoreForward, closeStoreForwardModal }; /** * Invalidate the map size (call after container resize) */ function invalidateMap() { if (meshMap) { setTimeout(() => meshMap.invalidateSize(), 100); } } })(); // Initialize when DOM is ready (will be called by selectMode) document.addEventListener('DOMContentLoaded', function() { // Initialization happens via selectMode when Meshtastic mode is activated });