UNPKG

signalk-peplink-monitor

Version:

SignalK plugin to monitor Peplink router WAN connections and cellular signal quality

380 lines (322 loc) 13.1 kB
// Peplink Query Script // File: peplink-query.js const { Client } = require('ssh2'); // Get configuration from environment variables const config = { host: process.env.PEPLINK_HOST, port: process.env.PEPLINK_PORT || 22, username: process.env.PEPLINK_USERNAME || 'admin', password: process.env.PEPLINK_PASSWORD, connectionName: process.env.PEPLINK_CONNECTION }; // Signal quality calculation function function calculateSignalQuality(rssi, sinr, rsrp, rsrq) { // Extract numeric values rssi = parseFloat(rssi.toString().replace(/[^-0-9.]/g, '')); sinr = parseFloat(sinr.toString().replace(/[^-0-9.]/g, '')); rsrp = parseFloat(rsrp.toString().replace(/[^-0-9.]/g, '')); rsrq = parseFloat(rsrq.toString().replace(/[^-0-9.]/g, '')); let rssiScore = 0, sinrScore = 0, rsrpScore = 0, rsrqScore = 0; // RSSI scoring (-50 = excellent, -70 = good, -85 = fair, -100+ = poor) if (rssi >= -50) rssiScore = 100; else if (rssi >= -70) rssiScore = 100 - ((rssi + 50) * 2.5); else if (rssi >= -85) rssiScore = 50 - ((rssi + 70) * 2); else if (rssi >= -100) rssiScore = 20 - ((rssi + 85) * 1.33); else rssiScore = 0; // SINR scoring (>20 = excellent, >13 = good, >0 = fair, <0 = poor) if (sinr >= 20) sinrScore = 100; else if (sinr >= 13) sinrScore = 80 + ((sinr - 13) * 2.86); else if (sinr >= 0) sinrScore = 40 + (sinr * 3.08); else if (sinr >= -10) sinrScore = (sinr + 10) * 4; else sinrScore = 0; // RSRP scoring (-80 = excellent, -90 = good, -100 = fair, -110+ = poor) if (rsrp >= -80) rsrpScore = 100; else if (rsrp >= -90) rsrpScore = 100 - ((rsrp + 80) * 5); else if (rsrp >= -100) rsrpScore = 50 - ((rsrp + 90) * 3); else if (rsrp >= -110) rsrpScore = 20 - ((rsrp + 100) * 2); else rsrpScore = 0; // RSRQ scoring (-3 = excellent, -6 = good, -9 = fair, -15+ = poor) if (rsrq >= -3) rsrqScore = 100; else if (rsrq >= -6) rsrqScore = 100 - ((rsrq + 3) * 16.67); else if (rsrq >= -9) rsrqScore = 50 - ((rsrq + 6) * 10); else if (rsrq >= -15) rsrqScore = 20 - ((rsrq + 9) * 3.33); else rsrqScore = 0; // Ensure scores are within bounds rssiScore = Math.max(0, Math.min(100, rssiScore)); sinrScore = Math.max(0, Math.min(100, sinrScore)); rsrpScore = Math.max(0, Math.min(100, rsrpScore)); rsrqScore = Math.max(0, Math.min(100, rsrqScore)); // Weighted average (RSRP and SINR are most important for LTE) const overallScore = (rsrpScore * 0.35) + (sinrScore * 0.35) + (rssiScore * 0.20) + (rsrqScore * 0.10); return `${Math.round(overallScore)}%`; } // Convert string to camelCase function toCamelCase(str) { return str.replace(/[^a-zA-Z0-9 ]/g, '') .split(' ') .map((word, index) => { if (index === 0) return word.toLowerCase(); return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); }) .join(''); } // Parse WAN output to JavaScript object function parseWanOutput(input) { const lines = input.split('\n').filter(line => line.trim() !== ''); const wanConnections = []; let currentConnection = null; let currentSection = null; let currentConnectionSignalMetrics = {}; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; // Check for WAN Connection header const connectionMatch = trimmed.match(/^WAN Connection \[(\d+)\]$/); if (connectionMatch) { if (currentConnection) { wanConnections.push(currentConnection); } currentConnection = { connectionNumber: parseInt(connectionMatch[1]), connectionName: '', connectionStatus: '', connectionType: '', connectionMethod: '' }; currentSection = null; currentConnectionSignalMetrics = {}; continue; } // Check for section headers if (line.match(/^\s{4}[A-Za-z]/) && !line.includes(':')) { const sectionName = trimmed.replace(/\s+/g, ''); const camelCaseName = sectionName.charAt(0).toLowerCase() + sectionName.slice(1); currentSection = camelCaseName; currentConnection[currentSection] = {}; continue; } // Parse key-value pairs const kvMatch = trimmed.match(/^(.+?)\s*:\s*(.+)$/); if (kvMatch) { let key = kvMatch[1].trim(); let value = kvMatch[2].trim(); const camelKey = toCamelCase(key); // Convert numeric values if (/^\d+$/.test(value)) { value = parseInt(value); } else if (/^\d+\.\d+$/.test(value)) { value = parseFloat(value); } // Track cellular signal metrics for ALL connections if (currentSection === 'cellularStatus') { if (['RSSI', 'SINR', 'RSRP', 'RSRQ'].includes(key)) { currentConnectionSignalMetrics[key] = value; console.error(`Debug: Found ${key}: ${value} for connection ${currentConnection.connectionName}`); } } if (currentSection) { currentConnection[currentSection][camelKey] = value; } else { // Handle DNS Servers as an array if (camelKey === 'dnsServers') { currentConnection[camelKey] = [value]; } else { currentConnection[camelKey] = value; } } } // Handle continuation lines (additional DNS servers) if (line.match(/^\s{40,}/) && currentConnection.dnsServers) { currentConnection.dnsServers.push(trimmed); } } // Add the last connection if (currentConnection) { wanConnections.push(currentConnection); } // Calculate signal quality for connections that have cellular data wanConnections.forEach(connection => { if (connection.cellularStatus && connection.connectionName === config.connectionName) { console.error(`Debug: Processing signal quality for ${connection.connectionName}`); console.error(`Debug: Available cellular fields:`, Object.keys(connection.cellularStatus)); // Extract signal values from cellularStatus const rssi = connection.cellularStatus.rssi; const sinr = connection.cellularStatus.sinr; const rsrp = connection.cellularStatus.rsrp; const rsrq = connection.cellularStatus.rsrq; console.error(`Debug: Signal values - RSSI: ${rssi}, SINR: ${sinr}, RSRP: ${rsrp}, RSRQ: ${rsrq}`); if (rssi && sinr && rsrp && rsrq) { const signalQuality = calculateSignalQuality(rssi, sinr, rsrp, rsrq); console.error(`Debug: Calculated signal quality: ${signalQuality}`); connection.cellularStatus.signalQuality = signalQuality; } else { console.error(`Debug: Missing signal values, cannot calculate quality`); } } }); return wanConnections; } // Execute SSH command using ssh2 library async function executeSSHCommand() { return new Promise((resolve, reject) => { const conn = new Client(); let connectionClosed = false; // Set up timeout to ensure connection doesn't hang const timeout = setTimeout(() => { if (!connectionClosed) { connectionClosed = true; conn.end(); reject(new Error('SSH connection timeout')); } }, 30000); // 30 second timeout conn.on('ready', () => { conn.shell((err, stream) => { if (err) { clearTimeout(timeout); connectionClosed = true; conn.end(); reject(err); return; } let output = ''; let commandSent = false; let capturing = false; stream.on('close', () => { clearTimeout(timeout); if (!connectionClosed) { connectionClosed = true; conn.end(); // Extract just the 'get wan' output const lines = output.split('\n'); const startIndex = lines.findIndex(line => line.includes('WAN Connection')); if (startIndex !== -1) { const endIndex = lines.findIndex((line, index) => index > startIndex && line.includes('>') && !line.includes('WAN Connection') ); const cleanOutput = lines.slice(startIndex, endIndex !== -1 ? endIndex : undefined) .filter(line => line.trim() && !line.includes('>')) .join('\n'); resolve(cleanOutput); } else { reject(new Error('No WAN connection data found in output')); } } }); stream.on('data', (data) => { const text = data.toString(); output += text; // Wait for the prompt and send command if (!commandSent && text.includes('>')) { stream.write('get wan\n'); commandSent = true; capturing = true; } else if (commandSent && capturing && text.includes('>')) { // Command finished, exit gracefully stream.write('exit\n'); } }); stream.on('error', (err) => { clearTimeout(timeout); if (!connectionClosed) { connectionClosed = true; conn.end(); reject(new Error(`SSH stream error: ${err.message}`)); } }); stream.stderr.on('data', (data) => { clearTimeout(timeout); if (!connectionClosed) { connectionClosed = true; conn.end(); reject(new Error(`SSH stderr: ${data}`)); } }); }); }); conn.on('error', (err) => { clearTimeout(timeout); if (!connectionClosed) { connectionClosed = true; conn.end(); reject(new Error(`SSH connection error: ${err.message}`)); } }); conn.on('end', () => { clearTimeout(timeout); connectionClosed = true; }); conn.on('close', () => { clearTimeout(timeout); connectionClosed = true; }); // Connect to SSH conn.connect({ host: config.host, port: parseInt(config.port), username: config.username, password: config.password, readyTimeout: 30000, keepaliveInterval: 0, // Disable keepalive since we're doing quick operations keepaliveCountMax: 0 }); }); } // Main execution async function main() { try { if (!config.password) { throw new Error('PEPLINK_PASSWORD environment variable is required'); } if (!config.host) { throw new Error('PEPLINK_HOST environment variable is required'); } if (!config.connectionName) { throw new Error('PEPLINK_CONNECTION environment variable is required'); } console.error(`Debug: Looking for connection named "${config.connectionName}"`); const rawOutput = await executeSSHCommand(); console.error('Debug: Raw SSH output received'); const wanData = parseWanOutput(rawOutput); console.error(`Debug: Parsed ${wanData.length} WAN connections:`); // Debug: Show all connections found wanData.forEach((conn, index) => { console.error(` Connection ${index + 1}: "${conn.connectionName}" (${conn.connectionType}) - Status: ${conn.connectionStatus}`); if (conn.cellularStatus) { console.error(` Has cellular data: RSSI=${conn.cellularStatus.rssi}, SignalQuality=${conn.cellularStatus.signalQuality}`); } }); // Find the target connection const targetConnection = wanData.find(conn => conn.connectionName === config.connectionName && conn.connectionType === 'Cellular' && conn.cellularStatus ); if (!targetConnection) { console.error(`Debug: No connection found matching name "${config.connectionName}" with type "Cellular" and cellular status`); // Try without cellular type requirement const anyConnection = wanData.find(conn => conn.connectionName === config.connectionName); if (anyConnection) { console.error(`Debug: Found connection "${config.connectionName}" but it's type "${anyConnection.connectionType}" not "Cellular"`); } else { console.error(`Debug: No connection found with name "${config.connectionName}" at all`); console.error('Available connection names:', wanData.map(c => `"${c.connectionName}"`).join(', ')); } } if (targetConnection && targetConnection.cellularStatus && targetConnection.cellularStatus.signalQuality) { // Convert percentage to ratio (0.0 to 1.0) for SignalK const percentageValue = parseFloat(targetConnection.cellularStatus.signalQuality.replace('%', '')); const signalQualityRatio = percentageValue / 100; console.error(`Debug: Found signal quality ${targetConnection.cellularStatus.signalQuality} -> ${signalQualityRatio}`); // Output just the signal quality value as a number console.log(signalQualityRatio.toFixed(3)); } else { // No cellular connection found or no signal quality available console.error('Debug: No signal quality data available'); console.log('null'); } } catch (error) { console.error('Error:', error.message); process.exit(1); } } main();