`;
}
/**
* 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') ? '▶' : '▼';
}
}
}
return {
init,
start,
stop,
refreshChannels,
openChannelModal,
closeChannelModal,
onPskFormatChange,
saveChannelConfig,
applyFilter,
showHelp,
closeHelp,
sendMessage,
updateCharCount,
invalidateMap,
handleComposeKeydown,
toggleSidebar,
toggleOptionsPanel
};
/**
* 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
});