feat(core): Centralize timeouts/constants, add CLI prompt helpers, and introduce webhook/script actions with safety and SNMP refactors

This commit is contained in:
2026-01-29 17:04:12 +00:00
parent d0e3a4ae74
commit 07648b4880
24 changed files with 1019 additions and 590 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/nupst',
version: '5.1.11',
version: '5.2.0',
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
}

View File

@@ -13,6 +13,7 @@ import { ScriptAction } from './script-action.ts';
// Re-export types for convenience
export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts';
export type { IWebhookPayload } from './webhook-action.ts';
export { Action } from './base-action.ts';
export { ShutdownAction } from './shutdown-action.ts';
export { WebhookAction } from './webhook-action.ts';

View File

@@ -1,8 +1,9 @@
import * as fs from 'node:fs';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
import { Action, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts';
import { SHUTDOWN, UI } from '../constants.ts';
const execFileAsync = promisify(execFile);
@@ -15,6 +16,81 @@ const execFileAsync = promisify(execFile);
export class ShutdownAction extends Action {
readonly type = 'shutdown';
/**
* Override shouldExecute to add shutdown-specific safety checks
*
* Key safety rules:
* 1. Shutdown should NEVER trigger unless UPS is actually on battery
* (low battery while on grid power is not an emergency - it's charging)
* 2. For power status changes, only trigger on transitions TO onBattery from online
* (ignore unknown → online at startup, and power restoration events)
* 3. For threshold violations, verify UPS is on battery before acting
*
* @param context Action context with UPS state
* @returns True if shutdown should execute
*/
protected override shouldExecute(context: IActionContext): boolean {
const mode = this.config.triggerMode || 'powerChangesAndThresholds';
// CRITICAL SAFETY CHECK: Shutdown should NEVER trigger unless UPS is on battery
// A low battery while on grid power is not an emergency (the battery is charging)
if (context.powerStatus !== 'onBattery') {
logger.info(`Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`);
return false;
}
// Handle threshold violations (UPS is confirmed on battery at this point)
if (context.triggerReason === 'thresholdViolation') {
// 'onlyPowerChanges' mode ignores thresholds
if (mode === 'onlyPowerChanges') {
logger.info('Shutdown action skipped: triggerMode is onlyPowerChanges, ignoring threshold');
return false;
}
// Check if thresholds are actually exceeded
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
}
// Handle power status changes
if (context.triggerReason === 'powerStatusChange') {
// 'onlyThresholds' mode ignores power status changes
if (mode === 'onlyThresholds') {
logger.info('Shutdown action skipped: triggerMode is onlyThresholds, ignoring power change');
return false;
}
const prev = context.previousPowerStatus;
// Only trigger on transitions TO onBattery from online (real power loss)
if (prev === 'online') {
logger.info('Shutdown action triggered: power loss detected (online → onBattery)');
return true;
}
// For unknown → onBattery (daemon started while on battery):
// This is a startup scenario - be cautious. The user may have just started
// the daemon for testing, or the UPS may have been on battery for a while.
// Only trigger if mode explicitly includes power changes.
if (prev === 'unknown') {
if (mode === 'onlyPowerChanges' || mode === 'powerChangesAndThresholds' || mode === 'anyChange') {
logger.info('Shutdown action triggered: UPS on battery at daemon startup (unknown → onBattery)');
return true;
}
return false;
}
// Other transitions (e.g., onBattery → onBattery) should not trigger
logger.info(`Shutdown action skipped: non-emergency transition (${prev}${context.powerStatus})`);
return false;
}
// For 'anyChange' mode, always execute (UPS is already confirmed on battery)
if (mode === 'anyChange') {
return true;
}
return false;
}
/**
* Execute the shutdown action
* @param context Action context with UPS state
@@ -26,10 +102,10 @@ export class ShutdownAction extends Action {
return;
}
const shutdownDelay = this.config.shutdownDelay || 5; // Default 5 minutes
const shutdownDelay = this.config.shutdownDelay || SHUTDOWN.DEFAULT_DELAY_MINUTES;
logger.log('');
logger.logBoxTitle('Initiating System Shutdown', 60, 'error');
logger.logBoxTitle('Initiating System Shutdown', UI.WIDE_BOX_WIDTH, 'error');
logger.logBoxLine(`UPS: ${context.upsName} (${context.upsId})`);
logger.logBoxLine(`Power Status: ${context.powerStatus}`);
logger.logBoxLine(`Battery: ${context.batteryCapacity}%`);

View File

@@ -3,6 +3,32 @@ import * as https from 'node:https';
import { URL } from 'node:url';
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts';
import { WEBHOOK } from '../constants.ts';
/**
* Payload sent to webhook endpoints
*/
export interface IWebhookPayload {
/** UPS ID */
upsId: string;
/** UPS name */
upsName: string;
/** Current power status */
powerStatus: 'online' | 'onBattery' | 'unknown';
/** Current battery capacity percentage */
batteryCapacity: number;
/** Current battery runtime in minutes */
batteryRuntime: number;
/** Reason this webhook was triggered */
triggerReason: 'powerStatusChange' | 'thresholdViolation';
/** Timestamp when webhook was triggered */
timestamp: number;
/** Thresholds configured for this action (if any) */
thresholds?: {
battery: number;
runtime: number;
};
}
/**
* WebhookAction - Calls an HTTP webhook with UPS state information
@@ -30,7 +56,7 @@ export class WebhookAction extends Action {
}
const method = this.config.webhookMethod || 'POST';
const timeout = this.config.webhookTimeout || 10000;
const timeout = this.config.webhookTimeout || WEBHOOK.DEFAULT_TIMEOUT_MS;
logger.info(`Calling webhook: ${method} ${this.config.webhookUrl}`);
@@ -56,7 +82,7 @@ export class WebhookAction extends Action {
method: 'GET' | 'POST',
timeout: number,
): Promise<void> {
const payload: any = {
const payload: IWebhookPayload = {
upsId: context.upsId,
upsName: context.upsName,
powerStatus: context.powerStatus,

View File

@@ -4,6 +4,7 @@ import { logger, type ITableColumn } from '../logger.ts';
import { theme, symbols } from '../colors.ts';
import type { IActionConfig } from '../actions/base-action.ts';
import type { IUpsConfig, IGroupConfig } from '../daemon.ts';
import * as helpers from '../helpers/index.ts';
/**
* Class for handling action-related CLI commands
@@ -57,21 +58,7 @@ export class ActionHandler {
const targetType = ups ? 'UPS' : 'Group';
const targetName = ups ? ups.name : group!.name;
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
await helpers.withPrompt(async (prompt) => {
logger.log('');
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
logger.log('');
@@ -154,9 +141,7 @@ export class ActionHandler {
logger.success(`Action added to ${targetType} ${targetName}`);
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
logger.log('');
} finally {
rl.close();
}
});
} catch (error) {
logger.error(
`Failed to add action: ${error instanceof Error ? error.message : String(error)}`,

View File

@@ -25,26 +25,9 @@ export class FeatureHandler {
*/
public async configureHttpServer(): Promise<void> {
try {
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
await helpers.withPrompt(async (prompt) => {
await this.runHttpServerConfig(prompt);
} finally {
rl.close();
process.stdin.destroy();
}
});
} catch (error) {
logger.error(`HTTP Server config error: ${error instanceof Error ? error.message : String(error)}`);
}
@@ -186,17 +169,9 @@ export class FeatureHandler {
if (isActive) {
logger.log('');
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await new Promise<string>((resolve) => {
rl.question('Service is running. Restart to apply changes? (Y/n): ', resolve);
});
rl.close();
const { prompt, close } = await helpers.createPrompt();
const answer = await prompt('Service is running. Restart to apply changes? (Y/n): ');
close();
if (!answer || answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
logger.info('Restarting service...');

View File

@@ -3,7 +3,7 @@ import { Nupst } from '../nupst.ts';
import { logger, type ITableColumn } from '../logger.ts';
import { theme } from '../colors.ts';
import * as helpers from '../helpers/index.ts';
import { type IGroupConfig } from '../daemon.ts';
import type { IGroupConfig, IUpsConfig, INupstConfig } from '../daemon.ts';
/**
* Class for handling group-related CLI commands
@@ -100,24 +100,7 @@ export class GroupHandler {
*/
public async add(): Promise<void> {
try {
// Import readline module for user input
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Helper function to prompt for input
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
await helpers.withPrompt(async (prompt) => {
// Try to load configuration
try {
await this.nupst.getDaemon().loadConfig();
@@ -200,10 +183,7 @@ export class GroupHandler {
this.nupst.getUpsHandler().restartServiceIfRunning();
logger.log('\nGroup setup complete!');
} finally {
rl.close();
process.stdin.destroy();
}
});
} catch (error) {
logger.error(`Add group error: ${error instanceof Error ? error.message : String(error)}`);
}
@@ -215,24 +195,7 @@ export class GroupHandler {
*/
public async edit(groupId: string): Promise<void> {
try {
// Import readline module for user input
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Helper function to prompt for input
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
await helpers.withPrompt(async (prompt) => {
// Try to load configuration
try {
await this.nupst.getDaemon().loadConfig();
@@ -318,10 +281,7 @@ export class GroupHandler {
this.nupst.getUpsHandler().restartServiceIfRunning();
logger.log('\nGroup edit complete!');
} finally {
rl.close();
process.stdin.destroy();
}
});
} catch (error) {
logger.error(`Edit group error: ${error instanceof Error ? error.message : String(error)}`);
}
@@ -362,23 +322,11 @@ export class GroupHandler {
const groupToDelete = config.groups[groupIndex];
// Get confirmation before deleting
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const confirm = await new Promise<string>((resolve) => {
rl.question(
`Are you sure you want to delete group "${groupToDelete.name}" (${groupId})? [y/N]: `,
(answer) => {
resolve(answer.toLowerCase());
},
);
});
rl.close();
process.stdin.destroy();
const { prompt, close } = await helpers.createPrompt();
const confirm = (await prompt(
`Are you sure you want to delete group "${groupToDelete.name}" (${groupId})? [y/N]: `,
)).toLowerCase();
close();
if (confirm !== 'y' && confirm !== 'yes') {
logger.log('Deletion cancelled.');
@@ -419,8 +367,8 @@ export class GroupHandler {
* @param prompt Function to prompt for user input
*/
public async assignUpsToGroups(
ups: any,
groups: any[],
ups: IUpsConfig,
groups: IGroupConfig[],
prompt: (question: string) => Promise<string>,
): Promise<void> {
// Initialize groups array if it doesn't exist
@@ -514,7 +462,7 @@ export class GroupHandler {
*/
public async assignUpsToGroup(
groupId: string,
config: any,
config: INupstConfig,
prompt: (question: string) => Promise<string>,
): Promise<void> {
if (!config.upsDevices || config.upsDevices.length === 0) {
@@ -522,7 +470,7 @@ export class GroupHandler {
return;
}
const group = config.groups.find((g: { id: string }) => g.id === groupId);
const group = config.groups.find((g) => g.id === groupId);
if (!group) {
logger.error(`Group with ID "${groupId}" not found.`);
return;
@@ -530,7 +478,7 @@ export class GroupHandler {
// Show current assignments
logger.log(`\nUPS devices in group "${group.name}" (${group.id}):`);
const upsInGroup = config.upsDevices.filter((ups: { groups?: string[] }) =>
const upsInGroup = config.upsDevices.filter((ups) =>
ups.groups && ups.groups.includes(groupId)
);
if (upsInGroup.length === 0) {

View File

@@ -2,6 +2,7 @@ import process from 'node:process';
import { execSync } from 'node:child_process';
import { Nupst } from '../nupst.ts';
import { logger } from '../logger.ts';
import * as helpers from '../helpers/index.ts';
/**
* Class for handling service-related CLI commands
@@ -196,22 +197,7 @@ export class ServiceHandler {
this.checkRootAccess('This command must be run as root.');
try {
// Import readline module for user input
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Helper function to prompt for input
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
const { prompt, close } = await helpers.createPrompt();
logger.log('');
logger.highlight('NUPST Uninstaller');
@@ -254,15 +240,13 @@ export class ServiceHandler {
if (!uninstallScriptPath) {
logger.error('Could not locate uninstall.sh script. Aborting uninstall.');
rl.close();
process.stdin.destroy();
close();
process.exit(1);
}
}
// Close readline before executing script
rl.close();
process.stdin.destroy();
// Close prompt before executing script
close();
// Execute uninstall.sh with the appropriate option
logger.log('');

View File

@@ -4,8 +4,17 @@ import { Nupst } from '../nupst.ts';
import { logger, type ITableColumn } from '../logger.ts';
import { theme } from '../colors.ts';
import * as helpers from '../helpers/index.ts';
import type { TUpsModel } from '../snmp/types.ts';
import type { INupstConfig } from '../daemon.ts';
import type { ISnmpConfig, TUpsModel, IUpsStatus as ISnmpUpsStatus } from '../snmp/types.ts';
import type { INupstConfig, IUpsConfig, IUpsStatus } from '../daemon.ts';
import type { IActionConfig } from '../actions/base-action.ts';
/**
* Thresholds configuration for CLI display
*/
interface IThresholds {
battery: number;
runtime: number;
}
/**
* Class for handling UPS-related CLI commands
@@ -27,29 +36,9 @@ export class UpsHandler {
*/
public async add(): Promise<void> {
try {
// Import readline module for user input
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Helper function to prompt for input
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
await helpers.withPrompt(async (prompt) => {
await this.runAddProcess(prompt);
} finally {
rl.close();
process.stdin.destroy();
}
});
} catch (error) {
logger.error(`Add UPS error: ${error instanceof Error ? error.message : String(error)}`);
}
@@ -160,29 +149,9 @@ export class UpsHandler {
*/
public async edit(upsId?: string): Promise<void> {
try {
// Import readline module for user input
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Helper function to prompt for input
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
await helpers.withPrompt(async (prompt) => {
await this.runEditProcess(upsId, prompt);
} finally {
rl.close();
process.stdin.destroy();
}
});
} catch (error) {
logger.error(`Edit UPS error: ${error instanceof Error ? error.message : String(error)}`);
}
@@ -337,23 +306,11 @@ export class UpsHandler {
const upsToDelete = config.upsDevices[upsIndex];
// Get confirmation before deleting
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const confirm = await new Promise<string>((resolve) => {
rl.question(
`Are you sure you want to delete UPS "${upsToDelete.name}" (${upsId})? [y/N]: `,
(answer) => {
resolve(answer.toLowerCase());
},
);
});
rl.close();
process.stdin.destroy();
const { prompt, close } = await helpers.createPrompt();
const confirm = (await prompt(
`Are you sure you want to delete UPS "${upsToDelete.name}" (${upsId})? [y/N]: `,
)).toLowerCase();
close();
if (confirm !== 'y' && confirm !== 'yes') {
logger.log('Deletion cancelled.');
@@ -509,7 +466,7 @@ export class UpsHandler {
* Display the configuration for testing
* @param config Current configuration or individual UPS configuration
*/
private displayTestConfig(config: any): void {
private displayTestConfig(config: IUpsConfig | INupstConfig): void {
// Check if this is a UPS device or full configuration
const isUpsConfig = config.snmp;
const snmpConfig = isUpsConfig ? config.snmp : config.snmp || {};
@@ -571,7 +528,7 @@ export class UpsHandler {
* Test connection to the UPS
* @param config Current UPS configuration or legacy config
*/
private async testConnection(config: any): Promise<void> {
private async testConnection(config: IUpsConfig | INupstConfig): Promise<void> {
const upsId = config.id || 'default';
const upsName = config.name || 'Default UPS';
logger.log(`\nTesting connection to UPS: ${upsName} (${upsId})...`);
@@ -610,7 +567,7 @@ export class UpsHandler {
* @param status UPS status
* @param thresholds Threshold configuration
*/
private analyzeThresholds(status: any, thresholds: any): void {
private analyzeThresholds(status: ISnmpUpsStatus, thresholds: IThresholds): void {
const boxWidth = 45;
logger.logBoxTitle('Threshold Analysis', boxWidth);
@@ -649,7 +606,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async gatherSnmpSettings(
snmpConfig: any,
snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>,
): Promise<void> {
// SNMP IP Address
@@ -693,7 +650,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async gatherSnmpV3Settings(
snmpConfig: any,
snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>,
): Promise<void> {
logger.log('');
@@ -771,7 +728,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async gatherAuthenticationSettings(
snmpConfig: any,
snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>,
): Promise<void> {
// Authentication protocol
@@ -798,7 +755,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async gatherPrivacySettings(
snmpConfig: any,
snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>,
): Promise<void> {
// Privacy protocol
@@ -823,7 +780,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async gatherUpsModelSettings(
snmpConfig: any,
snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>,
): Promise<void> {
logger.log('');
@@ -888,7 +845,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async gatherActionSettings(
actions: any[],
actions: IActionConfig[],
prompt: (question: string) => Promise<string>,
): Promise<void> {
logger.log('');
@@ -915,7 +872,7 @@ export class UpsHandler {
const typeInput = await prompt('Select action type [1]: ');
const typeValue = parseInt(typeInput, 10) || 1;
const action: any = {};
const action: Partial<IActionConfig> = {};
if (typeValue === 1) {
// Shutdown action
@@ -1014,8 +971,8 @@ export class UpsHandler {
};
}
actions.push(action);
logger.success(`${action.type.charAt(0).toUpperCase() + action.type.slice(1)} action added (mode: ${action.triggerMode || 'powerChangesAndThresholds'})`);
actions.push(action as IActionConfig);
logger.success(`${action.type!.charAt(0).toUpperCase() + action.type!.slice(1)} action added (mode: ${action.triggerMode || 'powerChangesAndThresholds'})`);
const more = await prompt('Add another action? (y/N): ');
addMore = more.toLowerCase() === 'y';
@@ -1031,7 +988,7 @@ export class UpsHandler {
* Display UPS configuration summary
* @param ups UPS configuration
*/
private displayUpsConfigSummary(ups: any): void {
private displayUpsConfigSummary(ups: IUpsConfig): void {
const boxWidth = 45;
logger.log('');
logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth);
@@ -1055,7 +1012,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async optionallyTestConnection(
snmpConfig: any,
snmpConfig: ISnmpConfig,
prompt: (question: string) => Promise<string>,
): Promise<void> {
const testConnection = await prompt(

118
ts/constants.ts Normal file
View File

@@ -0,0 +1,118 @@
/**
* NUPST Constants
*
* Central location for all timeout, interval, and threshold values.
* This makes configuration easier and code more self-documenting.
*/
/**
* Default timing values in milliseconds
*/
export const TIMING = {
/** Default interval between UPS status checks (30 seconds) */
CHECK_INTERVAL_MS: 30000,
/** Interval for idle monitoring mode (60 seconds) */
IDLE_CHECK_INTERVAL_MS: 60000,
/** Interval for checking config file changes (60 seconds) */
CONFIG_CHECK_INTERVAL_MS: 60000,
/** Interval for logging periodic status updates (5 minutes) */
LOG_INTERVAL_MS: 5 * 60 * 1000,
/** Maximum time to monitor during shutdown (5 minutes) */
MAX_SHUTDOWN_MONITORING_MS: 5 * 60 * 1000,
/** Interval for UPS checks during shutdown (30 seconds) */
SHUTDOWN_CHECK_INTERVAL_MS: 30000,
} as const;
/**
* SNMP-related constants
*/
export const SNMP = {
/** Default SNMP port */
DEFAULT_PORT: 161,
/** Default SNMP timeout (5 seconds) */
DEFAULT_TIMEOUT_MS: 5000,
/** Number of SNMP retries */
RETRIES: 2,
/** Timeout for noAuthNoPriv security level (5 seconds) */
TIMEOUT_NO_AUTH_MS: 5000,
/** Timeout for authNoPriv security level (10 seconds) */
TIMEOUT_AUTH_MS: 10000,
/** Timeout for authPriv security level (15 seconds) */
TIMEOUT_AUTH_PRIV_MS: 15000,
/** Maximum timeout for connection tests (10 seconds) */
MAX_TEST_TIMEOUT_MS: 10000,
} as const;
/**
* Default threshold values
*/
export const THRESHOLDS = {
/** Default battery capacity threshold for shutdown (60%) */
DEFAULT_BATTERY_PERCENT: 60,
/** Default runtime threshold for shutdown (20 minutes) */
DEFAULT_RUNTIME_MINUTES: 20,
/** Emergency runtime threshold during shutdown (5 minutes) */
EMERGENCY_RUNTIME_MINUTES: 5,
} as const;
/**
* Webhook action constants
*/
export const WEBHOOK = {
/** Default webhook request timeout (10 seconds) */
DEFAULT_TIMEOUT_MS: 10000,
} as const;
/**
* Script action constants
*/
export const SCRIPT = {
/** Default script execution timeout (60 seconds) */
DEFAULT_TIMEOUT_MS: 60000,
} as const;
/**
* Shutdown action constants
*/
export const SHUTDOWN = {
/** Default shutdown delay (5 minutes) */
DEFAULT_DELAY_MINUTES: 5,
} as const;
/**
* HTTP Server constants
*/
export const HTTP_SERVER = {
/** Default HTTP server port */
DEFAULT_PORT: 8080,
/** Default URL path for UPS status endpoint */
DEFAULT_PATH: '/ups-status',
} as const;
/**
* UI/Display constants
*/
export const UI = {
/** Default width for log boxes */
DEFAULT_BOX_WIDTH: 45,
/** Wide box width for status displays */
WIDE_BOX_WIDTH: 60,
/** Extra wide box width for detailed info */
EXTRA_WIDE_BOX_WIDTH: 70,
} as const;

View File

@@ -11,6 +11,7 @@ import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } f
import type { IActionConfig } from './actions/base-action.ts';
import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts';
import { NupstHttpServer } from './http-server.ts';
import { TIMING, THRESHOLDS, UI } from './constants.ts';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
@@ -144,8 +145,8 @@ export class NupstDaemon {
type: 'shutdown',
triggerMode: 'onlyThresholds',
thresholds: {
battery: 60, // Shutdown when battery below 60%
runtime: 20, // Shutdown when runtime below 20 minutes
battery: THRESHOLDS.DEFAULT_BATTERY_PERCENT, // Shutdown when battery below 60%
runtime: THRESHOLDS.DEFAULT_RUNTIME_MINUTES, // Shutdown when runtime below 20 minutes
},
shutdownDelay: 5,
},
@@ -153,7 +154,7 @@ export class NupstDaemon {
},
],
groups: [],
checkInterval: 30000, // Check every 30 seconds
checkInterval: TIMING.CHECK_INTERVAL_MS, // Check every 30 seconds
}
private config: INupstConfig;
@@ -282,20 +283,23 @@ export class NupstDaemon {
this.logConfigLoaded();
// Log version information
this.snmp.getNupst().logVersionInfo(false); // Don't check for updates immediately on startup
const nupst = this.snmp.getNupst();
if (nupst) {
nupst.logVersionInfo(false); // Don't check for updates immediately on startup
// Check for updates in the background
this.snmp.getNupst().checkForUpdates().then((updateAvailable: boolean) => {
if (updateAvailable) {
const updateStatus = this.snmp.getNupst().getUpdateStatus();
const boxWidth = 45;
logger.logBoxTitle('Update Available', boxWidth);
logger.logBoxLine(`Current Version: ${updateStatus.currentVersion}`);
logger.logBoxLine(`Latest Version: ${updateStatus.latestVersion}`);
logger.logBoxLine('Run "sudo nupst update" to update');
logger.logBoxEnd();
}
}).catch(() => {}); // Ignore errors checking for updates
// Check for updates in the background
nupst.checkForUpdates().then((updateAvailable: boolean) => {
if (updateAvailable) {
const updateStatus = nupst.getUpdateStatus();
const boxWidth = 45;
logger.logBoxTitle('Update Available', boxWidth);
logger.logBoxLine(`Current Version: ${updateStatus.currentVersion}`);
logger.logBoxLine(`Latest Version: ${updateStatus.latestVersion}`);
logger.logBoxLine('Run "sudo nupst update" to update');
logger.logBoxEnd();
}
}).catch(() => {}); // Ignore errors checking for updates
}
// Initialize UPS status tracking
this.initializeUpsStatus();
@@ -441,7 +445,6 @@ export class NupstDaemon {
}
let lastLogTime = 0; // Track when we last logged status
const LOG_INTERVAL = 5 * 60 * 1000; // Log at least every 5 minutes (300000ms)
// Monitor continuously
while (this.isRunning) {
@@ -451,7 +454,7 @@ export class NupstDaemon {
// Log periodic status update
const currentTime = Date.now();
if (currentTime - lastLogTime >= LOG_INTERVAL) {
if (currentTime - lastLogTime >= TIMING.LOG_INTERVAL_MS) {
this.logAllUpsStatus();
lastLogTime = currentTime;
}
@@ -789,21 +792,18 @@ export class NupstDaemon {
* Force immediate shutdown if any UPS gets critically low
*/
private async monitorDuringShutdown(): Promise<void> {
const EMERGENCY_RUNTIME_THRESHOLD = 5; // 5 minutes remaining is critical
const CHECK_INTERVAL = 30000; // Check every 30 seconds during shutdown
const MAX_MONITORING_TIME = 5 * 60 * 1000; // Max 5 minutes of monitoring
const startTime = Date.now();
logger.log('');
logger.logBoxTitle('Shutdown Monitoring Active', 60, 'warning');
logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes runtime`);
logger.logBoxLine(`Check interval: ${CHECK_INTERVAL / 1000} seconds`);
logger.logBoxLine(`Max monitoring time: ${MAX_MONITORING_TIME / 1000} seconds`);
logger.logBoxTitle('Shutdown Monitoring Active', UI.WIDE_BOX_WIDTH, 'warning');
logger.logBoxLine(`Emergency threshold: ${THRESHOLDS.EMERGENCY_RUNTIME_MINUTES} minutes runtime`);
logger.logBoxLine(`Check interval: ${TIMING.SHUTDOWN_CHECK_INTERVAL_MS / 1000} seconds`);
logger.logBoxLine(`Max monitoring time: ${TIMING.MAX_SHUTDOWN_MONITORING_MS / 1000} seconds`);
logger.logBoxEnd();
logger.log('');
// Continue monitoring until max monitoring time is reached
while (Date.now() - startTime < MAX_MONITORING_TIME) {
while (Date.now() - startTime < TIMING.MAX_SHUTDOWN_MONITORING_MS) {
try {
logger.info('Checking UPS status during shutdown...');
@@ -827,7 +827,7 @@ export class NupstDaemon {
const batteryColor = getBatteryColor(status.batteryCapacity);
const runtimeColor = getRuntimeColor(status.batteryRuntime);
const isCritical = status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD;
const isCritical = status.batteryRuntime < THRESHOLDS.EMERGENCY_RUNTIME_MINUTES;
rows.push({
name: ups.name,
@@ -868,7 +868,7 @@ export class NupstDaemon {
logger.logBoxLine(
`UPS ${emergencyUps.ups.name} runtime critically low: ${emergencyUps.status.batteryRuntime} minutes`,
);
logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes`);
logger.logBoxLine(`Emergency threshold: ${THRESHOLDS.EMERGENCY_RUNTIME_MINUTES} minutes`);
logger.logBoxLine('Forcing immediate shutdown!');
logger.logBoxEnd();
logger.log('');
@@ -879,14 +879,14 @@ export class NupstDaemon {
}
// Wait before checking again
await this.sleep(CHECK_INTERVAL);
await this.sleep(TIMING.SHUTDOWN_CHECK_INTERVAL_MS);
} catch (error) {
logger.error(
`Error monitoring UPS during shutdown: ${
error instanceof Error ? error.message : String(error)
}`,
);
await this.sleep(CHECK_INTERVAL);
await this.sleep(TIMING.SHUTDOWN_CHECK_INTERVAL_MS);
}
}
@@ -988,12 +988,10 @@ export class NupstDaemon {
* Watches for config changes and reloads when detected
*/
private async idleMonitoring(): Promise<void> {
const IDLE_CHECK_INTERVAL = 60000; // Check every 60 seconds
let lastConfigCheck = Date.now();
const CONFIG_CHECK_INTERVAL = 60000; // Check config every minute
logger.log('Entering idle monitoring mode...');
logger.log('Daemon will check for config changes every 60 seconds');
logger.log(`Daemon will check for config changes every ${TIMING.IDLE_CHECK_INTERVAL_MS / 1000} seconds`);
// Start file watcher for hot-reload
this.watchConfigFile();
@@ -1003,7 +1001,7 @@ export class NupstDaemon {
const currentTime = Date.now();
// Periodically check if config has been updated
if (currentTime - lastConfigCheck >= CONFIG_CHECK_INTERVAL) {
if (currentTime - lastConfigCheck >= TIMING.CONFIG_CHECK_INTERVAL_MS) {
try {
// Try to load config
const newConfig = await this.loadConfig();
@@ -1023,12 +1021,12 @@ export class NupstDaemon {
lastConfigCheck = currentTime;
}
await this.sleep(IDLE_CHECK_INTERVAL);
await this.sleep(TIMING.IDLE_CHECK_INTERVAL_MS);
} catch (error) {
logger.error(
`Error during idle monitoring: ${error instanceof Error ? error.message : String(error)}`,
);
await this.sleep(IDLE_CHECK_INTERVAL);
await this.sleep(TIMING.IDLE_CHECK_INTERVAL_MS);
}
}

View File

@@ -1 +1,2 @@
export * from './shortid.ts';
export * from './prompt.ts';

55
ts/helpers/prompt.ts Normal file
View File

@@ -0,0 +1,55 @@
import process from 'node:process';
/**
* Result from creating a prompt interface
*/
export interface IPromptInterface {
/** Function to prompt for user input */
prompt: (question: string) => Promise<string>;
/** Function to close the prompt interface */
close: () => void;
}
/**
* Create a readline prompt interface for interactive CLI input
* @returns Promise resolving to prompt function and close function
*/
export async function createPrompt(): Promise<IPromptInterface> {
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
const close = (): void => {
rl.close();
process.stdin.destroy();
};
return { prompt, close };
}
/**
* Run an async function with a prompt interface, ensuring cleanup
* @param fn Function to run with the prompt interface
* @returns Promise resolving to the function's return value
*/
export async function withPrompt<T>(
fn: (prompt: (question: string) => Promise<string>) => Promise<T>,
): Promise<T> {
const { prompt, close } = await createPrompt();
try {
return await fn(prompt);
} finally {
close();
}
}

1
ts/interfaces/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './nupst-accessor.ts';

View File

@@ -0,0 +1,41 @@
import type { NupstDaemon } from '../daemon.ts';
/**
* Update status information
*/
export interface IUpdateStatus {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
}
/**
* Interface for accessing Nupst functionality from SNMP manager
* This breaks the circular dependency between Nupst and NupstSnmp
*/
export interface INupstAccessor {
/**
* Get the daemon manager for background monitoring
*/
getDaemon(): NupstDaemon;
/**
* Get the current version of NUPST
*/
getVersion(): string;
/**
* Check if an update is available
*/
checkForUpdates(): Promise<boolean>;
/**
* Get update status information
*/
getUpdateStatus(): IUpdateStatus;
/**
* Log the current version and update status
*/
logVersionInfo(checkForUpdates?: boolean): void;
}

View File

@@ -9,12 +9,13 @@ import { ServiceHandler } from './cli/service-handler.ts';
import { ActionHandler } from './cli/action-handler.ts';
import { FeatureHandler } from './cli/feature-handler.ts';
import * as https from 'node:https';
import type { INupstAccessor, IUpdateStatus } from './interfaces/index.ts';
/**
* Main Nupst class that coordinates all components
* Acts as a facade to access SNMP, Daemon, and Systemd functionality
*/
export class Nupst {
export class Nupst implements INupstAccessor {
private readonly snmp: NupstSnmp;
private readonly daemon: NupstDaemon;
private readonly systemd: NupstSystemd;
@@ -134,11 +135,7 @@ export class Nupst {
* Get update status information
* @returns Object with update status information
*/
public getUpdateStatus(): {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
} {
public getUpdateStatus(): IUpdateStatus {
return {
currentVersion: this.getVersion(),
latestVersion: this.latestVersion || this.getVersion(),

View File

@@ -2,6 +2,9 @@ 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
@@ -10,18 +13,18 @@ import { UpsOidSets } from './oid-sets.ts';
export class NupstSnmp {
// Active OID set
private activeOIDs: IOidSet;
// Reference to the parent Nupst instance
private nupst: any; // Type 'any' to avoid circular dependency
// 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: 161, // Default SNMP port
port: SNMP.DEFAULT_PORT, // Default SNMP port
community: 'public', // Default community string for v1/v2c
version: 1, // SNMPv1
timeout: 5000, // 5 seconds timeout
timeout: SNMP.DEFAULT_TIMEOUT_MS, // 5 seconds timeout
upsModel: 'cyberpower', // Default UPS model
};
@@ -39,14 +42,14 @@ export class NupstSnmp {
* Set reference to the main Nupst instance
* @param nupst Reference to the main Nupst instance
*/
public setNupst(nupst: any): void {
public setNupst(nupst: INupstAccessor): void {
this.nupst = nupst;
}
/**
* Get reference to the main Nupst instance
*/
public getNupst(): any {
public getNupst(): INupstAccessor | null {
return this.nupst;
}
@@ -55,7 +58,7 @@ export class NupstSnmp {
*/
public enableDebug(): void {
this.debug = true;
console.log('SNMP debug mode enabled - detailed logs will be shown');
logger.info('SNMP debug mode enabled - detailed logs will be shown');
}
/**
@@ -67,7 +70,7 @@ export class NupstSnmp {
if (config.upsModel === 'custom' && config.customOIDs) {
this.activeOIDs = config.customOIDs;
if (this.debug) {
console.log('Using custom OIDs:', this.activeOIDs);
logger.dim(`Using custom OIDs: ${JSON.stringify(this.activeOIDs)}`);
}
return;
}
@@ -77,7 +80,7 @@ export class NupstSnmp {
this.activeOIDs = UpsOidSets.getOidSet(model);
if (this.debug) {
console.log(`Using OIDs for UPS model: ${model}`);
logger.dim(`Using OIDs for UPS model: ${model}`);
}
}
@@ -95,16 +98,16 @@ export class NupstSnmp {
): Promise<any> {
return new Promise((resolve, reject) => {
if (this.debug) {
console.log(
logger.dim(
`Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`,
);
console.log('Using community:', config.community);
logger.dim(`Using community: ${config.community}`);
}
// Create SNMP options based on configuration
const options: any = {
port: config.port,
retries: 2, // Number of retries
retries: SNMP.RETRIES, // Number of retries
timeout: config.timeout,
transport: 'udp4',
idBitsSize: 32,
@@ -151,7 +154,7 @@ export class NupstSnmp {
// 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');
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
}
}
} else if (securityLevel === 'authPriv') {
@@ -178,29 +181,23 @@ export class NupstSnmp {
// 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');
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) {
console.log('Warning: Missing authProtocol or authKey, falling back to noAuthNoPriv');
logger.warn('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',
});
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);
@@ -219,7 +216,7 @@ export class NupstSnmp {
if (error) {
if (this.debug) {
console.error('SNMP GET error:', error);
logger.error(`SNMP GET error: ${error}`);
}
reject(new Error(`SNMP GET error: ${error.message || error}`));
return;
@@ -227,7 +224,7 @@ export class NupstSnmp {
if (!varbinds || varbinds.length === 0) {
if (this.debug) {
console.error('No varbinds returned in response');
logger.error('No varbinds returned in response');
}
reject(new Error('No varbinds returned in response'));
return;
@@ -240,7 +237,7 @@ export class NupstSnmp {
varbinds[0].type === snmp.ObjectType.EndOfMibView
) {
if (this.debug) {
console.error('SNMP error:', snmp.ObjectType[varbinds[0].type]);
logger.error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`);
}
reject(new Error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`));
return;
@@ -262,11 +259,7 @@ export class NupstSnmp {
}
if (this.debug) {
console.log('SNMP response:', {
oid: varbinds[0].oid,
type: varbinds[0].type,
value: value,
});
logger.dim(`SNMP response: oid=${varbinds[0].oid}, type=${varbinds[0].type}, value=${value}`);
}
resolve(value);
@@ -285,30 +278,30 @@ export class NupstSnmp {
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');
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) {
console.log(' Community:', config.community);
logger.dim(` 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');
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'}`);
}
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(' Output Load:', this.activeOIDs.OUTPUT_LOAD);
console.log(' Output Power:', this.activeOIDs.OUTPUT_POWER);
console.log(' Output Voltage:', this.activeOIDs.OUTPUT_VOLTAGE);
console.log(' Output Current:', this.activeOIDs.OUTPUT_CURRENT);
console.log('---------------------------------------');
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
@@ -365,7 +358,7 @@ export class NupstSnmp {
if (outputPower === 0 && processedVoltage > 0 && processedCurrent > 0) {
processedPower = Math.round(processedVoltage * processedCurrent);
if (this.debug) {
console.log(
logger.dim(
`Calculated power from V×I: ${processedVoltage}V × ${processedCurrent}A = ${processedPower}W`,
);
}
@@ -391,27 +384,26 @@ export class NupstSnmp {
};
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(' Output Load:', result.outputLoad + '%');
console.log(' Output Power:', result.outputPower, 'watts');
console.log(' Output Voltage:', result.outputVoltage, 'volts');
console.log(' Output Current:', result.outputCurrent, 'amps');
console.log('---------------------------------------');
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) {
console.error('---------------------------------------');
console.error(
'Error getting UPS status:',
error instanceof Error ? error.message : String(error),
logger.error('---------------------------------------');
logger.error(
`Error getting UPS status: ${error instanceof Error ? error.message : String(error)}`,
);
console.error('---------------------------------------');
logger.error('---------------------------------------');
}
throw new Error(
`Failed to get UPS status: ${error instanceof Error ? error.message : String(error)}`,
@@ -433,26 +425,25 @@ export class NupstSnmp {
): Promise<any> {
if (oid === '') {
if (this.debug) {
console.log(`No OID provided for ${description}, skipping`);
logger.dim(`No OID provided for ${description}, skipping`);
}
return 0;
}
if (this.debug) {
console.log(`Getting ${description} OID: ${oid}`);
logger.dim(`Getting ${description} OID: ${oid}`);
}
try {
const value = await this.snmpGet(oid, config);
if (this.debug) {
console.log(`${description} value:`, value);
logger.dim(`${description} value: ${value}`);
}
return value;
} catch (error) {
if (this.debug) {
console.error(
`Error getting ${description}:`,
error instanceof Error ? error.message : String(error),
logger.error(
`Error getting ${description}: ${error instanceof Error ? error.message : String(error)}`,
);
}
@@ -468,7 +459,7 @@ export class NupstSnmp {
// Return a default value if all attempts fail
if (this.debug) {
console.log(`Using default value 0 for ${description}`);
logger.dim(`Using default value 0 for ${description}`);
}
return 0;
}
@@ -487,7 +478,7 @@ export class NupstSnmp {
config: ISnmpConfig,
): Promise<any> {
if (this.debug) {
console.log(`Retrying ${description} with fallback security level...`);
logger.dim(`Retrying ${description} with fallback security level...`);
}
// Try with authNoPriv if current level is authPriv
@@ -495,18 +486,17 @@ export class NupstSnmp {
const retryConfig = { ...config, securityLevel: 'authNoPriv' as 'authNoPriv' };
try {
if (this.debug) {
console.log(`Retrying with authNoPriv security level`);
logger.dim(`Retrying with authNoPriv security level`);
}
const value = await this.snmpGet(oid, retryConfig);
if (this.debug) {
console.log(`${description} retry value:`, value);
logger.dim(`${description} retry value: ${value}`);
}
return value;
} catch (retryError) {
if (this.debug) {
console.error(
`Retry failed for ${description}:`,
retryError instanceof Error ? retryError.message : String(retryError),
logger.error(
`Retry failed for ${description}: ${retryError instanceof Error ? retryError.message : String(retryError)}`,
);
}
}
@@ -517,18 +507,17 @@ export class NupstSnmp {
const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as 'noAuthNoPriv' };
try {
if (this.debug) {
console.log(`Retrying with noAuthNoPriv security level`);
logger.dim(`Retrying with noAuthNoPriv security level`);
}
const value = await this.snmpGet(oid, retryConfig);
if (this.debug) {
console.log(`${description} retry value:`, value);
logger.dim(`${description} retry value: ${value}`);
}
return value;
} catch (retryError) {
if (this.debug) {
console.error(
`Retry failed for ${description}:`,
retryError instanceof Error ? retryError.message : String(retryError),
logger.error(
`Retry failed for ${description}: ${retryError instanceof Error ? retryError.message : String(retryError)}`,
);
}
}
@@ -554,21 +543,20 @@ export class NupstSnmp {
const standardOIDs = UpsOidSets.getStandardOids();
if (this.debug) {
console.log(
logger.dim(
`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);
logger.dim(`${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),
logger.error(
`Standard OID retry failed for ${description}: ${stdError instanceof Error ? stdError.message : String(stdError)}`,
);
}
}
@@ -623,14 +611,14 @@ export class NupstSnmp {
batteryRuntime: number,
): number {
if (this.debug) {
console.log('Raw runtime value:', batteryRuntime);
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) {
console.log(
logger.dim(
`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`,
);
}
@@ -639,7 +627,7 @@ export class NupstSnmp {
// Eaton: Runtime is in seconds, convert to minutes
const minutes = Math.floor(batteryRuntime / 60);
if (this.debug) {
console.log(
logger.dim(
`Converting Eaton runtime from ${batteryRuntime} seconds to ${minutes} minutes`,
);
}
@@ -648,7 +636,7 @@ export class NupstSnmp {
// 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`);
logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes`);
}
return minutes;
}
@@ -667,14 +655,14 @@ export class NupstSnmp {
outputVoltage: number,
): number {
if (this.debug) {
console.log('Raw voltage value:', outputVoltage);
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) {
console.log(
logger.dim(
`Converting CyberPower voltage from ${outputVoltage} (0.1V) to ${volts} volts`,
);
}
@@ -695,14 +683,14 @@ export class NupstSnmp {
outputCurrent: number,
): number {
if (this.debug) {
console.log('Raw current value:', outputCurrent);
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) {
console.log(
logger.dim(
`Converting CyberPower current from ${outputCurrent} (0.1A) to ${amps} amps`,
);
}
@@ -711,7 +699,7 @@ export class NupstSnmp {
// RFC 1628 standard: Current is in 0.1A, convert to amps
const amps = outputCurrent / 10;
if (this.debug) {
console.log(
logger.dim(
`Converting RFC 1628 current from ${outputCurrent} (0.1A) to ${amps} amps`,
);
}
@@ -720,7 +708,7 @@ export class NupstSnmp {
// Eaton XUPS-MIB and APC PowerNet report current directly in RMS Amps (no scaling needed)
if ((upsModel === 'eaton' || upsModel === 'apc') && this.debug && outputCurrent > 0) {
console.log(`${upsModel.toUpperCase()} current already in RMS Amps: ${outputCurrent}A`);
logger.dim(`${upsModel.toUpperCase()} current already in RMS Amps: ${outputCurrent}A`);
}
return outputCurrent;