270 lines
8.3 KiB
TypeScript
270 lines
8.3 KiB
TypeScript
|
|
/**
|
||
|
|
* 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 <upsname> <varname>\n
|
||
|
|
* Response: VAR <upsname> <varname> "<value>"\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<IUpsStatus> {
|
||
|
|
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<string, string>();
|
||
|
|
|
||
|
|
// 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<net.Socket> {
|
||
|
|
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<string> {
|
||
|
|
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<string | null> {
|
||
|
|
try {
|
||
|
|
const response = await this.sendCommand(
|
||
|
|
socket,
|
||
|
|
`GET VAR ${upsName} ${varName}`,
|
||
|
|
timeout,
|
||
|
|
);
|
||
|
|
|
||
|
|
// Expected response: VAR <upsname> <varname> "<value>"
|
||
|
|
// 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';
|
||
|
|
}
|
||
|
|
}
|