Files
mrs/src/services/status.mjs
2026-04-05 20:27:43 -04:00

427 lines
12 KiB
JavaScript
Executable File

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 };