mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
Add QR code generation for sharing Meshtastic channel configurations. Add qrcode[pil] dependency for QR code generation. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2255 lines
87 KiB
JavaScript
2255 lines
87 KiB
JavaScript
/**
|
|
* 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 = '<option value="">Auto-detect</option>';
|
|
|
|
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
|
|
*/
|
|
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' && Settings.createTileLayer) {
|
|
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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
|
maxZoom: 19,
|
|
subdomains: 'abcd'
|
|
}).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);
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start Meshtastic connection
|
|
*/
|
|
async function start() {
|
|
// Try strip device select first, then sidebar
|
|
const stripDeviceSelect = document.getElementById('meshStripDevice');
|
|
const sidebarDeviceSelect = document.getElementById('meshDeviceSelect');
|
|
let 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 response = await fetch('/meshtastic/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ device: device || undefined })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'started' || data.status === 'already_running') {
|
|
isConnected = true;
|
|
updateConnectionUI(true, data.device);
|
|
if (data.node_info) {
|
|
updateNodeInfo(data.node_info);
|
|
localNodeId = data.node_info.num;
|
|
}
|
|
loadChannels();
|
|
loadNodes();
|
|
startStream();
|
|
showNotification('Meshtastic', 'Connected to device');
|
|
} 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) {
|
|
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) {
|
|
updateStatusIndicator('connected', device ? `Connected to ${device}` : 'Connected');
|
|
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 = device || 'Connected';
|
|
} 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 = '<p style="color: var(--text-dim); font-size: 11px; text-align: center; padding: 20px;">No channels configured</p>';
|
|
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 `
|
|
<div class="mesh-channel-item ${isDisabled ? 'disabled' : ''}">
|
|
<div class="mesh-channel-info">
|
|
<span class="mesh-channel-index">${ch.index}</span>
|
|
<span class="mesh-channel-name">${ch.name || (isDisabled ? '(disabled)' : '(unnamed)')}</span>
|
|
</div>
|
|
<div class="mesh-channel-badges">
|
|
<span class="mesh-channel-badge ${roleBadge}">${ch.role || 'SECONDARY'}</span>
|
|
<span class="mesh-channel-badge ${encBadge}">${encText}</span>
|
|
<button class="mesh-qr-btn" data-channel-index="${ch.index}" title="Generate QR Code">QR</button>
|
|
<button class="mesh-channel-configure" onclick="Meshtastic.openChannelModal(${ch.index})">Configure</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).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: `<div class="${markerClass}">${shortName.slice(0, 2).toUpperCase()}</div>`,
|
|
iconSize: [28, 28],
|
|
iconAnchor: [14, 14],
|
|
popupAnchor: [0, -14]
|
|
});
|
|
|
|
// Build telemetry section
|
|
let telemetryHtml = '';
|
|
if (node.voltage !== null || node.channel_utilization !== null || node.air_util_tx !== null) {
|
|
telemetryHtml += '<div style="margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--border-color);">';
|
|
telemetryHtml += '<span style="color: var(--text-dim); font-size: 9px; text-transform: uppercase;">Device Telemetry</span><br>';
|
|
if (node.voltage !== null) {
|
|
telemetryHtml += `<span style="color: var(--text-dim);">Voltage:</span> ${node.voltage.toFixed(2)}V<br>`;
|
|
}
|
|
if (node.channel_utilization !== null) {
|
|
telemetryHtml += `<span style="color: var(--text-dim);">Ch Util:</span> ${node.channel_utilization.toFixed(1)}%<br>`;
|
|
}
|
|
if (node.air_util_tx !== null) {
|
|
telemetryHtml += `<span style="color: var(--text-dim);">Air TX:</span> ${node.air_util_tx.toFixed(1)}%<br>`;
|
|
}
|
|
telemetryHtml += '</div>';
|
|
}
|
|
|
|
// Build environment section
|
|
let envHtml = '';
|
|
if (node.temperature !== null || node.humidity !== null || node.barometric_pressure !== null) {
|
|
envHtml += '<div style="margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--border-color);">';
|
|
envHtml += '<span style="color: var(--text-dim); font-size: 9px; text-transform: uppercase;">Environment</span><br>';
|
|
if (node.temperature !== null) {
|
|
telemetryHtml += `<span style="color: var(--text-dim);">Temp:</span> ${node.temperature.toFixed(1)}°C<br>`;
|
|
}
|
|
if (node.humidity !== null) {
|
|
envHtml += `<span style="color: var(--text-dim);">Humidity:</span> ${node.humidity.toFixed(1)}%<br>`;
|
|
}
|
|
if (node.barometric_pressure !== null) {
|
|
envHtml += `<span style="color: var(--text-dim);">Pressure:</span> ${node.barometric_pressure.toFixed(1)} hPa<br>`;
|
|
}
|
|
envHtml += '</div>';
|
|
}
|
|
|
|
// Build popup content with action buttons
|
|
let actionButtons = '';
|
|
if (!isLocal) {
|
|
actionButtons = `
|
|
<div style="margin-top: 8px; display: flex; gap: 4px; flex-wrap: wrap;">
|
|
<button class="mesh-traceroute-btn" data-node-id="${nodeId}">Traceroute</button>
|
|
<button class="mesh-position-btn" data-node-id="${nodeId}">Request Position</button>
|
|
<button class="mesh-telemetry-btn" onclick="Meshtastic.showTelemetryChart('${nodeId}')">Telemetry</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const popupContent = `
|
|
<div style="min-width: 150px;">
|
|
<strong style="color: var(--accent-cyan);">${node.long_name || shortName}</strong><br>
|
|
<span style="color: var(--text-dim);">ID:</span> ${nodeId}<br>
|
|
<span style="color: var(--text-dim);">Model:</span> ${node.hw_model || 'Unknown'}<br>
|
|
<span style="color: var(--text-dim);">Position:</span> ${node.latitude.toFixed(5)}, ${node.longitude.toFixed(5)}<br>
|
|
${node.altitude ? `<span style="color: var(--text-dim);">Altitude:</span> ${node.altitude}m<br>` : ''}
|
|
${node.battery_level !== null ? `<span style="color: var(--text-dim);">Battery:</span> ${node.battery_level}%<br>` : ''}
|
|
${node.snr !== null ? `<span style="color: var(--text-dim);">SNR:</span> ${node.snr.toFixed(1)} dB<br>` : ''}
|
|
${node.last_heard ? `<span style="color: var(--text-dim);">Last heard:</span> ${new Date(node.last_heard).toLocaleTimeString()}<br>` : ''}
|
|
${telemetryHtml}
|
|
${envHtml}
|
|
${actionButtons}
|
|
</div>
|
|
`;
|
|
|
|
// 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 = `
|
|
<div class="mesh-messages-empty">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="12" cy="12" r="10"/>
|
|
<circle cx="12" cy="12" r="3"/>
|
|
<path d="M12 2v4m0 12v4M2 12h4m12 0h4"/>
|
|
</svg>
|
|
<p>No messages received yet</p>
|
|
</div>
|
|
`;
|
|
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'
|
|
? '<span class="broadcast">^all</span>'
|
|
: (msg.to_name || formatNodeId(msg.to));
|
|
|
|
const time = msg.timestamp
|
|
? new Date(msg.timestamp * 1000).toLocaleTimeString()
|
|
: '--:--:--';
|
|
|
|
let body;
|
|
if (msg.text) {
|
|
body = `<div class="mesh-message-body">${escapeHtml(msg.text)}</div>`;
|
|
} else {
|
|
body = `<div class="mesh-message-body app-type">[${msg.app_type || msg.portnum || 'UNKNOWN'}]</div>`;
|
|
}
|
|
|
|
let signalInfo = '';
|
|
if (msg.rssi != null || msg.snr != null) {
|
|
const rssiHtml = msg.rssi != null
|
|
? `<div class="mesh-signal-item"><span class="mesh-signal-label">RSSI</span><span class="mesh-signal-value rssi">${msg.rssi}dBm</span></div>`
|
|
: '';
|
|
const snrClass = msg.snr != null ? (msg.snr < 0 ? 'bad' : msg.snr < 5 ? 'poor' : '') : '';
|
|
const snrHtml = msg.snr != null
|
|
? `<div class="mesh-signal-item"><span class="mesh-signal-label">SNR</span><span class="mesh-signal-value snr ${snrClass}">${msg.snr.toFixed(1)}</span></div>`
|
|
: '';
|
|
signalInfo = `<div class="mesh-message-signal">${rssiHtml}${snrHtml}</div>`;
|
|
}
|
|
|
|
// 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 = '<span class="mesh-message-status sending">Sending...</span>';
|
|
} else if (isFailed) {
|
|
statusIndicator = '<span class="mesh-message-status failed">Failed</span>';
|
|
}
|
|
|
|
return `
|
|
<div class="mesh-message-card ${typeClass} ${pendingClass}" ${pendingAttr}>
|
|
<div class="mesh-message-header">
|
|
<div class="mesh-message-route">
|
|
<span class="mesh-message-from">${fromDisplay}</span>
|
|
<span class="mesh-message-arrow">-></span>
|
|
<span class="mesh-message-to">${toDisplay}</span>
|
|
${statusIndicator}
|
|
</div>
|
|
<div class="mesh-message-meta">
|
|
<span class="mesh-message-channel">[CH${msg.channel !== undefined ? msg.channel : '?'}]</span>
|
|
<span class="mesh-message-time">${time}</span>
|
|
</div>
|
|
</div>
|
|
${body}
|
|
${signalInfo}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 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 = '<option value="">All Channels</option>';
|
|
|
|
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 = `
|
|
<div class="signal-details-modal-backdrop" onclick="Meshtastic.closeHelp()"></div>
|
|
<div class="signal-details-modal-content">
|
|
<div class="signal-details-modal-header">
|
|
<h3>About Meshtastic</h3>
|
|
<button class="signal-details-modal-close" onclick="Meshtastic.closeHelp()">×</button>
|
|
</div>
|
|
<div class="signal-details-modal-body">
|
|
<div class="signal-details-section">
|
|
<div class="signal-details-title">What is Meshtastic?</div>
|
|
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
|
|
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.
|
|
</p>
|
|
</div>
|
|
<div class="signal-details-section">
|
|
<div class="signal-details-title">Supported Hardware</div>
|
|
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
|
|
Common Meshtastic devices include Heltec LoRa32, LILYGO T-Beam, RAK WisBlock, and
|
|
many others. Connect your device via USB to start monitoring the mesh.
|
|
</p>
|
|
</div>
|
|
<div class="signal-details-section">
|
|
<div class="signal-details-title">Channel Encryption</div>
|
|
<ul style="color: var(--text-secondary); font-size: 12px; line-height: 1.6; padding-left: 20px;">
|
|
<li><strong>None:</strong> Messages are unencrypted (not recommended)</li>
|
|
<li><strong>Default:</strong> Uses a known public key (NOT SECURE)</li>
|
|
<li><strong>Random:</strong> Generates a new AES-256 key</li>
|
|
<li><strong>Passphrase:</strong> Derives a key from a passphrase</li>
|
|
<li><strong>Base64/Hex:</strong> Use your own pre-shared key</li>
|
|
</ul>
|
|
</div>
|
|
<div class="signal-details-section">
|
|
<div class="signal-details-title">Requirements</div>
|
|
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
|
|
Install the Meshtastic Python SDK: <code style="background: var(--bg-secondary); padding: 2px 6px; border-radius: 3px;">pip install meshtastic</code>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 `<option value="${ch.index}">${name}</option>`;
|
|
}).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 = `
|
|
<div class="mesh-traceroute-loading">
|
|
<div class="mesh-traceroute-spinner"></div>
|
|
<p>Waiting for traceroute response...</p>
|
|
</div>
|
|
`;
|
|
} else if (result && result.error) {
|
|
contentEl.innerHTML = `
|
|
<div class="mesh-traceroute-error">
|
|
<p>Error: ${escapeHtml(result.error)}</p>
|
|
</div>
|
|
`;
|
|
} 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 '<p style="color: var(--text-dim);">Direct connection (no intermediate hops)</p>';
|
|
}
|
|
|
|
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 += `<div class="mesh-traceroute-timestamp">Completed: ${new Date(result.timestamp).toLocaleString()}</div>`;
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
/**
|
|
* Render a single route path
|
|
*/
|
|
function renderRoutePath(label, route, snrValues) {
|
|
let html = `<div class="mesh-traceroute-section">
|
|
<div class="mesh-traceroute-label">${label}</div>
|
|
<div class="mesh-traceroute-path">`;
|
|
|
|
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 += `<div class="mesh-traceroute-hop">
|
|
<div class="mesh-traceroute-hop-node">${escapeHtml(nodeName)}</div>
|
|
<div class="mesh-traceroute-hop-id">${nodeId}</div>
|
|
${snr !== null ? `<div class="mesh-traceroute-snr ${snrClass}">${snr.toFixed(1)} dB</div>` : ''}
|
|
</div>`;
|
|
|
|
// Add arrow between hops
|
|
if (index < route.length - 1) {
|
|
html += '<div class="mesh-traceroute-arrow">→</div>';
|
|
}
|
|
});
|
|
|
|
html += '</div></div>';
|
|
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
|
|
? '<span class="mesh-badge mesh-badge-warning">Update Available</span>'
|
|
: '<span class="mesh-badge mesh-badge-success">Up to Date</span>';
|
|
|
|
modal.innerHTML = `
|
|
<div class="signal-details-modal-backdrop" onclick="Meshtastic.closeFirmwareModal()"></div>
|
|
<div class="signal-details-modal-content">
|
|
<div class="signal-details-modal-header">
|
|
<h3>Firmware Information</h3>
|
|
<button class="signal-details-modal-close" onclick="Meshtastic.closeFirmwareModal()">×</button>
|
|
</div>
|
|
<div class="signal-details-modal-body">
|
|
<div class="signal-details-section">
|
|
<div class="signal-details-title">Current Version</div>
|
|
<p style="color: var(--text-secondary); font-size: 14px;">
|
|
${info.current_version || 'Unknown'}
|
|
</p>
|
|
</div>
|
|
<div class="signal-details-section">
|
|
<div class="signal-details-title">Latest Version</div>
|
|
<p style="color: var(--text-secondary); font-size: 14px;">
|
|
${info.latest_version || 'Unknown'} ${updateBadge}
|
|
</p>
|
|
</div>
|
|
${info.release_url ? `
|
|
<div class="signal-details-section">
|
|
<a href="${info.release_url}" target="_blank" rel="noopener" class="preset-btn" style="display: inline-block; text-decoration: none;">
|
|
View Release Notes
|
|
</a>
|
|
</div>
|
|
` : ''}
|
|
${info.error ? `
|
|
<div class="signal-details-section">
|
|
<p style="color: var(--status-error); font-size: 12px;">
|
|
Note: ${info.error}
|
|
</p>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="signal-details-modal-backdrop" onclick="Meshtastic.closeQRModal()"></div>
|
|
<div class="signal-details-modal-content">
|
|
<div class="signal-details-modal-header">
|
|
<h3>Channel QR Code</h3>
|
|
<button class="signal-details-modal-close" onclick="Meshtastic.closeQRModal()">×</button>
|
|
</div>
|
|
<div class="signal-details-modal-body">
|
|
<div style="text-align: center; padding: 20px;">
|
|
<div class="mesh-traceroute-spinner"></div>
|
|
<p style="color: var(--text-dim); margin-top: 10px;">Generating QR code...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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 = `
|
|
<div class="signal-details-modal-backdrop" onclick="Meshtastic.closeQRModal()"></div>
|
|
<div class="signal-details-modal-content">
|
|
<div class="signal-details-modal-header">
|
|
<h3>Channel QR Code</h3>
|
|
<button class="signal-details-modal-close" onclick="Meshtastic.closeQRModal()">×</button>
|
|
</div>
|
|
<div class="signal-details-modal-body" style="text-align: center;">
|
|
<p style="color: var(--text-secondary); font-size: 14px; margin-bottom: 15px;">
|
|
${escapeHtml(channelName)}
|
|
</p>
|
|
<img src="${imageUrl}" alt="Channel QR Code" style="max-width: 256px; background: white; padding: 10px; border-radius: 8px;">
|
|
<p style="color: var(--text-dim); font-size: 11px; margin-top: 15px;">
|
|
Scan with the Meshtastic app to join this channel
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
} 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 = `
|
|
<div class="signal-details-modal-backdrop" onclick="Meshtastic.closeQRModal()"></div>
|
|
<div class="signal-details-modal-content">
|
|
<div class="signal-details-modal-header">
|
|
<h3>Channel QR Code</h3>
|
|
<button class="signal-details-modal-close" onclick="Meshtastic.closeQRModal()">×</button>
|
|
</div>
|
|
<div class="signal-details-modal-body">
|
|
<p style="color: var(--status-error);">
|
|
Error: ${escapeHtml(err.message)}
|
|
</p>
|
|
<p style="color: var(--text-dim); font-size: 11px; margin-top: 10px;">
|
|
Make sure the qrcode library is installed: pip install qrcode[pil]
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 = `
|
|
<div class="signal-details-modal-backdrop" onclick="Meshtastic.closeTelemetryModal()"></div>
|
|
<div class="signal-details-modal-content" style="max-width: 600px;">
|
|
<div class="signal-details-modal-header">
|
|
<h3>Telemetry History</h3>
|
|
<button class="signal-details-modal-close" onclick="Meshtastic.closeTelemetryModal()">×</button>
|
|
</div>
|
|
<div class="signal-details-modal-body">
|
|
<div style="text-align: center; padding: 20px;">
|
|
<div class="mesh-traceroute-spinner"></div>
|
|
<p style="color: var(--text-dim); margin-top: 10px;">Loading telemetry data...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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 = `
|
|
<p style="color: var(--status-error);">Error: ${escapeHtml(err.message)}</p>
|
|
`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render telemetry charts
|
|
*/
|
|
function renderTelemetryCharts(modal, nodeId, data, hours) {
|
|
if (!data || data.length === 0) {
|
|
modal.querySelector('.signal-details-modal-body').innerHTML = `
|
|
<p style="color: var(--text-dim); text-align: center; padding: 20px;">
|
|
No telemetry data available for this node in the last ${hours} hours.
|
|
</p>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// Build charts for available metrics
|
|
let chartsHtml = `
|
|
<div class="mesh-telemetry-header">
|
|
<span>Node: ${escapeHtml(nodeId)}</span>
|
|
<span style="color: var(--text-dim);">${data.length} data points</span>
|
|
</div>
|
|
`;
|
|
|
|
// 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 `
|
|
<div class="mesh-telemetry-chart">
|
|
<div class="mesh-telemetry-chart-title">${title}</div>
|
|
<p style="color: var(--text-dim); font-size: 11px;">Not enough data points</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// 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 `
|
|
<div class="mesh-telemetry-chart">
|
|
<div class="mesh-telemetry-chart-title">
|
|
${title}
|
|
<span class="mesh-telemetry-current">${currentValue.toFixed(1)}${unit}</span>
|
|
</div>
|
|
<svg viewBox="0 0 ${width} ${height}" class="mesh-telemetry-svg">
|
|
<!-- Y axis labels -->
|
|
<text x="${padding.left - 5}" y="${padding.top + 5}" class="mesh-chart-label" text-anchor="end">${max.toFixed(0)}</text>
|
|
<text x="${padding.left - 5}" y="${height - padding.bottom}" class="mesh-chart-label" text-anchor="end">${min.toFixed(0)}</text>
|
|
<!-- Grid lines -->
|
|
<line x1="${padding.left}" y1="${padding.top}" x2="${width - padding.right}" y2="${padding.top}" class="mesh-chart-grid"/>
|
|
<line x1="${padding.left}" y1="${height - padding.bottom}" x2="${width - padding.right}" y2="${height - padding.bottom}" class="mesh-chart-grid"/>
|
|
<!-- Data line -->
|
|
<path d="${pathD}" class="mesh-chart-line" fill="none"/>
|
|
</svg>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 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 = `
|
|
<div class="signal-details-modal-backdrop" onclick="Meshtastic.closeNetworkModal()"></div>
|
|
<div class="signal-details-modal-content" style="max-width: 700px;">
|
|
<div class="signal-details-modal-header">
|
|
<h3>Network Topology</h3>
|
|
<button class="signal-details-modal-close" onclick="Meshtastic.closeNetworkModal()">×</button>
|
|
</div>
|
|
<div class="signal-details-modal-body">
|
|
<div style="text-align: center; padding: 20px;">
|
|
<div class="mesh-traceroute-spinner"></div>
|
|
<p style="color: var(--text-dim); margin-top: 10px;">Loading neighbor data...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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 = `
|
|
<p style="color: var(--status-error);">Error: ${escapeHtml(err.message)}</p>
|
|
`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render network topology visualization
|
|
*/
|
|
function renderNetworkTopology(modal, neighbors) {
|
|
if (!neighbors || Object.keys(neighbors).length === 0) {
|
|
modal.querySelector('.signal-details-modal-body').innerHTML = `
|
|
<p style="color: var(--text-dim); text-align: center; padding: 20px;">
|
|
No neighbor information available yet.<br>
|
|
<span style="font-size: 11px;">Neighbor data is collected from NEIGHBOR_INFO_APP packets.</span>
|
|
</p>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// Build a simple list view of neighbors
|
|
let html = '<div class="mesh-network-list">';
|
|
|
|
for (const [nodeId, neighborList] of Object.entries(neighbors)) {
|
|
html += `
|
|
<div class="mesh-network-node">
|
|
<div class="mesh-network-node-header">
|
|
<span class="mesh-network-node-id">${escapeHtml(nodeId)}</span>
|
|
<span class="mesh-network-node-count">${neighborList.length} neighbors</span>
|
|
</div>
|
|
<div class="mesh-network-neighbors">
|
|
`;
|
|
|
|
neighborList.forEach(neighbor => {
|
|
const snrClass = getSnrClass(neighbor.snr);
|
|
html += `
|
|
<div class="mesh-network-neighbor">
|
|
<span class="mesh-network-neighbor-id">${escapeHtml(neighbor.neighbor_id)}</span>
|
|
<span class="mesh-network-neighbor-snr ${snrClass}">${neighbor.snr.toFixed(1)} dB</span>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += '</div></div>';
|
|
}
|
|
|
|
html += '</div>';
|
|
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 = `
|
|
<div class="signal-details-modal-backdrop" onclick="Meshtastic.closeRangeTestModal()"></div>
|
|
<div class="signal-details-modal-content">
|
|
<div class="signal-details-modal-header">
|
|
<h3>Range Test</h3>
|
|
<button class="signal-details-modal-close" onclick="Meshtastic.closeRangeTestModal()">×</button>
|
|
</div>
|
|
<div class="signal-details-modal-body">
|
|
<div class="form-group">
|
|
<label>Number of Packets</label>
|
|
<input type="number" id="rangeTestCount" value="10" min="1" max="100" style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
|
|
</div>
|
|
<div class="form-group" style="margin-top: 12px;">
|
|
<label>Interval (seconds)</label>
|
|
<input type="number" id="rangeTestInterval" value="5" min="1" max="60" style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
|
|
</div>
|
|
<div style="margin-top: 16px; display: flex; gap: 8px;">
|
|
<button id="rangeTestStartBtn" class="run-btn" onclick="Meshtastic.startRangeTest()">Start Test</button>
|
|
<button id="rangeTestStopBtn" class="run-btn" style="display: none; background: var(--accent-red);" onclick="Meshtastic.stopRangeTest()">Stop Test</button>
|
|
</div>
|
|
<div id="rangeTestStatus" style="margin-top: 16px; display: none;">
|
|
<div class="mesh-traceroute-spinner" style="margin: 0 auto;"></div>
|
|
<p style="color: var(--text-dim); text-align: center; margin-top: 10px;">Sending packets...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="signal-details-modal-backdrop" onclick="Meshtastic.closeStoreForwardModal()"></div>
|
|
<div class="signal-details-modal-content">
|
|
<div class="signal-details-modal-header">
|
|
<h3>Store & Forward</h3>
|
|
<button class="signal-details-modal-close" onclick="Meshtastic.closeStoreForwardModal()">×</button>
|
|
</div>
|
|
<div class="signal-details-modal-body">
|
|
<div style="text-align: center; padding: 20px;">
|
|
<div class="mesh-traceroute-spinner"></div>
|
|
<p style="color: var(--text-dim); margin-top: 10px;">Checking for S&F router...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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 = `
|
|
<div class="mesh-sf-info">
|
|
<p style="color: var(--accent-green); margin-bottom: 12px;">
|
|
✓ Store & Forward router found
|
|
</p>
|
|
<p style="color: var(--text-secondary); font-size: 12px;">
|
|
Router: ${escapeHtml(data.router_name || data.router_id || 'Unknown')}
|
|
</p>
|
|
</div>
|
|
<div class="form-group" style="margin-top: 16px;">
|
|
<label>Request history for:</label>
|
|
<select id="sfWindowMinutes" style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
|
|
<option value="15">Last 15 minutes</option>
|
|
<option value="60" selected>Last hour</option>
|
|
<option value="240">Last 4 hours</option>
|
|
<option value="1440">Last 24 hours</option>
|
|
</select>
|
|
</div>
|
|
<button class="run-btn" style="margin-top: 16px; width: 100%;" onclick="Meshtastic.requestStoreForward()">
|
|
Fetch Missed Messages
|
|
</button>
|
|
`;
|
|
} else {
|
|
modal.querySelector('.signal-details-modal-body').innerHTML = `
|
|
<p style="color: var(--text-dim); text-align: center; padding: 20px;">
|
|
No Store & Forward router found on the mesh.<br><br>
|
|
<span style="font-size: 11px;">
|
|
S&F requires a node with ROUTER role running the<br>
|
|
Store & Forward module with history enabled.
|
|
</span>
|
|
</p>
|
|
`;
|
|
}
|
|
} catch (err) {
|
|
console.error('S&F status error:', err);
|
|
modal.querySelector('.signal-details-modal-body').innerHTML = `
|
|
<p style="color: var(--status-error);">Error: ${escapeHtml(err.message)}</p>
|
|
`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
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
|
|
});
|