feat(snmp): add configurable SNMP runtime units with v4.3 migration support

This commit is contained in:
2026-03-30 06:46:28 +00:00
parent 0fb9678976
commit 11e549e68e
11 changed files with 142 additions and 15 deletions

View File

@@ -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'
}

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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';

View File

@@ -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

View 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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
/**