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

View File

@@ -1,5 +1,17 @@
# Changelog # Changelog
## 2026-02-20 - 5.3.0 - 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
- Add UPSD client (ts/upsd) and ProtocolResolver (ts/protocol) to support protocol-agnostic UPS queries (snmp or upsd).
- Introduce new TProtocol and IUpsdConfig types, wire up Nupst to initialize & expose UPSD client, and route status requests through ProtocolResolver.
- Add 'unreachable' TPowerStatus plus consecutiveFailures and unreachableSince tracking; mark UPS as unreachable after NETWORK.CONSECUTIVE_FAILURE_THRESHOLD failures and suppress shutdown actions while unreachable.
- Implement pause/resume feature: PAUSE.FILE_PATH state file, CLI commands (pause/resume), daemon pause-state polling, auto-resume, and include pause state in HTTP API responses.
- Add ProxmoxAction (ts/actions/proxmox-action.ts) with Proxmox API interaction, configuration options (token, node, timeout, force, insecure) and CLI prompts to configure proxmox actions.
- CLI and UI updates: protocol selection when adding UPS, protocol/host shown in lists, action details column supports proxmox, and status displays include protocol and unreachable state.
- Add migration MigrationV4_1ToV4_2 to set protocol:'snmp' for existing devices and bump config.version to '4.2'.
- Add new constants (NETWORK, UPSD, PAUSE, PROXMOX), update package.json scripts (test/build/lint/format), and wire protocol support across daemon, systemd, http-server, and various handlers.
## 2026-01-29 - 5.2.4 - fix() ## 2026-01-29 - 5.2.4 - fix()
no changes no changes

View File

@@ -34,8 +34,10 @@
"scripts": { "scripts": {
"postinstall": "node scripts/install-binary.js", "postinstall": "node scripts/install-binary.js",
"prepublishOnly": "echo 'Publishing NUPST binaries to npm...'", "prepublishOnly": "echo 'Publishing NUPST binaries to npm...'",
"test": "echo 'Tests are run with Deno: deno task test'", "test": "deno task test",
"build": "echo 'no build needed'" "build": "deno task check",
"lint": "deno task lint",
"format": "deno task fmt"
}, },
"files": [ "files": [
"bin/", "bin/",

View File

@@ -12,8 +12,9 @@
2. **Constants File (`ts/constants.ts`)** 2. **Constants File (`ts/constants.ts`)**
- Centralized all magic numbers (timeouts, intervals, thresholds) - Centralized all magic numbers (timeouts, intervals, thresholds)
- Contains: `TIMING`, `SNMP`, `THRESHOLDS`, `WEBHOOK`, `SCRIPT`, `SHUTDOWN`, `HTTP_SERVER`, `UI` - Contains: `TIMING`, `SNMP`, `THRESHOLDS`, `WEBHOOK`, `SCRIPT`, `SHUTDOWN`, `HTTP_SERVER`, `UI`,
- Used in: `daemon.ts`, `snmp/manager.ts`, `actions/*.ts` `NETWORK`, `UPSD`, `PAUSE`, `PROXMOX`
- Used in: `daemon.ts`, `snmp/manager.ts`, `actions/*.ts`, `upsd/client.ts`
3. **Logger Consistency** 3. **Logger Consistency**
- Replaced all `console.log/console.error` in `snmp/manager.ts` with proper `logger.*` calls - Replaced all `console.log/console.error` in `snmp/manager.ts` with proper `logger.*` calls
@@ -35,13 +36,47 @@
- Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`, - Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`,
`ISnmpUpsStatus` `ISnmpUpsStatus`
## Features Added (February 2026)
### Network Loss Handling
- `TPowerStatus` extended with `'unreachable'` state
- `IUpsStatus` has `consecutiveFailures` and `unreachableSince` tracking
- After `NETWORK.CONSECUTIVE_FAILURE_THRESHOLD` (3) failures, UPS transitions to `unreachable`
- Shutdown action explicitly won't fire on `unreachable` (prevents false shutdowns)
- Recovery is logged when UPS comes back from unreachable
### UPSD/NIS Protocol Support
- New `ts/upsd/` directory with TCP client for NUT (Network UPS Tools) servers
- `ts/protocol/` directory with `ProtocolResolver` for protocol-agnostic status queries
- `IUpsConfig.protocol` field: `'snmp'` (default) or `'upsd'`
- `IUpsConfig.snmp` is now optional (not needed for UPSD devices)
- CLI supports protocol selection during `nupst ups add`
- Config version bumped to `4.2` with migration from `4.1`
### Pause/Resume Command
- File-based signaling via `/etc/nupst/pause` JSON file
- `nupst pause [--duration 30m|2h|1d]` creates pause file
- `nupst resume` deletes pause file
- Daemon polls continue but actions are suppressed while paused
- Auto-resume after duration expires
- HTTP API includes pause state in response
### Proxmox VM Shutdown Action
- New action type `'proxmox'` in `ts/actions/proxmox-action.ts`
- Uses Proxmox REST API with PVEAPIToken authentication
- Shuts down QEMU VMs and LXC containers before host shutdown
- Supports: exclude IDs, configurable timeout, force-stop, TLS skip for self-signed certs
- Should be placed BEFORE shutdown actions in the action chain
## Architecture Notes ## Architecture Notes
- **SNMP Manager**: Uses `INupstAccessor` interface (not direct `Nupst` reference) to avoid circular - **SNMP Manager**: Uses `INupstAccessor` interface (not direct `Nupst` reference) to avoid circular
imports imports
- **Protocol Resolver**: Routes to SNMP or UPSD based on `IUpsConfig.protocol`
- **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input - **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input
- **Constants**: All timing values should be referenced from `ts/constants.ts` - **Constants**: All timing values should be referenced from `ts/constants.ts`
- **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration - **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration
- **Config version**: Currently `4.2`, migrations run automatically
## File Organization ## File Organization
@@ -54,9 +89,21 @@ ts/
│ ├── prompt.ts # Readline utility │ ├── prompt.ts # Readline utility
│ └── shortid.ts # ID generation │ └── shortid.ts # ID generation
├── actions/ ├── actions/
│ ├── base-action.ts # Base action class and interfaces │ ├── base-action.ts # Base action class, IActionConfig, TPowerStatus
│ ├── webhook-action.ts # Includes IWebhookPayload │ ├── webhook-action.ts # Includes IWebhookPayload
│ ├── proxmox-action.ts # Proxmox VM/LXC shutdown
│ └── ... │ └── ...
├── upsd/
│ ├── types.ts # IUpsdConfig
│ ├── client.ts # NupstUpsd TCP client
│ └── index.ts
├── protocol/
│ ├── types.ts # TProtocol = 'snmp' | 'upsd'
│ ├── resolver.ts # ProtocolResolver
│ └── index.ts
├── migrations/
│ ├── migration-runner.ts
│ └── migration-v4.1-to-v4.2.ts # Adds protocol field
└── cli/ └── cli/
└── ... # All handlers use helpers.withPrompt() └── ... # All handlers use helpers.withPrompt()
``` ```

999
readme.md

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/nupst', name: '@serve.zone/nupst',
version: '5.2.4', version: '5.3.0',
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies' description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
} }

View File

@@ -6,7 +6,7 @@
* 2. Threshold violations (battery/runtime cross below configured thresholds) * 2. Threshold violations (battery/runtime cross below configured thresholds)
*/ */
export type TPowerStatus = 'online' | 'onBattery' | 'unknown'; export type TPowerStatus = 'online' | 'onBattery' | 'unknown' | 'unreachable';
/** /**
* Context provided to actions when they execute * Context provided to actions when they execute
@@ -52,7 +52,7 @@ export type TActionTriggerMode =
*/ */
export interface IActionConfig { export interface IActionConfig {
/** Type of action to execute */ /** Type of action to execute */
type: 'shutdown' | 'webhook' | 'script'; type: 'shutdown' | 'webhook' | 'script' | 'proxmox';
// Trigger configuration // Trigger configuration
/** /**
@@ -96,6 +96,26 @@ export interface IActionConfig {
scriptTimeout?: number; scriptTimeout?: number;
/** Only execute script on threshold violation */ /** Only execute script on threshold violation */
scriptOnlyOnThresholdViolation?: boolean; scriptOnlyOnThresholdViolation?: boolean;
// Proxmox action configuration
/** Proxmox API host (default: localhost) */
proxmoxHost?: string;
/** Proxmox API port (default: 8006) */
proxmoxPort?: number;
/** Proxmox node name (default: auto-detect via hostname) */
proxmoxNode?: string;
/** Proxmox API token ID (e.g., 'root@pam!nupst') */
proxmoxTokenId?: string;
/** Proxmox API token secret */
proxmoxTokenSecret?: string;
/** VM/CT IDs to exclude from shutdown */
proxmoxExcludeIds?: number[];
/** Timeout for VM/CT shutdown in seconds (default: 120) */
proxmoxStopTimeout?: number;
/** Force-stop VMs that don't shut down gracefully (default: true) */
proxmoxForceStop?: boolean;
/** Skip TLS verification for self-signed certificates (default: true) */
proxmoxInsecure?: boolean;
} }
/** /**

View File

@@ -10,6 +10,7 @@ import type { Action, IActionConfig, IActionContext } from './base-action.ts';
import { ShutdownAction } from './shutdown-action.ts'; import { ShutdownAction } from './shutdown-action.ts';
import { WebhookAction } from './webhook-action.ts'; import { WebhookAction } from './webhook-action.ts';
import { ScriptAction } from './script-action.ts'; import { ScriptAction } from './script-action.ts';
import { ProxmoxAction } from './proxmox-action.ts';
// Re-export types for convenience // Re-export types for convenience
export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts'; export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts';
@@ -18,6 +19,7 @@ export { Action } from './base-action.ts';
export { ShutdownAction } from './shutdown-action.ts'; export { ShutdownAction } from './shutdown-action.ts';
export { WebhookAction } from './webhook-action.ts'; export { WebhookAction } from './webhook-action.ts';
export { ScriptAction } from './script-action.ts'; export { ScriptAction } from './script-action.ts';
export { ProxmoxAction } from './proxmox-action.ts';
/** /**
* ActionManager - Coordinates action creation and execution * ActionManager - Coordinates action creation and execution
@@ -40,6 +42,8 @@ export class ActionManager {
return new WebhookAction(config); return new WebhookAction(config);
case 'script': case 'script':
return new ScriptAction(config); return new ScriptAction(config);
case 'proxmox':
return new ProxmoxAction(config);
default: default:
throw new Error(`Unknown action type: ${(config as IActionConfig).type}`); throw new Error(`Unknown action type: ${(config as IActionConfig).type}`);
} }

View File

@@ -0,0 +1,352 @@
import * as os from 'node:os';
import { Action, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts';
import { PROXMOX, UI } from '../constants.ts';
/**
* ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers
*
* Uses the Proxmox REST API via HTTPS with API token authentication.
* Shuts down running QEMU VMs and LXC containers, waits for completion,
* and optionally force-stops any that don't respond.
*
* This action should be placed BEFORE shutdown actions in the action chain
* so that VMs are stopped before the host is shut down.
*/
export class ProxmoxAction extends Action {
readonly type = 'proxmox';
/**
* Execute the Proxmox shutdown action
*/
async execute(context: IActionContext): Promise<void> {
if (!this.shouldExecute(context)) {
logger.info(
`Proxmox action skipped (trigger mode: ${
this.config.triggerMode || 'powerChangesAndThresholds'
})`,
);
return;
}
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
const node = this.config.proxmoxNode || os.hostname();
const tokenId = this.config.proxmoxTokenId;
const tokenSecret = this.config.proxmoxTokenSecret;
const excludeIds = new Set(this.config.proxmoxExcludeIds || []);
const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) * 1000;
const forceStop = this.config.proxmoxForceStop !== false; // default true
const insecure = this.config.proxmoxInsecure !== false; // default true
if (!tokenId || !tokenSecret) {
logger.error('Proxmox API token ID and secret are required');
return;
}
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
const headers: Record<string, string> = {
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`,
};
logger.log('');
logger.logBoxTitle('Proxmox VM Shutdown', UI.WIDE_BOX_WIDTH, 'warning');
logger.logBoxLine(`Node: ${node}`);
logger.logBoxLine(`API: ${host}:${port}`);
logger.logBoxLine(`UPS: ${context.upsName} (${context.powerStatus})`);
logger.logBoxLine(`Trigger: ${context.triggerReason}`);
if (excludeIds.size > 0) {
logger.logBoxLine(`Excluded IDs: ${[...excludeIds].join(', ')}`);
}
logger.logBoxEnd();
logger.log('');
try {
// Collect running VMs and CTs
const runningVMs = await this.getRunningVMs(baseUrl, node, headers, insecure);
const runningCTs = await this.getRunningCTs(baseUrl, node, headers, insecure);
// Filter out excluded IDs
const vmsToStop = runningVMs.filter((vm) => !excludeIds.has(vm.vmid));
const ctsToStop = runningCTs.filter((ct) => !excludeIds.has(ct.vmid));
const totalToStop = vmsToStop.length + ctsToStop.length;
if (totalToStop === 0) {
logger.info('No running VMs or containers to shut down');
return;
}
logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`);
// Send shutdown commands to all VMs and CTs
for (const vm of vmsToStop) {
await this.shutdownVM(baseUrl, node, vm.vmid, headers, insecure);
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
}
for (const ct of ctsToStop) {
await this.shutdownCT(baseUrl, node, ct.vmid, headers, insecure);
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
}
// Poll until all stopped or timeout
const allIds = [
...vmsToStop.map((vm) => ({ type: 'qemu' as const, vmid: vm.vmid, name: vm.name })),
...ctsToStop.map((ct) => ({ type: 'lxc' as const, vmid: ct.vmid, name: ct.name })),
];
const remaining = await this.waitForShutdown(
baseUrl,
node,
allIds,
headers,
insecure,
stopTimeout,
);
if (remaining.length > 0 && forceStop) {
logger.warn(`${remaining.length} VMs/CTs didn't shut down gracefully, force-stopping...`);
for (const item of remaining) {
try {
if (item.type === 'qemu') {
await this.stopVM(baseUrl, node, item.vmid, headers, insecure);
} else {
await this.stopCT(baseUrl, node, item.vmid, headers, insecure);
}
logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`);
} catch (error) {
logger.error(
` Failed to force-stop ${item.type} ${item.vmid}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
} else if (remaining.length > 0) {
logger.warn(`${remaining.length} VMs/CTs still running (force-stop disabled)`);
}
logger.success('Proxmox shutdown sequence completed');
} catch (error) {
logger.error(
`Proxmox action failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Make an API request to the Proxmox server
*/
private async apiRequest(
url: string,
method: string,
headers: Record<string, string>,
insecure: boolean,
): Promise<unknown> {
const fetchOptions: RequestInit = {
method,
headers,
};
// Use NODE_TLS_REJECT_UNAUTHORIZED for insecure mode (self-signed certs)
if (insecure) {
// deno-lint-ignore no-explicit-any
(globalThis as any).process?.env && ((globalThis as any).process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0');
}
try {
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const body = await response.text();
throw new Error(`Proxmox API error ${response.status}: ${body}`);
}
return await response.json();
} finally {
// Restore TLS verification
if (insecure) {
// deno-lint-ignore no-explicit-any
(globalThis as any).process?.env && ((globalThis as any).process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1');
}
}
}
/**
* Get list of running QEMU VMs
*/
private async getRunningVMs(
baseUrl: string,
node: string,
headers: Record<string, string>,
insecure: boolean,
): Promise<Array<{ vmid: number; name: string }>> {
try {
const response = await this.apiRequest(
`${baseUrl}/nodes/${node}/qemu`,
'GET',
headers,
insecure,
) as { data: Array<{ vmid: number; name: string; status: string }> };
return (response.data || [])
.filter((vm) => vm.status === 'running')
.map((vm) => ({ vmid: vm.vmid, name: vm.name || '' }));
} catch (error) {
logger.error(
`Failed to list VMs: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
}
}
/**
* Get list of running LXC containers
*/
private async getRunningCTs(
baseUrl: string,
node: string,
headers: Record<string, string>,
insecure: boolean,
): Promise<Array<{ vmid: number; name: string }>> {
try {
const response = await this.apiRequest(
`${baseUrl}/nodes/${node}/lxc`,
'GET',
headers,
insecure,
) as { data: Array<{ vmid: number; name: string; status: string }> };
return (response.data || [])
.filter((ct) => ct.status === 'running')
.map((ct) => ({ vmid: ct.vmid, name: ct.name || '' }));
} catch (error) {
logger.error(
`Failed to list CTs: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
}
}
/**
* Send graceful shutdown to a QEMU VM
*/
private async shutdownVM(
baseUrl: string,
node: string,
vmid: number,
headers: Record<string, string>,
insecure: boolean,
): Promise<void> {
await this.apiRequest(
`${baseUrl}/nodes/${node}/qemu/${vmid}/status/shutdown`,
'POST',
headers,
insecure,
);
}
/**
* Send graceful shutdown to an LXC container
*/
private async shutdownCT(
baseUrl: string,
node: string,
vmid: number,
headers: Record<string, string>,
insecure: boolean,
): Promise<void> {
await this.apiRequest(
`${baseUrl}/nodes/${node}/lxc/${vmid}/status/shutdown`,
'POST',
headers,
insecure,
);
}
/**
* Force-stop a QEMU VM
*/
private async stopVM(
baseUrl: string,
node: string,
vmid: number,
headers: Record<string, string>,
insecure: boolean,
): Promise<void> {
await this.apiRequest(
`${baseUrl}/nodes/${node}/qemu/${vmid}/status/stop`,
'POST',
headers,
insecure,
);
}
/**
* Force-stop an LXC container
*/
private async stopCT(
baseUrl: string,
node: string,
vmid: number,
headers: Record<string, string>,
insecure: boolean,
): Promise<void> {
await this.apiRequest(
`${baseUrl}/nodes/${node}/lxc/${vmid}/status/stop`,
'POST',
headers,
insecure,
);
}
/**
* Wait for VMs/CTs to shut down, return any that are still running after timeout
*/
private async waitForShutdown(
baseUrl: string,
node: string,
items: Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>,
headers: Record<string, string>,
insecure: boolean,
timeout: number,
): Promise<Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>> {
const startTime = Date.now();
let remaining = [...items];
while (remaining.length > 0 && (Date.now() - startTime) < timeout) {
// Wait before polling
await new Promise((resolve) => setTimeout(resolve, PROXMOX.STATUS_POLL_INTERVAL_SECONDS * 1000));
// Check which are still running
const stillRunning: typeof remaining = [];
for (const item of remaining) {
try {
const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`;
const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as {
data: { status: string };
};
if (response.data?.status === 'running') {
stillRunning.push(item);
} else {
logger.dim(` ${item.type} ${item.vmid} (${item.name}) stopped`);
}
} catch (_error) {
// If we can't check status, assume it might still be running
stillRunning.push(item);
}
}
remaining = stillRunning;
if (remaining.length > 0) {
const elapsed = Math.round((Date.now() - startTime) / 1000);
logger.dim(` Waiting... ${remaining.length} still running (${elapsed}s elapsed)`);
}
}
return remaining;
}
}

View File

@@ -34,10 +34,17 @@ export class ShutdownAction extends Action {
// CRITICAL SAFETY CHECK: Shutdown should NEVER trigger unless UPS is on battery // CRITICAL SAFETY CHECK: Shutdown should NEVER trigger unless UPS is on battery
// A low battery while on grid power is not an emergency (the battery is charging) // A low battery while on grid power is not an emergency (the battery is charging)
// When UPS is unreachable, we don't know the actual state - don't trigger false shutdown
if (context.powerStatus !== 'onBattery') { if (context.powerStatus !== 'onBattery') {
logger.info( if (context.powerStatus === 'unreachable') {
`Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`, logger.info(
); `Shutdown action skipped: UPS is unreachable (communication failure, actual state unknown)`,
);
} else {
logger.info(
`Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`,
);
}
return false; return false;
} }

View File

@@ -14,7 +14,7 @@ export interface IWebhookPayload {
/** UPS name */ /** UPS name */
upsName: string; upsName: string;
/** Current power status */ /** Current power status */
powerStatus: 'online' | 'onBattery' | 'unknown'; powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
/** Current battery capacity percentage */ /** Current battery capacity percentage */
batteryCapacity: number; batteryCapacity: number;
/** Current battery runtime in minutes */ /** Current battery runtime in minutes */

View File

@@ -26,8 +26,9 @@ export class NupstCli {
const debugOptions = this.extractDebugOptions(args); const debugOptions = this.extractDebugOptions(args);
if (debugOptions.debugMode) { if (debugOptions.debugMode) {
logger.log('Debug mode enabled'); logger.log('Debug mode enabled');
// Enable debug mode in the SNMP client // Enable debug mode in both protocol clients
this.nupst.getSnmp().enableDebug(); this.nupst.getSnmp().enableDebug();
this.nupst.getUpsd().enableDebug();
} }
// Check for version flag // Check for version flag
@@ -259,6 +260,12 @@ export class NupstCli {
// Handle top-level commands // Handle top-level commands
switch (command) { switch (command) {
case 'pause':
await serviceHandler.pause(commandArgs);
break;
case 'resume':
await serviceHandler.resume();
break;
case 'update': case 'update':
await serviceHandler.update(); await serviceHandler.update();
break; break;
@@ -351,18 +358,32 @@ export class NupstCli {
// UPS Devices Table // UPS Devices Table
if (config.upsDevices.length > 0) { if (config.upsDevices.length > 0) {
const upsRows = config.upsDevices.map((ups) => ({ const upsRows = config.upsDevices.map((ups) => {
name: ups.name, const protocol = ups.protocol || 'snmp';
id: theme.dim(ups.id), let host = 'N/A';
host: `${ups.snmp.host}:${ups.snmp.port}`, let model = '';
model: ups.snmp.upsModel || 'cyberpower', if (protocol === 'upsd' && ups.upsd) {
actions: `${(ups.actions || []).length} configured`, host = `${ups.upsd.host}:${ups.upsd.port}`;
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'), model = `NUT:${ups.upsd.upsName}`;
})); } else if (ups.snmp) {
host = `${ups.snmp.host}:${ups.snmp.port}`;
model = ups.snmp.upsModel || 'cyberpower';
}
return {
name: ups.name,
id: theme.dim(ups.id),
protocol: protocol.toUpperCase(),
host,
model,
actions: `${(ups.actions || []).length} configured`,
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
};
});
const upsColumns: ITableColumn[] = [ const upsColumns: ITableColumn[] = [
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight }, { header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left' }, { header: 'ID', key: 'id', align: 'left' },
{ header: 'Protocol', key: 'protocol', align: 'left' },
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info }, { header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
{ header: 'Model', key: 'model', align: 'left' }, { header: 'Model', key: 'model', align: 'left' },
{ header: 'Actions', key: 'actions', align: 'left' }, { header: 'Actions', key: 'actions', align: 'left' },
@@ -534,6 +555,8 @@ export class NupstCli {
this.printCommand('action <subcommand>', 'Manage UPS actions'); this.printCommand('action <subcommand>', 'Manage UPS actions');
this.printCommand('feature <subcommand>', 'Manage optional features'); this.printCommand('feature <subcommand>', 'Manage optional features');
this.printCommand('config [show]', 'Display current configuration'); this.printCommand('config [show]', 'Display current configuration');
this.printCommand('pause [--duration <time>]', 'Pause action monitoring');
this.printCommand('resume', 'Resume action monitoring');
this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)')); this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)'));
this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)')); this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)'));
this.printCommand('help, --help, -h', 'Show this help message'); this.printCommand('help, --help, -h', 'Show this help message');

View File

@@ -346,17 +346,30 @@ export class ActionHandler {
{ header: 'Battery', key: 'battery', align: 'right' }, { header: 'Battery', key: 'battery', align: 'right' },
{ header: 'Runtime', key: 'runtime', align: 'right' }, { header: 'Runtime', key: 'runtime', align: 'right' },
{ header: 'Trigger Mode', key: 'triggerMode', align: 'left' }, { header: 'Trigger Mode', key: 'triggerMode', align: 'left' },
{ header: 'Delay', key: 'delay', align: 'right' }, { header: 'Details', key: 'details', align: 'left' },
]; ];
const rows = target.actions.map((action, index) => ({ const rows = target.actions.map((action, index) => {
index: theme.dim(index.toString()), let details = `${action.shutdownDelay || 5}s delay`;
type: theme.highlight(action.type), if (action.type === 'proxmox') {
battery: action.thresholds ? `${action.thresholds.battery}%` : theme.dim('N/A'), const host = action.proxmoxHost || 'localhost';
runtime: action.thresholds ? `${action.thresholds.runtime}min` : theme.dim('N/A'), const port = action.proxmoxPort || 8006;
triggerMode: theme.dim(action.triggerMode || 'onlyThresholds'), details = `${host}:${port}`;
delay: `${action.shutdownDelay || 5}s`, } else if (action.type === 'webhook') {
})); details = action.webhookUrl || theme.dim('N/A');
} else if (action.type === 'script') {
details = action.scriptPath || theme.dim('N/A');
}
return {
index: theme.dim(index.toString()),
type: theme.highlight(action.type),
battery: action.thresholds ? `${action.thresholds.battery}%` : theme.dim('N/A'),
runtime: action.thresholds ? `${action.thresholds.runtime}min` : theme.dim('N/A'),
triggerMode: theme.dim(action.triggerMode || 'onlyThresholds'),
details,
};
});
logger.logTable(columns, rows); logger.logTable(columns, rows);
logger.log(''); logger.log('');

View File

@@ -1,7 +1,12 @@
import process from 'node:process'; import process from 'node:process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { Nupst } from '../nupst.ts'; import { Nupst } from '../nupst.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
import { theme } from '../colors.ts';
import { PAUSE } from '../constants.ts';
import type { IPauseState } from '../daemon.ts';
import * as helpers from '../helpers/index.ts'; import * as helpers from '../helpers/index.ts';
/** /**
@@ -104,6 +109,125 @@ export class ServiceHandler {
await this.nupst.getSystemd().getStatus(debugOptions.debugMode); await this.nupst.getSystemd().getStatus(debugOptions.debugMode);
} }
/**
* Pause action monitoring
* @param args Command arguments (e.g., ['--duration', '30m'])
*/
public async pause(args: string[]): Promise<void> {
try {
// Parse --duration argument
let resumeAt: number | null = null;
const durationIdx = args.indexOf('--duration');
if (durationIdx !== -1 && args[durationIdx + 1]) {
const durationStr = args[durationIdx + 1];
const durationMs = this.parseDuration(durationStr);
if (durationMs === null) {
logger.error(`Invalid duration format: ${durationStr}`);
logger.dim(' Valid formats: 30m, 2h, 1d (minutes, hours, days)');
return;
}
if (durationMs > PAUSE.MAX_DURATION_MS) {
logger.error(`Duration exceeds maximum of 24 hours`);
return;
}
resumeAt = Date.now() + durationMs;
}
// Check if already paused
if (fs.existsSync(PAUSE.FILE_PATH)) {
logger.warn('Monitoring is already paused');
try {
const data = fs.readFileSync(PAUSE.FILE_PATH, 'utf8');
const state = JSON.parse(data) as IPauseState;
logger.dim(` Paused at: ${new Date(state.pausedAt).toISOString()}`);
if (state.resumeAt) {
const remaining = Math.round((state.resumeAt - Date.now()) / 1000);
logger.dim(` Auto-resume in: ${remaining > 0 ? remaining : 0} seconds`);
}
} catch (_e) {
// Ignore parse errors
}
logger.dim(' Run "nupst resume" to resume monitoring');
return;
}
// Create pause state
const pauseState: IPauseState = {
pausedAt: Date.now(),
pausedBy: 'cli',
resumeAt,
};
// Ensure config directory exists
const pauseDir = path.dirname(PAUSE.FILE_PATH);
if (!fs.existsSync(pauseDir)) {
fs.mkdirSync(pauseDir, { recursive: true });
}
fs.writeFileSync(PAUSE.FILE_PATH, JSON.stringify(pauseState, null, 2));
logger.log('');
logger.logBoxTitle('Monitoring Paused', 45, 'warning');
logger.logBoxLine('UPS polling continues but actions are suppressed');
if (resumeAt) {
const durationStr = args[args.indexOf('--duration') + 1];
logger.logBoxLine(`Auto-resume after: ${durationStr}`);
logger.logBoxLine(`Resume at: ${new Date(resumeAt).toISOString()}`);
} else {
logger.logBoxLine('Duration: Indefinite');
logger.logBoxLine('Run "nupst resume" to resume');
}
logger.logBoxEnd();
logger.log('');
} catch (error) {
logger.error(
`Failed to pause: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Resume action monitoring
*/
public async resume(): Promise<void> {
try {
if (!fs.existsSync(PAUSE.FILE_PATH)) {
logger.info('Monitoring is not paused');
return;
}
fs.unlinkSync(PAUSE.FILE_PATH);
logger.log('');
logger.logBoxTitle('Monitoring Resumed', 45, 'success');
logger.logBoxLine('Action monitoring has been resumed');
logger.logBoxEnd();
logger.log('');
} catch (error) {
logger.error(
`Failed to resume: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Parse a duration string like '30m', '2h', '1d' into milliseconds
*/
private parseDuration(duration: string): number | null {
const match = duration.match(/^(\d+)\s*(m|h|d)$/i);
if (!match) return null;
const value = parseInt(match[1], 10);
const unit = match[2].toLowerCase();
switch (unit) {
case 'm': return value * 60 * 1000;
case 'h': return value * 60 * 60 * 1000;
case 'd': return value * 24 * 60 * 60 * 1000;
default: return null;
}
}
/** /**
* Disable the service (requires root) * Disable the service (requires root)
*/ */

View File

@@ -5,8 +5,11 @@ import { type ITableColumn, logger } from '../logger.ts';
import { theme } from '../colors.ts'; import { theme } from '../colors.ts';
import * as helpers from '../helpers/index.ts'; import * as helpers from '../helpers/index.ts';
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus, TUpsModel } from '../snmp/types.ts'; import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus, TUpsModel } from '../snmp/types.ts';
import type { IUpsdConfig } from '../upsd/types.ts';
import type { TProtocol } from '../protocol/types.ts';
import type { INupstConfig, IUpsConfig } from '../daemon.ts'; import type { INupstConfig, IUpsConfig } from '../daemon.ts';
import type { IActionConfig } from '../actions/base-action.ts'; import type { IActionConfig } from '../actions/base-action.ts';
import { UPSD } from '../constants.ts';
/** /**
* Thresholds configuration for CLI display * Thresholds configuration for CLI display
@@ -89,31 +92,46 @@ export class UpsHandler {
const upsId = helpers.shortId(); const upsId = helpers.shortId();
const name = await prompt('UPS Name: '); const name = await prompt('UPS Name: ');
// Select protocol
logger.log('');
logger.info('Communication Protocol:');
logger.dim(' 1) SNMP (network UPS with SNMP agent)');
logger.dim(' 2) UPSD/NIS (local NUT server, e.g. USB-connected UPS)');
const protocolInput = await prompt('Select protocol [1]: ');
const protocolChoice = parseInt(protocolInput, 10) || 1;
const protocol: TProtocol = protocolChoice === 2 ? 'upsd' : 'snmp';
// Create a new UPS configuration object with defaults // Create a new UPS configuration object with defaults
const newUps = { const newUps: Record<string, unknown> & { id: string; name: string; groups: string[]; actions: IActionConfig[]; protocol: TProtocol; snmp?: ISnmpConfig; upsd?: IUpsdConfig } = {
id: upsId, id: upsId,
name: name || `UPS-${upsId}`, name: name || `UPS-${upsId}`,
snmp: { protocol,
groups: [],
actions: [],
};
if (protocol === 'snmp') {
newUps.snmp = {
host: '127.0.0.1', host: '127.0.0.1',
port: 161, port: 161,
community: 'public', community: 'public',
version: 1, version: 1,
timeout: 5000, timeout: 5000,
upsModel: 'cyberpower' as TUpsModel, upsModel: 'cyberpower' as TUpsModel,
}, };
thresholds: { // Gather SNMP settings
battery: 60, await this.gatherSnmpSettings(newUps.snmp, prompt);
runtime: 20, // Gather UPS model settings
}, await this.gatherUpsModelSettings(newUps.snmp, prompt);
groups: [], } else {
actions: [], newUps.upsd = {
}; host: '127.0.0.1',
port: UPSD.DEFAULT_PORT,
// Gather SNMP settings upsName: UPSD.DEFAULT_UPS_NAME,
await this.gatherSnmpSettings(newUps.snmp, prompt); timeout: UPSD.DEFAULT_TIMEOUT_MS,
};
// Gather UPS model settings await this.gatherUpsdSettings(newUps.upsd, prompt);
await this.gatherUpsModelSettings(newUps.snmp, prompt); }
// Get access to GroupHandler for group assignments // Get access to GroupHandler for group assignments
const groupHandler = this.nupst.getGroupHandler(); const groupHandler = this.nupst.getGroupHandler();
@@ -132,10 +150,14 @@ export class UpsHandler {
// Save the configuration // Save the configuration
await this.nupst.getDaemon().saveConfig(config as INupstConfig); await this.nupst.getDaemon().saveConfig(config as INupstConfig);
this.displayUpsConfigSummary(newUps); this.displayUpsConfigSummary(newUps as unknown as IUpsConfig);
// Test the connection if requested // Test the connection if requested
await this.optionallyTestConnection(newUps.snmp, prompt); if (protocol === 'snmp' && newUps.snmp) {
await this.optionallyTestConnection(newUps.snmp as ISnmpConfig, prompt);
} else if (protocol === 'upsd' && newUps.upsd) {
await this.optionallyTestUpsdConnection(newUps.upsd, prompt);
}
// Check if service is running and restart it if needed // Check if service is running and restart it if needed
await this.restartServiceIfRunning(); await this.restartServiceIfRunning();
@@ -232,11 +254,51 @@ export class UpsHandler {
upsToEdit.name = newName; upsToEdit.name = newName;
} }
// Edit SNMP settings // Show current protocol and allow changing
await this.gatherSnmpSettings(upsToEdit.snmp, prompt); const currentProtocol = upsToEdit.protocol || 'snmp';
logger.log('');
logger.info(`Current Protocol: ${currentProtocol.toUpperCase()}`);
logger.dim(' 1) SNMP (network UPS with SNMP agent)');
logger.dim(' 2) UPSD/NIS (local NUT server, e.g. USB-connected UPS)');
const protocolInput = await prompt(`Select protocol [${currentProtocol === 'upsd' ? '2' : '1'}]: `);
const protocolChoice = parseInt(protocolInput, 10);
if (protocolChoice === 2) {
upsToEdit.protocol = 'upsd';
} else if (protocolChoice === 1) {
upsToEdit.protocol = 'snmp';
}
// else keep current
// Edit UPS model settings const editProtocol = upsToEdit.protocol || 'snmp';
await this.gatherUpsModelSettings(upsToEdit.snmp, prompt);
if (editProtocol === 'snmp') {
// Initialize SNMP config if switching from UPSD
if (!upsToEdit.snmp) {
upsToEdit.snmp = {
host: '127.0.0.1',
port: 161,
community: 'public',
version: 1,
timeout: 5000,
upsModel: 'cyberpower' as TUpsModel,
};
}
// Edit SNMP settings
await this.gatherSnmpSettings(upsToEdit.snmp, prompt);
// Edit UPS model settings
await this.gatherUpsModelSettings(upsToEdit.snmp, prompt);
} else {
// Initialize UPSD config if switching from SNMP
if (!upsToEdit.upsd) {
upsToEdit.upsd = {
host: '127.0.0.1',
port: UPSD.DEFAULT_PORT,
upsName: UPSD.DEFAULT_UPS_NAME,
timeout: UPSD.DEFAULT_TIMEOUT_MS,
};
}
await this.gatherUpsdSettings(upsToEdit.upsd, prompt);
}
// Get access to GroupHandler for group assignments // Get access to GroupHandler for group assignments
const groupHandler = this.nupst.getGroupHandler(); const groupHandler = this.nupst.getGroupHandler();
@@ -260,7 +322,11 @@ export class UpsHandler {
this.displayUpsConfigSummary(upsToEdit); this.displayUpsConfigSummary(upsToEdit);
// Test the connection if requested // Test the connection if requested
await this.optionallyTestConnection(upsToEdit.snmp, prompt); if (editProtocol === 'snmp' && upsToEdit.snmp) {
await this.optionallyTestConnection(upsToEdit.snmp, prompt);
} else if (editProtocol === 'upsd' && upsToEdit.upsd) {
await this.optionallyTestUpsdConnection(upsToEdit.upsd, prompt);
}
// Check if service is running and restart it if needed // Check if service is running and restart it if needed
await this.restartServiceIfRunning(); await this.restartServiceIfRunning();
@@ -397,17 +463,31 @@ export class UpsHandler {
} }
// Prepare table data // Prepare table data
const rows = config.upsDevices.map((ups) => ({ const rows = config.upsDevices.map((ups) => {
id: ups.id, const protocol = ups.protocol || 'snmp';
name: ups.name || '', let host = 'N/A';
host: `${ups.snmp.host}:${ups.snmp.port}`, let model = '';
model: ups.snmp.upsModel || 'cyberpower', if (protocol === 'upsd' && ups.upsd) {
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'), host = `${ups.upsd.host}:${ups.upsd.port}`;
})); model = `NUT:${ups.upsd.upsName}`;
} else if (ups.snmp) {
host = `${ups.snmp.host}:${ups.snmp.port}`;
model = ups.snmp.upsModel || 'cyberpower';
}
return {
id: ups.id,
name: ups.name || '',
protocol: protocol.toUpperCase(),
host,
model,
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
};
});
const columns: ITableColumn[] = [ const columns: ITableColumn[] = [
{ header: 'ID', key: 'id', align: 'left', color: theme.highlight }, { header: 'ID', key: 'id', align: 'left', color: theme.highlight },
{ header: 'Name', key: 'name', align: 'left' }, { header: 'Name', key: 'name', align: 'left' },
{ header: 'Protocol', key: 'protocol', align: 'left' },
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info }, { header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
{ header: 'Model', key: 'model', align: 'left' }, { header: 'Model', key: 'model', align: 'left' },
{ header: 'Groups', key: 'groups', align: 'left' }, { header: 'Groups', key: 'groups', align: 'left' },
@@ -482,58 +562,71 @@ export class UpsHandler {
// Type guard: IUpsConfig has 'id' and 'name' at root level, INupstConfig doesn't // Type guard: IUpsConfig has 'id' and 'name' at root level, INupstConfig doesn't
const isUpsConfig = 'id' in config && 'name' in config; const isUpsConfig = 'id' in config && 'name' in config;
// Get SNMP config and other values based on config type
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
? (config as IUpsConfig).snmp
: (config as INupstConfig).snmp;
const checkInterval = isUpsConfig ? 30000 : (config as INupstConfig).checkInterval || 30000; const checkInterval = isUpsConfig ? 30000 : (config as INupstConfig).checkInterval || 30000;
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS'; const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default'; const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
const protocol = isUpsConfig ? ((config as IUpsConfig).protocol || 'snmp') : 'snmp';
const boxWidth = 45; const boxWidth = 45;
logger.logBoxTitle(`Testing Configuration: ${upsName}`, boxWidth); logger.logBoxTitle(`Testing Configuration: ${upsName}`, boxWidth);
logger.logBoxLine(`UPS ID: ${upsId}`); logger.logBoxLine(`UPS ID: ${upsId}`);
logger.logBoxLine(`Protocol: ${protocol.toUpperCase()}`);
if (!snmpConfig) { if (protocol === 'upsd' && isUpsConfig && (config as IUpsConfig).upsd) {
logger.logBoxLine('SNMP Settings: Not configured'); const upsdConfig = (config as IUpsConfig).upsd!;
logger.logBoxEnd(); logger.logBoxLine('UPSD/NIS Settings:');
return; logger.logBoxLine(` Host: ${upsdConfig.host}`);
} logger.logBoxLine(` Port: ${upsdConfig.port}`);
logger.logBoxLine(` UPS Name: ${upsdConfig.upsName}`);
logger.logBoxLine(` Timeout: ${upsdConfig.timeout / 1000} seconds`);
if (upsdConfig.username) {
logger.logBoxLine(` Auth: ${upsdConfig.username}`);
}
} else {
// SNMP display
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
? (config as IUpsConfig).snmp
: (config as INupstConfig).snmp;
logger.logBoxLine('SNMP Settings:'); if (!snmpConfig) {
logger.logBoxLine(` Host: ${snmpConfig.host}`); logger.logBoxLine('SNMP Settings: Not configured');
logger.logBoxLine(` Port: ${snmpConfig.port}`); logger.logBoxEnd();
logger.logBoxLine(` Version: ${snmpConfig.version}`); return;
logger.logBoxLine(` UPS Model: ${snmpConfig.upsModel || 'cyberpower'}`);
if (snmpConfig.version === 1 || snmpConfig.version === 2) {
logger.logBoxLine(` Community: ${snmpConfig.community}`);
} else if (snmpConfig.version === 3) {
logger.logBoxLine(` Security Level: ${snmpConfig.securityLevel}`);
logger.logBoxLine(` Username: ${snmpConfig.username}`);
// Show auth and privacy details based on security level
if (snmpConfig.securityLevel === 'authNoPriv' || snmpConfig.securityLevel === 'authPriv') {
logger.logBoxLine(` Auth Protocol: ${snmpConfig.authProtocol || 'None'}`);
} }
if (snmpConfig.securityLevel === 'authPriv') { logger.logBoxLine('SNMP Settings:');
logger.logBoxLine(` Privacy Protocol: ${snmpConfig.privProtocol || 'None'}`); logger.logBoxLine(` Host: ${snmpConfig.host}`);
logger.logBoxLine(` Port: ${snmpConfig.port}`);
logger.logBoxLine(` Version: ${snmpConfig.version}`);
logger.logBoxLine(` UPS Model: ${snmpConfig.upsModel || 'cyberpower'}`);
if (snmpConfig.version === 1 || snmpConfig.version === 2) {
logger.logBoxLine(` Community: ${snmpConfig.community}`);
} else if (snmpConfig.version === 3) {
logger.logBoxLine(` Security Level: ${snmpConfig.securityLevel}`);
logger.logBoxLine(` Username: ${snmpConfig.username}`);
if (snmpConfig.securityLevel === 'authNoPriv' || snmpConfig.securityLevel === 'authPriv') {
logger.logBoxLine(` Auth Protocol: ${snmpConfig.authProtocol || 'None'}`);
}
if (snmpConfig.securityLevel === 'authPriv') {
logger.logBoxLine(` Privacy Protocol: ${snmpConfig.privProtocol || 'None'}`);
}
logger.logBoxLine(` Timeout: ${snmpConfig.timeout / 1000} seconds`);
} }
// Show timeout value if (snmpConfig.upsModel === 'custom' && snmpConfig.customOIDs) {
logger.logBoxLine(` Timeout: ${snmpConfig.timeout / 1000} seconds`); logger.logBoxLine('Custom OIDs:');
logger.logBoxLine(` Power Status: ${snmpConfig.customOIDs.POWER_STATUS || 'Not set'}`);
logger.logBoxLine(
` Battery Capacity: ${snmpConfig.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
);
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
}
} }
// Show OIDs if custom model is selected
if (snmpConfig.upsModel === 'custom' && snmpConfig.customOIDs) {
logger.logBoxLine('Custom OIDs:');
logger.logBoxLine(` Power Status: ${snmpConfig.customOIDs.POWER_STATUS || 'Not set'}`);
logger.logBoxLine(
` Battery Capacity: ${snmpConfig.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
);
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
}
// Show group assignments if this is a UPS config // Show group assignments if this is a UPS config
if (isUpsConfig) { if (isUpsConfig) {
const groups = (config as IUpsConfig).groups; const groups = (config as IUpsConfig).groups;
@@ -555,25 +648,36 @@ export class UpsHandler {
const isUpsConfig = 'id' in config && 'name' in config; const isUpsConfig = 'id' in config && 'name' in config;
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default'; const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS'; const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
logger.log(`\nTesting connection to UPS: ${upsName} (${upsId})...`); const protocol = isUpsConfig ? ((config as IUpsConfig).protocol || 'snmp') : 'snmp';
logger.log(`\nTesting connection to UPS: ${upsName} (${upsId}) via ${protocol.toUpperCase()}...`);
try { try {
// Get SNMP config based on config type let status: ISnmpUpsStatus;
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
? (config as IUpsConfig).snmp
: (config as INupstConfig).snmp;
if (!snmpConfig) { if (protocol === 'upsd' && isUpsConfig && (config as IUpsConfig).upsd) {
throw new Error('SNMP configuration not found'); const upsdConfig = (config as IUpsConfig).upsd!;
const testConfig = {
...upsdConfig,
timeout: Math.min(upsdConfig.timeout, 10000),
};
status = await this.nupst.getUpsd().getUpsStatus(testConfig);
} else {
// SNMP protocol
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
? (config as IUpsConfig).snmp
: (config as INupstConfig).snmp;
if (!snmpConfig) {
throw new Error('SNMP configuration not found');
}
const testConfig: ISnmpConfig = {
...snmpConfig,
timeout: Math.min(snmpConfig.timeout, 10000),
};
status = await this.nupst.getSnmp().getUpsStatus(testConfig);
} }
const testConfig: ISnmpConfig = {
...snmpConfig,
timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing
};
const status = await this.nupst.getSnmp().getUpsStatus(testConfig);
const boxWidth = 45; const boxWidth = 45;
logger.logBoxTitle(`Connection Successful: ${upsName}`, boxWidth); logger.logBoxTitle(`Connection Successful: ${upsName}`, boxWidth);
logger.logBoxLine('UPS Status:'); logger.logBoxLine('UPS Status:');
@@ -872,6 +976,97 @@ export class UpsHandler {
} }
} }
/**
* Gather UPSD/NIS connection settings
* @param upsdConfig UPSD configuration object to update
* @param prompt Function to prompt for user input
*/
private async gatherUpsdSettings(
upsdConfig: IUpsdConfig,
prompt: (question: string) => Promise<string>,
): Promise<void> {
logger.log('');
logger.info('UPSD/NIS Connection Settings:');
logger.dim('Connect to a local NUT (Network UPS Tools) server');
// Host
const defaultHost = upsdConfig.host || '127.0.0.1';
const host = await prompt(`UPSD Host [${defaultHost}]: `);
upsdConfig.host = host.trim() || defaultHost;
// Port
const defaultPort = upsdConfig.port || UPSD.DEFAULT_PORT;
const portInput = await prompt(`UPSD Port [${defaultPort}]: `);
const port = parseInt(portInput, 10);
upsdConfig.port = portInput.trim() && !isNaN(port) ? port : defaultPort;
// UPS Name
const defaultUpsName = upsdConfig.upsName || UPSD.DEFAULT_UPS_NAME;
const upsName = await prompt(`NUT UPS Name [${defaultUpsName}]: `);
upsdConfig.upsName = upsName.trim() || defaultUpsName;
// Timeout
const defaultTimeout = (upsdConfig.timeout || UPSD.DEFAULT_TIMEOUT_MS) / 1000;
const timeoutInput = await prompt(`Timeout in seconds [${defaultTimeout}]: `);
const timeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(timeout)) {
upsdConfig.timeout = timeout * 1000;
}
// Authentication (optional)
logger.log('');
logger.info('Authentication (optional):');
logger.dim('Leave blank if your NUT server does not require authentication');
const username = await prompt(`Username [${upsdConfig.username || ''}]: `);
if (username.trim()) {
upsdConfig.username = username.trim();
const password = await prompt(`Password: `);
if (password.trim()) {
upsdConfig.password = password.trim();
}
}
}
/**
* Optionally test UPSD connection
* @param upsdConfig UPSD configuration to test
* @param prompt Function to prompt for user input
*/
private async optionallyTestUpsdConnection(
upsdConfig: IUpsdConfig,
prompt: (question: string) => Promise<string>,
): Promise<void> {
const testConnection = await prompt(
'Would you like to test the connection to your UPS? (y/N): ',
);
if (testConnection.toLowerCase() === 'y') {
logger.log('\nTesting connection to UPSD server...');
try {
const testConfig = {
...upsdConfig,
timeout: Math.min(upsdConfig.timeout, 10000),
};
const status = await this.nupst.getUpsd().getUpsStatus(testConfig);
const boxWidth = 45;
logger.log('');
logger.logBoxTitle('Connection Successful!', boxWidth);
logger.logBoxLine('UPS Status:');
logger.logBoxLine(`✓ Power Status: ${status.powerStatus}`);
logger.logBoxLine(`✓ Battery Capacity: ${status.batteryCapacity}%`);
logger.logBoxLine(`✓ Runtime Remaining: ${status.batteryRuntime} minutes`);
logger.logBoxEnd();
} catch (error) {
const errorBoxWidth = 45;
logger.log('');
logger.logBoxTitle('Connection Failed!', errorBoxWidth);
logger.logBoxLine(`Error: ${error instanceof Error ? error.message : String(error)}`);
logger.logBoxEnd();
logger.log('\nPlease check your NUT server settings and try again.');
}
}
}
/** /**
* Gather action configuration settings * Gather action configuration settings
* @param actions Actions array to configure * @param actions Actions array to configure
@@ -901,6 +1096,7 @@ export class UpsHandler {
logger.dim(' 1) Shutdown (system shutdown)'); logger.dim(' 1) Shutdown (system shutdown)');
logger.dim(' 2) Webhook (HTTP notification)'); logger.dim(' 2) Webhook (HTTP notification)');
logger.dim(' 3) Custom Script (run .sh file from /etc/nupst)'); logger.dim(' 3) Custom Script (run .sh file from /etc/nupst)');
logger.dim(' 4) Proxmox (gracefully shut down VMs/LXCs before host shutdown)');
const typeInput = await prompt('Select action type [1]: '); const typeInput = await prompt('Select action type [1]: ');
const typeValue = parseInt(typeInput, 10) || 1; const typeValue = parseInt(typeInput, 10) || 1;
@@ -955,6 +1151,61 @@ export class UpsHandler {
if (timeoutInput.trim() && !isNaN(timeout)) { if (timeoutInput.trim() && !isNaN(timeout)) {
action.scriptTimeout = timeout * 1000; // Convert to ms action.scriptTimeout = timeout * 1000; // Convert to ms
} }
} else if (typeValue === 4) {
// Proxmox action
action.type = 'proxmox';
logger.log('');
logger.info('Proxmox API Settings:');
logger.dim('Requires a Proxmox API token. Create one with:');
logger.dim(' pveum user token add root@pam nupst --privsep=0');
const pxHost = await prompt('Proxmox Host [localhost]: ');
action.proxmoxHost = pxHost.trim() || 'localhost';
const pxPortInput = await prompt('Proxmox API Port [8006]: ');
const pxPort = parseInt(pxPortInput, 10);
action.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
const pxNode = await prompt('Proxmox Node Name (empty = auto-detect via hostname): ');
if (pxNode.trim()) {
action.proxmoxNode = pxNode.trim();
}
const tokenId = await prompt('API Token ID (e.g., root@pam!nupst): ');
if (!tokenId.trim()) {
logger.warn('Token ID is required for Proxmox action, skipping');
continue;
}
action.proxmoxTokenId = tokenId.trim();
const tokenSecret = await prompt('API Token Secret: ');
if (!tokenSecret.trim()) {
logger.warn('Token Secret is required for Proxmox action, skipping');
continue;
}
action.proxmoxTokenSecret = tokenSecret.trim();
const excludeInput = await prompt('VM/CT IDs to exclude (comma-separated, or empty): ');
if (excludeInput.trim()) {
action.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
}
const timeoutInput = await prompt('VM shutdown timeout in seconds [120]: ');
const stopTimeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(stopTimeout)) {
action.proxmoxStopTimeout = stopTimeout;
}
const forceInput = await prompt('Force-stop VMs that don\'t shut down in time? (Y/n): ');
action.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
const insecureInput = await prompt('Skip TLS verification (self-signed cert)? (Y/n): ');
action.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
logger.log('');
logger.info('Note: Place the Proxmox action BEFORE the shutdown action');
logger.dim('in the action chain so VMs shut down before the host.');
} else { } else {
logger.warn('Invalid action type, skipping'); logger.warn('Invalid action type, skipping');
continue; continue;
@@ -1032,12 +1283,20 @@ export class UpsHandler {
*/ */
private displayUpsConfigSummary(ups: IUpsConfig): void { private displayUpsConfigSummary(ups: IUpsConfig): void {
const boxWidth = 45; const boxWidth = 45;
const protocol = ups.protocol || 'snmp';
logger.log(''); logger.log('');
logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth); logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth);
logger.logBoxLine(`UPS ID: ${ups.id}`); logger.logBoxLine(`UPS ID: ${ups.id}`);
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`); logger.logBoxLine(`Protocol: ${protocol.toUpperCase()}`);
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`); if (protocol === 'upsd' && ups.upsd) {
logger.logBoxLine(`UPSD Host: ${ups.upsd.host}:${ups.upsd.port}`);
logger.logBoxLine(`NUT UPS Name: ${ups.upsd.upsName}`);
} else if (ups.snmp) {
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
}
if (ups.groups && ups.groups.length > 0) { if (ups.groups && ups.groups.length > 0) {
logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`); logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`);

View File

@@ -75,12 +75,14 @@ export function getRuntimeColor(minutes: number): (text: string) => string {
/** /**
* Format UPS power status with color * Format UPS power status with color
*/ */
export function formatPowerStatus(status: 'online' | 'onBattery' | 'unknown'): string { export function formatPowerStatus(status: 'online' | 'onBattery' | 'unknown' | 'unreachable'): string {
switch (status) { switch (status) {
case 'online': case 'online':
return theme.success('Online'); return theme.success('Online');
case 'onBattery': case 'onBattery':
return theme.warning('On Battery'); return theme.warning('On Battery');
case 'unreachable':
return theme.error('Unreachable');
case 'unknown': case 'unknown':
default: default:
return theme.dim('Unknown'); return theme.dim('Unknown');

View File

@@ -103,6 +103,62 @@ export const HTTP_SERVER = {
DEFAULT_PATH: '/ups-status', DEFAULT_PATH: '/ups-status',
} as const; } as const;
/**
* Network failure detection constants
*/
export const NETWORK = {
/** Number of consecutive failures before marking UPS as unreachable */
CONSECUTIVE_FAILURE_THRESHOLD: 3,
/** Maximum tracked consecutive failures (prevents overflow) */
MAX_CONSECUTIVE_FAILURES: 100,
} as const;
/**
* UPSD/NIS protocol constants
*/
export const UPSD = {
/** Default UPSD port (NUT standard) */
DEFAULT_PORT: 3493,
/** Default timeout in milliseconds */
DEFAULT_TIMEOUT_MS: 5000,
/** Default NUT device name */
DEFAULT_UPS_NAME: 'ups',
} as const;
/**
* Pause/resume constants
*/
export const PAUSE = {
/** Path to the pause state file */
FILE_PATH: '/etc/nupst/pause',
/** Maximum pause duration (24 hours) */
MAX_DURATION_MS: 24 * 60 * 60 * 1000,
} as const;
/**
* Proxmox VM shutdown constants
*/
export const PROXMOX = {
/** Default Proxmox API port */
DEFAULT_PORT: 8006,
/** Default Proxmox host */
DEFAULT_HOST: 'localhost',
/** Default timeout for VM/CT shutdown in seconds */
DEFAULT_STOP_TIMEOUT_SECONDS: 120,
/** Poll interval for checking VM/CT status in seconds */
STATUS_POLL_INTERVAL_SECONDS: 5,
/** Proxmox API base path */
API_BASE: '/api2/json',
} as const;
/** /**
* UI/Display constants * UI/Display constants
*/ */

View File

@@ -5,13 +5,17 @@ import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { NupstSnmp } from './snmp/manager.ts'; import { NupstSnmp } from './snmp/manager.ts';
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts'; import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts';
import { NupstUpsd } from './upsd/client.ts';
import type { IUpsdConfig } from './upsd/types.ts';
import type { TProtocol } from './protocol/types.ts';
import { ProtocolResolver } from './protocol/resolver.ts';
import { logger } from './logger.ts'; import { logger } from './logger.ts';
import { MigrationRunner } from './migrations/index.ts'; import { MigrationRunner } from './migrations/index.ts';
import { formatPowerStatus, getBatteryColor, getRuntimeColor, theme } from './colors.ts'; import { formatPowerStatus, getBatteryColor, getRuntimeColor, theme } from './colors.ts';
import type { IActionConfig } from './actions/base-action.ts'; import type { IActionConfig } from './actions/base-action.ts';
import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts'; import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts';
import { NupstHttpServer } from './http-server.ts'; import { NupstHttpServer } from './http-server.ts';
import { THRESHOLDS, TIMING, UI } from './constants.ts'; import { NETWORK, PAUSE, THRESHOLDS, TIMING, UI } from './constants.ts';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -24,8 +28,12 @@ export interface IUpsConfig {
id: string; id: string;
/** Friendly name for the UPS */ /** Friendly name for the UPS */
name: string; name: string;
/** SNMP configuration settings */ /** Communication protocol (defaults to 'snmp') */
snmp: ISnmpConfig; protocol?: TProtocol;
/** SNMP configuration settings (required for 'snmp' protocol) */
snmp?: ISnmpConfig;
/** UPSD/NIS configuration settings (required for 'upsd' protocol) */
upsd?: IUpsdConfig;
/** Group IDs this UPS belongs to */ /** Group IDs this UPS belongs to */
groups: string[]; groups: string[];
/** Actions to trigger on power status changes and threshold violations */ /** Actions to trigger on power status changes and threshold violations */
@@ -62,6 +70,20 @@ export interface IHttpServerConfig {
authToken: string; authToken: string;
} }
/**
* Pause state interface
*/
export interface IPauseState {
/** Timestamp when pause was activated */
pausedAt: number;
/** Who initiated the pause (e.g., 'cli', 'api') */
pausedBy: string;
/** Optional reason for pausing */
reason?: string;
/** When to auto-resume (null = indefinite, timestamp in ms) */
resumeAt?: number | null;
}
/** /**
* Configuration interface for the daemon * Configuration interface for the daemon
*/ */
@@ -97,7 +119,7 @@ export interface INupstConfig {
export interface IUpsStatus { export interface IUpsStatus {
id: string; id: string;
name: string; name: string;
powerStatus: 'online' | 'onBattery' | 'unknown'; powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
batteryCapacity: number; batteryCapacity: number;
batteryRuntime: number; batteryRuntime: number;
outputLoad: number; // Load percentage (0-100%) outputLoad: number; // Load percentage (0-100%)
@@ -106,6 +128,8 @@ export interface IUpsStatus {
outputCurrent: number; // Current in amps outputCurrent: number; // Current in amps
lastStatusChange: number; lastStatusChange: number;
lastCheckTime: number; lastCheckTime: number;
consecutiveFailures: number;
unreachableSince: number;
} }
/** /**
@@ -159,15 +183,21 @@ export class NupstDaemon {
private config: INupstConfig; private config: INupstConfig;
private snmp: NupstSnmp; private snmp: NupstSnmp;
private upsd: NupstUpsd;
private protocolResolver: ProtocolResolver;
private isRunning: boolean = false; private isRunning: boolean = false;
private isPaused: boolean = false;
private pauseState: IPauseState | null = null;
private upsStatus: Map<string, IUpsStatus> = new Map(); private upsStatus: Map<string, IUpsStatus> = new Map();
private httpServer?: NupstHttpServer; private httpServer?: NupstHttpServer;
/** /**
* Create a new daemon instance with the given SNMP manager * Create a new daemon instance with the given protocol managers
*/ */
constructor(snmp: NupstSnmp) { constructor(snmp: NupstSnmp, upsd: NupstUpsd) {
this.snmp = snmp; this.snmp = snmp;
this.upsd = upsd;
this.protocolResolver = new ProtocolResolver(snmp, upsd);
this.config = this.DEFAULT_CONFIG; this.config = this.DEFAULT_CONFIG;
} }
@@ -230,10 +260,11 @@ export class NupstDaemon {
// Ensure version is always set and remove legacy fields before saving // Ensure version is always set and remove legacy fields before saving
const configToSave: INupstConfig = { const configToSave: INupstConfig = {
version: '4.1', version: '4.2',
upsDevices: config.upsDevices, upsDevices: config.upsDevices,
groups: config.groups, groups: config.groups,
checkInterval: config.checkInterval, checkInterval: config.checkInterval,
...(config.httpServer ? { httpServer: config.httpServer } : {}),
}; };
fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(configToSave, null, 2)); fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(configToSave, null, 2));
@@ -271,6 +302,13 @@ export class NupstDaemon {
return this.snmp; return this.snmp;
} }
/**
* Get the UPSD instance
*/
public getNupstUpsd(): NupstUpsd {
return this.upsd;
}
/** /**
* Start the monitoring daemon * Start the monitoring daemon
*/ */
@@ -317,6 +355,7 @@ export class NupstDaemon {
this.config.httpServer.path, this.config.httpServer.path,
this.config.httpServer.authToken, this.config.httpServer.authToken,
() => this.upsStatus, () => this.upsStatus,
() => this.pauseState,
); );
this.httpServer.start(); this.httpServer.start();
} catch (error) { } catch (error) {
@@ -360,6 +399,8 @@ export class NupstDaemon {
outputCurrent: 0, outputCurrent: 0,
lastStatusChange: Date.now(), lastStatusChange: Date.now(),
lastCheckTime: 0, lastCheckTime: 0,
consecutiveFailures: 0,
unreachableSince: 0,
}); });
} }
@@ -388,16 +429,27 @@ export class NupstDaemon {
> = [ > = [
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight }, { header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left', color: theme.dim }, { header: 'ID', key: 'id', align: 'left', color: theme.dim },
{ header: 'Protocol', key: 'protocol', align: 'left' },
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info }, { header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
{ header: 'Actions', key: 'actions', align: 'left' }, { header: 'Actions', key: 'actions', align: 'left' },
]; ];
const upsRows: Array<Record<string, string>> = this.config.upsDevices.map((ups) => ({ const upsRows: Array<Record<string, string>> = this.config.upsDevices.map((ups) => {
name: ups.name, const protocol = ups.protocol || 'snmp';
id: ups.id, let host = 'N/A';
host: `${ups.snmp.host}:${ups.snmp.port}`, if (protocol === 'upsd' && ups.upsd) {
actions: `${(ups.actions || []).length} configured`, host = `${ups.upsd.host}:${ups.upsd.port}`;
})); } else if (ups.snmp) {
host = `${ups.snmp.host}:${ups.snmp.port}`;
}
return {
name: ups.name,
id: ups.id,
protocol: protocol.toUpperCase(),
host,
actions: `${(ups.actions || []).length} configured`,
};
});
logger.logTable(upsColumns, upsRows); logger.logTable(upsColumns, upsRows);
logger.log(''); logger.log('');
@@ -443,6 +495,79 @@ export class NupstDaemon {
this.isRunning = false; this.isRunning = false;
} }
/**
* Get the current pause state
*/
public getPauseState(): IPauseState | null {
return this.pauseState;
}
/**
* Check and update pause state from the pause file
*/
private checkPauseState(): void {
try {
if (fs.existsSync(PAUSE.FILE_PATH)) {
const data = fs.readFileSync(PAUSE.FILE_PATH, 'utf8');
const state = JSON.parse(data) as IPauseState;
// Check if auto-resume time has passed
if (state.resumeAt && Date.now() >= state.resumeAt) {
// Auto-resume: delete the pause file
try {
fs.unlinkSync(PAUSE.FILE_PATH);
} catch (_e) {
// Ignore deletion errors
}
if (this.isPaused) {
logger.log('');
logger.logBoxTitle('Auto-Resume', 45, 'success');
logger.logBoxLine('Pause duration expired, resuming action monitoring');
logger.logBoxEnd();
logger.log('');
}
this.isPaused = false;
this.pauseState = null;
return;
}
if (!this.isPaused) {
logger.log('');
logger.logBoxTitle('Actions Paused', 45, 'warning');
logger.logBoxLine(`Paused by: ${state.pausedBy}`);
if (state.reason) {
logger.logBoxLine(`Reason: ${state.reason}`);
}
if (state.resumeAt) {
const remaining = Math.round((state.resumeAt - Date.now()) / 1000);
logger.logBoxLine(`Auto-resume in: ${remaining} seconds`);
} else {
logger.logBoxLine('Duration: Indefinite (run "nupst resume" to resume)');
}
logger.logBoxEnd();
logger.log('');
}
this.isPaused = true;
this.pauseState = state;
} else {
if (this.isPaused) {
logger.log('');
logger.logBoxTitle('Actions Resumed', 45, 'success');
logger.logBoxLine('Action monitoring has been resumed');
logger.logBoxEnd();
logger.log('');
}
this.isPaused = false;
this.pauseState = null;
}
} catch (_error) {
// If we can't read the pause file, assume not paused
this.isPaused = false;
this.pauseState = null;
}
}
/** /**
* Monitor the UPS status and trigger shutdown when necessary * Monitor the UPS status and trigger shutdown when necessary
*/ */
@@ -461,7 +586,10 @@ export class NupstDaemon {
// Monitor continuously // Monitor continuously
while (this.isRunning) { while (this.isRunning) {
try { try {
// Check all UPS devices // Check pause state before each cycle
this.checkPauseState();
// Check all UPS devices (polling continues even when paused for visibility)
await this.checkAllUpsDevices(); await this.checkAllUpsDevices();
// Log periodic status update // Log periodic status update
@@ -505,16 +633,24 @@ export class NupstDaemon {
outputCurrent: 0, outputCurrent: 0,
lastStatusChange: Date.now(), lastStatusChange: Date.now(),
lastCheckTime: 0, lastCheckTime: 0,
consecutiveFailures: 0,
unreachableSince: 0,
}); });
} }
// Check UPS status // Check UPS status via configured protocol
const status = await this.snmp.getUpsStatus(ups.snmp); const protocol = ups.protocol || 'snmp';
const status = protocol === 'upsd' && ups.upsd
? await this.protocolResolver.getUpsStatus('upsd', undefined, ups.upsd)
: await this.protocolResolver.getUpsStatus('snmp', ups.snmp);
const currentTime = Date.now(); const currentTime = Date.now();
// Get the current status from the map // Get the current status from the map
const currentStatus = this.upsStatus.get(ups.id); const currentStatus = this.upsStatus.get(ups.id);
// Successful query: reset consecutive failures
const wasUnreachable = currentStatus?.powerStatus === 'unreachable';
// Update status with new values // Update status with new values
const updatedStatus: IUpsStatus = { const updatedStatus: IUpsStatus = {
id: ups.id, id: ups.id,
@@ -528,10 +664,27 @@ export class NupstDaemon {
outputCurrent: status.outputCurrent, outputCurrent: status.outputCurrent,
lastCheckTime: currentTime, lastCheckTime: currentTime,
lastStatusChange: currentStatus?.lastStatusChange || currentTime, lastStatusChange: currentStatus?.lastStatusChange || currentTime,
consecutiveFailures: 0,
unreachableSince: 0,
}; };
// Check if power status changed // If UPS was unreachable and is now reachable, log recovery
if (currentStatus && currentStatus.powerStatus !== status.powerStatus) { if (wasUnreachable && currentStatus) {
const downtime = Math.round((currentTime - currentStatus.unreachableSince) / 1000);
logger.log('');
logger.logBoxTitle(`UPS Recovered: ${ups.name}`, 60, 'success');
logger.logBoxLine(`UPS is reachable again after ${downtime} seconds`);
logger.logBoxLine(`Current Status: ${formatPowerStatus(status.powerStatus)}`);
logger.logBoxLine(`Time: ${new Date().toISOString()}`);
logger.logBoxEnd();
logger.log('');
updatedStatus.lastStatusChange = currentTime;
// Trigger power status change action for recovery
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'powerStatusChange');
} else if (currentStatus && currentStatus.powerStatus !== status.powerStatus) {
// Check if power status changed
logger.log(''); logger.log('');
logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning'); logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning');
logger.logBoxLine(`Previous: ${formatPowerStatus(currentStatus.powerStatus)}`); logger.logBoxLine(`Previous: ${formatPowerStatus(currentStatus.powerStatus)}`);
@@ -573,11 +726,48 @@ export class NupstDaemon {
// Update the status in the map // Update the status in the map
this.upsStatus.set(ups.id, updatedStatus); this.upsStatus.set(ups.id, updatedStatus);
} catch (error) { } catch (error) {
// Network loss / query failure tracking
const currentStatus = this.upsStatus.get(ups.id);
const failures = Math.min(
(currentStatus?.consecutiveFailures || 0) + 1,
NETWORK.MAX_CONSECUTIVE_FAILURES,
);
logger.error( logger.error(
`Error checking UPS ${ups.name} (${ups.id}): ${ `Error checking UPS ${ups.name} (${ups.id}) [failure ${failures}/${NETWORK.CONSECUTIVE_FAILURE_THRESHOLD}]: ${
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
}`, }`,
); );
// Transition to unreachable after threshold consecutive failures
if (
failures >= NETWORK.CONSECUTIVE_FAILURE_THRESHOLD &&
currentStatus &&
currentStatus.powerStatus !== 'unreachable'
) {
const currentTime = Date.now();
const previousStatus = { ...currentStatus };
currentStatus.powerStatus = 'unreachable';
currentStatus.consecutiveFailures = failures;
currentStatus.unreachableSince = currentTime;
currentStatus.lastStatusChange = currentTime;
this.upsStatus.set(ups.id, currentStatus);
logger.log('');
logger.logBoxTitle(`UPS Unreachable: ${ups.name}`, 60, 'error');
logger.logBoxLine(`${failures} consecutive communication failures`);
logger.logBoxLine(`Last known status: ${formatPowerStatus(previousStatus.powerStatus)}`);
logger.logBoxLine(`Time: ${new Date().toISOString()}`);
logger.logBoxEnd();
logger.log('');
// Trigger power status change action for unreachable
await this.triggerUpsActions(ups, currentStatus, previousStatus, 'powerStatusChange');
} else if (currentStatus) {
currentStatus.consecutiveFailures = failures;
this.upsStatus.set(ups.id, currentStatus);
}
} }
} }
} }
@@ -589,8 +779,16 @@ export class NupstDaemon {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
logger.log(''); logger.log('');
logger.logBoxTitle('Periodic Status Update', 70, 'info'); const pauseLabel = this.isPaused ? ' [PAUSED]' : '';
logger.logBoxTitle(`Periodic Status Update${pauseLabel}`, 70, this.isPaused ? 'warning' : 'info');
logger.logBoxLine(`Timestamp: ${timestamp}`); logger.logBoxLine(`Timestamp: ${timestamp}`);
if (this.isPaused && this.pauseState) {
logger.logBoxLine(`Actions paused by: ${this.pauseState.pausedBy}`);
if (this.pauseState.resumeAt) {
const remaining = Math.round((this.pauseState.resumeAt - Date.now()) / 1000);
logger.logBoxLine(`Auto-resume in: ${remaining > 0 ? remaining : 0} seconds`);
}
}
logger.logBoxEnd(); logger.logBoxEnd();
logger.log(''); logger.log('');
@@ -660,6 +858,14 @@ export class NupstDaemon {
previousStatus: IUpsStatus | undefined, previousStatus: IUpsStatus | undefined,
triggerReason: 'powerStatusChange' | 'thresholdViolation', triggerReason: 'powerStatusChange' | 'thresholdViolation',
): Promise<void> { ): Promise<void> {
// Check if actions are paused
if (this.isPaused) {
logger.info(
`[PAUSED] Actions suppressed for UPS ${ups.name} (trigger: ${triggerReason})`,
);
return;
}
const actions = ups.actions || []; const actions = ups.actions || [];
// Backward compatibility: if no actions configured, use default shutdown behavior // Backward compatibility: if no actions configured, use default shutdown behavior
@@ -836,7 +1042,10 @@ export class NupstDaemon {
// Check all UPS devices // Check all UPS devices
for (const ups of this.config.upsDevices) { for (const ups of this.config.upsDevices) {
try { try {
const status = await this.snmp.getUpsStatus(ups.snmp); const protocol = ups.protocol || 'snmp';
const status = protocol === 'upsd' && ups.upsd
? await this.protocolResolver.getUpsStatus('upsd', undefined, ups.upsd)
: await this.protocolResolver.getUpsStatus('snmp', ups.snmp);
const batteryColor = getBatteryColor(status.batteryCapacity); const batteryColor = getBatteryColor(status.batteryCapacity);
const runtimeColor = getRuntimeColor(status.batteryRuntime); const runtimeColor = getRuntimeColor(status.batteryRuntime);
@@ -1065,7 +1274,7 @@ export class NupstDaemon {
logger.log('Config file watcher started'); logger.log('Config file watcher started');
for await (const event of watcher) { for await (const event of watcher) {
// Only respond to modify events on the config file // Respond to modify events on config file
if ( if (
event.kind === 'modify' && event.kind === 'modify' &&
event.paths.some((p) => p.includes('config.json')) event.paths.some((p) => p.includes('config.json'))
@@ -1074,6 +1283,14 @@ export class NupstDaemon {
await this.reloadConfig(); await this.reloadConfig();
} }
// Detect pause file changes
if (
(event.kind === 'create' || event.kind === 'modify' || event.kind === 'remove') &&
event.paths.some((p) => p.includes('pause'))
) {
this.checkPauseState();
}
// Stop watching if daemon stopped // Stop watching if daemon stopped
if (!this.isRunning) { if (!this.isRunning) {
break; break;

View File

@@ -1,7 +1,7 @@
import * as http from 'node:http'; import * as http from 'node:http';
import { URL } from 'node:url'; import { URL } from 'node:url';
import { logger } from './logger.ts'; import { logger } from './logger.ts';
import type { IUpsStatus } from './daemon.ts'; import type { IPauseState, IUpsStatus } from './daemon.ts';
/** /**
* HTTP Server for exposing UPS status as JSON * HTTP Server for exposing UPS status as JSON
@@ -13,6 +13,7 @@ export class NupstHttpServer {
private path: string; private path: string;
private authToken: string; private authToken: string;
private getUpsStatus: () => Map<string, IUpsStatus>; private getUpsStatus: () => Map<string, IUpsStatus>;
private getPauseState: () => IPauseState | null;
/** /**
* Create a new HTTP server instance * Create a new HTTP server instance
@@ -20,17 +21,20 @@ export class NupstHttpServer {
* @param path URL path for the endpoint * @param path URL path for the endpoint
* @param authToken Authentication token required for access * @param authToken Authentication token required for access
* @param getUpsStatus Function to retrieve cached UPS status * @param getUpsStatus Function to retrieve cached UPS status
* @param getPauseState Function to retrieve current pause state
*/ */
constructor( constructor(
port: number, port: number,
path: string, path: string,
authToken: string, authToken: string,
getUpsStatus: () => Map<string, IUpsStatus>, getUpsStatus: () => Map<string, IUpsStatus>,
getPauseState: () => IPauseState | null,
) { ) {
this.port = port; this.port = port;
this.path = path; this.path = path;
this.authToken = authToken; this.authToken = authToken;
this.getUpsStatus = getUpsStatus; this.getUpsStatus = getUpsStatus;
this.getPauseState = getPauseState;
} }
/** /**
@@ -79,12 +83,18 @@ export class NupstHttpServer {
// Get cached status (no refresh) // Get cached status (no refresh)
const statusMap = this.getUpsStatus(); const statusMap = this.getUpsStatus();
const statusArray = Array.from(statusMap.values()); const statusArray = Array.from(statusMap.values());
const pauseState = this.getPauseState();
const response = {
upsDevices: statusArray,
...(pauseState ? { paused: true, pauseState } : { paused: false }),
};
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
}); });
res.end(JSON.stringify(statusArray, null, 2)); res.end(JSON.stringify(response, null, 2));
} else { } else {
res.writeHead(404, { 'Content-Type': 'application/json' }); res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not Found' })); res.end(JSON.stringify({ error: 'Not Found' }));

View File

@@ -9,3 +9,4 @@ export { MigrationRunner } from './migration-runner.ts';
export { MigrationV1ToV2 } from './migration-v1-to-v2.ts'; export { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
export { MigrationV3ToV4 } from './migration-v3-to-v4.ts'; export { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts'; export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
export { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';

View File

@@ -2,6 +2,7 @@ import { BaseMigration } from './base-migration.ts';
import { MigrationV1ToV2 } from './migration-v1-to-v2.ts'; import { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
import { MigrationV3ToV4 } from './migration-v3-to-v4.ts'; import { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts'; import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
import { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
/** /**
@@ -19,7 +20,7 @@ export class MigrationRunner {
new MigrationV1ToV2(), new MigrationV1ToV2(),
new MigrationV3ToV4(), new MigrationV3ToV4(),
new MigrationV4_0ToV4_1(), new MigrationV4_0ToV4_1(),
// Add future migrations here (v4.3, v4.4, etc.) new MigrationV4_1ToV4_2(),
]; ];
// Sort by version order to ensure they run in sequence // Sort by version order to ensure they run in sequence

View File

@@ -0,0 +1,43 @@
import { BaseMigration } from './base-migration.ts';
import { logger } from '../logger.ts';
/**
* Migration from v4.1 to v4.2
*
* Changes:
* 1. Adds `protocol: 'snmp'` to all existing UPS devices (explicit default)
* 2. Bumps version from '4.1' to '4.2'
*/
export class MigrationV4_1ToV4_2 extends BaseMigration {
readonly fromVersion = '4.1';
readonly toVersion = '4.2';
shouldRun(config: Record<string, unknown>): boolean {
return config.version === '4.1';
}
migrate(config: Record<string, unknown>): Record<string, unknown> {
logger.info(`${this.getName()}: Adding protocol field to UPS devices...`);
const devices = (config.upsDevices as Array<Record<string, unknown>>) || [];
const migratedDevices = devices.map((device) => {
// Add protocol: 'snmp' if not already present
if (!device.protocol) {
device.protocol = 'snmp';
logger.dim(`${device.name}: Set protocol to 'snmp'`);
}
return device;
});
const result = {
...config,
version: this.toVersion,
upsDevices: migratedDevices,
};
logger.success(
`${this.getName()}: Migration complete (${migratedDevices.length} devices updated)`,
);
return result;
}
}

View File

@@ -1,4 +1,5 @@
import { NupstSnmp } from './snmp/manager.ts'; import { NupstSnmp } from './snmp/manager.ts';
import { NupstUpsd } from './upsd/client.ts';
import { NupstDaemon } from './daemon.ts'; import { NupstDaemon } from './daemon.ts';
import { NupstSystemd } from './systemd.ts'; import { NupstSystemd } from './systemd.ts';
import denoConfig from '../deno.json' with { type: 'json' }; import denoConfig from '../deno.json' with { type: 'json' };
@@ -17,6 +18,7 @@ import type { INupstAccessor, IUpdateStatus } from './interfaces/index.ts';
*/ */
export class Nupst implements INupstAccessor { export class Nupst implements INupstAccessor {
private readonly snmp: NupstSnmp; private readonly snmp: NupstSnmp;
private readonly upsd: NupstUpsd;
private readonly daemon: NupstDaemon; private readonly daemon: NupstDaemon;
private readonly systemd: NupstSystemd; private readonly systemd: NupstSystemd;
private readonly upsHandler: UpsHandler; private readonly upsHandler: UpsHandler;
@@ -34,7 +36,8 @@ export class Nupst implements INupstAccessor {
// Initialize core components // Initialize core components
this.snmp = new NupstSnmp(); this.snmp = new NupstSnmp();
this.snmp.setNupst(this); // Set up bidirectional reference this.snmp.setNupst(this); // Set up bidirectional reference
this.daemon = new NupstDaemon(this.snmp); this.upsd = new NupstUpsd();
this.daemon = new NupstDaemon(this.snmp, this.upsd);
this.systemd = new NupstSystemd(this.daemon); this.systemd = new NupstSystemd(this.daemon);
// Initialize handlers // Initialize handlers
@@ -52,6 +55,13 @@ export class Nupst implements INupstAccessor {
return this.snmp; return this.snmp;
} }
/**
* Get the UPSD manager for NUT protocol communication
*/
public getUpsd(): NupstUpsd {
return this.upsd;
}
/** /**
* Get the daemon manager for background monitoring * Get the daemon manager for background monitoring
*/ */

7
ts/protocol/index.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* Protocol abstraction module
* Re-exports public types and classes
*/
export type { TProtocol } from './types.ts';
export { ProtocolResolver } from './resolver.ts';

49
ts/protocol/resolver.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* ProtocolResolver - Routes UPS status queries to the correct protocol implementation
*
* Abstracts away SNMP vs UPSD differences so the daemon is protocol-agnostic.
* Both protocols return the same IUpsStatus interface from ts/snmp/types.ts.
*/
import type { NupstSnmp } from '../snmp/manager.ts';
import type { NupstUpsd } from '../upsd/client.ts';
import type { ISnmpConfig, IUpsStatus } from '../snmp/types.ts';
import type { IUpsdConfig } from '../upsd/types.ts';
import type { TProtocol } from './types.ts';
export class ProtocolResolver {
private snmp: NupstSnmp;
private upsd: NupstUpsd;
constructor(snmp: NupstSnmp, upsd: NupstUpsd) {
this.snmp = snmp;
this.upsd = upsd;
}
/**
* Get UPS status using the specified protocol
* @param protocol Protocol to use ('snmp' or 'upsd')
* @param snmpConfig SNMP configuration (required for 'snmp' protocol)
* @param upsdConfig UPSD configuration (required for 'upsd' protocol)
* @returns UPS status
*/
public getUpsStatus(
protocol: TProtocol,
snmpConfig?: ISnmpConfig,
upsdConfig?: IUpsdConfig,
): Promise<IUpsStatus> {
switch (protocol) {
case 'upsd':
if (!upsdConfig) {
throw new Error('UPSD configuration required for UPSD protocol');
}
return this.upsd.getUpsStatus(upsdConfig);
case 'snmp':
default:
if (!snmpConfig) {
throw new Error('SNMP configuration required for SNMP protocol');
}
return this.snmp.getUpsStatus(snmpConfig);
}
}
}

4
ts/protocol/types.ts Normal file
View File

@@ -0,0 +1,4 @@
/**
* Protocol type for UPS communication
*/
export type TProtocol = 'snmp' | 'upsd';

View File

@@ -9,7 +9,7 @@ import { Buffer } from 'node:buffer';
*/ */
export interface IUpsStatus { export interface IUpsStatus {
/** Current power status */ /** Current power status */
powerStatus: 'online' | 'onBattery' | 'unknown'; powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
/** Battery capacity percentage */ /** Battery capacity percentage */
batteryCapacity: number; batteryCapacity: number;
/** Remaining runtime in minutes */ /** Remaining runtime in minutes */

View File

@@ -346,13 +346,24 @@ WantedBy=multi-user.target
*/ */
private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> { private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
try { try {
// Create a test config with a short timeout const protocol = ups.protocol || 'snmp';
const testConfig = { let status;
...ups.snmp,
timeout: Math.min(ups.snmp.timeout, 10000), // Use at most 10 seconds for status check
};
const status = await snmp.getUpsStatus(testConfig); if (protocol === 'upsd' && ups.upsd) {
const testConfig = {
...ups.upsd,
timeout: Math.min(ups.upsd.timeout, 10000),
};
status = await this.daemon.getNupstUpsd().getUpsStatus(testConfig);
} else if (ups.snmp) {
const testConfig = {
...ups.snmp,
timeout: Math.min(ups.snmp.timeout, 10000),
};
status = await snmp.getUpsStatus(testConfig);
} else {
throw new Error('No protocol configuration found');
}
// Determine status symbol based on power status // Determine status symbol based on power status
let statusSymbol = symbols.unknown; let statusSymbol = symbols.unknown;
@@ -396,7 +407,12 @@ WantedBy=multi-user.target
); );
// Display host info // Display host info
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`); const hostInfo = protocol === 'upsd' && ups.upsd
? `${ups.upsd.host}:${ups.upsd.port} (UPSD)`
: ups.snmp
? `${ups.snmp.host}:${ups.snmp.port} (SNMP)`
: 'N/A';
logger.log(` ${theme.dim(`Host: ${hostInfo}`)}`);
// Display groups if any // Display groups if any
if (ups.groups && ups.groups.length > 0) { if (ups.groups && ups.groups.length > 0) {
@@ -434,11 +450,16 @@ WantedBy=multi-user.target
logger.log(''); logger.log('');
} catch (error) { } catch (error) {
// Display error for this UPS // Display error for this UPS
const errorHostInfo = (ups.protocol || 'snmp') === 'upsd' && ups.upsd
? `${ups.upsd.host}:${ups.upsd.port} (UPSD)`
: ups.snmp
? `${ups.snmp.host}:${ups.snmp.port} (SNMP)`
: 'N/A';
logger.log( logger.log(
` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`, ` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`,
); );
logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`); logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`); logger.log(` ${theme.dim(`Host: ${errorHostInfo}`)}`);
logger.log(''); logger.log('');
} }
} }

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';
}
}

7
ts/upsd/index.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* UPSD/NIS protocol module
* Re-exports public types and classes
*/
export type { IUpsdConfig } from './types.ts';
export { NupstUpsd } from './client.ts';

21
ts/upsd/types.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* Type definitions for UPSD/NIS (Network UPS Tools) protocol module
*/
/**
* UPSD connection configuration
*/
export interface IUpsdConfig {
/** UPSD server host (default: 127.0.0.1) */
host: string;
/** UPSD server port (default: 3493) */
port: number;
/** NUT device name (default: 'ups') */
upsName: string;
/** Connection timeout in milliseconds (default: 5000) */
timeout: number;
/** Optional username for authentication */
username?: string;
/** Optional password for authentication */
password?: string;
}