import { exec } from 'child_process'; import { promisify } from 'util'; import * as dgram from 'dgram'; import type { OIDSet, SnmpConfig, UpsModel, UpsStatus } from './types.js'; import { UpsOidSets } from './oid-sets.js'; import { SnmpPacketCreator } from './packet-creator.js'; import { SnmpPacketParser } from './packet-parser.js'; const execAsync = promisify(exec); /** * Class for SNMP communication with UPS devices * Main entry point for SNMP functionality */ export class NupstSnmp { // Active OID set private activeOIDs: OIDSet; // Default SNMP configuration private readonly DEFAULT_CONFIG: SnmpConfig = { host: '127.0.0.1', // Default to localhost port: 161, // Default SNMP port community: 'public', // Default community string for v1/v2c version: 1, // SNMPv1 timeout: 5000, // 5 seconds timeout upsModel: 'cyberpower', // Default UPS model }; // SNMPv3 engine ID and counters private engineID: Buffer = Buffer.from([0x80, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]); private engineBoots: number = 0; private engineTime: number = 0; private requestID: number = 1; private debug: boolean = false; // Enable for debug output /** * Create a new SNMP manager * @param debug Whether to enable debug mode */ constructor(debug = false) { this.debug = debug; // Set default OID set this.activeOIDs = UpsOidSets.getOidSet('cyberpower'); } /** * Set active OID set based on UPS model * @param config SNMP configuration */ private setActiveOIDs(config: SnmpConfig): void { // If custom OIDs are provided, use them if (config.upsModel === 'custom' && config.customOIDs) { this.activeOIDs = config.customOIDs; if (this.debug) { console.log('Using custom OIDs:', this.activeOIDs); } return; } // Use OIDs for the specified UPS model or default to Cyberpower const model = config.upsModel || 'cyberpower'; this.activeOIDs = UpsOidSets.getOidSet(model); if (this.debug) { console.log(`Using OIDs for UPS model: ${model}`); } } /** * Enable debug mode */ public enableDebug(): void { this.debug = true; console.log('SNMP debug mode enabled - detailed logs will be shown'); } /** * Send an SNMP GET request * @param oid OID to query * @param config SNMP configuration * @returns Promise resolving to the SNMP response value */ public async snmpGet(oid: string, config = this.DEFAULT_CONFIG): Promise { return new Promise((resolve, reject) => { const socket = dgram.createSocket('udp4'); // Create appropriate request based on SNMP version let request: Buffer; if (config.version === 3) { request = SnmpPacketCreator.createSnmpV3GetRequest( oid, config, this.engineID, this.engineBoots, this.engineTime, this.requestID++, this.debug ); } else { request = SnmpPacketCreator.createSnmpGetRequest(oid, config.community || 'public', this.debug); } if (this.debug) { console.log(`Sending SNMP ${config.version === 3 ? 'v3' : ('v' + config.version)} request to ${config.host}:${config.port}`); console.log('Request length:', request.length); console.log('First 16 bytes of request:', request.slice(0, 16).toString('hex')); console.log('Full request hex:', request.toString('hex')); } // Set timeout - add extra logging for debugging const timeout = setTimeout(() => { socket.close(); if (this.debug) { console.error('---------------------------------------'); console.error('SNMP request timed out after', config.timeout, 'ms'); console.error('SNMP Version:', config.version); if (config.version === 3) { console.error('SNMPv3 Security Level:', config.securityLevel); console.error('SNMPv3 Username:', config.username); console.error('SNMPv3 Auth Protocol:', config.authProtocol || 'None'); console.error('SNMPv3 Privacy Protocol:', config.privProtocol || 'None'); } console.error('OID:', oid); console.error('Host:', config.host); console.error('Port:', config.port); console.error('---------------------------------------'); } reject(new Error(`SNMP request timed out after ${config.timeout}ms`)); }, config.timeout); // Listen for responses socket.on('message', (message, rinfo) => { clearTimeout(timeout); if (this.debug) { console.log(`Received SNMP response from ${rinfo.address}:${rinfo.port}`); console.log('Response length:', message.length); console.log('First 16 bytes of response:', message.slice(0, 16).toString('hex')); console.log('Full response hex:', message.toString('hex')); } try { const result = SnmpPacketParser.parseSnmpResponse(message, config, this.debug); if (this.debug) { console.log('Parsed SNMP response:', result); } socket.close(); resolve(result); } catch (error) { if (this.debug) { console.error('Error parsing SNMP response:', error); } socket.close(); reject(error); } }); // Handle errors socket.on('error', (error) => { clearTimeout(timeout); socket.close(); if (this.debug) { console.error('Socket error during SNMP request:', error); } reject(error); }); // First send the request directly without binding to a specific port // This lets the OS pick an available port instead of trying to bind to one socket.send(request, 0, request.length, config.port, config.host, (error) => { if (error) { clearTimeout(timeout); socket.close(); if (this.debug) { console.error('Error sending SNMP request:', error); } reject(error); } else if (this.debug) { console.log('SNMP request sent successfully'); } }); }); } /** * Get the current status of the UPS * @param config SNMP configuration * @returns Promise resolving to the UPS status */ public async getUpsStatus(config = this.DEFAULT_CONFIG): Promise { try { // Set active OID set based on UPS model in config this.setActiveOIDs(config); if (this.debug) { console.log('---------------------------------------'); console.log('Getting UPS status with config:'); console.log(' Host:', config.host); console.log(' Port:', config.port); console.log(' Version:', config.version); console.log(' Timeout:', config.timeout, 'ms'); console.log(' UPS Model:', config.upsModel || 'cyberpower'); if (config.version === 1 || config.version === 2) { console.log(' Community:', config.community); } else if (config.version === 3) { console.log(' Security Level:', config.securityLevel); console.log(' Username:', config.username); console.log(' Auth Protocol:', config.authProtocol || 'None'); console.log(' Privacy Protocol:', config.privProtocol || 'None'); } console.log('Using OIDs:'); console.log(' Power Status:', this.activeOIDs.POWER_STATUS); console.log(' Battery Capacity:', this.activeOIDs.BATTERY_CAPACITY); console.log(' Battery Runtime:', this.activeOIDs.BATTERY_RUNTIME); console.log('---------------------------------------'); } // For SNMPv3, we need to discover the engine ID first if (config.version === 3) { if (this.debug) { console.log('SNMPv3 detected, starting engine ID discovery'); } try { const discoveredEngineId = await this.discoverEngineId(config); if (discoveredEngineId) { this.engineID = discoveredEngineId; if (this.debug) { console.log('Using discovered engine ID:', this.engineID.toString('hex')); } } } catch (error) { if (this.debug) { console.warn('Engine ID discovery failed, using default:', error); } } } // Helper function to get SNMP value with retry const getSNMPValueWithRetry = async (oid: string, description: string) => { if (oid === '') { if (this.debug) { console.log(`No OID provided for ${description}, skipping`); } return 0; } if (this.debug) { console.log(`Getting ${description} OID: ${oid}`); } try { const value = await this.snmpGet(oid, config); if (this.debug) { console.log(`${description} value:`, value); } return value; } catch (error) { if (this.debug) { console.error(`Error getting ${description}:`, error.message); } // If we got a timeout and it's SNMPv3, try with different security levels if (error.message.includes('timed out') && config.version === 3) { if (this.debug) { console.log(`Retrying ${description} with fallback settings...`); } // Create a retry config with lower security level if (config.securityLevel === 'authPriv') { const retryConfig = { ...config, securityLevel: 'authNoPriv' as 'authNoPriv' }; try { if (this.debug) { console.log(`Retrying with authNoPriv security level`); } const value = await this.snmpGet(oid, retryConfig); if (this.debug) { console.log(`${description} retry value:`, value); } return value; } catch (retryError) { if (this.debug) { console.error(`Retry failed for ${description}:`, retryError.message); } } } } // If we're still having trouble, try with standard OIDs if (config.upsModel !== 'custom') { try { // Try RFC 1628 standard UPS MIB OIDs const standardOIDs = UpsOidSets.getStandardOids(); if (this.debug) { console.log(`Trying standard RFC 1628 OID for ${description}: ${standardOIDs[description]}`); } const standardValue = await this.snmpGet(standardOIDs[description], config); if (this.debug) { console.log(`${description} standard OID value:`, standardValue); } return standardValue; } catch (stdError) { if (this.debug) { console.error(`Standard OID retry failed for ${description}:`, stdError.message); } } } // Return a default value if all attempts fail if (this.debug) { console.log(`Using default value 0 for ${description}`); } return 0; } }; // Get all values with independent retry logic const powerStatusValue = await getSNMPValueWithRetry(this.activeOIDs.POWER_STATUS, 'power status'); const batteryCapacity = await getSNMPValueWithRetry(this.activeOIDs.BATTERY_CAPACITY, 'battery capacity') || 0; const batteryRuntime = await getSNMPValueWithRetry(this.activeOIDs.BATTERY_RUNTIME, 'battery runtime') || 0; // Determine power status - handle different values for different UPS models let powerStatus: 'online' | 'onBattery' | 'unknown' = 'unknown'; // Different UPS models use different values for power status if (config.upsModel === 'cyberpower') { // CyberPower RMCARD205: upsBaseOutputStatus values // 2=onLine, 3=onBattery, 4=onBoost, 5=onSleep, 6=off, etc. if (powerStatusValue === 2) { powerStatus = 'online'; } else if (powerStatusValue === 3) { powerStatus = 'onBattery'; } } else { // Default interpretation for other UPS models if (powerStatusValue === 1) { powerStatus = 'online'; } else if (powerStatusValue === 2) { powerStatus = 'onBattery'; } } // Convert TimeTicks to minutes for CyberPower runtime (value is in 1/100 seconds) let processedRuntime = batteryRuntime; if (config.upsModel === 'cyberpower' && batteryRuntime > 0) { // TimeTicks is in 1/100 seconds, convert to minutes processedRuntime = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute if (this.debug) { console.log(`Converting CyberPower runtime from ${batteryRuntime} ticks to ${processedRuntime} minutes`); } } const result = { powerStatus, batteryCapacity, batteryRuntime: processedRuntime, raw: { powerStatus: powerStatusValue, batteryCapacity, batteryRuntime, }, }; if (this.debug) { console.log('---------------------------------------'); console.log('UPS status result:'); console.log(' Power Status:', result.powerStatus); console.log(' Battery Capacity:', result.batteryCapacity + '%'); console.log(' Battery Runtime:', result.batteryRuntime, 'minutes'); console.log('---------------------------------------'); } return result; } catch (error) { if (this.debug) { console.error('---------------------------------------'); console.error('Error getting UPS status:', error.message); console.error('---------------------------------------'); } throw new Error(`Failed to get UPS status: ${error.message}`); } } /** * Discover SNMP engine ID (for SNMPv3) * Sends a proper discovery message to get the engine ID from the device * @param config SNMP configuration * @returns Promise resolving to the discovered engine ID */ public async discoverEngineId(config: SnmpConfig): Promise { return new Promise((resolve, reject) => { const socket = dgram.createSocket('udp4'); // Create a proper discovery message (SNMPv3 with noAuthNoPriv) const discoveryConfig: SnmpConfig = { ...config, securityLevel: 'noAuthNoPriv', username: '', // Empty username for discovery }; // Create a simple GetRequest for sysDescr (a commonly available OID) const request = SnmpPacketCreator.createDiscoveryMessage(discoveryConfig, this.requestID++); if (this.debug) { console.log('Sending SNMPv3 discovery message'); console.log('SNMPv3 Discovery message:', request.toString('hex')); } // Set timeout - use a longer timeout for discovery phase const discoveryTimeout = Math.max(config.timeout, 15000); // At least 15 seconds for discovery const timeout = setTimeout(() => { socket.close(); // Fall back to default engine ID if discovery fails if (this.debug) { console.error('---------------------------------------'); console.error('Engine ID discovery timed out after', discoveryTimeout, 'ms'); console.error('SNMPv3 settings:'); console.error(' Username:', config.username); console.error(' Security Level:', config.securityLevel); console.error(' Host:', config.host); console.error(' Port:', config.port); console.error('Using default engine ID:', this.engineID.toString('hex')); console.error('---------------------------------------'); } resolve(this.engineID); }, discoveryTimeout); // Listen for responses socket.on('message', (message, rinfo) => { clearTimeout(timeout); if (this.debug) { console.log(`Received SNMPv3 discovery response from ${rinfo.address}:${rinfo.port}`); console.log('Response:', message.toString('hex')); } try { // Extract engine ID from response const engineId = SnmpPacketParser.extractEngineId(message, this.debug); if (engineId) { this.engineID = engineId; // Update the engine ID if (this.debug) { console.log('Discovered engine ID:', engineId.toString('hex')); } socket.close(); resolve(engineId); } else { if (this.debug) { console.log('Could not extract engine ID, using default'); } socket.close(); resolve(this.engineID); } } catch (error) { if (this.debug) { console.error('Error extracting engine ID:', error); } socket.close(); resolve(this.engineID); // Fall back to default engine ID } }); // Handle errors socket.on('error', (error) => { clearTimeout(timeout); socket.close(); if (this.debug) { console.error('Engine ID discovery socket error:', error); } resolve(this.engineID); // Fall back to default engine ID }); // Send request directly without binding socket.send(request, 0, request.length, config.port, config.host, (error) => { if (error) { clearTimeout(timeout); socket.close(); if (this.debug) { console.error('Error sending discovery message:', error); } resolve(this.engineID); // Fall back to default engine ID } else if (this.debug) { console.log('Discovery message sent successfully'); } }); }); } /** * Initiate system shutdown * @param reason Reason for shutdown */ public async initiateShutdown(reason: string): Promise { console.log(`Initiating system shutdown due to: ${reason}`); try { // Execute shutdown command const { stdout } = await execAsync('shutdown -h +1 "UPS battery critical, shutting down in 1 minute"'); console.log('Shutdown initiated:', stdout); } catch (error) { console.error('Failed to initiate shutdown:', error); // Try a different method if first one fails try { console.log('Trying alternative shutdown method...'); await execAsync('poweroff --force'); } catch (innerError) { console.error('All shutdown methods failed:', innerError); } } } }