/** * UPSD/NIS (Network UPS Tools) TCP client * * Connects to a NUT upsd server via TCP and queries UPS variables * using the NUT network protocol (RFC-style line protocol). * * Protocol format: * Request: GET VAR \n * Response: VAR ""\n * Logout: LOGOUT\n */ import * as net from 'node:net'; import { logger } from '../logger.ts'; import { UPSD } from '../constants.ts'; import type { IUpsdConfig } from './types.ts'; import type { IUpsStatus } from '../snmp/types.ts'; /** * NupstUpsd - TCP client for the NUT UPSD protocol */ export class NupstUpsd { private debug = false; /** * Enable debug logging */ public enableDebug(): void { this.debug = true; logger.info('UPSD debug mode enabled'); } /** * Get the current UPS status via UPSD protocol * @param config UPSD connection configuration * @returns UPS status matching the IUpsStatus interface */ public async getUpsStatus(config: IUpsdConfig): Promise { const host = config.host || '127.0.0.1'; const port = config.port || UPSD.DEFAULT_PORT; const upsName = config.upsName || UPSD.DEFAULT_UPS_NAME; const timeout = config.timeout || UPSD.DEFAULT_TIMEOUT_MS; if (this.debug) { logger.dim('---------------------------------------'); logger.dim('Getting UPS status via UPSD protocol:'); logger.dim(` Host: ${host}:${port}`); logger.dim(` UPS Name: ${upsName}`); logger.dim(` Timeout: ${timeout}ms`); logger.dim('---------------------------------------'); } // Variables to query from NUT const varsToQuery = [ 'ups.status', 'battery.charge', 'battery.runtime', 'ups.load', 'ups.realpower', 'output.voltage', 'output.current', ]; const values = new Map(); // Open a TCP connection, query all variables, then logout const conn = await this.connect(host, port, timeout); try { // Authenticate if credentials provided if (config.username && config.password) { await this.sendCommand(conn, `USERNAME ${config.username}`, timeout); await this.sendCommand(conn, `PASSWORD ${config.password}`, timeout); } // Query each variable for (const varName of varsToQuery) { const value = await this.safeGetVar(conn, upsName, varName, timeout); if (value !== null) { values.set(varName, value); } } // Logout gracefully try { await this.sendCommand(conn, 'LOGOUT', timeout); } catch (_e) { // Ignore logout errors } } finally { conn.destroy(); } // Map NUT variables to IUpsStatus const powerStatus = this.parsePowerStatus(values.get('ups.status') || ''); const batteryCapacity = parseFloat(values.get('battery.charge') || '0'); const batteryRuntimeSeconds = parseFloat(values.get('battery.runtime') || '0'); const batteryRuntime = Math.floor(batteryRuntimeSeconds / 60); // NUT reports seconds, convert to minutes const outputLoad = parseFloat(values.get('ups.load') || '0'); const outputPower = parseFloat(values.get('ups.realpower') || '0'); const outputVoltage = parseFloat(values.get('output.voltage') || '0'); const outputCurrent = parseFloat(values.get('output.current') || '0'); const result: IUpsStatus = { powerStatus, batteryCapacity: isNaN(batteryCapacity) ? 0 : batteryCapacity, batteryRuntime: isNaN(batteryRuntime) ? 0 : batteryRuntime, outputLoad: isNaN(outputLoad) ? 0 : outputLoad, outputPower: isNaN(outputPower) ? 0 : outputPower, outputVoltage: isNaN(outputVoltage) ? 0 : outputVoltage, outputCurrent: isNaN(outputCurrent) ? 0 : outputCurrent, raw: Object.fromEntries(values), }; if (this.debug) { logger.dim('---------------------------------------'); logger.dim('UPSD 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; } /** * Open a TCP connection to the UPSD server */ private connect(host: string, port: number, timeout: number): Promise { return new Promise((resolve, reject) => { const socket = net.createConnection({ host, port }, () => { if (this.debug) { logger.dim(`Connected to UPSD at ${host}:${port}`); } resolve(socket); }); socket.setTimeout(timeout); socket.on('timeout', () => { socket.destroy(); reject(new Error(`UPSD connection timed out after ${timeout}ms`)); }); socket.on('error', (err) => { reject(new Error(`UPSD connection error: ${err.message}`)); }); }); } /** * Send a command and read the response line */ private sendCommand(socket: net.Socket, command: string, timeout: number): Promise { return new Promise((resolve, reject) => { let responseData = ''; const timer = setTimeout(() => { cleanup(); reject(new Error(`UPSD command timed out: ${command}`)); }, timeout); const decoder = new TextDecoder(); const onData = (data: Uint8Array) => { responseData += decoder.decode(data, { stream: true }); // Look for newline to indicate end of response const newlineIdx = responseData.indexOf('\n'); if (newlineIdx !== -1) { cleanup(); const line = responseData.substring(0, newlineIdx).trim(); if (this.debug) { logger.dim(`UPSD << ${line}`); } resolve(line); } }; const onError = (err: Error) => { cleanup(); reject(err); }; const cleanup = () => { clearTimeout(timer); socket.removeListener('data', onData); socket.removeListener('error', onError); }; socket.on('data', onData); socket.on('error', onError); if (this.debug) { logger.dim(`UPSD >> ${command}`); } socket.write(command + '\n'); }); } /** * Safely get a single NUT variable, returning null on error */ private async safeGetVar( socket: net.Socket, upsName: string, varName: string, timeout: number, ): Promise { try { const response = await this.sendCommand( socket, `GET VAR ${upsName} ${varName}`, timeout, ); // Expected response: VAR "" // Also handle: ERR ... for unsupported variables if (response.startsWith('ERR')) { if (this.debug) { logger.dim(`UPSD variable ${varName} not available: ${response}`); } return null; } // Parse: VAR ups battery.charge "100" const match = response.match(/^VAR\s+\S+\s+\S+\s+"(.*)"/); if (match) { return match[1]; } // Some implementations don't quote the value const parts = response.split(/\s+/); if (parts.length >= 4 && parts[0] === 'VAR') { return parts.slice(3).join(' ').replace(/^"/, '').replace(/"$/, ''); } if (this.debug) { logger.dim(`UPSD unexpected response for ${varName}: ${response}`); } return null; } catch (error) { if (this.debug) { logger.dim( `UPSD error getting ${varName}: ${error instanceof Error ? error.message : String(error)}`, ); } return null; } } /** * Parse NUT ups.status tokens into a power status * NUT status tokens: OL (online), OB (on battery), LB (low battery), * HB (high battery), RB (replace battery), CHRG (charging), etc. */ private parsePowerStatus(statusString: string): 'online' | 'onBattery' | 'unknown' { const tokens = statusString.trim().split(/\s+/); if (tokens.includes('OB')) { return 'onBattery'; } if (tokens.includes('OL')) { return 'online'; } return 'unknown'; } }