Files
nupst/ts/snmp/manager.ts
Juergen Kunz bb87316dd3
All checks were successful
CI / Type Check & Lint (push) Successful in 7s
CI / Build Test (Current Platform) (push) Successful in 5s
Release / build-and-release (push) Successful in 44s
CI / Build All Platforms (push) Successful in 49s
fix(snmp): correct power status interpretation using OID set mappings
Move power status value interpretation from hardcoded logic to OID set configuration.
Each UPS model now defines its own value mappings (e.g., CyberPower: 2=online, 3=onBattery).

Fixes incorrect status display where UPS showed "On Battery" when actually online.

Changes:
- Add POWER_STATUS_VALUES to IOidSet interface
- Define value mappings for all UPS models (cyberpower, apc, eaton, tripplite, liebert)
- Refactor determinePowerStatus() to use OID set mappings instead of hardcoded values
- CyberPower now correctly interprets value 2 as online (was incorrectly onBattery)
2025-10-19 23:48:13 +00:00

606 lines
19 KiB
TypeScript

import * as snmp from 'npm:net-snmp@3.20.0';
import { Buffer } from 'node:buffer';
import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts';
import { UpsOidSets } from './oid-sets.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
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 snmpGet(
oid: string,
config = this.DEFAULT_CONFIG,
retryCount = 0,
): Promise<any> {
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: 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) {
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<IUpsStatus> {
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 instanceof Error ? error.message : String(error),
);
console.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<any> {
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 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) {
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<any> {
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 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) {
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 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<any> {
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 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) {
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;
}
}