diff --git a/README.md b/README.md index afe0ea7..58c2fd3 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,13 @@ Log level can be configured with `LOG_LEVEL` in `.env` (debug, info, warn, error 2. Check SESSION_SECRET is set in `.env` 3. Clear browser cookies and try again +## T-Deck + +- It's touch screen, which is often easier than using the trackball. +- To pair over Bluetooth, power on the T-Deck and LONG PRESS (about 2 seconds) the Meshtastic logo. +- To set the timezone properly, it should be `PST8PDT,M3.2.0,M11.1.0` for PST +- To get map tiles: https://www.jeffgeerling.com/blog/2025/adding-gps-and-grid-maps-my-meshtastic-t-deck + ## Development ### Project Structure diff --git a/public/css/style.css b/public/css/style.css index 83a20aa..9560cce 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -351,6 +351,16 @@ body { color: var(--text-secondary); } +.message-channel { + display: inline-block; + padding: 0.125rem 0.5rem; + color: white; + border-radius: 0.25rem; + font-weight: 500; + font-size: 0.7rem; + text-transform: uppercase; +} + /* Nodes Grid */ .nodes-grid { display: grid; diff --git a/public/index.html b/public/index.html index 55a04fa..b432d54 100644 --- a/public/index.html +++ b/public/index.html @@ -119,9 +119,7 @@ diff --git a/public/js/app.js b/public/js/app.js index 11c456e..bdd9baa 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -18,7 +18,8 @@ const state = { reconnectAttempts: 0, maxReconnectAttempts: 5, reconnectDelay: 5000, // 5 seconds - isReconnecting: false + isReconnecting: false, + channelConfig: {} // Channel name configuration }; // Handle authentication errors (401) @@ -189,6 +190,10 @@ const api = { async getMqttStatus() { return this.request('/api/mqtt/status'); + }, + + async getChannelConfig() { + return this.request('/api/config/channels'); } }; @@ -321,6 +326,35 @@ const ui = { const bucketIndex = Math.min(7, Math.floor(minutesSince / bucketSize)); return colors[bucketIndex]; + }, + + getChannelName(channelNumber) { + return state.channelConfig[channelNumber] || `Channel ${channelNumber}`; + }, + + // Hash a string to a color + hashStringToColor(str) { + if (!str) return '#2563eb'; // Default primary color + + // Simple hash function + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + hash = hash & hash; // Convert to 32bit integer + } + + // Convert to HSL for better color distribution + // Use hue from hash, fixed saturation and lightness for readability + const hue = Math.abs(hash % 360); + const saturation = 65; // 65% saturation for vibrant colors + const lightness = 45; // 45% lightness for good contrast with white text + + return `hsl(${hue}, ${saturation}%, ${lightness}%)`; + }, + + getChannelColor(channelNumber) { + const channelName = this.getChannelName(channelNumber); + return this.hashStringToColor(channelName); } }; @@ -378,6 +412,25 @@ async function initDashboard() { // Set user info document.getElementById('user-info').textContent = state.user.username; + // Load channel configuration + try { + state.channelConfig = await api.getChannelConfig(); + populateChannelSelector(); + } catch (error) { + console.error('Error loading channel config:', error); + // Use defaults if config fails to load + state.channelConfig = { + 0: 'LongFast', + 1: 'Aether', + 2: 'Channel 2', + 3: 'Channel 3', + 4: 'Channel 4', + 5: 'Channel 5', + 6: 'Channel 6', + 7: 'Channel 7' + }; + } + // Update MQTT status updateMqttStatus(); @@ -388,6 +441,23 @@ async function initDashboard() { startRefreshIntervals(); } +// Populate channel selector dropdown +function populateChannelSelector() { + const channelSelect = document.getElementById('message-channel'); + if (!channelSelect) return; + + // Clear existing options + channelSelect.innerHTML = ''; + + // Add options from config + Object.entries(state.channelConfig).forEach(([channelNum, channelName]) => { + const option = document.createElement('option'); + option.value = channelNum; + option.textContent = channelName; + channelSelect.appendChild(option); + }); +} + // Update MQTT Status async function updateMqttStatus() { try { @@ -504,6 +574,7 @@ function displayMessages(messages, containerId) { if (!existingIds.has(msgId)) { // Create new message element + const channelColor = msg.channel !== null && msg.channel !== undefined ? ui.getChannelColor(msg.channel) : '#2563eb'; const messageHtml = `
`; @@ -590,108 +659,15 @@ function displayNodes(nodes) { return; } - // Build a map of node IDs to nodes - const nodeMap = new Map(); - nodes.forEach(node => { - nodeMap.set(node.node_id, node); - }); + // Clear and rebuild to ensure proper sorting + container.innerHTML = ''; - // Get existing node elements - const existingNodes = container.querySelectorAll('.node-card'); - const existingIds = new Set(); - - existingNodes.forEach(elem => { - const nodeId = elem.getAttribute('data-node-id'); - if (nodeId) existingIds.add(nodeId); - }); - - // Remove nodes that are no longer in the list - existingNodes.forEach(elem => { - const nodeId = elem.getAttribute('data-node-id'); - if (nodeId && !nodeMap.has(nodeId)) { - elem.remove(); - } - }); - - // Add new nodes or update existing ones + // Add nodes in the order they come from API (already sorted) nodes.forEach(node => { const lastHeard = node.last_heard ? new Date(node.last_heard) : null; const isOnline = lastHeard && (Date.now() - lastHeard.getTime()) < 900000; // 15 minutes - if (existingIds.has(node.node_id)) { - // Update existing node card content - const card = container.querySelector(`[data-node-id="${node.node_id}"]`); - if (card) { - // Update node name - const nameElem = card.querySelector('.node-name'); - if (nameElem) nameElem.textContent = node.long_name || node.short_name || 'Unknown'; - - // Update badge - const badge = card.querySelector('.node-badge'); - if (badge) { - badge.className = `node-badge ${isOnline ? 'online' : 'offline'}`; - badge.textContent = isOnline ? 'Online' : 'Offline'; - } - - // Update last heard time - const timeElem = card.querySelector('.relative-time'); - if (timeElem && node.last_heard) { - timeElem.setAttribute('data-timestamp', node.last_heard); - timeElem.textContent = ui.formatRelativeTime(node.last_heard); - } - - // Update battery if present - const nodeInfo = card.querySelector('.node-info'); - if (nodeInfo && node.battery_level) { - // Check if battery row already exists - let batteryRow = null; - nodeInfo.querySelectorAll('.node-info-row').forEach(row => { - const label = row.querySelector('span:first-child'); - if (label && label.textContent.includes('Battery:')) { - batteryRow = row; - } - }); - - if (!batteryRow) { - nodeInfo.insertAdjacentHTML('beforeend', - `