549 lines
15 KiB
TypeScript
549 lines
15 KiB
TypeScript
import * as plugins from '../plugins.js';
|
|
import { Device } from '../abstract/device.abstract.js';
|
|
import { NutProtocol, NUT_COMMANDS, NUT_VARIABLES, type TNutStatusFlag } from './ups.classes.nutprotocol.js';
|
|
import { UpsSnmpHandler, type TUpsBatteryStatus, type TUpsOutputSource } from './ups.classes.upssnmp.js';
|
|
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
|
|
|
|
/**
|
|
* UPS status enumeration
|
|
*/
|
|
export type TUpsStatus = 'online' | 'onbattery' | 'lowbattery' | 'charging' | 'discharging' | 'bypass' | 'offline' | 'error' | 'unknown';
|
|
|
|
/**
|
|
* UPS protocol type
|
|
*/
|
|
export type TUpsProtocol = 'nut' | 'snmp';
|
|
|
|
/**
|
|
* UPS device information
|
|
*/
|
|
export interface IUpsDeviceInfo extends IDeviceInfo {
|
|
type: 'ups';
|
|
protocol: TUpsProtocol;
|
|
upsName?: string; // NUT ups name
|
|
manufacturer: string;
|
|
model: string;
|
|
serialNumber?: string;
|
|
firmwareVersion?: string;
|
|
}
|
|
|
|
/**
|
|
* UPS battery information
|
|
*/
|
|
export interface IUpsBatteryInfo {
|
|
charge: number; // 0-100%
|
|
runtime: number; // seconds remaining
|
|
voltage: number; // volts
|
|
temperature?: number; // celsius
|
|
status: 'normal' | 'low' | 'depleted' | 'unknown';
|
|
}
|
|
|
|
/**
|
|
* UPS input/output power info
|
|
*/
|
|
export interface IUpsPowerInfo {
|
|
inputVoltage: number;
|
|
inputFrequency?: number;
|
|
outputVoltage: number;
|
|
outputFrequency?: number;
|
|
outputCurrent?: number;
|
|
outputPower?: number;
|
|
load: number; // 0-100%
|
|
}
|
|
|
|
/**
|
|
* Full UPS status
|
|
*/
|
|
export interface IUpsFullStatus {
|
|
status: TUpsStatus;
|
|
battery: IUpsBatteryInfo;
|
|
power: IUpsPowerInfo;
|
|
alarms: string[];
|
|
secondsOnBattery: number;
|
|
}
|
|
|
|
/**
|
|
* UPS Device class supporting both NUT and SNMP protocols
|
|
*/
|
|
export class UpsDevice extends Device {
|
|
private nutProtocol: NutProtocol | null = null;
|
|
private snmpHandler: UpsSnmpHandler | null = null;
|
|
private upsProtocol: TUpsProtocol;
|
|
private upsName: string;
|
|
private snmpCommunity: string;
|
|
|
|
private _upsStatus: TUpsStatus = 'unknown';
|
|
private _manufacturer: string = '';
|
|
private _model: string = '';
|
|
private _batteryCharge: number = 0;
|
|
private _batteryRuntime: number = 0;
|
|
private _inputVoltage: number = 0;
|
|
private _outputVoltage: number = 0;
|
|
private _load: number = 0;
|
|
|
|
constructor(
|
|
info: IDeviceInfo,
|
|
options: {
|
|
protocol: TUpsProtocol;
|
|
upsName?: string; // Required for NUT
|
|
snmpCommunity?: string; // For SNMP
|
|
},
|
|
retryOptions?: IRetryOptions
|
|
) {
|
|
super(info, retryOptions);
|
|
this.upsProtocol = options.protocol;
|
|
this.upsName = options.upsName || 'ups';
|
|
this.snmpCommunity = options.snmpCommunity || 'public';
|
|
}
|
|
|
|
// Getters for UPS properties
|
|
public get upsStatus(): TUpsStatus {
|
|
return this._upsStatus;
|
|
}
|
|
|
|
public get upsManufacturer(): string {
|
|
return this._manufacturer;
|
|
}
|
|
|
|
public get upsModel(): string {
|
|
return this._model;
|
|
}
|
|
|
|
public get batteryCharge(): number {
|
|
return this._batteryCharge;
|
|
}
|
|
|
|
public get batteryRuntime(): number {
|
|
return this._batteryRuntime;
|
|
}
|
|
|
|
public get inputVoltage(): number {
|
|
return this._inputVoltage;
|
|
}
|
|
|
|
public get outputVoltage(): number {
|
|
return this._outputVoltage;
|
|
}
|
|
|
|
public get load(): number {
|
|
return this._load;
|
|
}
|
|
|
|
public get protocol(): TUpsProtocol {
|
|
return this.upsProtocol;
|
|
}
|
|
|
|
/**
|
|
* Connect to UPS
|
|
*/
|
|
protected async doConnect(): Promise<void> {
|
|
if (this.upsProtocol === 'nut') {
|
|
await this.connectNut();
|
|
} else {
|
|
await this.connectSnmp();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Connect via NUT protocol
|
|
*/
|
|
private async connectNut(): Promise<void> {
|
|
this.nutProtocol = new NutProtocol(this.address, this.port);
|
|
await this.nutProtocol.connect();
|
|
|
|
// Get device info
|
|
const deviceInfo = await this.nutProtocol.getDeviceInfo(this.upsName);
|
|
this._manufacturer = deviceInfo.manufacturer;
|
|
this._model = deviceInfo.model;
|
|
this.manufacturer = deviceInfo.manufacturer;
|
|
this.model = deviceInfo.model;
|
|
this.serialNumber = deviceInfo.serial;
|
|
|
|
// Get initial status
|
|
await this.refreshStatus();
|
|
}
|
|
|
|
/**
|
|
* Connect via SNMP protocol
|
|
*/
|
|
private async connectSnmp(): Promise<void> {
|
|
this.snmpHandler = new UpsSnmpHandler(this.address, {
|
|
community: this.snmpCommunity,
|
|
port: this.port,
|
|
});
|
|
|
|
// Verify it's a UPS
|
|
const isUps = await this.snmpHandler.isUpsDevice();
|
|
if (!isUps) {
|
|
this.snmpHandler.close();
|
|
this.snmpHandler = null;
|
|
throw new Error('Device does not support UPS-MIB');
|
|
}
|
|
|
|
// Get identity
|
|
const identity = await this.snmpHandler.getIdentity();
|
|
this._manufacturer = identity.manufacturer;
|
|
this._model = identity.model;
|
|
this.manufacturer = identity.manufacturer;
|
|
this.model = identity.model;
|
|
this.firmwareVersion = identity.softwareVersion;
|
|
|
|
// Get initial status
|
|
await this.refreshStatus();
|
|
}
|
|
|
|
/**
|
|
* Disconnect from UPS
|
|
*/
|
|
protected async doDisconnect(): Promise<void> {
|
|
if (this.nutProtocol) {
|
|
await this.nutProtocol.disconnect();
|
|
this.nutProtocol = null;
|
|
}
|
|
if (this.snmpHandler) {
|
|
this.snmpHandler.close();
|
|
this.snmpHandler = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh UPS status
|
|
*/
|
|
public async refreshStatus(): Promise<void> {
|
|
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
|
await this.refreshNutStatus();
|
|
} else if (this.snmpHandler) {
|
|
await this.refreshSnmpStatus();
|
|
} else {
|
|
throw new Error('Not connected');
|
|
}
|
|
|
|
this.emit('status:updated', this.getDeviceInfo());
|
|
}
|
|
|
|
/**
|
|
* Refresh status via NUT
|
|
*/
|
|
private async refreshNutStatus(): Promise<void> {
|
|
if (!this.nutProtocol) return;
|
|
|
|
const status = await this.nutProtocol.getUpsStatus(this.upsName);
|
|
|
|
this._batteryCharge = status.batteryCharge;
|
|
this._batteryRuntime = status.batteryRuntime;
|
|
this._inputVoltage = status.inputVoltage;
|
|
this._outputVoltage = status.outputVoltage;
|
|
this._load = status.load;
|
|
|
|
// Convert NUT status flags to our status
|
|
this._upsStatus = this.nutStatusToUpsStatus(status.status);
|
|
}
|
|
|
|
/**
|
|
* Refresh status via SNMP
|
|
*/
|
|
private async refreshSnmpStatus(): Promise<void> {
|
|
if (!this.snmpHandler) return;
|
|
|
|
const status = await this.snmpHandler.getFullStatus();
|
|
|
|
this._batteryCharge = status.estimatedChargeRemaining;
|
|
this._batteryRuntime = status.estimatedMinutesRemaining * 60; // Convert to seconds
|
|
this._inputVoltage = status.inputVoltage;
|
|
this._outputVoltage = status.outputVoltage;
|
|
this._load = status.outputPercentLoad;
|
|
|
|
// Convert SNMP status to our status
|
|
this._upsStatus = this.snmpStatusToUpsStatus(status.outputSource, status.batteryStatus);
|
|
}
|
|
|
|
/**
|
|
* Convert NUT status flags to TUpsStatus
|
|
*/
|
|
private nutStatusToUpsStatus(flags: TNutStatusFlag[]): TUpsStatus {
|
|
if (flags.includes('OFF')) return 'offline';
|
|
if (flags.includes('LB')) return 'lowbattery';
|
|
if (flags.includes('OB')) return 'onbattery';
|
|
if (flags.includes('BYPASS')) return 'bypass';
|
|
if (flags.includes('CHRG')) return 'charging';
|
|
if (flags.includes('DISCHRG')) return 'discharging';
|
|
if (flags.includes('OL')) return 'online';
|
|
return 'unknown';
|
|
}
|
|
|
|
/**
|
|
* Convert SNMP status to TUpsStatus
|
|
*/
|
|
private snmpStatusToUpsStatus(source: TUpsOutputSource, battery: TUpsBatteryStatus): TUpsStatus {
|
|
if (source === 'none') return 'offline';
|
|
if (source === 'battery') {
|
|
if (battery === 'batteryLow') return 'lowbattery';
|
|
if (battery === 'batteryDepleted') return 'lowbattery';
|
|
return 'onbattery';
|
|
}
|
|
if (source === 'bypass') return 'bypass';
|
|
if (source === 'normal') return 'online';
|
|
if (source === 'booster' || source === 'reducer') return 'online';
|
|
return 'unknown';
|
|
}
|
|
|
|
/**
|
|
* Get battery information
|
|
*/
|
|
public async getBatteryInfo(): Promise<IUpsBatteryInfo> {
|
|
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
|
const vars = await this.nutProtocol.getVariables(this.upsName, [
|
|
NUT_VARIABLES.batteryCharge,
|
|
NUT_VARIABLES.batteryRuntime,
|
|
NUT_VARIABLES.batteryVoltage,
|
|
NUT_VARIABLES.batteryTemperature,
|
|
]);
|
|
|
|
return {
|
|
charge: parseFloat(vars.get(NUT_VARIABLES.batteryCharge) || '0'),
|
|
runtime: parseFloat(vars.get(NUT_VARIABLES.batteryRuntime) || '0'),
|
|
voltage: parseFloat(vars.get(NUT_VARIABLES.batteryVoltage) || '0'),
|
|
temperature: vars.has(NUT_VARIABLES.batteryTemperature)
|
|
? parseFloat(vars.get(NUT_VARIABLES.batteryTemperature)!)
|
|
: undefined,
|
|
status: 'normal',
|
|
};
|
|
} else if (this.snmpHandler) {
|
|
const battery = await this.snmpHandler.getBatteryStatus();
|
|
|
|
const statusMap: Record<TUpsBatteryStatus, IUpsBatteryInfo['status']> = {
|
|
unknown: 'unknown',
|
|
batteryNormal: 'normal',
|
|
batteryLow: 'low',
|
|
batteryDepleted: 'depleted',
|
|
};
|
|
|
|
return {
|
|
charge: battery.estimatedChargeRemaining,
|
|
runtime: battery.estimatedMinutesRemaining * 60,
|
|
voltage: battery.voltage,
|
|
temperature: battery.temperature || undefined,
|
|
status: statusMap[battery.status],
|
|
};
|
|
}
|
|
|
|
throw new Error('Not connected');
|
|
}
|
|
|
|
/**
|
|
* Get power information
|
|
*/
|
|
public async getPowerInfo(): Promise<IUpsPowerInfo> {
|
|
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
|
const vars = await this.nutProtocol.getVariables(this.upsName, [
|
|
NUT_VARIABLES.inputVoltage,
|
|
NUT_VARIABLES.inputFrequency,
|
|
NUT_VARIABLES.outputVoltage,
|
|
NUT_VARIABLES.outputCurrent,
|
|
NUT_VARIABLES.upsLoad,
|
|
]);
|
|
|
|
return {
|
|
inputVoltage: parseFloat(vars.get(NUT_VARIABLES.inputVoltage) || '0'),
|
|
inputFrequency: vars.has(NUT_VARIABLES.inputFrequency)
|
|
? parseFloat(vars.get(NUT_VARIABLES.inputFrequency)!)
|
|
: undefined,
|
|
outputVoltage: parseFloat(vars.get(NUT_VARIABLES.outputVoltage) || '0'),
|
|
outputCurrent: vars.has(NUT_VARIABLES.outputCurrent)
|
|
? parseFloat(vars.get(NUT_VARIABLES.outputCurrent)!)
|
|
: undefined,
|
|
load: parseFloat(vars.get(NUT_VARIABLES.upsLoad) || '0'),
|
|
};
|
|
} else if (this.snmpHandler) {
|
|
const [input, output] = await Promise.all([
|
|
this.snmpHandler.getInputStatus(),
|
|
this.snmpHandler.getOutputStatus(),
|
|
]);
|
|
|
|
return {
|
|
inputVoltage: input.voltage,
|
|
inputFrequency: input.frequency,
|
|
outputVoltage: output.voltage,
|
|
outputFrequency: output.frequency,
|
|
outputCurrent: output.current,
|
|
outputPower: output.power,
|
|
load: output.percentLoad,
|
|
};
|
|
}
|
|
|
|
throw new Error('Not connected');
|
|
}
|
|
|
|
/**
|
|
* Get full status
|
|
*/
|
|
public async getFullStatus(): Promise<IUpsFullStatus> {
|
|
const [battery, power] = await Promise.all([
|
|
this.getBatteryInfo(),
|
|
this.getPowerInfo(),
|
|
]);
|
|
|
|
let secondsOnBattery = 0;
|
|
const alarms: string[] = [];
|
|
|
|
if (this.upsProtocol === 'nut' && this.nutProtocol) {
|
|
const vars = await this.nutProtocol.getVariables(this.upsName, [
|
|
NUT_VARIABLES.upsStatus,
|
|
NUT_VARIABLES.upsAlarm,
|
|
]);
|
|
const alarm = vars.get(NUT_VARIABLES.upsAlarm);
|
|
if (alarm) {
|
|
alarms.push(alarm);
|
|
}
|
|
} else if (this.snmpHandler) {
|
|
const snmpStatus = await this.snmpHandler.getFullStatus();
|
|
secondsOnBattery = snmpStatus.secondsOnBattery;
|
|
if (snmpStatus.alarmsPresent > 0) {
|
|
alarms.push(`${snmpStatus.alarmsPresent} alarm(s) present`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: this._upsStatus,
|
|
battery,
|
|
power,
|
|
alarms,
|
|
secondsOnBattery,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run a UPS command (NUT only)
|
|
*/
|
|
public async runCommand(command: string): Promise<boolean> {
|
|
if (this.upsProtocol !== 'nut' || !this.nutProtocol) {
|
|
throw new Error('Commands only supported via NUT protocol');
|
|
}
|
|
|
|
const result = await this.nutProtocol.runCommand(this.upsName, command);
|
|
this.emit('command:executed', { command, success: result });
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Start battery test
|
|
*/
|
|
public async startBatteryTest(type: 'quick' | 'deep' = 'quick'): Promise<boolean> {
|
|
const command = type === 'deep'
|
|
? NUT_COMMANDS.testBatteryStartDeep
|
|
: NUT_COMMANDS.testBatteryStartQuick;
|
|
return this.runCommand(command);
|
|
}
|
|
|
|
/**
|
|
* Stop battery test
|
|
*/
|
|
public async stopBatteryTest(): Promise<boolean> {
|
|
return this.runCommand(NUT_COMMANDS.testBatteryStop);
|
|
}
|
|
|
|
/**
|
|
* Toggle beeper
|
|
*/
|
|
public async toggleBeeper(): Promise<boolean> {
|
|
return this.runCommand(NUT_COMMANDS.beeperToggle);
|
|
}
|
|
|
|
/**
|
|
* Get device info
|
|
*/
|
|
public getDeviceInfo(): IUpsDeviceInfo {
|
|
return {
|
|
id: this.id,
|
|
name: this.name,
|
|
type: 'ups',
|
|
address: this.address,
|
|
port: this.port,
|
|
status: this.status,
|
|
protocol: this.upsProtocol,
|
|
upsName: this.upsName,
|
|
manufacturer: this._manufacturer,
|
|
model: this._model,
|
|
serialNumber: this.serialNumber,
|
|
firmwareVersion: this.firmwareVersion,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create UPS device from discovery
|
|
*/
|
|
public static fromDiscovery(
|
|
data: {
|
|
id: string;
|
|
name: string;
|
|
address: string;
|
|
port?: number;
|
|
protocol: TUpsProtocol;
|
|
upsName?: string;
|
|
community?: string;
|
|
},
|
|
retryOptions?: IRetryOptions
|
|
): UpsDevice {
|
|
const info: IDeviceInfo = {
|
|
id: data.id,
|
|
name: data.name,
|
|
type: 'ups',
|
|
address: data.address,
|
|
port: data.port ?? (data.protocol === 'nut' ? 3493 : 161),
|
|
status: 'unknown',
|
|
};
|
|
|
|
return new UpsDevice(
|
|
info,
|
|
{
|
|
protocol: data.protocol,
|
|
upsName: data.upsName,
|
|
snmpCommunity: data.community,
|
|
},
|
|
retryOptions
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Probe for UPS device (NUT or SNMP)
|
|
*/
|
|
public static async probe(
|
|
address: string,
|
|
options?: {
|
|
nutPort?: number;
|
|
snmpPort?: number;
|
|
snmpCommunity?: string;
|
|
timeout?: number;
|
|
}
|
|
): Promise<{ protocol: TUpsProtocol; port: number } | null> {
|
|
const nutPort = options?.nutPort ?? 3493;
|
|
const snmpPort = options?.snmpPort ?? 161;
|
|
const community = options?.snmpCommunity ?? 'public';
|
|
|
|
// Try NUT first
|
|
const nutAvailable = await NutProtocol.probe(address, nutPort, options?.timeout);
|
|
if (nutAvailable) {
|
|
return { protocol: 'nut', port: nutPort };
|
|
}
|
|
|
|
// Try SNMP UPS-MIB
|
|
try {
|
|
const handler = new UpsSnmpHandler(address, { community, port: snmpPort, timeout: options?.timeout ?? 3000 });
|
|
const isUps = await handler.isUpsDevice();
|
|
handler.close();
|
|
|
|
if (isUps) {
|
|
return { protocol: 'snmp', port: snmpPort };
|
|
}
|
|
} catch {
|
|
// Ignore SNMP errors
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Re-export types
|
|
export { NUT_COMMANDS, NUT_VARIABLES, type TNutStatusFlag } from './ups.classes.nutprotocol.js';
|
|
export { UPS_SNMP_OIDS, type TUpsBatteryStatus, type TUpsOutputSource } from './ups.classes.upssnmp.js';
|