472 lines
12 KiB
TypeScript
472 lines
12 KiB
TypeScript
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<void> {
|
|
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) => {
|
|
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<void> {
|
|
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<string[]> {
|
|
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<INutUpsInfo[]> {
|
|
await this.ensureConnected();
|
|
const response = await this.sendCommand('LIST UPS');
|
|
|
|
const upsList: INutUpsInfo[] = [];
|
|
|
|
for (const line of response) {
|
|
// Format: UPS <name> "<description>"
|
|
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<INutVariable[]> {
|
|
await this.ensureConnected();
|
|
const response = await this.sendCommand(`LIST VAR ${upsName}`);
|
|
|
|
const variables: INutVariable[] = [];
|
|
|
|
for (const line of response) {
|
|
// Format: VAR <ups> <name> "<value>"
|
|
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<string | null> {
|
|
await this.ensureConnected();
|
|
const response = await this.sendCommand(`GET VAR ${upsName} ${varName}`);
|
|
|
|
for (const line of response) {
|
|
// Format: VAR <ups> <name> "<value>"
|
|
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<Map<string, string>> {
|
|
const results = new Map<string, string>();
|
|
|
|
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<boolean> {
|
|
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<string[]> {
|
|
await this.ensureConnected();
|
|
const response = await this.sendCommand(`LIST CMD ${upsName}`);
|
|
|
|
const commands: string[] = [];
|
|
|
|
for (const line of response) {
|
|
// Format: CMD <ups> <command>
|
|
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<void> {
|
|
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<boolean> {
|
|
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);
|
|
});
|
|
}
|
|
}
|