import process from 'node:process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { exec, execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { NupstSnmp } from './snmp/manager.ts'; import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts'; import { NupstUpsd } from './upsd/client.ts'; import type { IUpsdConfig } from './upsd/types.ts'; import type { TProtocol } from './protocol/types.ts'; import { ProtocolResolver } from './protocol/resolver.ts'; import { logger } from './logger.ts'; import { MigrationRunner } from './migrations/index.ts'; import { formatPowerStatus, getBatteryColor, getRuntimeColor, theme } from './colors.ts'; 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 { NETWORK, PAUSE, THRESHOLDS, TIMING, UI } from './constants.ts'; const execAsync = promisify(exec); const execFileAsync = promisify(execFile); /** * UPS configuration interface */ export interface IUpsConfig { /** Unique ID for the UPS */ id: string; /** Friendly name for the UPS */ name: string; /** Communication protocol (defaults to 'snmp') */ protocol?: TProtocol; /** SNMP configuration settings (required for 'snmp' protocol) */ snmp?: ISnmpConfig; /** UPSD/NIS configuration settings (required for 'upsd' protocol) */ upsd?: IUpsdConfig; /** Group IDs this UPS belongs to */ groups: string[]; /** Actions to trigger on power status changes and threshold violations */ actions?: IActionConfig[]; } /** * Group configuration interface */ export interface IGroupConfig { /** Unique ID for the group */ id: string; /** Friendly name for the group */ name: string; /** Group operation mode */ mode: 'redundant' | 'nonRedundant'; /** Optional description */ description?: string; /** Actions to trigger on power status changes and threshold violations */ actions?: IActionConfig[]; } /** * HTTP Server configuration interface */ export interface IHttpServerConfig { /** Whether HTTP server is enabled */ enabled: boolean; /** Port to listen on */ port: number; /** URL path for the endpoint */ path: string; /** Authentication token */ authToken: string; } /** * Pause state interface */ export interface IPauseState { /** Timestamp when pause was activated */ pausedAt: number; /** Who initiated the pause (e.g., 'cli', 'api') */ pausedBy: string; /** Optional reason for pausing */ reason?: string; /** When to auto-resume (null = indefinite, timestamp in ms) */ resumeAt?: number | null; } /** * Configuration interface for the daemon */ export interface INupstConfig { /** Configuration format version */ version?: string; /** UPS devices configuration */ upsDevices: IUpsConfig[]; /** Groups configuration */ groups: IGroupConfig[]; /** Check interval in milliseconds */ checkInterval: number; /** HTTP Server configuration */ httpServer?: IHttpServerConfig; // Legacy fields for backward compatibility (will be migrated away) /** UPS list (v3 format - legacy) */ upsList?: IUpsConfig[]; /** SNMP configuration settings (v1 format - legacy) */ snmp?: ISnmpConfig; /** Threshold settings (v1 format - legacy) */ thresholds?: { /** Shutdown when battery below this percentage */ battery: number; /** Shutdown when runtime below this minutes */ runtime: number; }; } /** * UPS status tracking interface */ export interface IUpsStatus { id: string; name: string; powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable'; batteryCapacity: number; batteryRuntime: number; outputLoad: number; // Load percentage (0-100%) outputPower: number; // Power in watts outputVoltage: number; // Voltage in volts outputCurrent: number; // Current in amps lastStatusChange: number; lastCheckTime: number; consecutiveFailures: number; unreachableSince: number; } /** * Daemon class for monitoring UPS and handling shutdown * Responsible for loading/saving config and monitoring the UPS status */ export class NupstDaemon { /** Default configuration path */ private readonly CONFIG_PATH = '/etc/nupst/config.json'; /** Default configuration */ private readonly DEFAULT_CONFIG: INupstConfig = { version: '4.2', upsDevices: [ { id: 'default', name: 'Default UPS', snmp: { host: '127.0.0.1', port: 161, community: 'public', version: 1, timeout: 5000, // SNMPv3 defaults (used only if version === 3) securityLevel: 'authPriv', username: '', authProtocol: 'SHA', authKey: '', privProtocol: 'AES', privKey: '', // UPS model for OID selection upsModel: 'cyberpower', }, groups: [], actions: [ { type: 'shutdown', triggerMode: 'onlyThresholds', thresholds: { battery: THRESHOLDS.DEFAULT_BATTERY_PERCENT, // Shutdown when battery below 60% runtime: THRESHOLDS.DEFAULT_RUNTIME_MINUTES, // Shutdown when runtime below 20 minutes }, shutdownDelay: 5, }, ], }, ], groups: [], checkInterval: TIMING.CHECK_INTERVAL_MS, // Check every 30 seconds }; private config: INupstConfig; private snmp: NupstSnmp; private upsd: NupstUpsd; private protocolResolver: ProtocolResolver; private isRunning: boolean = false; private isPaused: boolean = false; private pauseState: IPauseState | null = null; private upsStatus: Map = new Map(); private httpServer?: NupstHttpServer; /** * Create a new daemon instance with the given protocol managers */ constructor(snmp: NupstSnmp, upsd: NupstUpsd) { this.snmp = snmp; this.upsd = upsd; this.protocolResolver = new ProtocolResolver(snmp, upsd); this.config = this.DEFAULT_CONFIG; } /** * Load configuration from file * @throws Error if configuration file doesn't exist */ public async loadConfig(): Promise { try { // Check if config file exists const configExists = fs.existsSync(this.CONFIG_PATH); if (!configExists) { const errorMsg = `No configuration found at ${this.CONFIG_PATH}`; this.logConfigError(errorMsg); throw new Error(errorMsg); } // Read and parse config const configData = fs.readFileSync(this.CONFIG_PATH, 'utf8'); const parsedConfig = JSON.parse(configData); // Run migrations to upgrade config format if needed const migrationRunner = new MigrationRunner(); const { config: migratedConfig, migrated } = await migrationRunner.run(parsedConfig); // Save migrated config back to disk if any migrations ran // Cast to INupstConfig since migrations ensure the output is valid const validConfig = migratedConfig as unknown as INupstConfig; if (migrated) { this.config = validConfig; await this.saveConfig(this.config); } else { this.config = validConfig; } return this.config; } catch (error) { if ( error instanceof Error && error.message && error.message.includes('No configuration found') ) { throw error; // Re-throw the no configuration error } this.logConfigError( `Error loading configuration: ${error instanceof Error ? error.message : String(error)}`, ); throw new Error('Failed to load configuration'); } } /** * Save configuration to file */ public saveConfig(config: INupstConfig): void { try { const configDir = path.dirname(this.CONFIG_PATH); if (!fs.existsSync(configDir)) { fs.mkdirSync(configDir, { recursive: true }); } // Ensure version is always set and remove legacy fields before saving const configToSave: INupstConfig = { version: '4.2', upsDevices: config.upsDevices, groups: config.groups, checkInterval: config.checkInterval, ...(config.httpServer ? { httpServer: config.httpServer } : {}), }; fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(configToSave, null, 2)); this.config = configToSave; logger.logBox('Configuration Saved', [`Location: ${this.CONFIG_PATH}`], 45, 'success'); } catch (error) { logger.error(`Error saving configuration: ${error}`); } } /** * Helper method to log configuration errors consistently */ private logConfigError(message: string): void { logger.logBox( 'Configuration Error', [message, "Please run 'nupst setup' first to create a configuration."], 45, 'error', ); } /** * Get the current configuration */ public getConfig(): INupstConfig { return this.config; } /** * Get the SNMP instance */ public getNupstSnmp(): NupstSnmp { return this.snmp; } /** * Get the UPSD instance */ public getNupstUpsd(): NupstUpsd { return this.upsd; } /** * Start the monitoring daemon */ public async start(): Promise { if (this.isRunning) { logger.log('Daemon is already running'); return; } logger.log('Starting NUPST daemon...'); try { // Load configuration - this will throw an error if config doesn't exist await this.loadConfig(); this.logConfigLoaded(); // Log version information const nupst = this.snmp.getNupst(); if (nupst) { nupst.logVersionInfo(false); // Don't check for updates immediately on startup // 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(); // Start HTTP server if configured if (this.config.httpServer?.enabled && this.config.httpServer.authToken) { try { this.httpServer = new NupstHttpServer( this.config.httpServer.port, this.config.httpServer.path, this.config.httpServer.authToken, () => this.upsStatus, () => this.pauseState, ); this.httpServer.start(); } catch (error) { logger.error( `Failed to start HTTP server: ${ error instanceof Error ? error.message : String(error) }`, ); } } // Start UPS monitoring this.isRunning = true; await this.monitor(); } catch (error) { this.isRunning = false; logger.error( `Daemon failed to start: ${error instanceof Error ? error.message : String(error)}`, ); process.exit(1); // Exit with error } } /** * Initialize UPS status tracking for all UPS devices */ private initializeUpsStatus(): void { this.upsStatus.clear(); if (this.config.upsDevices && this.config.upsDevices.length > 0) { for (const ups of this.config.upsDevices) { this.upsStatus.set(ups.id, { id: ups.id, name: ups.name, powerStatus: 'unknown', batteryCapacity: 100, batteryRuntime: 999, // High value as default outputLoad: 0, outputPower: 0, outputVoltage: 0, outputCurrent: 0, lastStatusChange: Date.now(), lastCheckTime: 0, consecutiveFailures: 0, unreachableSince: 0, }); } logger.log(`Initialized status tracking for ${this.config.upsDevices.length} UPS devices`); } else { logger.error('No UPS devices found in configuration'); } } /** * Log the loaded configuration settings */ private logConfigLoaded(): void { logger.log(''); logger.logBoxTitle('Configuration Loaded', 70, 'success'); logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`); logger.logBoxEnd(); logger.log(''); // Display UPS devices in a table if (this.config.upsDevices && this.config.upsDevices.length > 0) { logger.info(`UPS Devices (${this.config.upsDevices.length}):`); const upsColumns: Array< { header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string } > = [ { header: 'Name', key: 'name', align: 'left', color: theme.highlight }, { header: 'ID', key: 'id', align: 'left', color: theme.dim }, { header: 'Protocol', key: 'protocol', align: 'left' }, { header: 'Host:Port', key: 'host', align: 'left', color: theme.info }, { header: 'Actions', key: 'actions', align: 'left' }, ]; const upsRows: Array> = this.config.upsDevices.map((ups) => { const protocol = ups.protocol || 'snmp'; let host = 'N/A'; if (protocol === 'upsd' && ups.upsd) { host = `${ups.upsd.host}:${ups.upsd.port}`; } else if (ups.snmp) { host = `${ups.snmp.host}:${ups.snmp.port}`; } return { name: ups.name, id: ups.id, protocol: protocol.toUpperCase(), host, actions: `${(ups.actions || []).length} configured`, }; }); logger.logTable(upsColumns, upsRows); logger.log(''); } else { logger.warn('No UPS devices configured'); logger.log(''); } // Display groups in a table if (this.config.groups && this.config.groups.length > 0) { logger.info(`Groups (${this.config.groups.length}):`); const groupColumns: Array< { header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string } > = [ { header: 'Name', key: 'name', align: 'left', color: theme.highlight }, { header: 'ID', key: 'id', align: 'left', color: theme.dim }, { header: 'Mode', key: 'mode', align: 'left', color: theme.info }, ]; const groupRows: Array> = this.config.groups.map((group) => ({ name: group.name, id: group.id, mode: group.mode, })); logger.logTable(groupColumns, groupRows); logger.log(''); } } /** * Stop the monitoring daemon */ public stop(): void { logger.log('Stopping NUPST daemon...'); // Stop HTTP server if running if (this.httpServer) { this.httpServer.stop(); } this.isRunning = false; } /** * Get the current pause state */ public getPauseState(): IPauseState | null { return this.pauseState; } /** * Check and update pause state from the pause file */ private checkPauseState(): void { try { if (fs.existsSync(PAUSE.FILE_PATH)) { const data = fs.readFileSync(PAUSE.FILE_PATH, 'utf8'); const state = JSON.parse(data) as IPauseState; // Check if auto-resume time has passed if (state.resumeAt && Date.now() >= state.resumeAt) { // Auto-resume: delete the pause file try { fs.unlinkSync(PAUSE.FILE_PATH); } catch (_e) { // Ignore deletion errors } if (this.isPaused) { logger.log(''); logger.logBoxTitle('Auto-Resume', 45, 'success'); logger.logBoxLine('Pause duration expired, resuming action monitoring'); logger.logBoxEnd(); logger.log(''); } this.isPaused = false; this.pauseState = null; return; } if (!this.isPaused) { logger.log(''); logger.logBoxTitle('Actions Paused', 45, 'warning'); logger.logBoxLine(`Paused by: ${state.pausedBy}`); if (state.reason) { logger.logBoxLine(`Reason: ${state.reason}`); } if (state.resumeAt) { const remaining = Math.round((state.resumeAt - Date.now()) / 1000); logger.logBoxLine(`Auto-resume in: ${remaining} seconds`); } else { logger.logBoxLine('Duration: Indefinite (run "nupst resume" to resume)'); } logger.logBoxEnd(); logger.log(''); } this.isPaused = true; this.pauseState = state; } else { if (this.isPaused) { logger.log(''); logger.logBoxTitle('Actions Resumed', 45, 'success'); logger.logBoxLine('Action monitoring has been resumed'); logger.logBoxEnd(); logger.log(''); } this.isPaused = false; this.pauseState = null; } } catch (_error) { // If we can't read the pause file, assume not paused this.isPaused = false; this.pauseState = null; } } /** * Monitor the UPS status and trigger shutdown when necessary */ private async monitor(): Promise { logger.log('Starting UPS monitoring...'); if (!this.config.upsDevices || this.config.upsDevices.length === 0) { logger.warn('No UPS devices found in configuration. Daemon will remain idle...'); // Don't exit - enter idle monitoring mode instead await this.idleMonitoring(); return; } let lastLogTime = 0; // Track when we last logged status // Monitor continuously while (this.isRunning) { try { // Check pause state before each cycle this.checkPauseState(); // Check all UPS devices (polling continues even when paused for visibility) await this.checkAllUpsDevices(); // Log periodic status update const currentTime = Date.now(); if (currentTime - lastLogTime >= TIMING.LOG_INTERVAL_MS) { this.logAllUpsStatus(); lastLogTime = currentTime; } // Wait before next check await this.sleep(this.config.checkInterval); } catch (error) { logger.error( `Error during UPS monitoring: ${error instanceof Error ? error.message : String(error)}`, ); await this.sleep(this.config.checkInterval); } } logger.log('UPS monitoring stopped'); } /** * Check status of all UPS devices */ private async checkAllUpsDevices(): Promise { for (const ups of this.config.upsDevices) { try { const upsStatus = this.upsStatus.get(ups.id); if (!upsStatus) { // Initialize status for this UPS if not exists this.upsStatus.set(ups.id, { id: ups.id, name: ups.name, powerStatus: 'unknown', batteryCapacity: 100, batteryRuntime: 999, outputLoad: 0, outputPower: 0, outputVoltage: 0, outputCurrent: 0, lastStatusChange: Date.now(), lastCheckTime: 0, consecutiveFailures: 0, unreachableSince: 0, }); } // Check UPS status via configured protocol const protocol = ups.protocol || 'snmp'; const status = protocol === 'upsd' && ups.upsd ? await this.protocolResolver.getUpsStatus('upsd', undefined, ups.upsd) : await this.protocolResolver.getUpsStatus('snmp', ups.snmp); const currentTime = Date.now(); // Get the current status from the map const currentStatus = this.upsStatus.get(ups.id); // Successful query: reset consecutive failures const wasUnreachable = currentStatus?.powerStatus === 'unreachable'; // Update status with new values const updatedStatus: IUpsStatus = { id: ups.id, name: ups.name, powerStatus: status.powerStatus, batteryCapacity: status.batteryCapacity, batteryRuntime: status.batteryRuntime, outputLoad: status.outputLoad, outputPower: status.outputPower, outputVoltage: status.outputVoltage, outputCurrent: status.outputCurrent, lastCheckTime: currentTime, lastStatusChange: currentStatus?.lastStatusChange || currentTime, consecutiveFailures: 0, unreachableSince: 0, }; // If UPS was unreachable and is now reachable, log recovery if (wasUnreachable && currentStatus) { const downtime = Math.round((currentTime - currentStatus.unreachableSince) / 1000); logger.log(''); logger.logBoxTitle(`UPS Recovered: ${ups.name}`, 60, 'success'); logger.logBoxLine(`UPS is reachable again after ${downtime} seconds`); logger.logBoxLine(`Current Status: ${formatPowerStatus(status.powerStatus)}`); logger.logBoxLine(`Time: ${new Date().toISOString()}`); logger.logBoxEnd(); logger.log(''); updatedStatus.lastStatusChange = currentTime; // Trigger power status change action for recovery await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'powerStatusChange'); } else if (currentStatus && currentStatus.powerStatus !== status.powerStatus) { // Check if power status changed logger.log(''); logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning'); logger.logBoxLine(`Previous: ${formatPowerStatus(currentStatus.powerStatus)}`); logger.logBoxLine(`Current: ${formatPowerStatus(status.powerStatus)}`); logger.logBoxLine(`Time: ${new Date().toISOString()}`); logger.logBoxEnd(); logger.log(''); updatedStatus.lastStatusChange = currentTime; // Trigger actions for power status change await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'powerStatusChange'); } // Check if any action's thresholds are exceeded (for threshold violation triggers) // Only check when on battery power if (status.powerStatus === 'onBattery' && ups.actions && ups.actions.length > 0) { let anyThresholdExceeded = false; for (const actionConfig of ups.actions) { if (actionConfig.thresholds) { if ( status.batteryCapacity < actionConfig.thresholds.battery || status.batteryRuntime < actionConfig.thresholds.runtime ) { anyThresholdExceeded = true; break; } } } // Trigger actions with threshold violation reason if any threshold is exceeded // Actions will individually check their own thresholds in shouldExecute() if (anyThresholdExceeded) { await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'thresholdViolation'); } } // Update the status in the map this.upsStatus.set(ups.id, updatedStatus); } catch (error) { // Network loss / query failure tracking const currentStatus = this.upsStatus.get(ups.id); const failures = Math.min( (currentStatus?.consecutiveFailures || 0) + 1, NETWORK.MAX_CONSECUTIVE_FAILURES, ); logger.error( `Error checking UPS ${ups.name} (${ups.id}) [failure ${failures}/${NETWORK.CONSECUTIVE_FAILURE_THRESHOLD}]: ${ error instanceof Error ? error.message : String(error) }`, ); // Transition to unreachable after threshold consecutive failures if ( failures >= NETWORK.CONSECUTIVE_FAILURE_THRESHOLD && currentStatus && currentStatus.powerStatus !== 'unreachable' ) { const currentTime = Date.now(); const previousStatus = { ...currentStatus }; currentStatus.powerStatus = 'unreachable'; currentStatus.consecutiveFailures = failures; currentStatus.unreachableSince = currentTime; currentStatus.lastStatusChange = currentTime; this.upsStatus.set(ups.id, currentStatus); logger.log(''); logger.logBoxTitle(`UPS Unreachable: ${ups.name}`, 60, 'error'); logger.logBoxLine(`${failures} consecutive communication failures`); logger.logBoxLine(`Last known status: ${formatPowerStatus(previousStatus.powerStatus)}`); logger.logBoxLine(`Time: ${new Date().toISOString()}`); logger.logBoxEnd(); logger.log(''); // Trigger power status change action for unreachable await this.triggerUpsActions(ups, currentStatus, previousStatus, 'powerStatusChange'); } else if (currentStatus) { currentStatus.consecutiveFailures = failures; this.upsStatus.set(ups.id, currentStatus); } } } } /** * Log status of all UPS devices */ private logAllUpsStatus(): void { const timestamp = new Date().toISOString(); logger.log(''); const pauseLabel = this.isPaused ? ' [PAUSED]' : ''; logger.logBoxTitle(`Periodic Status Update${pauseLabel}`, 70, this.isPaused ? 'warning' : 'info'); logger.logBoxLine(`Timestamp: ${timestamp}`); if (this.isPaused && this.pauseState) { logger.logBoxLine(`Actions paused by: ${this.pauseState.pausedBy}`); if (this.pauseState.resumeAt) { const remaining = Math.round((this.pauseState.resumeAt - Date.now()) / 1000); logger.logBoxLine(`Auto-resume in: ${remaining > 0 ? remaining : 0} seconds`); } } logger.logBoxEnd(); logger.log(''); // Build table data const columns: Array< { header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string } > = [ { header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight }, { header: 'ID', key: 'id', align: 'left', color: theme.dim }, { header: 'Power Status', key: 'powerStatus', align: 'left' }, { header: 'Battery', key: 'battery', align: 'right' }, { header: 'Runtime', key: 'runtime', align: 'right' }, ]; const rows: Array> = []; for (const [id, status] of this.upsStatus.entries()) { const batteryColor = getBatteryColor(status.batteryCapacity); const runtimeColor = getRuntimeColor(status.batteryRuntime); rows.push({ name: status.name, id: id, powerStatus: formatPowerStatus(status.powerStatus), battery: batteryColor(status.batteryCapacity + '%'), runtime: runtimeColor(status.batteryRuntime + ' min'), }); } logger.logTable(columns, rows); logger.log(''); } /** * Build action context from UPS state * @param ups UPS configuration * @param status Current UPS status * @param triggerReason Why this action is being triggered * @returns Action context */ private buildActionContext( ups: IUpsConfig, status: IUpsStatus, triggerReason: 'powerStatusChange' | 'thresholdViolation', ): IActionContext { return { upsId: ups.id, upsName: ups.name, powerStatus: status.powerStatus as TPowerStatus, batteryCapacity: status.batteryCapacity, batteryRuntime: status.batteryRuntime, previousPowerStatus: 'unknown' as TPowerStatus, // Will be set from map in calling code timestamp: Date.now(), triggerReason, }; } /** * Trigger actions for a UPS device * @param ups UPS configuration * @param status Current UPS status * @param previousStatus Previous UPS status (for determining previousPowerStatus) * @param triggerReason Why actions are being triggered */ private async triggerUpsActions( ups: IUpsConfig, status: IUpsStatus, previousStatus: IUpsStatus | undefined, triggerReason: 'powerStatusChange' | 'thresholdViolation', ): Promise { // Check if actions are paused if (this.isPaused) { logger.info( `[PAUSED] Actions suppressed for UPS ${ups.name} (trigger: ${triggerReason})`, ); return; } const actions = ups.actions || []; // Backward compatibility: if no actions configured, use default shutdown behavior if (actions.length === 0 && triggerReason === 'thresholdViolation') { // Fall back to old shutdown logic for backward compatibility await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`); return; } if (actions.length === 0) { return; // No actions to execute } // Build action context const context = this.buildActionContext(ups, status, triggerReason); context.previousPowerStatus = (previousStatus?.powerStatus || 'unknown') as TPowerStatus; // Execute actions await ActionManager.executeActions(actions, context); } /** * Initiate system shutdown with UPS monitoring during shutdown * @param reason Reason for shutdown */ public async initiateShutdown(reason: string): Promise { logger.log(`Initiating system shutdown due to: ${reason}`); // Set a longer delay for shutdown to allow VMs and services to close const shutdownDelayMinutes = 5; try { // Find shutdown command in common system paths const shutdownPaths = [ '/sbin/shutdown', '/usr/sbin/shutdown', '/bin/shutdown', '/usr/bin/shutdown', ]; let shutdownCmd = ''; for (const path of shutdownPaths) { try { if (fs.existsSync(path)) { shutdownCmd = path; logger.log(`Found shutdown command at: ${shutdownCmd}`); break; } } catch (e) { // Continue checking other paths } } if (shutdownCmd) { // Execute shutdown command with delay to allow for VM graceful shutdown logger.log( `Executing: ${shutdownCmd} -h +${shutdownDelayMinutes} "UPS battery critical..."`, ); const { stdout } = await execFileAsync(shutdownCmd, [ '-h', `+${shutdownDelayMinutes}`, `UPS battery critical, shutting down in ${shutdownDelayMinutes} minutes`, ]); logger.log(`Shutdown initiated: ${stdout}`); logger.log(`Allowing ${shutdownDelayMinutes} minutes for VMs to shut down safely`); } else { // Try using the PATH to find shutdown try { logger.log('Shutdown command not found in common paths, trying via PATH...'); const { stdout } = await execAsync( `shutdown -h +${shutdownDelayMinutes} "UPS battery critical, shutting down in ${shutdownDelayMinutes} minutes"`, { env: process.env, // Pass the current environment }, ); logger.log(`Shutdown initiated: ${stdout}`); } catch (e) { throw new Error( `Shutdown command not found: ${e instanceof Error ? e.message : String(e)}`, ); } } // Monitor UPS during shutdown and force immediate shutdown if battery gets too low logger.log('Monitoring UPS during shutdown process...'); await this.monitorDuringShutdown(); } catch (error) { logger.error(`Failed to initiate shutdown: ${error}`); // Try alternative shutdown methods const alternatives = [ { cmd: 'poweroff', args: ['--force'] }, { cmd: 'halt', args: ['-p'] }, { cmd: 'systemctl', args: ['poweroff'] }, { cmd: 'reboot', args: ['-p'] }, // Some systems allow reboot -p for power off ]; for (const alt of alternatives) { try { // First check if command exists in common system paths const paths = [ `/sbin/${alt.cmd}`, `/usr/sbin/${alt.cmd}`, `/bin/${alt.cmd}`, `/usr/bin/${alt.cmd}`, ]; let cmdPath = ''; for (const path of paths) { if (fs.existsSync(path)) { cmdPath = path; break; } } if (cmdPath) { logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`); await execFileAsync(cmdPath, alt.args); return; // Exit if successful } else { // Try using PATH environment logger.log(`Trying alternative via PATH: ${alt.cmd} ${alt.args.join(' ')}`); await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, { env: process.env, // Pass the current environment }); return; // Exit if successful } } catch (altError) { logger.error(`Alternative method ${alt.cmd} failed: ${altError}`); // Continue to next method } } logger.error('All shutdown methods failed'); } } /** * Monitor UPS during system shutdown * Force immediate shutdown if any UPS gets critically low */ private async monitorDuringShutdown(): Promise { const startTime = Date.now(); logger.log(''); 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 < TIMING.MAX_SHUTDOWN_MONITORING_MS) { try { logger.info('Checking UPS status during shutdown...'); // Build table for UPS status during shutdown const columns: Array< { header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string } > = [ { header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight }, { header: 'Battery', key: 'battery', align: 'right' }, { header: 'Runtime', key: 'runtime', align: 'right' }, { header: 'Status', key: 'status', align: 'left' }, ]; const rows: Array> = []; let emergencyDetected = false; let emergencyUps: { ups: IUpsConfig; status: ISnmpUpsStatus } | null = null; // Check all UPS devices for (const ups of this.config.upsDevices) { try { const protocol = ups.protocol || 'snmp'; const status = protocol === 'upsd' && ups.upsd ? await this.protocolResolver.getUpsStatus('upsd', undefined, ups.upsd) : await this.protocolResolver.getUpsStatus('snmp', ups.snmp); const batteryColor = getBatteryColor(status.batteryCapacity); const runtimeColor = getRuntimeColor(status.batteryRuntime); const isCritical = status.batteryRuntime < THRESHOLDS.EMERGENCY_RUNTIME_MINUTES; rows.push({ name: ups.name, battery: batteryColor(status.batteryCapacity + '%'), runtime: runtimeColor(status.batteryRuntime + ' min'), status: isCritical ? theme.error('CRITICAL!') : theme.success('OK'), }); // If any UPS battery runtime gets critically low, flag for immediate shutdown if (isCritical && !emergencyDetected) { emergencyDetected = true; emergencyUps = { ups, status }; } } catch (upsError) { rows.push({ name: ups.name, battery: theme.error('N/A'), runtime: theme.error('N/A'), status: theme.error('ERROR'), }); logger.error( `Error checking UPS ${ups.name} during shutdown: ${ upsError instanceof Error ? upsError.message : String(upsError) }`, ); } } // Display the table logger.logTable(columns, rows); logger.log(''); // If emergency detected, trigger immediate shutdown if (emergencyDetected && emergencyUps) { logger.log(''); logger.logBoxTitle('EMERGENCY SHUTDOWN', 60, 'error'); logger.logBoxLine( `UPS ${emergencyUps.ups.name} runtime critically low: ${emergencyUps.status.batteryRuntime} minutes`, ); logger.logBoxLine(`Emergency threshold: ${THRESHOLDS.EMERGENCY_RUNTIME_MINUTES} minutes`); logger.logBoxLine('Forcing immediate shutdown!'); logger.logBoxEnd(); logger.log(''); // Force immediate shutdown await this.forceImmediateShutdown(); return; } // Wait before checking again 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(TIMING.SHUTDOWN_CHECK_INTERVAL_MS); } } logger.log(''); logger.success('UPS monitoring during shutdown completed'); logger.log(''); } /** * Force an immediate system shutdown */ private async forceImmediateShutdown(): Promise { try { // Find shutdown command in common system paths const shutdownPaths = [ '/sbin/shutdown', '/usr/sbin/shutdown', '/bin/shutdown', '/usr/bin/shutdown', ]; let shutdownCmd = ''; for (const path of shutdownPaths) { if (fs.existsSync(path)) { shutdownCmd = path; logger.log(`Found shutdown command at: ${shutdownCmd}`); break; } } if (shutdownCmd) { logger.log(`Executing emergency shutdown: ${shutdownCmd} -h now`); await execFileAsync(shutdownCmd, [ '-h', 'now', 'EMERGENCY: UPS battery critically low, shutting down NOW', ]); } else { // Try using the PATH to find shutdown logger.log('Shutdown command not found in common paths, trying via PATH...'); await execAsync( 'shutdown -h now "EMERGENCY: UPS battery critically low, shutting down NOW"', { env: process.env, // Pass the current environment }, ); } } catch (error) { logger.error('Emergency shutdown failed, trying alternative methods...'); // Try alternative shutdown methods in sequence const alternatives = [ { cmd: 'poweroff', args: ['--force'] }, { cmd: 'halt', args: ['-p'] }, { cmd: 'systemctl', args: ['poweroff'] }, ]; for (const alt of alternatives) { try { // Check common paths const paths = [ `/sbin/${alt.cmd}`, `/usr/sbin/${alt.cmd}`, `/bin/${alt.cmd}`, `/usr/bin/${alt.cmd}`, ]; let cmdPath = ''; for (const path of paths) { if (fs.existsSync(path)) { cmdPath = path; break; } } if (cmdPath) { logger.log(`Emergency: using ${cmdPath} ${alt.args.join(' ')}`); await execFileAsync(cmdPath, alt.args); return; // Exit if successful } else { // Try using PATH logger.log(`Emergency: trying ${alt.cmd} via PATH`); await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, { env: process.env, }); return; // Exit if successful } } catch (altError) { // Continue to next method } } logger.error('All emergency shutdown methods failed'); } } /** * Idle monitoring loop when no UPS devices are configured * Watches for config changes and reloads when detected */ private async idleMonitoring(): Promise { let lastConfigCheck = Date.now(); logger.log('Entering idle monitoring mode...'); logger.log( `Daemon will check for config changes every ${TIMING.IDLE_CHECK_INTERVAL_MS / 1000} seconds`, ); // Start file watcher for hot-reload this.watchConfigFile(); while (this.isRunning) { try { const currentTime = Date.now(); // Periodically check if config has been updated if (currentTime - lastConfigCheck >= TIMING.CONFIG_CHECK_INTERVAL_MS) { try { // Try to load config const newConfig = await this.loadConfig(); // Check if we now have UPS devices configured if (newConfig.upsDevices && newConfig.upsDevices.length > 0) { logger.success('Configuration updated! UPS devices found. Starting monitoring...'); this.initializeUpsStatus(); // Exit idle mode and start monitoring await this.monitor(); return; } } catch (error) { // Config still doesn't exist or invalid, continue waiting } lastConfigCheck = currentTime; } 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(TIMING.IDLE_CHECK_INTERVAL_MS); } } logger.log('Idle monitoring stopped'); } /** * Watch config file for changes and reload automatically */ private watchConfigFile(): void { try { // Use Deno's file watcher to monitor config file const configDir = path.dirname(this.CONFIG_PATH); // Spawn a background watcher (non-blocking) (async () => { try { const watcher = Deno.watchFs(configDir); logger.log('Config file watcher started'); for await (const event of watcher) { // Respond to modify events on config file if ( event.kind === 'modify' && event.paths.some((p) => p.includes('config.json')) ) { logger.info('Config file changed, reloading...'); await this.reloadConfig(); } // Detect pause file changes if ( (event.kind === 'create' || event.kind === 'modify' || event.kind === 'remove') && event.paths.some((p) => p.includes('pause')) ) { this.checkPauseState(); } // Stop watching if daemon stopped if (!this.isRunning) { break; } } } catch (error) { // Watcher error - not critical, just log it logger.dim( `Config watcher stopped: ${error instanceof Error ? error.message : String(error)}`, ); } })(); } catch (error) { // If we can't start the watcher, just log and continue // The periodic check will still work logger.dim('Could not start config file watcher, using periodic checks only'); } } /** * Reload configuration and restart monitoring if needed */ private async reloadConfig(): Promise { try { const oldDeviceCount = this.config.upsDevices?.length || 0; // Load the new configuration await this.loadConfig(); const newDeviceCount = this.config.upsDevices?.length || 0; if (newDeviceCount > 0 && oldDeviceCount === 0) { logger.success(`Configuration reloaded! Found ${newDeviceCount} UPS device(s)`); logger.info('Monitoring will start automatically...'); } else if (newDeviceCount !== oldDeviceCount) { logger.success( `Configuration reloaded! UPS devices: ${oldDeviceCount} → ${newDeviceCount}`, ); // Reinitialize UPS status tracking this.initializeUpsStatus(); } else { logger.success('Configuration reloaded successfully'); } } catch (error) { logger.warn( `Failed to reload config: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Sleep for the specified milliseconds */ private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } }