import express from 'express'; import { exec } from 'child_process'; import { promisify } from 'util'; import { readFileSync, existsSync } from 'fs'; import pkg from 'node-gpsd'; const { GPS } = pkg; import {environment} from './environment.mjs'; const router = express.Router(); const execAsync = promisify(exec); // Status cache with TTL let statusCache = null; let lastUpdate = 0; const CACHE_TTL = 15 * 1000; // 15 seconds // GPS client setup let gpsClient = null; let gpsData = { fix: 'No Fix', latitude: null, longitude: null, altitude: null, satellites: 0 }; // Initialize GPS connection async function initGPS() { try { gpsClient = new GPS({ hostname: 'localhost', port: 2947 }); gpsClient.on('TPV', (data) => { gpsData.latitude = data.lat || null; gpsData.longitude = data.lon || null; gpsData.altitude = data.alt || null; gpsData.fix = data.mode === 3 ? '3D Fix' : data.mode === 2 ? '2D Fix' : 'No Fix'; }); gpsClient.on('SKY', (data) => { gpsData.satellites = (data.satellites || []).length; }); gpsClient.on('error', (err) => { console.warn('GPS error:', err.message); }); await gpsClient.watch(); console.log('GPS client initialized'); } catch (error) { console.warn('GPS not available:', error.message); } } // CLI-based system information gathering async function getSystemInfo() { const info = { temperature: 0, load: 0, cpu: 0, memory: 0, storage: 0 }; try { // CPU temperature (Raspberry Pi specific) if (existsSync('/sys/class/thermal/thermal_zone0/temp')) { const tempData = readFileSync('/sys/class/thermal/thermal_zone0/temp', 'utf8'); info.temperature = Math.round(parseInt(tempData.trim()) / 1000); } // Load average const { stdout: loadAvg } = await execAsync("cat /proc/loadavg | awk '{print $1}'"); info.load = parseFloat(loadAvg.trim()) || 0; // CPU usage const { stdout: cpuUsage } = await execAsync("grep 'cpu ' /proc/stat | awk '{usage=($2+$4)*100/($2+$3+$4+$5)} END {print usage}'"); info.cpu = parseFloat(cpuUsage.trim()) || 0; // Memory usage percentage const { stdout: memUsage } = await execAsync("free | awk 'NR==2{printf \"%.1f\", $3*100/$2}'"); info.memory = parseFloat(memUsage.trim()) || 0; // Disk usage percentage for root filesystem const { stdout: diskUsage } = await execAsync("df / | awk 'NR==2{printf \"%.1f\", $5}' | sed 's/%//'"); info.storage = parseFloat(diskUsage.trim()) || 0; } catch (error) { console.warn('System info error:', error.message); } return info; } async function getGPSInfo() { const gpsInfo = { satellites: 0, latitude: null, longitude: null, altitude: null }; try { // Check if gpsd is running await execAsync('systemctl is-active gpsd'); // Try to get GPS data using gpspipe const { stdout: gpsRaw } = await execAsync('timeout 3s gpspipe -w -n 5 | grep -E \'"class":"TPV"\' | head -1', { timeout: 4000 }); if (gpsRaw.trim()) { const gpsJson = JSON.parse(gpsRaw.trim()); gpsInfo.latitude = gpsJson.lat || null; gpsInfo.longitude = gpsJson.lon || null; gpsInfo.altitude = gpsJson.alt || null; } // Get satellite count try { const { stdout: skyRaw } = await execAsync('timeout 2s gpspipe -w -n 3 | grep -E \'"class":"SKY"\' | head -1', { timeout: 3000 }); if (skyRaw.trim()) { const skyJson = JSON.parse(skyRaw.trim()); gpsInfo.satellites = (skyJson.satellites || []).length; } } catch (skyError) { } } catch (error) { console.warn('GPS info error:', error.message); // Use cached GPS data from node-gpsd if available if (gpsData.latitude !== null) { return gpsData; } } return gpsInfo; } async function getNetworkInfo() { const networkInfo = { wifi: { mode: 'client', ssid: 'Unknown' }, ethernet: { connected: false } }; try { // Check if hostapd is running (AP mode) try { await execAsync('systemctl is-active hostapd'); networkInfo.wifi.mode = 'ap'; // Get AP SSID from hostapd config or use hostname try { const { stdout: hostname } = await execAsync('hostname'); networkInfo.wifi.ssid = process.env.AP_SSID || hostname.trim(); } catch { networkInfo.wifi.ssid = 'MRS-AP'; } } catch { // Not in AP mode, check if connected to WiFi try { // First, try iwconfig directly since it's more reliable for ESSID const { stdout: iwconfig } = await execAsync("iwconfig wlan0 2>/dev/null"); const essidMatch = iwconfig.match(/ESSID:"([^"]+)"/); if (essidMatch && essidMatch[1] && essidMatch[1] !== 'off/any') { networkInfo.wifi.mode = 'client'; networkInfo.wifi.ssid = essidMatch[1]; } else { // Fallback to nmcli if iwconfig doesn't work try { const { stdout: activeWifi } = await execAsync("nmcli -t -f NAME,TYPE,DEVICE connection show --active | grep -E '(wifi|802-11-wireless)' | cut -d':' -f1 | head -1"); if (activeWifi.trim()) { networkInfo.wifi.mode = 'client'; networkInfo.wifi.ssid = activeWifi.trim(); } else { // Try getting SSID from currently connected interface const { stdout: connectedSSID } = await execAsync("nmcli -t -f active,ssid dev wifi | grep '^yes' | cut -d':' -f2 | head -1"); if (connectedSSID.trim()) { networkInfo.wifi.mode = 'client'; networkInfo.wifi.ssid = connectedSSID.trim(); } else { throw new Error('No active WiFi connection found'); } } } catch { // Final fallback: check if wlan0 interface is up and get info via ip const { stdout: wlanStatus } = await execAsync("ip addr show wlan0 2>/dev/null | grep 'inet ' | wc -l"); if (parseInt(wlanStatus.trim()) > 0) { // Interface has IP but we can't determine SSID networkInfo.wifi.mode = 'client'; networkInfo.wifi.ssid = 'Connected (Unknown SSID)'; } else { networkInfo.wifi.mode = 'disconnected'; networkInfo.wifi.ssid = 'Not Connected'; } } } } catch (error) { console.warn('WiFi detection error:', error.message); networkInfo.wifi.mode = 'disconnected'; networkInfo.wifi.ssid = 'Not Connected'; } } // Check Ethernet status try { const { stdout: ethStatus } = await execAsync("ip link show eth0 | grep 'state UP'"); networkInfo.ethernet.connected = ethStatus.includes('state UP'); } catch { networkInfo.ethernet.connected = false; } } catch (error) { console.warn('Network info error:', error.message); } return networkInfo; } let loraInfo = null; async function getLoRaInfo() { if(loraInfo) return loraInfo; loraInfo = { connected: false, onlineNodes: 0, totalNodes: 0, channelUtilization: 0, airtimeUtilization: 0 }; try { console.log('Getting LoRa/Meshtastic status...'); // Get node information first const { stdout: nodeList } = await execAsync('timeout 10s meshtastic --nodes 2>/dev/null', { timeout: 15000 }); if (nodeList && nodeList.trim()) { loraInfo.connected = true; // Parse node list - look for actual node entries const lines = nodeList.split('\n'); let totalNodes = 0; let onlineNodes = 0; for (const line of lines) { const trimmedLine = line.trim(); // Skip header lines and separators if (trimmedLine.includes('│') && !trimmedLine.includes('User') && !trimmedLine.includes('───') && !trimmedLine.includes('Node') && trimmedLine.length > 10) { totalNodes++; // Check if node is online (has recent lastHeard or SNR data) if (trimmedLine.includes('SNR') || trimmedLine.match(/\d+[smh]/) || // Time indicators like 5m, 2h, 30s trimmedLine.includes('now')) { onlineNodes++; } } } loraInfo.totalNodes = totalNodes; loraInfo.onlineNodes = onlineNodes; } // Get channel and airtime utilization from --info try { const { stdout: meshInfo } = await execAsync('timeout 10s meshtastic --info 2>/dev/null', { timeout: 15000 }); if (meshInfo && meshInfo.trim()) { const lines = meshInfo.split('\n'); for (const line of lines) { const trimmedLine = line.trim(); // Look for channel utilization (various formats) if (trimmedLine.includes('channelUtilization') || trimmedLine.includes('Channel utilization')) { const channelMatch = trimmedLine.match(/([\d.]+)%?/) || trimmedLine.match(/:\s*([\d.]+)/); if (channelMatch) { loraInfo.channelUtilization = parseFloat(channelMatch[1]); } } // Look for airtime utilization (various formats) if (trimmedLine.includes('airUtilTx') || trimmedLine.includes('Air time') || trimmedLine.includes('Airtime')) { const airtimeMatch = trimmedLine.match(/([\d.]+)%?/) || trimmedLine.match(/:\s*([\d.]+)/); if (airtimeMatch) { loraInfo.airtimeUtilization = parseFloat(airtimeMatch[1]); } } } } } catch (infoError) { console.warn('Failed to get mesh info:', infoError.message); } } catch (error) { console.warn('LoRa info error:', error.message); loraInfo.connected = false; } setTimeout(() => loraInfo = null, 60000) return loraInfo; } // Main status generation function - matches frontend expectations async function generateStatus() { try { console.log('Generating fresh status...'); const [systemInfo, gpsInfo, networkInfo, loraInfo] = await Promise.all([ getSystemInfo(), getGPSInfo(), getNetworkInfo(), getLoRaInfo() ]); // Format exactly as the frontend expects const status = { system: { temperature: systemInfo.temperature, load: systemInfo.load, cpu: systemInfo.cpu, memory: systemInfo.memory, storage: systemInfo.storage }, gps: { fix: gpsInfo.fix, satellites: gpsInfo.satellites, latitude: gpsInfo.latitude, longitude: gpsInfo.longitude, altitude: gpsInfo.altitude }, network: { wifi: networkInfo.wifi, ethernet: networkInfo.ethernet }, lora: loraInfo, time: new Date().toISOString() }; return status; } catch (error) { console.error('Status generation error:', error); throw error; } } router.get('/hostname', (req, res) => res.json({ hostname: environment.hostname })); // Main status endpoint that matches frontend expectations router.get('/status', async (req, res) => { try { const now = Date.now(); // Return cached status if within TTL if (statusCache && (now - lastUpdate) < CACHE_TTL) { console.log('Returning cached status'); return res.json(statusCache); } // Generate fresh status statusCache = await generateStatus(); lastUpdate = now; console.log('Status updated successfully'); res.json(statusCache); } catch (error) { console.error('Status API error:', error); res.status(500).json({ error: 'Failed to get system status', message: error.message, timestamp: new Date().toISOString() }); } }); // Individual debug endpoints router.get('/system', async (req, res) => { try { const systemInfo = await getSystemInfo(); res.json(systemInfo); } catch (error) { res.status(500).json({ error: error.message }); } }); router.get('/gps', async (req, res) => { try { const gpsInfo = await getGPSInfo(); res.json(gpsInfo); } catch (error) { res.status(500).json({ error: error.message }); } }); router.get('/network', async (req, res) => { try { const networkInfo = await getNetworkInfo(); res.json(networkInfo); } catch (error) { res.status(500).json({ error: error.message }); } }); // Add individual LoRa endpoint router.get('/lora', async (req, res) => { try { const loraInfo = await getLoRaInfo(); res.json(loraInfo); } catch (error) { res.status(500).json({ error: error.message }); } }); // Initialize GPS on startup initGPS().catch(err => console.warn('GPS initialization failed:', err.message)); export { router as statusRouter };