Compare commits

..

4 Commits

Author SHA1 Message Date
0d863a1028 v5.4.1
All checks were successful
Release / build-and-release (push) Successful in 51s
2026-03-30 06:50:36 +00:00
c410a663b1 fix(deps): bump tsdeno and net-snmp patch dependencies 2026-03-30 06:50:36 +00:00
6aa1fc651f v5.4.0
Some checks failed
Release / build-and-release (push) Failing after 15s
2026-03-30 06:46:28 +00:00
11e549e68e feat(snmp): add configurable SNMP runtime units with v4.3 migration support 2026-03-30 06:46:28 +00:00
14 changed files with 178 additions and 45 deletions

View File

@@ -1,5 +1,20 @@
# Changelog
## 2026-03-30 - 5.4.1 - fix(deps)
bump tsdeno and net-snmp patch dependencies
- update @git.zone/tsdeno from ^1.2.0 to ^1.3.1
- update net-snmp import from 3.26.0 to 3.26.1 in the SNMP manager
## 2026-03-30 - 5.4.0 - feat(snmp)
add configurable SNMP runtime units with v4.3 migration support
- Adds explicit `runtimeUnit` support for SNMP devices with `minutes`, `seconds`, and `ticks` options.
- Updates runtime processing to prefer configured units over UPS model heuristics.
- Introduces a v4.2 to v4.3 migration that populates `runtimeUnit` for existing SNMP device configs based on `upsModel`.
- Extends the CLI setup and device summary output to configure and display the selected runtime unit.
- Updates default config version to 4.3 and documents the new SNMP runtime unit setting in the README.
## 2026-03-18 - 5.3.3 - fix(deps)
add @git.zone/tsdeno as a development dependency

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/nupst",
"version": "5.3.3",
"version": "5.4.1",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/nupst",
"version": "5.3.3",
"version": "5.4.1",
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
"keywords": [
"ups",
@@ -64,6 +64,6 @@
},
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"devDependencies": {
"@git.zone/tsdeno": "^1.2.0"
"@git.zone/tsdeno": "^1.3.1"
}
}

52
pnpm-lock.yaml generated
View File

@@ -9,8 +9,8 @@ importers:
.:
devDependencies:
'@git.zone/tsdeno':
specifier: ^1.2.0
version: 1.2.0
specifier: ^1.3.1
version: 1.3.1
packages:
@@ -39,8 +39,8 @@ packages:
'@design.estate/dees-element@2.2.3':
resolution: {integrity: sha512-MpAvJPrJDTDad8hUtdOzMgMFRE7n84O7INhvSlkTTLB3b84j8EKjwfUCMErGAo7Bq5zfw4LG7NnKhLYXXXjkXA==}
'@git.zone/tsdeno@1.2.0':
resolution: {integrity: sha512-Kx/9NchzKRoBhgj44V/ymF3rtspMAO+U+x7d3CFfBAyHCzVCrFtzIdVb5JULSTSR89d9a0L4VJVyVUoRdz/MUA==}
'@git.zone/tsdeno@1.3.1':
resolution: {integrity: sha512-1shZOSwMUfmonIe8OsX40EcQcS7/WFt+y+5+TQ7N6QmU+Hp6zjrfFvQwanr5Qcgh/E54WFNTfJk/PsJ26s6Oxw==}
hasBin: true
'@isaacs/cliui@9.0.0':
@@ -68,9 +68,6 @@ packages:
'@push.rocks/lik@6.3.1':
resolution: {integrity: sha512-UWDwGBaVx5yPtAFXqDDBtQZCzETUOA/7myQIXb+YBsuiIw4yQuhNZ23uY2ChQH2Zn6DLqdNSgQcYC0WywMZBNQ==}
'@push.rocks/npmextra@5.3.3':
resolution: {integrity: sha512-snLpSHwaQ5OXlZzF1KX/FY71W5LwajjBzor82Vue0smjEPnSeUPY5/JcVdMwtdprdJe13pc/EQQuIiL/zw4/yg==}
'@push.rocks/qenv@6.1.3':
resolution: {integrity: sha512-+z2hsAU/7CIgpYLFqvda8cn9rUBMHqLdQLjsFfRn5jPoD7dJ5rFlpkbhfM4Ws8mHMniwWaxGKo+q/YBhtzRBLg==}
@@ -83,6 +80,9 @@ packages:
'@push.rocks/smartclickhouse@2.2.0':
resolution: {integrity: sha512-eTzKiREIPSzL1kPkVyD6vEbn+WV/DvQqDjP67VlhNlQGbRcemnJG/eLrUUR1ytmdIqnsZGEK6UYBgyj5nhzLNQ==}
'@push.rocks/smartconfig@6.1.0':
resolution: {integrity: sha512-B+xh63PhGAsSwuRyCKXr4PAjJ4HoVKhNysi67OGY6gGqGm6uopgEW1cvrUZ7T5ZSck9KlVx7ZTugbqm6dqBK1Q==}
'@push.rocks/smartdelay@3.0.5':
resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==}
@@ -1025,11 +1025,11 @@ snapshots:
- supports-color
- vue
'@git.zone/tsdeno@1.2.0':
'@git.zone/tsdeno@1.3.1':
dependencies:
'@push.rocks/early': 4.0.4
'@push.rocks/npmextra': 5.3.3
'@push.rocks/smartcli': 4.0.20
'@push.rocks/smartconfig': 6.1.0
'@push.rocks/smartfs': 1.5.0
'@push.rocks/smartshell': 3.3.8
transitivePeerDependencies:
@@ -1070,23 +1070,6 @@ snapshots:
'@types/symbol-tree': 3.2.5
symbol-tree: 3.2.4
'@push.rocks/npmextra@5.3.3':
dependencies:
'@push.rocks/qenv': 6.1.3
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartjson': 5.2.0
'@push.rocks/smartlog': 3.2.1
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/taskbuffer': 3.5.0
'@tsclass/tsclass': 9.4.0
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@push.rocks/qenv@6.1.3':
dependencies:
'@api.global/typedrequest': 3.3.0
@@ -1117,6 +1100,23 @@ snapshots:
'@push.rocks/smarturl': 3.1.0
'@push.rocks/webrequest': 4.0.5
'@push.rocks/smartconfig@6.1.0':
dependencies:
'@push.rocks/qenv': 6.1.3
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartjson': 5.2.0
'@push.rocks/smartlog': 3.2.1
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/taskbuffer': 3.5.0
'@tsclass/tsclass': 9.4.0
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@push.rocks/smartdelay@3.0.5':
dependencies:
'@push.rocks/smartpromise': 4.2.3

View File

@@ -223,7 +223,7 @@ NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to confi
```json
{
"version": "4.2",
"version": "4.3",
"checkInterval": 30000,
"httpServer": {
"enabled": true,
@@ -242,7 +242,8 @@ NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to confi
"community": "public",
"version": 1,
"timeout": 5000,
"upsModel": "cyberpower"
"upsModel": "cyberpower",
"runtimeUnit": "ticks"
},
"actions": [
{
@@ -323,6 +324,7 @@ Each UPS device has a `protocol` field:
| `version` | SNMP version | `1`, `2`, or `3` |
| `timeout` | Timeout in milliseconds | Default: `5000` |
| `upsModel` | UPS brand/model | `cyberpower`, `apc`, `eaton`, `tripplite`, `liebert`, `custom` |
| `runtimeUnit` | Battery runtime unit | `minutes`, `seconds`, or `ticks` (1/100s). Overrides auto-detection |
| `community` | Community string (v1/v2c) | Default: `"public"` |
**SNMPv3 fields** (when `version: 3`):
@@ -659,6 +661,7 @@ sha256sum -c SHA256SUMS.txt --ignore-missing
```json
{
"upsModel": "custom",
"runtimeUnit": "seconds",
"customOIDs": {
"POWER_STATUS": "1.3.6.1.4.1.1234.1.1.0",
"BATTERY_CAPACITY": "1.3.6.1.4.1.1234.1.2.0",
@@ -667,6 +670,8 @@ sha256sum -c SHA256SUMS.txt --ignore-missing
}
```
> 💡 **Tip:** If your UPS (e.g., HPE, Huawei) reports runtime in seconds instead of minutes, set `"runtimeUnit": "seconds"`. For CyberPower-style TimeTicks (1/100 second), use `"ticks"`. When omitted, NUPST auto-detects based on `upsModel`.
### UPSD/NIS-based
Any UPS supported by [NUT (Network UPS Tools)](https://networkupstools.org/) — this covers **hundreds of models** from virtually every manufacturer, including USB-connected devices. Check the [NUT hardware compatibility list](https://networkupstools.org/stable-hcl.html).
@@ -848,7 +853,7 @@ nupst/
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

View File

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

View File

@@ -974,6 +974,35 @@ export class UpsHandler {
OUTPUT_CURRENT: '',
};
}
// Runtime unit selection
logger.log('');
logger.info('Battery Runtime Unit:');
logger.dim(' Controls how NUPST interprets the runtime value from your UPS.');
logger.dim(' 1) Minutes (APC, TrippLite, Liebert - most common)');
logger.dim(' 2) Seconds (Eaton, HPE, many RFC 1628 devices)');
logger.dim(' 3) Ticks (CyberPower - 1/100 second increments)');
const defaultUnitValue = snmpConfig.runtimeUnit === 'seconds'
? 2
: snmpConfig.runtimeUnit === 'ticks'
? 3
: snmpConfig.upsModel === 'cyberpower'
? 3
: snmpConfig.upsModel === 'eaton'
? 2
: 1;
const unitInput = await prompt(`Select runtime unit [${defaultUnitValue}]: `);
const unitValue = parseInt(unitInput, 10) || defaultUnitValue;
if (unitValue === 2) {
snmpConfig.runtimeUnit = 'seconds';
} else if (unitValue === 3) {
snmpConfig.runtimeUnit = 'ticks';
} else {
snmpConfig.runtimeUnit = 'minutes';
}
}
/**
@@ -1296,6 +1325,7 @@ export class UpsHandler {
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
logger.logBoxLine(`Runtime Unit: ${ups.snmp.runtimeUnit || 'auto'}`);
}
if (ups.groups && ups.groups.length > 0) {

View File

@@ -142,7 +142,7 @@ export class NupstDaemon {
/** Default configuration */
private readonly DEFAULT_CONFIG: INupstConfig = {
version: '4.2',
version: '4.3',
upsDevices: [
{
id: 'default',
@@ -162,6 +162,7 @@ export class NupstDaemon {
privKey: '',
// UPS model for OID selection
upsModel: 'cyberpower',
runtimeUnit: 'ticks',
},
groups: [],
actions: [
@@ -260,7 +261,7 @@ export class NupstDaemon {
// Ensure version is always set and remove legacy fields before saving
const configToSave: INupstConfig = {
version: '4.2',
version: '4.3',
upsDevices: config.upsDevices,
groups: config.groups,
checkInterval: config.checkInterval,

View File

@@ -10,3 +10,4 @@ export { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
export { MigrationV3ToV4 } from './migration-v3-to-v4.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';
export { MigrationV4_2ToV4_3 } from './migration-v4.2-to-v4.3.ts';

View File

@@ -3,6 +3,7 @@ import { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
import { MigrationV3ToV4 } from './migration-v3-to-v4.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 { MigrationV4_2ToV4_3 } from './migration-v4.2-to-v4.3.ts';
import { logger } from '../logger.ts';
/**
@@ -21,6 +22,7 @@ export class MigrationRunner {
new MigrationV3ToV4(),
new MigrationV4_0ToV4_1(),
new MigrationV4_1ToV4_2(),
new MigrationV4_2ToV4_3(),
];
// Sort by version order to ensure they run in sequence

View File

@@ -0,0 +1,50 @@
import { BaseMigration } from './base-migration.ts';
import { logger } from '../logger.ts';
/**
* Migration from v4.2 to v4.3
*
* Changes:
* 1. Adds `runtimeUnit` to SNMP configs based on existing `upsModel`
* 2. Bumps version from '4.2' to '4.3'
*/
export class MigrationV4_2ToV4_3 extends BaseMigration {
readonly fromVersion = '4.2';
readonly toVersion = '4.3';
shouldRun(config: Record<string, unknown>): boolean {
return config.version === '4.2';
}
migrate(config: Record<string, unknown>): Record<string, unknown> {
logger.info(`${this.getName()}: Adding runtimeUnit to SNMP configs...`);
const devices = (config.upsDevices as Array<Record<string, unknown>>) || [];
const migratedDevices = devices.map((device) => {
const snmp = device.snmp as Record<string, unknown> | undefined;
if (snmp && !snmp.runtimeUnit) {
const model = snmp.upsModel as string | undefined;
if (model === 'cyberpower') {
snmp.runtimeUnit = 'ticks';
} else if (model === 'eaton') {
snmp.runtimeUnit = 'seconds';
} else {
snmp.runtimeUnit = 'minutes';
}
logger.dim(`${device.name}: Set runtimeUnit to '${snmp.runtimeUnit}'`);
}
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,4 @@
import * as snmp from 'npm:net-snmp@3.26.0';
import * as snmp from 'npm:net-snmp@3.26.1';
import { Buffer } from 'node:buffer';
import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts';
import { UpsOidSets } from './oid-sets.ts';
@@ -357,7 +357,7 @@ export class NupstSnmp {
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
// Convert to minutes for UPS models with different time units
const processedRuntime = this.processRuntimeValue(config.upsModel, batteryRuntime);
const processedRuntime = this.processRuntimeValue(config, batteryRuntime);
// Process power metrics with vendor-specific scaling
const processedVoltage = this.processVoltageValue(config.upsModel, outputVoltage);
@@ -620,22 +620,46 @@ export class NupstSnmp {
}
/**
* Process runtime value based on UPS model
* @param upsModel UPS model
* Process runtime value based on config runtimeUnit or UPS model
* @param config SNMP configuration (uses runtimeUnit if set, otherwise falls back to upsModel)
* @param batteryRuntime Raw battery runtime value
* @returns Processed runtime in minutes
*/
private processRuntimeValue(
upsModel: TUpsModel | undefined,
config: ISnmpConfig,
batteryRuntime: number,
): number {
if (this.debug) {
logger.dim(`Raw runtime value: ${batteryRuntime}`);
}
// Explicit runtimeUnit takes precedence over model-based detection
if (config.runtimeUnit) {
if (config.runtimeUnit === 'seconds' && batteryRuntime > 0) {
const minutes = Math.floor(batteryRuntime / 60);
if (this.debug) {
logger.dim(
`Converting runtime from ${batteryRuntime} seconds to ${minutes} minutes (runtimeUnit: seconds)`,
);
}
return minutes;
} else if (config.runtimeUnit === 'ticks' && batteryRuntime > 0) {
const minutes = Math.floor(batteryRuntime / 6000);
if (this.debug) {
logger.dim(
`Converting runtime from ${batteryRuntime} ticks to ${minutes} minutes (runtimeUnit: ticks)`,
);
}
return minutes;
}
// runtimeUnit === 'minutes' — return as-is
return batteryRuntime;
}
// Fallback: model-based detection (for configs without runtimeUnit)
const upsModel = config.upsModel;
if (upsModel === 'cyberpower' && batteryRuntime > 0) {
// CyberPower: TimeTicks is in 1/100 seconds, convert to minutes
const minutes = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute
const minutes = Math.floor(batteryRuntime / 6000);
if (this.debug) {
logger.dim(
`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`,
@@ -643,7 +667,6 @@ export class NupstSnmp {
}
return minutes;
} else if (upsModel === 'eaton' && batteryRuntime > 0) {
// Eaton: Runtime is in seconds, convert to minutes
const minutes = Math.floor(batteryRuntime / 60);
if (this.debug) {
logger.dim(
@@ -652,10 +675,9 @@ export class NupstSnmp {
}
return minutes;
} else if (batteryRuntime > 10000) {
// Generic conversion for large tick values (likely TimeTicks)
const minutes = Math.floor(batteryRuntime / 6000);
if (this.debug) {
logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes`);
logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes (heuristic)`);
}
return minutes;
}

View File

@@ -58,6 +58,11 @@ export interface IOidSet {
*/
export type TUpsModel = 'cyberpower' | 'apc' | 'eaton' | 'tripplite' | 'liebert' | 'custom';
/**
* Runtime unit for battery runtime SNMP values
*/
export type TRuntimeUnit = 'minutes' | 'seconds' | 'ticks';
/**
* SNMP Configuration interface
*/
@@ -96,6 +101,8 @@ export interface ISnmpConfig {
upsModel?: TUpsModel;
/** Custom OIDs when using custom UPS model */
customOIDs?: IOidSet;
/** Unit of the battery runtime SNMP value. Overrides model-based auto-detection when set. */
runtimeUnit?: TRuntimeUnit;
}
/**