import * as plugins from '../plugins.js'; /** * NUT Protocol variable definitions */ export const NUT_VARIABLES = { // Device info deviceMfr: 'device.mfr', deviceModel: 'device.model', deviceSerial: 'device.serial', deviceType: 'device.type', // UPS status upsStatus: 'ups.status', upsAlarm: 'ups.alarm', upsTime: 'ups.time', upsLoad: 'ups.load', upsTemperature: 'ups.temperature', // Battery batteryCharge: 'battery.charge', batteryRuntime: 'battery.runtime', batteryVoltage: 'battery.voltage', batteryVoltageNominal: 'battery.voltage.nominal', batteryType: 'battery.type', batteryDate: 'battery.date', batteryTemperature: 'battery.temperature', // Input inputVoltage: 'input.voltage', inputVoltageNominal: 'input.voltage.nominal', inputFrequency: 'input.frequency', inputFrequencyNominal: 'input.frequency.nominal', inputTransferHigh: 'input.transfer.high', inputTransferLow: 'input.transfer.low', // Output outputVoltage: 'output.voltage', outputVoltageNominal: 'output.voltage.nominal', outputFrequency: 'output.frequency', outputCurrent: 'output.current', }; /** * NUT instant commands */ export const NUT_COMMANDS = { testBatteryStart: 'test.battery.start', testBatteryStartQuick: 'test.battery.start.quick', testBatteryStartDeep: 'test.battery.start.deep', testBatteryStop: 'test.battery.stop', calibrateStart: 'calibrate.start', calibrateStop: 'calibrate.stop', shutdown: 'shutdown.return', shutdownStayOff: 'shutdown.stayoff', shutdownStop: 'shutdown.stop', shutdownReboot: 'shutdown.reboot', beeperEnable: 'beeper.enable', beeperDisable: 'beeper.disable', beeperMute: 'beeper.mute', beeperToggle: 'beeper.toggle', loadOff: 'load.off', loadOn: 'load.on', }; /** * UPS status flags from NUT */ export type TNutStatusFlag = | 'OL' // Online (on utility power) | 'OB' // On battery | 'LB' // Low battery | 'HB' // High battery | 'RB' // Replace battery | 'CHRG' // Charging | 'DISCHRG' // Discharging | 'BYPASS' // On bypass | 'CAL' // Calibrating | 'OFF' // Offline | 'OVER' // Overloaded | 'TRIM' // Trimming voltage | 'BOOST' // Boosting voltage | 'FSD'; // Forced shutdown /** * NUT UPS information */ export interface INutUpsInfo { name: string; description: string; } /** * NUT variable */ export interface INutVariable { name: string; value: string; } /** * NUT Protocol handler for Network UPS Tools * TCP-based text protocol on port 3493 */ export class NutProtocol { private socket: plugins.net.Socket | null = null; private address: string; private port: number; private connected: boolean = false; private responseBuffer: string = ''; private responseResolver: ((value: string[]) => void) | null = null; private responseRejecter: ((error: Error) => void) | null = null; constructor(address: string, port: number = 3493) { this.address = address; this.port = port; } /** * Connect to NUT server */ public async connect(): Promise { if (this.connected) { return; } return new Promise((resolve, reject) => { this.socket = new plugins.net.Socket(); const timeout = setTimeout(() => { if (this.socket) { this.socket.destroy(); this.socket = null; } reject(new Error(`Connection timeout to ${this.address}:${this.port}`)); }, 5000); this.socket.on('connect', () => { clearTimeout(timeout); this.connected = true; resolve(); }); this.socket.on('error', (err) => { clearTimeout(timeout); this.connected = false; if (this.responseRejecter) { this.responseRejecter(err); this.responseRejecter = null; this.responseResolver = null; } reject(err); }); this.socket.on('data', (data: Buffer) => { this.handleData(data); }); this.socket.on('close', () => { this.connected = false; if (this.responseRejecter) { this.responseRejecter(new Error('Connection closed')); this.responseRejecter = null; this.responseResolver = null; } }); this.socket.connect(this.port, this.address); }); } /** * Disconnect from NUT server */ public async disconnect(): Promise { if (!this.connected || !this.socket) { return; } try { await this.sendCommand('LOGOUT'); } catch { // Ignore logout errors } this.socket.destroy(); this.socket = null; this.connected = false; } /** * Check if connected */ public get isConnected(): boolean { return this.connected; } /** * Handle incoming data */ private handleData(data: Buffer): void { this.responseBuffer += data.toString(); // Check for complete response (ends with newline) const lines = this.responseBuffer.split('\n'); // Check if we have a complete response if (this.responseBuffer.endsWith('\n')) { const responseLines = lines.filter((l) => l.trim().length > 0); this.responseBuffer = ''; if (this.responseResolver) { this.responseResolver(responseLines); this.responseResolver = null; this.responseRejecter = null; } } } /** * Send command and get response */ private async sendCommand(command: string): Promise { if (!this.socket || !this.connected) { throw new Error('Not connected to NUT server'); } return new Promise((resolve, reject) => { this.responseResolver = resolve; this.responseRejecter = reject; const timeout = setTimeout(() => { this.responseResolver = null; this.responseRejecter = null; reject(new Error(`Command timeout: ${command}`)); }, 10000); this.responseResolver = (lines) => { clearTimeout(timeout); resolve(lines); }; this.responseRejecter = (err) => { clearTimeout(timeout); reject(err); }; this.socket!.write(`${command}\n`); }); } /** * List available UPS devices */ public async listUps(): Promise { await this.ensureConnected(); const response = await this.sendCommand('LIST UPS'); const upsList: INutUpsInfo[] = []; for (const line of response) { // Format: UPS "" const match = line.match(/^UPS\s+(\S+)\s+"([^"]*)"/); if (match) { upsList.push({ name: match[1], description: match[2], }); } } return upsList; } /** * Get all variables for a UPS */ public async listVariables(upsName: string): Promise { await this.ensureConnected(); const response = await this.sendCommand(`LIST VAR ${upsName}`); const variables: INutVariable[] = []; for (const line of response) { // Format: VAR "" const match = line.match(/^VAR\s+\S+\s+(\S+)\s+"([^"]*)"/); if (match) { variables.push({ name: match[1], value: match[2], }); } } return variables; } /** * Get a specific variable value */ public async getVariable(upsName: string, varName: string): Promise { await this.ensureConnected(); const response = await this.sendCommand(`GET VAR ${upsName} ${varName}`); for (const line of response) { // Format: VAR "" const match = line.match(/^VAR\s+\S+\s+\S+\s+"([^"]*)"/); if (match) { return match[1]; } // Handle error responses if (line.startsWith('ERR')) { return null; } } return null; } /** * Get multiple variables at once */ public async getVariables(upsName: string, varNames: string[]): Promise> { const results = new Map(); for (const varName of varNames) { const value = await this.getVariable(upsName, varName); if (value !== null) { results.set(varName, value); } } return results; } /** * Execute an instant command */ public async runCommand(upsName: string, command: string): Promise { await this.ensureConnected(); const response = await this.sendCommand(`INSTCMD ${upsName} ${command}`); for (const line of response) { if (line === 'OK') { return true; } if (line.startsWith('ERR')) { return false; } } return false; } /** * List available commands for a UPS */ public async listCommands(upsName: string): Promise { await this.ensureConnected(); const response = await this.sendCommand(`LIST CMD ${upsName}`); const commands: string[] = []; for (const line of response) { // Format: CMD const match = line.match(/^CMD\s+\S+\s+(\S+)/); if (match) { commands.push(match[1]); } } return commands; } /** * Parse UPS status string into flags */ public parseStatus(statusString: string): TNutStatusFlag[] { return statusString.split(/\s+/).filter((s) => s.length > 0) as TNutStatusFlag[]; } /** * Get comprehensive UPS status */ public async getUpsStatus(upsName: string): Promise<{ status: TNutStatusFlag[]; batteryCharge: number; batteryRuntime: number; inputVoltage: number; outputVoltage: number; load: number; }> { const vars = await this.getVariables(upsName, [ NUT_VARIABLES.upsStatus, NUT_VARIABLES.batteryCharge, NUT_VARIABLES.batteryRuntime, NUT_VARIABLES.inputVoltage, NUT_VARIABLES.outputVoltage, NUT_VARIABLES.upsLoad, ]); return { status: this.parseStatus(vars.get(NUT_VARIABLES.upsStatus) || ''), batteryCharge: parseFloat(vars.get(NUT_VARIABLES.batteryCharge) || '0'), batteryRuntime: parseFloat(vars.get(NUT_VARIABLES.batteryRuntime) || '0'), inputVoltage: parseFloat(vars.get(NUT_VARIABLES.inputVoltage) || '0'), outputVoltage: parseFloat(vars.get(NUT_VARIABLES.outputVoltage) || '0'), load: parseFloat(vars.get(NUT_VARIABLES.upsLoad) || '0'), }; } /** * Get device information */ public async getDeviceInfo(upsName: string): Promise<{ manufacturer: string; model: string; serial: string; type: string; }> { const vars = await this.getVariables(upsName, [ NUT_VARIABLES.deviceMfr, NUT_VARIABLES.deviceModel, NUT_VARIABLES.deviceSerial, NUT_VARIABLES.deviceType, ]); return { manufacturer: vars.get(NUT_VARIABLES.deviceMfr) || '', model: vars.get(NUT_VARIABLES.deviceModel) || '', serial: vars.get(NUT_VARIABLES.deviceSerial) || '', type: vars.get(NUT_VARIABLES.deviceType) || '', }; } /** * Ensure connected before command */ private async ensureConnected(): Promise { if (!this.connected) { await this.connect(); } } /** * Check if a NUT server is reachable */ public static async probe(address: string, port: number = 3493, timeout: number = 3000): Promise { return new Promise((resolve) => { const socket = new plugins.net.Socket(); const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeout); socket.on('connect', () => { clearTimeout(timer); socket.destroy(); resolve(true); }); socket.on('error', () => { clearTimeout(timer); resolve(false); }); socket.connect(port, address); }); } }