import process from 'node:process'; import * as fs from 'node:fs'; import * as path from 'node:path'; 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 } from './actions/index.ts'; import { applyDefaultShutdownDelay, buildUpsActionContext, decideUpsActionExecution, type TUpsTriggerReason, } from './action-orchestration.ts'; import { NupstHttpServer } from './http-server.ts'; import { NETWORK, PAUSE, SHUTDOWN, THRESHOLDS, TIMING, UI } from './constants.ts'; import { analyzeConfigReload, shouldRefreshPauseState, shouldReloadConfig, } from './config-watch.ts'; import { type IPauseState, loadPauseSnapshot } from './pause-state.ts'; import { ShutdownExecutor } from './shutdown-executor.ts'; import { buildGroupStatusSnapshot, buildGroupThresholdContextStatus, evaluateGroupActionThreshold, } from './group-monitoring.ts'; import { buildFailedUpsPollSnapshot, buildSuccessfulUpsPollSnapshot, ensureUpsStatus, getActionThresholdStates, getEnteredThresholdIndexes, } from './ups-monitoring.ts'; import { buildShutdownErrorRow, buildShutdownStatusRow, selectEmergencyCandidate, } from './shutdown-monitoring.ts'; import { createInitialUpsStatus, type IUpsStatus } from './ups-status.ts'; /** * 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; } /** * 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; /** Default delay in minutes for shutdown actions without an override */ defaultShutdownDelay?: 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; }; } /** * 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.4', defaultShutdownDelay: SHUTDOWN.DEFAULT_DELAY_MINUTES, 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', runtimeUnit: 'ticks', }, 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 }, }, ], }, ], 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 groupStatus: Map = new Map(); private thresholdState: Map = new Map(); private httpServer?: NupstHttpServer; private readonly shutdownExecutor: ShutdownExecutor; /** * 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.shutdownExecutor = new ShutdownExecutor(); 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 or normalized config back to disk when needed. // Cast to INupstConfig since migrations ensure the output is valid. const validConfig = migratedConfig as unknown as INupstConfig; const normalizedShutdownDelay = this.normalizeShutdownDelay(validConfig.defaultShutdownDelay); const shouldPersistNormalizedConfig = validConfig.defaultShutdownDelay !== normalizedShutdownDelay; validConfig.defaultShutdownDelay = normalizedShutdownDelay; if (migrated || shouldPersistNormalizedConfig) { 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.4', upsDevices: config.upsDevices, groups: config.groups, checkInterval: config.checkInterval, defaultShutdownDelay: this.normalizeShutdownDelay(config.defaultShutdownDelay), ...(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 ups add' first to create a configuration."], 45, 'error', ); } /** * Get the current configuration */ public getConfig(): INupstConfig { return this.config; } private normalizeShutdownDelay(delayMinutes: number | undefined): number { if ( typeof delayMinutes !== 'number' || !Number.isFinite(delayMinutes) || delayMinutes < 0 ) { return SHUTDOWN.DEFAULT_DELAY_MINUTES; } return delayMinutes; } private getDefaultShutdownDelayMinutes(): number { return this.normalizeShutdownDelay(this.config.defaultShutdownDelay); } /** * 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 upgrade" to upgrade'); 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, createInitialUpsStatus(ups)); } 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 { const snapshot = loadPauseSnapshot(PAUSE.FILE_PATH, this.isPaused); if (snapshot.transition === 'autoResumed') { logger.log(''); logger.logBoxTitle('Auto-Resume', 45, 'success'); logger.logBoxLine('Pause duration expired, resuming action monitoring'); logger.logBoxEnd(); logger.log(''); } else if (snapshot.transition === 'paused' && snapshot.pauseState) { logger.log(''); logger.logBoxTitle('Actions Paused', 45, 'warning'); logger.logBoxLine(`Paused by: ${snapshot.pauseState.pausedBy}`); if (snapshot.pauseState.reason) { logger.logBoxLine(`Reason: ${snapshot.pauseState.reason}`); } if (snapshot.pauseState.resumeAt) { const remaining = Math.round((snapshot.pauseState.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(''); } else if (snapshot.transition === 'resumed') { logger.log(''); logger.logBoxTitle('Actions Resumed', 45, 'success'); logger.logBoxLine('Action monitoring has been resumed'); logger.logBoxEnd(); logger.log(''); } this.isPaused = snapshot.isPaused; this.pauseState = snapshot.pauseState; } /** * 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 initialStatus = ensureUpsStatus(this.upsStatus.get(ups.id), ups); this.upsStatus.set(ups.id, initialStatus); // 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(); const currentStatus = this.upsStatus.get(ups.id); const pollSnapshot = buildSuccessfulUpsPollSnapshot( ups, status, currentStatus, currentTime, ); if (pollSnapshot.transition === 'recovered' && pollSnapshot.previousStatus) { logger.log(''); logger.logBoxTitle(`UPS Recovered: ${ups.name}`, 60, 'success'); logger.logBoxLine(`UPS is reachable again after ${pollSnapshot.downtimeSeconds} seconds`); logger.logBoxLine(`Current Status: ${formatPowerStatus(status.powerStatus)}`); logger.logBoxLine(`Time: ${new Date().toISOString()}`); logger.logBoxEnd(); logger.log(''); // Trigger power status change action for recovery await this.triggerUpsActions( ups, pollSnapshot.updatedStatus, pollSnapshot.previousStatus, 'powerStatusChange', ); } else if (pollSnapshot.transition === 'powerStatusChange' && pollSnapshot.previousStatus) { logger.log(''); logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning'); logger.logBoxLine( `Previous: ${formatPowerStatus(pollSnapshot.previousStatus.powerStatus)}`, ); logger.logBoxLine(`Current: ${formatPowerStatus(status.powerStatus)}`); logger.logBoxLine(`Time: ${new Date().toISOString()}`); logger.logBoxEnd(); logger.log(''); // Trigger actions for power status change await this.triggerUpsActions( ups, pollSnapshot.updatedStatus, pollSnapshot.previousStatus, 'powerStatusChange', ); } const thresholdStates = getActionThresholdStates( status.powerStatus, status.batteryCapacity, status.batteryRuntime, ups.actions, ); const enteredThresholdIndexes = this.trackEnteredThresholdIndexes( `ups:${ups.id}`, thresholdStates, ); if (enteredThresholdIndexes.length > 0) { await this.triggerUpsActions( ups, pollSnapshot.updatedStatus, pollSnapshot.previousStatus, 'thresholdViolation', enteredThresholdIndexes, ); } // Update the status in the map this.upsStatus.set(ups.id, pollSnapshot.updatedStatus); } catch (error) { const currentTime = Date.now(); const currentStatus = this.upsStatus.get(ups.id); const failureSnapshot = buildFailedUpsPollSnapshot(ups, currentStatus, currentTime); logger.error( `Error checking UPS ${ups.name} (${ups.id}) [failure ${failureSnapshot.failures}/${NETWORK.CONSECUTIVE_FAILURE_THRESHOLD}]: ${ error instanceof Error ? error.message : String(error) }`, ); if (failureSnapshot.transition === 'unreachable' && failureSnapshot.previousStatus) { logger.log(''); logger.logBoxTitle(`UPS Unreachable: ${ups.name}`, 60, 'error'); logger.logBoxLine(`${failureSnapshot.failures} consecutive communication failures`); logger.logBoxLine( `Last known status: ${formatPowerStatus(failureSnapshot.previousStatus.powerStatus)}`, ); logger.logBoxLine(`Time: ${new Date().toISOString()}`); logger.logBoxEnd(); logger.log(''); // Trigger power status change action for unreachable await this.triggerUpsActions( ups, failureSnapshot.updatedStatus, failureSnapshot.previousStatus, 'powerStatusChange', ); } this.upsStatus.set(ups.id, failureSnapshot.updatedStatus); } } await this.checkGroupActions(); } private trackEnteredThresholdIndexes(sourceKey: string, currentStates: boolean[]): number[] { const previousStates = this.thresholdState.get(sourceKey); const enteredIndexes = getEnteredThresholdIndexes(previousStates, currentStates); this.thresholdState.set(sourceKey, [...currentStates]); return enteredIndexes; } private getGroupActionIdentity(group: IGroupConfig): { id: string; name: string } { return { id: group.id, name: `Group ${group.name}`, }; } private async checkGroupActions(): Promise { for (const group of this.config.groups || []) { const groupIdentity = this.getGroupActionIdentity(group); const memberStatuses = this.config.upsDevices .filter((ups) => ups.groups?.includes(group.id)) .map((ups) => this.upsStatus.get(ups.id)) .filter((status): status is IUpsStatus => !!status); if (memberStatuses.length === 0) { continue; } const currentTime = Date.now(); const pollSnapshot = buildGroupStatusSnapshot( groupIdentity, group.mode, memberStatuses, this.groupStatus.get(group.id), currentTime, ); if (pollSnapshot.transition === 'powerStatusChange' && pollSnapshot.previousStatus) { logger.log(''); logger.logBoxTitle(`Group Power Status Change: ${group.name}`, 60, 'warning'); logger.logBoxLine( `Previous: ${formatPowerStatus(pollSnapshot.previousStatus.powerStatus)}`, ); logger.logBoxLine(`Current: ${formatPowerStatus(pollSnapshot.updatedStatus.powerStatus)}`); logger.logBoxLine(`Members: ${memberStatuses.map((status) => status.name).join(', ')}`); logger.logBoxLine(`Time: ${new Date().toISOString()}`); logger.logBoxEnd(); logger.log(''); await this.triggerGroupActions( group, pollSnapshot.updatedStatus, pollSnapshot.previousStatus, 'powerStatusChange', ); } const thresholdEvaluations = (group.actions || []).map((action) => evaluateGroupActionThreshold(action, group.mode, memberStatuses) ); const thresholdStates = thresholdEvaluations.map((evaluation) => evaluation.exceedsThreshold && !evaluation.blockedByUnreachable ); const enteredThresholdIndexes = this.trackEnteredThresholdIndexes( `group:${group.id}`, thresholdStates, ); if (enteredThresholdIndexes.length > 0) { const thresholdStatus = buildGroupThresholdContextStatus( groupIdentity, thresholdEvaluations, enteredThresholdIndexes, pollSnapshot.updatedStatus, currentTime, ); await this.triggerGroupActions( group, thresholdStatus, pollSnapshot.previousStatus, 'thresholdViolation', enteredThresholdIndexes, ); } this.groupStatus.set(group.id, pollSnapshot.updatedStatus); } } /** * 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(''); } /** * 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: TUpsTriggerReason, actionIndexes?: number[], ): Promise { const decision = decideUpsActionExecution( this.isPaused, ups, status, previousStatus, triggerReason, ); if (decision.type === 'suppressed') { logger.info(decision.message); return; } if (decision.type === 'legacyShutdown') { await this.initiateShutdown(decision.reason); return; } if (decision.type === 'skip') { return; } const selectedActions = actionIndexes ? decision.actions.filter((_action, index) => actionIndexes.includes(index)) : decision.actions; if (selectedActions.length === 0) { return; } const actions = applyDefaultShutdownDelay( selectedActions, this.getDefaultShutdownDelayMinutes(), ); await ActionManager.executeActions(actions, decision.context); } private async triggerGroupActions( group: IGroupConfig, status: IUpsStatus, previousStatus: IUpsStatus | undefined, triggerReason: TUpsTriggerReason, actionIndexes?: number[], ): Promise { if (this.isPaused) { logger.info( `[PAUSED] Actions suppressed for Group ${group.name} (trigger: ${triggerReason})`, ); return; } const configuredActions = group.actions || []; if (configuredActions.length === 0) { return; } const selectedActions = actionIndexes ? configuredActions.filter((_action, index) => actionIndexes.includes(index)) : configuredActions; if (selectedActions.length === 0) { return; } const actions = applyDefaultShutdownDelay( selectedActions, this.getDefaultShutdownDelayMinutes(), ); const context = buildUpsActionContext( this.getGroupActionIdentity(group), status, previousStatus, triggerReason, ); 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}`); const shutdownDelayMinutes = this.getDefaultShutdownDelayMinutes(); try { await this.shutdownExecutor.scheduleShutdown(shutdownDelayMinutes); logger.log(`Allowing ${shutdownDelayMinutes} minutes for VMs to shut down safely`); // 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}`); const shutdownTriggered = await this.shutdownExecutor.tryScheduledAlternatives(); if (!shutdownTriggered) { 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 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 rowSnapshot = buildShutdownStatusRow( ups.name, status, THRESHOLDS.EMERGENCY_RUNTIME_MINUTES, { battery: (batteryCapacity) => getBatteryColor(batteryCapacity)(`${batteryCapacity}%`), runtime: (batteryRuntime) => getRuntimeColor(batteryRuntime)(`${batteryRuntime} min`), ok: theme.success, critical: theme.error, error: theme.error, }, ); rows.push(rowSnapshot.row); emergencyUps = selectEmergencyCandidate( emergencyUps, ups, status, THRESHOLDS.EMERGENCY_RUNTIME_MINUTES, ); } catch (upsError) { rows.push(buildShutdownErrorRow(ups.name, theme.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 (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 { await this.shutdownExecutor.forceImmediateShutdown(); } catch (error) { logger.error('Emergency shutdown failed, trying alternative methods...'); const shutdownTriggered = await this.shutdownExecutor.tryEmergencyAlternatives(); if (!shutdownTriggered) { 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 (shouldReloadConfig(event)) { logger.info('Config file changed, reloading...'); await this.reloadConfig(); } // Detect pause file changes if (shouldRefreshPauseState(event)) { 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(); this.thresholdState.clear(); this.groupStatus.clear(); const newDeviceCount = this.config.upsDevices?.length || 0; const reloadSnapshot = analyzeConfigReload(oldDeviceCount, newDeviceCount); logger.success(reloadSnapshot.message); if (reloadSnapshot.shouldLogMonitoringStart) { logger.info('Monitoring will start automatically...'); } if (reloadSnapshot.shouldInitializeUpsStatus) { // Reinitialize UPS status tracking this.initializeUpsStatus(); } } 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)); } }