import * as snmp from 'npm:net-snmp@3.26.0'; import { Buffer } from 'node:buffer'; import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts'; import { UpsOidSets } from './oid-sets.ts'; import { SNMP } from '../constants.ts'; import { logger } from '../logger.ts'; import type { INupstAccessor } from '../interfaces/index.ts'; /** * 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 (uses interface to avoid circular dependency) private nupst: INupstAccessor | null = null; // Debug mode flag private debug: boolean = false; // Default SNMP configuration private readonly DEFAULT_CONFIG: ISnmpConfig = { host: '127.0.0.1', // Default to localhost port: SNMP.DEFAULT_PORT, // Default SNMP port community: 'public', // Default community string for v1/v2c version: 1, // SNMPv1 timeout: SNMP.DEFAULT_TIMEOUT_MS, // 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: INupstAccessor): void { this.nupst = nupst; } /** * Get reference to the main Nupst instance */ public getNupst(): INupstAccessor | null { return this.nupst; } /** * Enable debug mode */ public enableDebug(): void { this.debug = true; logger.info('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) { logger.dim(`Using custom OIDs: ${JSON.stringify(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) { logger.dim(`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 snmpGet( oid: string, config = this.DEFAULT_CONFIG, retryCount = 0, ): Promise { return new Promise((resolve, reject) => { if (this.debug) { logger.dim( `Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`, ); logger.dim(`Using community: ${config.community}`); } // Create SNMP options based on configuration const options: any = { port: config.port, retries: SNMP.RETRIES, // 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) { logger.warn('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) { logger.warn('Missing privProtocol or privKey, falling back to authNoPriv'); } } } else { // Fallback to noAuthNoPriv if auth details missing user.level = snmp.SecurityLevel.noAuthNoPriv; if (this.debug) { logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv'); } } } if (this.debug) { const levelName = Object.keys(snmp.SecurityLevel).find((key) => snmp.SecurityLevel[key] === user.level ); logger.dim(`SNMPv3 user configuration: name=${user.name}, level=${levelName}, authProtocol=${user.authProtocol ? 'Set' : 'Not Set'}, privProtocol=${user.privProtocol ? '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) { logger.error(`SNMP GET error: ${error}`); } reject(new Error(`SNMP GET error: ${error.message || error}`)); return; } if (!varbinds || varbinds.length === 0) { if (this.debug) { logger.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) { logger.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: number) => 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) { logger.dim(`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) { logger.dim('---------------------------------------'); logger.dim('Getting UPS status with config:'); logger.dim(` Host: ${config.host}`); logger.dim(` Port: ${config.port}`); logger.dim(` Version: ${config.version}`); logger.dim(` Timeout: ${config.timeout} ms`); logger.dim(` UPS Model: ${config.upsModel || 'cyberpower'}`); if (config.version === 1 || config.version === 2) { logger.dim(` Community: ${config.community}`); } else if (config.version === 3) { logger.dim(` Security Level: ${config.securityLevel}`); logger.dim(` Username: ${config.username}`); logger.dim(` Auth Protocol: ${config.authProtocol || 'None'}`); logger.dim(` Privacy Protocol: ${config.privProtocol || 'None'}`); } logger.dim('Using OIDs:'); logger.dim(` Power Status: ${this.activeOIDs.POWER_STATUS}`); logger.dim(` Battery Capacity: ${this.activeOIDs.BATTERY_CAPACITY}`); logger.dim(` Battery Runtime: ${this.activeOIDs.BATTERY_RUNTIME}`); logger.dim(` Output Load: ${this.activeOIDs.OUTPUT_LOAD}`); logger.dim(` Output Power: ${this.activeOIDs.OUTPUT_POWER}`); logger.dim(` Output Voltage: ${this.activeOIDs.OUTPUT_VOLTAGE}`); logger.dim(` Output Current: ${this.activeOIDs.OUTPUT_CURRENT}`); logger.dim('---------------------------------------'); } // 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; // Get power draw metrics const outputLoad = await this.getSNMPValueWithRetry( this.activeOIDs.OUTPUT_LOAD, 'output load', config, ) || 0; const outputPower = await this.getSNMPValueWithRetry( this.activeOIDs.OUTPUT_POWER, 'output power', config, ) || 0; const outputVoltage = await this.getSNMPValueWithRetry( this.activeOIDs.OUTPUT_VOLTAGE, 'output voltage', config, ) || 0; const outputCurrent = await this.getSNMPValueWithRetry( this.activeOIDs.OUTPUT_CURRENT, 'output current', 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); // Process power metrics with vendor-specific scaling const processedVoltage = this.processVoltageValue(config.upsModel, outputVoltage); const processedCurrent = this.processCurrentValue(config.upsModel, outputCurrent); // Calculate power from voltage × current if not provided by UPS let processedPower = outputPower; if (outputPower === 0 && processedVoltage > 0 && processedCurrent > 0) { processedPower = Math.round(processedVoltage * processedCurrent); if (this.debug) { logger.dim( `Calculated power from V×I: ${processedVoltage}V × ${processedCurrent}A = ${processedPower}W`, ); } } const result = { powerStatus, batteryCapacity, batteryRuntime: processedRuntime, outputLoad, outputPower: processedPower, outputVoltage: processedVoltage, outputCurrent: processedCurrent, raw: { powerStatus: powerStatusValue, batteryCapacity, batteryRuntime, outputLoad, outputPower, outputVoltage, outputCurrent, }, }; if (this.debug) { logger.dim('---------------------------------------'); logger.dim('UPS status result:'); logger.dim(` Power Status: ${result.powerStatus}`); logger.dim(` Battery Capacity: ${result.batteryCapacity}%`); logger.dim(` Battery Runtime: ${result.batteryRuntime} minutes`); logger.dim(` Output Load: ${result.outputLoad}%`); logger.dim(` Output Power: ${result.outputPower} watts`); logger.dim(` Output Voltage: ${result.outputVoltage} volts`); logger.dim(` Output Current: ${result.outputCurrent} amps`); logger.dim('---------------------------------------'); } return result; } catch (error) { if (this.debug) { logger.error('---------------------------------------'); logger.error( `Error getting UPS status: ${error instanceof Error ? error.message : String(error)}`, ); logger.error('---------------------------------------'); } throw new Error( `Failed to get UPS status: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * 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) { logger.dim(`No OID provided for ${description}, skipping`); } return 0; } if (this.debug) { logger.dim(`Getting ${description} OID: ${oid}`); } try { const value = await this.snmpGet(oid, config); if (this.debug) { logger.dim(`${description} value: ${value}`); } return value; } catch (error) { if (this.debug) { logger.error( `Error getting ${description}: ${error instanceof Error ? error.message : String(error)}`, ); } // 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) { logger.dim(`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) { logger.dim(`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) { logger.dim(`Retrying with authNoPriv security level`); } const value = await this.snmpGet(oid, retryConfig); if (this.debug) { logger.dim(`${description} retry value: ${value}`); } return value; } catch (retryError) { if (this.debug) { logger.error( `Retry failed for ${description}: ${retryError instanceof Error ? retryError.message : String(retryError)}`, ); } } } // 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) { logger.dim(`Retrying with noAuthNoPriv security level`); } const value = await this.snmpGet(oid, retryConfig); if (this.debug) { logger.dim(`${description} retry value: ${value}`); } return value; } catch (retryError) { if (this.debug) { logger.error( `Retry failed for ${description}: ${retryError instanceof Error ? retryError.message : String(retryError)}`, ); } } } 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) { logger.dim( `Trying standard RFC 1628 OID for ${description}: ${standardOIDs[description]}`, ); } const standardValue = await this.snmpGet(standardOIDs[description], config); if (this.debug) { logger.dim(`${description} standard OID value: ${standardValue}`); } return standardValue; } catch (stdError) { if (this.debug) { logger.error( `Standard OID retry failed for ${description}: ${stdError instanceof Error ? stdError.message : String(stdError)}`, ); } } return 0; } /** * Determine power status based on UPS model and raw value * Uses the value mappings defined in the OID sets * @param upsModel UPS model * @param powerStatusValue Raw power status value * @returns Standardized power status */ private determinePowerStatus( upsModel: TUpsModel | undefined, powerStatusValue: number, ): 'online' | 'onBattery' | 'unknown' { // Get the OID set for this UPS model if (upsModel && upsModel !== 'custom') { const oidSet = UpsOidSets.getOidSet(upsModel); // Use the value mappings if available if (oidSet.POWER_STATUS_VALUES) { if (powerStatusValue === oidSet.POWER_STATUS_VALUES.online) { return 'online'; } else if (powerStatusValue === oidSet.POWER_STATUS_VALUES.onBattery) { return 'onBattery'; } } } // Fallback for custom or undefined models (RFC 1628 standard) // upsOutputSource: 3=normal (mains), 5=battery if (powerStatusValue === 3) { return 'online'; } else if (powerStatusValue === 5) { 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) { logger.dim(`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) { logger.dim( `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) { logger.dim( `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) { logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes`); } return minutes; } return batteryRuntime; } /** * Process voltage value based on UPS model * @param upsModel UPS model * @param outputVoltage Raw output voltage value * @returns Processed voltage in volts */ private processVoltageValue( upsModel: TUpsModel | undefined, outputVoltage: number, ): number { if (this.debug) { logger.dim(`Raw voltage value: ${outputVoltage}`); } if (upsModel === 'cyberpower' && outputVoltage > 0) { // CyberPower: Voltage is in 0.1V, convert to volts const volts = outputVoltage / 10; if (this.debug) { logger.dim( `Converting CyberPower voltage from ${outputVoltage} (0.1V) to ${volts} volts`, ); } return volts; } return outputVoltage; } /** * Process current value based on UPS model * @param upsModel UPS model * @param outputCurrent Raw output current value * @returns Processed current in amps */ private processCurrentValue( upsModel: TUpsModel | undefined, outputCurrent: number, ): number { if (this.debug) { logger.dim(`Raw current value: ${outputCurrent}`); } if (upsModel === 'cyberpower' && outputCurrent > 0) { // CyberPower: Current is in 0.1A, convert to amps const amps = outputCurrent / 10; if (this.debug) { logger.dim( `Converting CyberPower current from ${outputCurrent} (0.1A) to ${amps} amps`, ); } return amps; } else if ((upsModel === 'tripplite' || upsModel === 'liebert') && outputCurrent > 0) { // RFC 1628 standard: Current is in 0.1A, convert to amps const amps = outputCurrent / 10; if (this.debug) { logger.dim( `Converting RFC 1628 current from ${outputCurrent} (0.1A) to ${amps} amps`, ); } return amps; } // Eaton XUPS-MIB and APC PowerNet report current directly in RMS Amps (no scaling needed) if ((upsModel === 'eaton' || upsModel === 'apc') && this.debug && outputCurrent > 0) { logger.dim(`${upsModel.toUpperCase()} current already in RMS Amps: ${outputCurrent}A`); } return outputCurrent; } }