initial
This commit is contained in:
471
ts/ups/ups.classes.nutprotocol.ts
Normal file
471
ts/ups/ups.classes.nutprotocol.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user