feat(snmp): add configurable SNMP runtime units with v4.3 migration support
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/nupst',
|
||||
version: '5.3.3',
|
||||
version: '5.4.0',
|
||||
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
|
||||
}
|
||||
|
||||
@@ -974,6 +974,35 @@ export class UpsHandler {
|
||||
OUTPUT_CURRENT: '',
|
||||
};
|
||||
}
|
||||
|
||||
// Runtime unit selection
|
||||
logger.log('');
|
||||
logger.info('Battery Runtime Unit:');
|
||||
logger.dim(' Controls how NUPST interprets the runtime value from your UPS.');
|
||||
logger.dim(' 1) Minutes (APC, TrippLite, Liebert - most common)');
|
||||
logger.dim(' 2) Seconds (Eaton, HPE, many RFC 1628 devices)');
|
||||
logger.dim(' 3) Ticks (CyberPower - 1/100 second increments)');
|
||||
|
||||
const defaultUnitValue = snmpConfig.runtimeUnit === 'seconds'
|
||||
? 2
|
||||
: snmpConfig.runtimeUnit === 'ticks'
|
||||
? 3
|
||||
: snmpConfig.upsModel === 'cyberpower'
|
||||
? 3
|
||||
: snmpConfig.upsModel === 'eaton'
|
||||
? 2
|
||||
: 1;
|
||||
|
||||
const unitInput = await prompt(`Select runtime unit [${defaultUnitValue}]: `);
|
||||
const unitValue = parseInt(unitInput, 10) || defaultUnitValue;
|
||||
|
||||
if (unitValue === 2) {
|
||||
snmpConfig.runtimeUnit = 'seconds';
|
||||
} else if (unitValue === 3) {
|
||||
snmpConfig.runtimeUnit = 'ticks';
|
||||
} else {
|
||||
snmpConfig.runtimeUnit = 'minutes';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1296,6 +1325,7 @@ export class UpsHandler {
|
||||
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
|
||||
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
|
||||
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
|
||||
logger.logBoxLine(`Runtime Unit: ${ups.snmp.runtimeUnit || 'auto'}`);
|
||||
}
|
||||
|
||||
if (ups.groups && ups.groups.length > 0) {
|
||||
|
||||
@@ -142,7 +142,7 @@ export class NupstDaemon {
|
||||
|
||||
/** Default configuration */
|
||||
private readonly DEFAULT_CONFIG: INupstConfig = {
|
||||
version: '4.2',
|
||||
version: '4.3',
|
||||
upsDevices: [
|
||||
{
|
||||
id: 'default',
|
||||
@@ -162,6 +162,7 @@ export class NupstDaemon {
|
||||
privKey: '',
|
||||
// UPS model for OID selection
|
||||
upsModel: 'cyberpower',
|
||||
runtimeUnit: 'ticks',
|
||||
},
|
||||
groups: [],
|
||||
actions: [
|
||||
@@ -260,7 +261,7 @@ export class NupstDaemon {
|
||||
|
||||
// Ensure version is always set and remove legacy fields before saving
|
||||
const configToSave: INupstConfig = {
|
||||
version: '4.2',
|
||||
version: '4.3',
|
||||
upsDevices: config.upsDevices,
|
||||
groups: config.groups,
|
||||
checkInterval: config.checkInterval,
|
||||
|
||||
@@ -10,3 +10,4 @@ export { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
|
||||
export { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
|
||||
export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
|
||||
export { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
|
||||
export { MigrationV4_2ToV4_3 } from './migration-v4.2-to-v4.3.ts';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
|
||||
import { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
|
||||
import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
|
||||
import { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
|
||||
import { MigrationV4_2ToV4_3 } from './migration-v4.2-to-v4.3.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
/**
|
||||
@@ -21,6 +22,7 @@ export class MigrationRunner {
|
||||
new MigrationV3ToV4(),
|
||||
new MigrationV4_0ToV4_1(),
|
||||
new MigrationV4_1ToV4_2(),
|
||||
new MigrationV4_2ToV4_3(),
|
||||
];
|
||||
|
||||
// Sort by version order to ensure they run in sequence
|
||||
|
||||
50
ts/migrations/migration-v4.2-to-v4.3.ts
Normal file
50
ts/migrations/migration-v4.2-to-v4.3.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { BaseMigration } from './base-migration.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
/**
|
||||
* Migration from v4.2 to v4.3
|
||||
*
|
||||
* Changes:
|
||||
* 1. Adds `runtimeUnit` to SNMP configs based on existing `upsModel`
|
||||
* 2. Bumps version from '4.2' to '4.3'
|
||||
*/
|
||||
export class MigrationV4_2ToV4_3 extends BaseMigration {
|
||||
readonly fromVersion = '4.2';
|
||||
readonly toVersion = '4.3';
|
||||
|
||||
shouldRun(config: Record<string, unknown>): boolean {
|
||||
return config.version === '4.2';
|
||||
}
|
||||
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
logger.info(`${this.getName()}: Adding runtimeUnit to SNMP configs...`);
|
||||
|
||||
const devices = (config.upsDevices as Array<Record<string, unknown>>) || [];
|
||||
const migratedDevices = devices.map((device) => {
|
||||
const snmp = device.snmp as Record<string, unknown> | undefined;
|
||||
if (snmp && !snmp.runtimeUnit) {
|
||||
const model = snmp.upsModel as string | undefined;
|
||||
if (model === 'cyberpower') {
|
||||
snmp.runtimeUnit = 'ticks';
|
||||
} else if (model === 'eaton') {
|
||||
snmp.runtimeUnit = 'seconds';
|
||||
} else {
|
||||
snmp.runtimeUnit = 'minutes';
|
||||
}
|
||||
logger.dim(` → ${device.name}: Set runtimeUnit to '${snmp.runtimeUnit}'`);
|
||||
}
|
||||
return device;
|
||||
});
|
||||
|
||||
const result = {
|
||||
...config,
|
||||
version: this.toVersion,
|
||||
upsDevices: migratedDevices,
|
||||
};
|
||||
|
||||
logger.success(
|
||||
`${this.getName()}: Migration complete (${migratedDevices.length} devices updated)`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -357,7 +357,7 @@ export class NupstSnmp {
|
||||
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 processedRuntime = this.processRuntimeValue(config, batteryRuntime);
|
||||
|
||||
// Process power metrics with vendor-specific scaling
|
||||
const processedVoltage = this.processVoltageValue(config.upsModel, outputVoltage);
|
||||
@@ -620,22 +620,46 @@ export class NupstSnmp {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process runtime value based on UPS model
|
||||
* @param upsModel UPS model
|
||||
* 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(
|
||||
upsModel: TUpsModel | undefined,
|
||||
config: ISnmpConfig,
|
||||
batteryRuntime: number,
|
||||
): number {
|
||||
if (this.debug) {
|
||||
logger.dim(`Raw runtime value: ${batteryRuntime}`);
|
||||
}
|
||||
|
||||
// Explicit runtimeUnit takes precedence over model-based detection
|
||||
if (config.runtimeUnit) {
|
||||
if (config.runtimeUnit === 'seconds' && batteryRuntime > 0) {
|
||||
const minutes = Math.floor(batteryRuntime / 60);
|
||||
if (this.debug) {
|
||||
logger.dim(
|
||||
`Converting runtime from ${batteryRuntime} seconds to ${minutes} minutes (runtimeUnit: seconds)`,
|
||||
);
|
||||
}
|
||||
return minutes;
|
||||
} else if (config.runtimeUnit === 'ticks' && batteryRuntime > 0) {
|
||||
const minutes = Math.floor(batteryRuntime / 6000);
|
||||
if (this.debug) {
|
||||
logger.dim(
|
||||
`Converting runtime from ${batteryRuntime} ticks to ${minutes} minutes (runtimeUnit: ticks)`,
|
||||
);
|
||||
}
|
||||
return minutes;
|
||||
}
|
||||
// runtimeUnit === 'minutes' — return as-is
|
||||
return batteryRuntime;
|
||||
}
|
||||
|
||||
// Fallback: model-based detection (for configs without runtimeUnit)
|
||||
const upsModel = config.upsModel;
|
||||
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
|
||||
const minutes = Math.floor(batteryRuntime / 6000);
|
||||
if (this.debug) {
|
||||
logger.dim(
|
||||
`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`,
|
||||
@@ -643,7 +667,6 @@ export class NupstSnmp {
|
||||
}
|
||||
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(
|
||||
@@ -652,10 +675,9 @@ export class NupstSnmp {
|
||||
}
|
||||
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`);
|
||||
logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes (heuristic)`);
|
||||
}
|
||||
return minutes;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,11 @@ export interface IOidSet {
|
||||
*/
|
||||
export type TUpsModel = 'cyberpower' | 'apc' | 'eaton' | 'tripplite' | 'liebert' | 'custom';
|
||||
|
||||
/**
|
||||
* Runtime unit for battery runtime SNMP values
|
||||
*/
|
||||
export type TRuntimeUnit = 'minutes' | 'seconds' | 'ticks';
|
||||
|
||||
/**
|
||||
* SNMP Configuration interface
|
||||
*/
|
||||
@@ -96,6 +101,8 @@ export interface ISnmpConfig {
|
||||
upsModel?: TUpsModel;
|
||||
/** Custom OIDs when using custom UPS model */
|
||||
customOIDs?: IOidSet;
|
||||
/** Unit of the battery runtime SNMP value. Overrides model-based auto-detection when set. */
|
||||
runtimeUnit?: TRuntimeUnit;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user