import * as snmp from 'npm:net-snmp@3.26.1'; import { Buffer } from 'node:buffer'; import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts'; import { UpsOidSets } from './oid-sets.ts'; import { convertRuntimeValueToMinutes, getDefaultRuntimeUnitForUpsModel } from './runtime-units.ts'; import { SNMP } from '../constants.ts'; import { logger } from '../logger.ts'; import type { INupstAccessor } from '../interfaces/index.ts'; type TSnmpMetricDescription = | 'power status' | 'battery capacity' | 'battery runtime' | 'output load' | 'output power' | 'output voltage' | 'output current'; type TSnmpResponseValue = string | number | bigint | boolean | Buffer; type TSnmpValue = string | number | boolean | Buffer; interface ISnmpVarbind { oid: string; type: number; value: TSnmpResponseValue; } interface ISnmpSessionOptions { port: number; retries: number; timeout: number; transport: 'udp4' | 'udp6'; idBitsSize: 16 | 32; context: string; version: number; } interface ISnmpV3User { name: string; level: number; authProtocol?: string; authKey?: string; privProtocol?: string; privKey?: string; } interface ISnmpSession { get(oids: string[], callback: (error: Error | null, varbinds?: ISnmpVarbind[]) => void): void; close(): void; } interface ISnmpModule { Version1: number; Version2c: number; Version3: number; SecurityLevel: { noAuthNoPriv: number; authNoPriv: number; authPriv: number; }; AuthProtocols: { md5: string; sha: string; }; PrivProtocols: { des: string; aes: string; }; createSession(target: string, community: string, options: ISnmpSessionOptions): ISnmpSession; createV3Session(target: string, user: ISnmpV3User, options: ISnmpSessionOptions): ISnmpSession; isVarbindError(varbind: ISnmpVarbind): boolean; varbindError(varbind: ISnmpVarbind): string; } const snmpLib = snmp as unknown as ISnmpModule; /** * 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}`); } } private createSessionOptions(config: ISnmpConfig): ISnmpSessionOptions { return { port: config.port, retries: SNMP.RETRIES, timeout: config.timeout, transport: 'udp4', idBitsSize: 32, context: config.context || '', version: config.version === 1 ? snmpLib.Version1 : config.version === 2 ? snmpLib.Version2c : snmpLib.Version3, }; } private buildV3User( config: ISnmpConfig, ): { user: ISnmpV3User; levelLabel: NonNullable } { const requestedSecurityLevel = config.securityLevel || 'noAuthNoPriv'; const user: ISnmpV3User = { name: config.username || '', level: snmpLib.SecurityLevel.noAuthNoPriv, }; let levelLabel: NonNullable = 'noAuthNoPriv'; if (requestedSecurityLevel === 'authNoPriv') { user.level = snmpLib.SecurityLevel.authNoPriv; levelLabel = 'authNoPriv'; if (config.authProtocol && config.authKey) { user.authProtocol = this.resolveAuthProtocol(config.authProtocol); user.authKey = config.authKey; } else { user.level = snmpLib.SecurityLevel.noAuthNoPriv; levelLabel = 'noAuthNoPriv'; if (this.debug) { logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv'); } } } else if (requestedSecurityLevel === 'authPriv') { user.level = snmpLib.SecurityLevel.authPriv; levelLabel = 'authPriv'; if (config.authProtocol && config.authKey) { user.authProtocol = this.resolveAuthProtocol(config.authProtocol); user.authKey = config.authKey; if (config.privProtocol && config.privKey) { user.privProtocol = this.resolvePrivProtocol(config.privProtocol); user.privKey = config.privKey; } else { user.level = snmpLib.SecurityLevel.authNoPriv; levelLabel = 'authNoPriv'; if (this.debug) { logger.warn('Missing privProtocol or privKey, falling back to authNoPriv'); } } } else { user.level = snmpLib.SecurityLevel.noAuthNoPriv; levelLabel = 'noAuthNoPriv'; if (this.debug) { logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv'); } } } return { user, levelLabel }; } private resolveAuthProtocol(protocol: NonNullable): string { return protocol === 'MD5' ? snmpLib.AuthProtocols.md5 : snmpLib.AuthProtocols.sha; } private resolvePrivProtocol(protocol: NonNullable): string { return protocol === 'DES' ? snmpLib.PrivProtocols.des : snmpLib.PrivProtocols.aes; } private normalizeSnmpValue(value: TSnmpResponseValue): TSnmpValue { if (Buffer.isBuffer(value)) { const isPrintableAscii = value.every((byte: number) => byte >= 32 && byte <= 126); return isPrintableAscii ? value.toString() : value; } if (typeof value === 'bigint') { return Number(value); } return value; } private coerceNumericSnmpValue( value: TSnmpValue | 0, description: TSnmpMetricDescription, ): number { if (typeof value === 'number') { return Number.isFinite(value) ? value : 0; } if (typeof value === 'string') { const trimmedValue = value.trim(); const parsedValue = Number(trimmedValue); if (trimmedValue && Number.isFinite(parsedValue)) { return parsedValue; } } if (this.debug) { logger.warn(`Non-numeric ${description} value received from SNMP, using 0`); } return 0; } /** * 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}`, ); if (config.version === 1 || config.version === 2) { logger.dim(`Using community: ${config.community}`); } } const options = this.createSessionOptions(config); const session: ISnmpSession = config.version === 3 ? (() => { const { user, levelLabel } = this.buildV3User(config); if (this.debug) { logger.dim( `SNMPv3 user configuration: name=${user.name}, level=${levelLabel}, authProtocol=${ user.authProtocol ? 'Set' : 'Not Set' }, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`, ); } return snmpLib.createV3Session(config.host, user, options); })() : snmpLib.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: Error | null, varbinds?: ISnmpVarbind[]) => { // 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; } const varbind = varbinds?.[0]; if (!varbind) { 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 (snmpLib.isVarbindError(varbind)) { const errorMessage = snmpLib.varbindError(varbind); if (this.debug) { logger.error(`SNMP error: ${errorMessage}`); } reject(new Error(`SNMP error: ${errorMessage}`)); return; } const value = this.normalizeSnmpValue(varbind.value); if (this.debug) { logger.dim( `SNMP response: oid=${varbind.oid}, type=${varbind.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 = this.coerceNumericSnmpValue( await this.getSNMPValueWithRetry(this.activeOIDs.POWER_STATUS, 'power status', config), 'power status', ); const batteryCapacity = this.coerceNumericSnmpValue( await this.getSNMPValueWithRetry( this.activeOIDs.BATTERY_CAPACITY, 'battery capacity', config, ), 'battery capacity', ); const batteryRuntime = this.coerceNumericSnmpValue( await this.getSNMPValueWithRetry( this.activeOIDs.BATTERY_RUNTIME, 'battery runtime', config, ), 'battery runtime', ); // Get power draw metrics const outputLoad = this.coerceNumericSnmpValue( await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_LOAD, 'output load', config), 'output load', ); const outputPower = this.coerceNumericSnmpValue( await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_POWER, 'output power', config), 'output power', ); const outputVoltage = this.coerceNumericSnmpValue( await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_VOLTAGE, 'output voltage', config), 'output voltage', ); const outputCurrent = this.coerceNumericSnmpValue( await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_CURRENT, 'output current', config), 'output current', ); // 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, 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: TSnmpMetricDescription, 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: TSnmpMetricDescription, 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 const }; 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 const }; 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 Original OID (unused, kept for method signature consistency) * @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: TSnmpMetricDescription, 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 config runtimeUnit or UPS model * @param config SNMP configuration (uses runtimeUnit if set, otherwise falls back to upsModel) * @param batteryRuntime Raw battery runtime value * @returns Processed runtime in minutes */ private processRuntimeValue( config: ISnmpConfig, batteryRuntime: number, ): number { if (this.debug) { logger.dim(`Raw runtime value: ${batteryRuntime}`); } const runtimeUnit = config.runtimeUnit || getDefaultRuntimeUnitForUpsModel(config.upsModel, batteryRuntime); const minutes = convertRuntimeValueToMinutes(config, batteryRuntime); if (this.debug && minutes !== batteryRuntime) { const source = config.runtimeUnit ? `runtimeUnit: ${runtimeUnit}` : `upsModel: ${config.upsModel || 'auto'}`; logger.dim( `Converting runtime from ${batteryRuntime} ${runtimeUnit} to ${minutes} minutes (${source})`, ); } return minutes; } /** * 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; } }