import * as snmp from 'net-snmp'; import type { IOidSet, ISnmpConfig, TUpsModel, IUpsStatus } from './types.js'; import { UpsOidSets } from './oid-sets.js'; /** * Class for SNMP communication with UPS devices * Main entry point for SNMP functionality */ export class NupstSnmp { // Active OID set private activeOIDs: IOidSet; // Reference to the parent Nupst instance private nupst: any; // Type 'any' to avoid circular dependency // Debug mode flag private debug: boolean = false; // Default SNMP configuration private readonly DEFAULT_CONFIG: ISnmpConfig = { 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 }; /** * 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 reference to the main Nupst instance * @param nupst Reference to the main Nupst instance */ public setNupst(nupst: any): void { this.nupst = nupst; } /** * Get reference to the main Nupst instance */ public getNupst(): any { return this.nupst; } /** * Enable debug mode */ public enableDebug(): void { this.debug = true; console.log('SNMP debug mode enabled - detailed logs will be shown'); } /** * Set active OID set based on UPS model * @param config SNMP configuration */ private setActiveOIDs(config: ISnmpConfig): 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}`); } } /** * Send an SNMP GET request using the net-snmp package * @param oid OID to query * @param config SNMP configuration * @param retryCount Current retry count (unused in this implementation) * @returns Promise resolving to the SNMP response value */ public async snmpGet( oid: string, config = this.DEFAULT_CONFIG, retryCount = 0 ): Promise { return new Promise((resolve, reject) => { if (this.debug) { console.log(`Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`); console.log('Using community:', config.community); } // Create SNMP options based on configuration const options: any = { port: config.port, retries: 2, // Number of retries timeout: config.timeout, transport: 'udp4', idBitsSize: 32, context: config.context || '' }; // Set version based on config if (config.version === 1) { options.version = snmp.Version1; } else if (config.version === 2) { options.version = snmp.Version2c; } else { options.version = snmp.Version3; } // Create appropriate session based on SNMP version let session; if (config.version === 3) { // For SNMPv3, we need to set up authentication and privacy // For SNMPv3, we need a valid security level const securityLevel = config.securityLevel || 'noAuthNoPriv'; // Create the user object with required structure for net-snmp const user: any = { name: config.username || '' }; // Set security level if (securityLevel === 'noAuthNoPriv') { user.level = snmp.SecurityLevel.noAuthNoPriv; } else if (securityLevel === 'authNoPriv') { user.level = snmp.SecurityLevel.authNoPriv; // Set auth protocol - must provide both protocol and key if (config.authProtocol && config.authKey) { if (config.authProtocol === 'MD5') { user.authProtocol = snmp.AuthProtocols.md5; } else if (config.authProtocol === 'SHA') { user.authProtocol = snmp.AuthProtocols.sha; } user.authKey = config.authKey; } else { // Fallback to noAuthNoPriv if auth details missing user.level = snmp.SecurityLevel.noAuthNoPriv; if (this.debug) { console.log('Warning: Missing authProtocol or authKey, falling back to noAuthNoPriv'); } } } else if (securityLevel === 'authPriv') { user.level = snmp.SecurityLevel.authPriv; // Set auth protocol - must provide both protocol and key if (config.authProtocol && config.authKey) { if (config.authProtocol === 'MD5') { user.authProtocol = snmp.AuthProtocols.md5; } else if (config.authProtocol === 'SHA') { user.authProtocol = snmp.AuthProtocols.sha; } user.authKey = config.authKey; // Set privacy protocol - must provide both protocol and key if (config.privProtocol && config.privKey) { if (config.privProtocol === 'DES') { user.privProtocol = snmp.PrivProtocols.des; } else if (config.privProtocol === 'AES') { user.privProtocol = snmp.PrivProtocols.aes; } user.privKey = config.privKey; } else { // Fallback to authNoPriv if priv details missing user.level = snmp.SecurityLevel.authNoPriv; if (this.debug) { console.log('Warning: Missing privProtocol or privKey, falling back to authNoPriv'); } } } else { // Fallback to noAuthNoPriv if auth details missing user.level = snmp.SecurityLevel.noAuthNoPriv; if (this.debug) { console.log('Warning: Missing authProtocol or authKey, falling back to noAuthNoPriv'); } } } if (this.debug) { console.log('SNMPv3 user configuration:', { name: user.name, level: Object.keys(snmp.SecurityLevel).find(key => snmp.SecurityLevel[key] === user.level), authProtocol: user.authProtocol ? 'Set' : 'Not Set', authKey: user.authKey ? 'Set' : 'Not Set', privProtocol: user.privProtocol ? 'Set' : 'Not Set', privKey: user.privKey ? 'Set' : 'Not Set' }); } session = snmp.createV3Session(config.host, user, options); } else { // For SNMPv1/v2c, we use the community string session = snmp.createSession(config.host, config.community || 'public', options); } // Convert the OID string to an array of OIDs if multiple OIDs are needed const oids = [oid]; // Send the GET request session.get(oids, (error: any, varbinds: any[]) => { // Close the session to release resources session.close(); if (error) { if (this.debug) { console.error('SNMP GET error:', error); } reject(new Error(`SNMP GET error: ${error.message || error}`)); return; } if (!varbinds || varbinds.length === 0) { if (this.debug) { console.error('No varbinds returned in response'); } reject(new Error('No varbinds returned in response')); return; } // Check for SNMP errors in the response if (varbinds[0].type === snmp.ObjectType.NoSuchObject || varbinds[0].type === snmp.ObjectType.NoSuchInstance || varbinds[0].type === snmp.ObjectType.EndOfMibView) { if (this.debug) { console.error('SNMP error:', snmp.ObjectType[varbinds[0].type]); } reject(new Error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`)); return; } // Process the response value based on its type let value = varbinds[0].value; // Handle specific types that might need conversion if (Buffer.isBuffer(value)) { // If value is a Buffer, try to convert it to a string if it's printable ASCII const isPrintableAscii = value.every(byte => byte >= 32 && byte <= 126); if (isPrintableAscii) { value = value.toString(); } } else if (typeof value === 'bigint') { // Convert BigInt to a normal number or string if needed value = Number(value); } if (this.debug) { console.log('SNMP response:', { oid: varbinds[0].oid, type: varbinds[0].type, value: value }); } resolve(value); }); }); } /** * 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('---------------------------------------'); } // Get all values with independent retry logic const powerStatusValue = await this.getSNMPValueWithRetry(this.activeOIDs.POWER_STATUS, 'power status', config); const batteryCapacity = await this.getSNMPValueWithRetry(this.activeOIDs.BATTERY_CAPACITY, 'battery capacity', config) || 0; const batteryRuntime = await this.getSNMPValueWithRetry(this.activeOIDs.BATTERY_RUNTIME, 'battery runtime', config) || 0; // Determine power status - handle different values for different UPS models const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue); // Convert to minutes for UPS models with different time units const processedRuntime = this.processRuntimeValue(config.upsModel, batteryRuntime); 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}`); } } /** * Helper method to get SNMP value with retry and fallback logic * @param oid OID to query * @param description Description of the value for logging * @param config SNMP configuration * @returns Promise resolving to the SNMP value */ private async getSNMPValueWithRetry( oid: string, description: string, config: ISnmpConfig ): Promise { 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're using SNMPv3, try with different security levels if (config.version === 3) { return await this.tryFallbackSecurityLevels(oid, description, config); } // Try with standard OIDs as fallback if (config.upsModel !== 'custom') { return await this.tryStandardOids(oid, description, config); } // Return a default value if all attempts fail if (this.debug) { console.log(`Using default value 0 for ${description}`); } return 0; } } /** * Try fallback security levels for SNMPv3 * @param oid OID to query * @param description Description of the value for logging * @param config SNMP configuration * @returns Promise resolving to the SNMP value */ private async tryFallbackSecurityLevels( oid: string, description: string, config: ISnmpConfig ): Promise { if (this.debug) { console.log(`Retrying ${description} with fallback security level...`); } // Try with authNoPriv if current level is authPriv 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); } } } // Try with noAuthNoPriv as a last resort if (config.securityLevel === 'authPriv' || config.securityLevel === 'authNoPriv') { const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as 'noAuthNoPriv' }; try { if (this.debug) { console.log(`Retrying with noAuthNoPriv 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); } } } return 0; } /** * Try standard OIDs as fallback * @param oid OID to query * @param description Description of the value for logging * @param config SNMP configuration * @returns Promise resolving to the SNMP value */ private async tryStandardOids( oid: string, description: string, config: ISnmpConfig ): Promise { 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 0; } /** * Determine power status based on UPS model and raw value * @param upsModel UPS model * @param powerStatusValue Raw power status value * @returns Standardized power status */ private determinePowerStatus( upsModel: TUpsModel | undefined, powerStatusValue: number ): 'online' | 'onBattery' | 'unknown' { if (upsModel === 'cyberpower') { // CyberPower RMCARD205: upsBaseOutputStatus values // 2=onLine, 3=onBattery, 4=onBoost, 5=onSleep, 6=off, etc. if (powerStatusValue === 2) { return 'online'; } else if (powerStatusValue === 3) { return 'onBattery'; } } else if (upsModel === 'eaton') { // Eaton UPS: xupsOutputSource values // 3=normal/mains, 5=battery, etc. if (powerStatusValue === 3) { return 'online'; } else if (powerStatusValue === 5) { return 'onBattery'; } } else if (upsModel === 'apc') { // APC UPS: upsBasicOutputStatus values // 2=online, 3=onBattery, etc. if (powerStatusValue === 2) { return 'online'; } else if (powerStatusValue === 3) { return 'onBattery'; } } else { // Default interpretation for other UPS models if (powerStatusValue === 1) { return 'online'; } else if (powerStatusValue === 2) { return 'onBattery'; } } return 'unknown'; } /** * Process runtime value based on UPS model * @param upsModel UPS model * @param batteryRuntime Raw battery runtime value * @returns Processed runtime in minutes */ private processRuntimeValue( upsModel: TUpsModel | undefined, batteryRuntime: number ): number { if (this.debug) { console.log('Raw runtime value:', batteryRuntime); } if (upsModel === 'cyberpower' && batteryRuntime > 0) { // CyberPower: TimeTicks is in 1/100 seconds, convert to minutes const minutes = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute if (this.debug) { console.log(`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`); } return minutes; } else if (upsModel === 'eaton' && batteryRuntime > 0) { // Eaton: Runtime is in seconds, convert to minutes const minutes = Math.floor(batteryRuntime / 60); if (this.debug) { console.log(`Converting Eaton runtime from ${batteryRuntime} seconds to ${minutes} minutes`); } return minutes; } else if (batteryRuntime > 10000) { // Generic conversion for large tick values (likely TimeTicks) const minutes = Math.floor(batteryRuntime / 6000); if (this.debug) { console.log(`Converting ${batteryRuntime} ticks to ${minutes} minutes`); } return minutes; } return batteryRuntime; } }