diff --git a/changelog.md b/changelog.md index 5b8ce7a..6e0aaf8 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## Pending + +### Fixes + +- require confirmed battery state before running Proxmox actions (proxmox) + - Skip Proxmox actions when UPS status is unreachable, unknown, or online + - Respect triggerMode for confirmed onBattery power changes and threshold violations + - Add tests for Proxmox action execution gating + - Bump @git.zone/tsdeno to ^1.4.0 + ## 2026-04-16 - 5.11.1 - fix(deps) remove unused smartchangelog dependency diff --git a/package.json b/package.json index 66c5f9d..28172c9 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,6 @@ }, "packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34", "devDependencies": { - "@git.zone/tsdeno": "^1.3.1" + "@git.zone/tsdeno": "^1.4.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c75637..c913fb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: '@git.zone/tsdeno': - specifier: ^1.3.1 - version: 1.3.1 + specifier: ^1.4.0 + version: 1.4.0 packages: @@ -36,8 +36,8 @@ packages: '@design.estate/dees-element@2.2.4': resolution: {integrity: sha512-O9cA6flBMMd+pBwMQrZXwAWel9yVxgokolb+Em6gvkXxPJ0P/B5UDn4Vc2d4ts3ta55PTBm+l2dPeDVGx/bl7Q==} - '@git.zone/tsdeno@1.3.1': - resolution: {integrity: sha512-1shZOSwMUfmonIe8OsX40EcQcS7/WFt+y+5+TQ7N6QmU+Hp6zjrfFvQwanr5Qcgh/E54WFNTfJk/PsJ26s6Oxw==} + '@git.zone/tsdeno@1.4.0': + resolution: {integrity: sha512-84kFa/uKPTlzeLxtHoFxefk6O9khsWWQ2PCWNbCNYIUqWHUvN9COpGq0GXWtsoxLWPhTTIeHsOX4+O55uT2MPw==} hasBin: true '@lit-labs/ssr-dom-shim@1.5.1': @@ -826,7 +826,7 @@ snapshots: - supports-color - vue - '@git.zone/tsdeno@1.3.1': + '@git.zone/tsdeno@1.4.0': dependencies: '@push.rocks/early': 4.0.4 '@push.rocks/smartcli': 4.0.21 diff --git a/test/test.ts b/test/test.ts index e1846f0..c48c5fe 100644 --- a/test/test.ts +++ b/test/test.ts @@ -42,6 +42,7 @@ import { createInitialUpsStatus } from '../ts/ups-status.ts'; import { MigrationV4_2ToV4_3 } from '../ts/migrations/migration-v4.2-to-v4.3.ts'; import { MigrationV4_3ToV4_4 } from '../ts/migrations/migration-v4.3-to-v4.4.ts'; import { ActionHandler } from '../ts/cli/action-handler.ts'; +import { ProxmoxAction } from '../ts/actions/proxmox-action.ts'; import { renderUpgradeChangelog } from '../ts/upgrade-changelog.ts'; import * as qenv from 'npm:@push.rocks/qenv@^6.0.0'; @@ -927,6 +928,12 @@ class TestAction extends Action { } } +class TestProxmoxAction extends ProxmoxAction { + public testShouldExecute(context: IActionContext): boolean { + return this.shouldExecute(context); + } +} + function createMockContext(overrides: Partial = {}): IActionContext { return { upsId: 'test-ups', @@ -1063,6 +1070,84 @@ Deno.test('Action.shouldExecute: anyChange mode always returns true', () => { ); }); +Deno.test('ProxmoxAction.shouldExecute: skips unreachable and unknown UPS status changes', () => { + const action = new TestProxmoxAction({ type: 'proxmox' }); + + assertEquals( + action.testShouldExecute(createMockContext({ + powerStatus: 'unreachable', + previousPowerStatus: 'online', + triggerReason: 'powerStatusChange', + })), + false, + ); + + assertEquals( + action.testShouldExecute(createMockContext({ + powerStatus: 'unknown', + previousPowerStatus: 'online', + triggerReason: 'powerStatusChange', + })), + false, + ); +}); + +Deno.test('ProxmoxAction.shouldExecute: skips thresholds unless UPS is confirmed on battery', () => { + const action = new TestProxmoxAction({ + type: 'proxmox', + triggerMode: 'onlyThresholds', + thresholds: { battery: 50, runtime: 20 }, + }); + + assertEquals( + action.testShouldExecute(createMockContext({ + powerStatus: 'unreachable', + triggerReason: 'thresholdViolation', + batteryCapacity: 10, + batteryRuntime: 5, + })), + false, + ); + + assertEquals( + action.testShouldExecute(createMockContext({ + powerStatus: 'online', + triggerReason: 'thresholdViolation', + batteryCapacity: 10, + batteryRuntime: 5, + })), + false, + ); +}); + +Deno.test('ProxmoxAction.shouldExecute: allows confirmed battery events', () => { + const powerChangeAction = new TestProxmoxAction({ type: 'proxmox' }); + const thresholdAction = new TestProxmoxAction({ + type: 'proxmox', + triggerMode: 'onlyThresholds', + thresholds: { battery: 50, runtime: 20 }, + }); + + assertEquals( + powerChangeAction.testShouldExecute(createMockContext({ + powerStatus: 'onBattery', + previousPowerStatus: 'online', + triggerReason: 'powerStatusChange', + })), + true, + ); + + assertEquals( + thresholdAction.testShouldExecute(createMockContext({ + powerStatus: 'onBattery', + triggerReason: 'thresholdViolation', + batteryCapacity: 10, + batteryRuntime: 5, + })), + true, + ); +}); + // ----------------------------------------------------------------------------- // Action Handler Tests // ----------------------------------------------------------------------------- diff --git a/ts/actions/proxmox-action.ts b/ts/actions/proxmox-action.ts index ebe0a4e..7e50bc4 100644 --- a/ts/actions/proxmox-action.ts +++ b/ts/actions/proxmox-action.ts @@ -30,6 +30,69 @@ export class ProxmoxAction extends Action { readonly type = 'proxmox'; private static readonly activeRunKeys = new Set(); + protected override shouldExecute(context: IActionContext): boolean { + const mode = this.config.triggerMode || 'powerChangesAndThresholds'; + + if (context.powerStatus !== 'onBattery') { + if (context.powerStatus === 'unreachable') { + logger.info( + 'Proxmox action skipped: UPS is unreachable (communication failure, actual state unknown)', + ); + } else { + logger.info( + `Proxmox action skipped: UPS is not on battery (status: ${context.powerStatus})`, + ); + } + return false; + } + + if (context.triggerReason === 'thresholdViolation') { + if (mode === 'onlyPowerChanges') { + logger.info('Proxmox action skipped: triggerMode is onlyPowerChanges, ignoring threshold'); + return false; + } + + return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime); + } + + if (context.triggerReason === 'powerStatusChange') { + if (mode === 'onlyThresholds') { + logger.info('Proxmox action skipped: triggerMode is onlyThresholds, ignoring power change'); + return false; + } + + const previousPowerStatus = context.previousPowerStatus; + if (previousPowerStatus === 'online') { + logger.info('Proxmox action triggered: power loss detected (online -> onBattery)'); + return true; + } + + if (previousPowerStatus === 'unknown') { + if ( + mode === 'onlyPowerChanges' || mode === 'powerChangesAndThresholds' || + mode === 'anyChange' + ) { + logger.info( + 'Proxmox action triggered: UPS on battery at daemon startup (unknown -> onBattery)', + ); + return true; + } + return false; + } + + logger.info( + `Proxmox action skipped: non-emergency transition (${previousPowerStatus} -> ${context.powerStatus})`, + ); + return false; + } + + if (mode === 'anyChange') { + return true; + } + + return false; + } + private static findCliTool(command: string): string | null { for (const dir of PROXMOX.CLI_TOOL_PATHS) { const candidate = `${dir}/${command}`;