init
This commit is contained in:
426
src/services/status.mjs
Executable file
426
src/services/status.mjs
Executable file
@@ -0,0 +1,426 @@
|
||||
|
||||
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 };
|
||||
Reference in New Issue
Block a user