feat(daemon): Add UPSD (NUT) protocol support, Proxmox VM shutdown action, pause/resume monitoring, and network-loss/unreachable handling; bump config version to 4.2

This commit is contained in:
2026-02-20 11:51:59 +00:00
parent 782c8c9555
commit 42b8eaf6d2
30 changed files with 2183 additions and 697 deletions

269
ts/upsd/client.ts Normal file
View File

@@ -0,0 +1,269 @@
/**
* 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';
}
}