diff --git a/package-lock.json b/package-lock.json index ebeb561..7e2253e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "express": "^4.21.2", "geoip-lite": "^1.4.10", "i18next": "^23.15.1", + "node-hid": "^3.3.0", "postcss": "^8.4.40", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -9474,6 +9475,12 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "license": "MIT" + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -9524,6 +9531,23 @@ } } }, + "node_modules/node-hid": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/node-hid/-/node-hid-3.3.0.tgz", + "integrity": "sha512-j+dFgJLRAE0nufQKXk3IfS6T6YuHhCgMvz4TrG0sgtb6DSCdYpfJ1etcdmeCmPQjUgO+yo32ktVrRliNs/+fmg==", + "hasInstallScript": true, + "license": "(MIT OR X11)", + "dependencies": { + "node-addon-api": "^3.2.1", + "pkg-prebuilds": "^1.0.0" + }, + "bin": { + "hid-showdevices": "src/show-devices.js" + }, + "engines": { + "node": ">=10.16" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -10017,6 +10041,63 @@ "node": ">=8" } }, + "node_modules/pkg-prebuilds": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/pkg-prebuilds/-/pkg-prebuilds-1.0.0.tgz", + "integrity": "sha512-D9wlkXZCmjxj2kBHTw3fGSyjoahr33breGBoJcoezpi7ouYS59DJVOHMZ+dgqacSrZiJo4qtkXxLQTE+BqXJmQ==", + "license": "MIT", + "dependencies": { + "yargs": "^17.7.2" + }, + "bin": { + "pkg-prebuilds-copy": "bin/copy.mjs", + "pkg-prebuilds-verify": "bin/verify.mjs" + }, + "engines": { + "node": ">= 14.15.0" + } + }, + "node_modules/pkg-prebuilds/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/pkg-prebuilds/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/pkg-prebuilds/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/pluralize": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-1.2.1.tgz", diff --git a/package.json b/package.json index bab55ad..c9c9b62 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "express": "^4.21.2", "geoip-lite": "^1.4.10", "i18next": "^23.15.1", + "node-hid": "^3.3.0", "postcss": "^8.4.40", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/api/android/adb/AdbManager.ts b/src/api/android/adb/AdbManager.ts index f016aca..76e1db3 100644 --- a/src/api/android/adb/AdbManager.ts +++ b/src/api/android/adb/AdbManager.ts @@ -203,6 +203,20 @@ export class AdbManager { return isReady; } + /** Send reboot -p to every currently streaming ADB-connected headset */ + async shutdownAllHeadsets(): Promise { + for (const device of this.clientCurrentlyStreaming) { + try { + const transport = await this.adbServer.createTransport(device); + const adb = new Adb(transport); + await adb.subprocess.noneProtocol.spawn('reboot -p'); + logger.info(`[${device.serial}] Power-off command sent`); + } catch (e) { + logger.warn(`[${device.serial}] Failed to send power-off command: {e}`, { e }); + } + } + } + async disconnectDevice(serial: string): Promise { const index = this.clientCurrentlyStreaming.findIndex(d => d.serial === serial); if (index > -1) this.clientCurrentlyStreaming.splice(index, 1); diff --git a/src/api/core/Controller.ts b/src/api/core/Controller.ts index b27ec51..e574fb6 100644 --- a/src/api/core/Controller.ts +++ b/src/api/core/Controller.ts @@ -7,6 +7,8 @@ import { useAdb, ENV_GAMALESS } from "../index.ts"; import { JsonPlayerAsk, JsonOutput } from "./Constants.ts"; // import {mDnsService} from "../infra/mDnsService.ts"; import { getLogger } from "@logtape/logtape"; +import { spawnSync } from 'child_process'; +import { UpsManager } from '../infra/ups/UpsManager.ts'; const logger = getLogger(["core", "Controller"]); @@ -17,6 +19,7 @@ export class Controller { gama_connector: GamaConnector | undefined; adb_manager: AdbManager | undefined; + ups_service: UpsManager; // mDnsService: mDnsService; @@ -43,12 +46,45 @@ export class Controller { } else { logger.warn("Couldn't find ADB working or started, cancelling ADB management") } + + this.ups_service = new UpsManager(); } // Allow running init functions for some components needing it async initialize() { if (this.adb_manager) await this.adb_manager.init(); + + await this.ups_service.connect(); + + const THREE_HOURS_MS = 3 * 60 * 60 * 1000; + setTimeout(() => void this.handleSessionTimeout(), THREE_HOURS_MS); + logger.info('Session timer started — shutdown sequence armed for 3h if on battery'); + } + + /** Called after 3 hours. Shuts down headsets, UPS, and host if UPS is on battery. */ + private async handleSessionTimeout(): Promise { + logger.warn('3-hour session timer fired'); + + if (this.ups_service.isConnected() && this.ups_service.isOnAC()) { + logger.info('UPS is on AC power — no shutdown needed'); + return; + } + + logger.warn('UPS is on battery or not connected — initiating shutdown sequence'); + + // (1) Power off all headsets + if (this.adb_manager) + await this.adb_manager.shutdownAllHeadsets(); + + // (2) Arm UPS output cut in 2 minutes (only executes when on battery) + this.ups_service.armShutdown(120); + + // (3) Shutdown host computer after 30s to allow headsets and UPS time to process + setTimeout(() => { + logger.warn('Shutting down host computer now'); + spawnSync('shutdown', ['-h', 'now']); + }, 30_000); } async restart() { diff --git a/src/api/infra/ups/UpsManager.ts b/src/api/infra/ups/UpsManager.ts new file mode 100644 index 0000000..6f76089 --- /dev/null +++ b/src/api/infra/ups/UpsManager.ts @@ -0,0 +1,188 @@ +import { ApcUpsHid } from './apc-ups-hid.ts'; +import { getLogger } from '@logtape/logtape'; + +const logger = getLogger(['infra', 'UpsManager']); + +const MAX_RETRIES = 4; +const RETRY_DELAY_MS = 5000; +const RECONNECT_DELAY_MS = 5000; +const EXPECTED_PRODUCT = 'Back-UPS BX2200MI'; + +export class UpsManager { + // Single ApcUpsHid instance reused across reconnects — event handlers registered once stay valid + private ups: ApcUpsHid; + private _closed = false; + private reconnectTimer: ReturnType | null = null; + + constructor() { + this.ups = new ApcUpsHid({ autoOpen: false }); + this.setupEventHandlers(); + } + + /** + * Attempt to open the UPS connection, with up to MAX_RETRIES attempts. + * @returns true if connected, false if all attempts failed + */ + async connect(): Promise { + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + this.ups.open(); + + const info = this.ups.info; + if (info && !info.product.includes(EXPECTED_PRODUCT)) { + const border = "=".repeat(58); + logger.warn(border); + logger.warn("= ="); + logger.warn(`= UPS product "{product}" is not "${EXPECTED_PRODUCT}" =`, { product: info.product }); + logger.warn("= - behavior may differ from expected. ="); + logger.warn("= ="); + logger.warn(border); + } + + this.ups.setBeeper('disabled'); + logger.debug(`[{product}] UPS beeper disabled`, { product: info?.product }); + + this.ups.startPolling(5000); + return true; + } catch (e) { + logger.warn(`UPS connection attempt ${attempt}/${MAX_RETRIES} failed: {error}`, { error: e instanceof Error ? e.message : String(e) }); + if (attempt < MAX_RETRIES) { + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS)); + } + } + } + + logger.warn(`Could not connect to UPS after ${MAX_RETRIES} attempts — UPS monitoring disabled`); + return false; + } + + private setupEventHandlers(): void { + this.ups.on('connected', (info) => { + const status = this.ups.getStatus(); + logger.info('UPS device opened: {product}\n\t=> battery={charge}% runtime={runtime}min ac={ac}', { + product: info.product, + charge: status.batteryCharge, + runtime: status.runtimeMinutes, + ac: status.acPresent, + }); + logger.debug('UPS device opened: {product} (S/N: {serial})\n\t- battery={charge}% runtime={runtime}min ac={ac} voltage={voltage}V', { + product: info.product, + serial: info.serialNumber, + charge: status.batteryCharge, + runtime: status.runtimeMinutes, + ac: status.acPresent, + voltage: status.inputVoltage, + }); + }); + + this.ups.on('disconnected', () => { + logger.warn('UPS device disconnected'); + if (!this._closed) this.scheduleReconnect(); + }); + + // HID read error likely means the USB cable was pulled — close cleanly so 'disconnected' fires and triggers reconnect + this.ups.on('error', (error) => { + logger.error('UPS HID error: {error}', { error: error.message }); + if (!this._closed) { + try { this.ups.close(); } catch { /* 'disconnected' event will schedule the reconnect */ } + } + }); + + this.ups.on('power-lost', () => { + logger.warn('UPS: AC power LOST — now running on battery'); + }); + + this.ups.on('power-restored', () => { + logger.info('UPS: AC power RESTORED — back on mains'); + }); + + this.ups.on('battery-low', (charge) => { + logger.warn('UPS: Battery LOW at {charge}%', { charge }); + }); + + this.ups.on('battery-critical', (charge) => { + logger.error('UPS: Battery CRITICAL at {charge}% — immediate shutdown risk', { charge }); + }); + + this.ups.on('status', (status) => { + logger.trace('UPS status: battery={charge}% runtime={runtime}min ac={ac} voltage={voltage}V', { + charge: status.batteryCharge, + runtime: status.runtimeMinutes, + ac: status.acPresent, + voltage: status.inputVoltage, + }); + }); + } + + private scheduleReconnect(): void { + if (this._closed || this.reconnectTimer) return; + logger.info('UPS reconnect scheduled in {delay}ms', { delay: RECONNECT_DELAY_MS }); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + void this.doReconnect(); + }, RECONNECT_DELAY_MS); + } + + private async doReconnect(): Promise { + if (this._closed) return; + logger.info('Attempting UPS reconnect...'); + try { + this.ups.open(); + this.ups.setBeeper('disabled'); + this.ups.startPolling(5000); + logger.info('[{product}] UPS reconnected', { product: this.ups.info?.product }); + } catch (e) { + logger.warn('UPS reconnect failed: {error} — retrying in {delay}ms', { + error: e instanceof Error ? e.message : String(e), + delay: RECONNECT_DELAY_MS, + }); + this.scheduleReconnect(); + } + } + + /** Whether the UPS HID device is currently open */ + isConnected(): boolean { + return this.ups.isConnected; + } + + /** Whether AC power is present. Returns false if UPS is not connected. */ + isOnAC(): boolean { + if (!this.ups.isConnected) return false; + try { + return this.ups.isOnAC(); + } catch (e) { + logger.warn('Failed to read AC status from UPS: {error}', { error: e instanceof Error ? e.message : String(e) }); + return false; + } + } + + /** + * Arm the UPS shutdown timer. + * NOTE: APC UPS devices only execute the shutdown when running on battery. + * @param seconds - seconds before UPS cuts output power + */ + armShutdown(seconds: number): void { + if (!this.ups.isConnected) { + logger.warn('Cannot arm UPS shutdown — UPS not connected'); + return; + } + try { + this.ups.shutdown(seconds); + logger.warn('UPS shutdown armed: output will cut in {seconds}s (only takes effect on battery)', { seconds }); + } catch (e) { + logger.error('Failed to arm UPS shutdown: {error}', { error: e instanceof Error ? e.message : String(e) }); + } + } + + /** Permanently close the UPS connection and cancel any pending reconnect */ + close(): void { + this._closed = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.ups.close(); + } +} + +export default UpsManager; diff --git a/src/api/infra/ups/apc-ups-hid.d.ts b/src/api/infra/ups/apc-ups-hid.d.ts new file mode 100644 index 0000000..e33ea34 --- /dev/null +++ b/src/api/infra/ups/apc-ups-hid.d.ts @@ -0,0 +1,189 @@ +/** + * apc-ups-hid — Direct HID control of APC UPS devices on macOS (and Linux) + * + * Bypasses NUT/libusb entirely by using node-hid (hidapi/IOKit), which + * coexists with macOS's native DriverKit UPS drivers instead of fighting them. + * + * Works on stock macOS — no SIP, AMFI, or entitlement signing required. + * + * Tested on: APC Back-UPS BX2200MI (vendorId 0x051d, productId 0x0002) + * + * @example + * ```ts + * import { ApcUpsHid } from './apc-ups-hid'; + * + * const ups = new ApcUpsHid(); + * ups.on('status', (status) => console.log(status)); + * ups.on('power-lost', () => console.log('On battery!')); + * ups.on('power-restored', () => console.log('AC is back!')); + * ups.startPolling(5000); + * + * // Mute the beeper + * ups.setBeeper('mute'); + * + * // Shutdown UPS in 60 seconds (only works on battery!) + * ups.shutdown(60); + * + * // Cancel a pending shutdown + * ups.cancelShutdown(); + * + * // Cleanup + * ups.close(); + * ``` + */ +import { EventEmitter } from 'events'; +export interface UpsDeviceInfo { + manufacturer: string; + product: string; + serialNumber: string; + path: string; +} +export interface UpsStatus { + /** Battery charge percentage (0–100) */ + batteryCharge: number; + /** Estimated runtime remaining in seconds */ + runtimeSeconds: number; + /** Estimated runtime remaining in minutes (convenience) */ + runtimeMinutes: number; + /** Current input voltage (0 when on battery) */ + inputVoltage: number; + /** Precise input voltage from the UPS ADC */ + inputVoltagePrecise: number; + /** Whether AC power is currently present */ + acPresent: boolean; + /** Whether the UPS is currently on battery */ + onBattery: boolean; + /** Shutdown timer state: -1 = not armed, >0 = seconds until shutdown */ + shutdownTimer: number; + /** Beeper state: 'disabled' | 'enabled' | 'muted' | 'unknown' */ + beeperStatus: BeeperState; + /** Nominal output power in watts */ + nominalPowerWatts: number; + /** Low voltage transfer point */ + lowVoltageTransfer: number; + /** High voltage transfer point */ + highVoltageTransfer: number; + /** Full charge capacity percentage */ + fullChargeCapacity: number; + /** Raw timestamp of this reading */ + timestamp: number; +} +export type BeeperState = 'disabled' | 'enabled' | 'muted' | 'unknown'; +export interface ApcUpsHidOptions { + /** USB Vendor ID (default: 0x051d for APC) */ + vendorId?: number; + /** USB Product ID (default: 0x0002) */ + productId?: number; + /** Auto-open device on construction (default: true) */ + autoOpen?: boolean; +} +export interface ApcUpsHidEvents { + /** Emitted on every poll with the full status */ + status: (status: UpsStatus) => void; + /** Emitted when AC power is lost (UPS switches to battery) */ + 'power-lost': () => void; + /** Emitted when AC power is restored */ + 'power-restored': () => void; + /** Emitted when battery charge drops below 20% */ + 'battery-low': (charge: number) => void; + /** Emitted when battery charge drops below 10% */ + 'battery-critical': (charge: number) => void; + /** Emitted when the UPS device is opened */ + 'connected': (info: UpsDeviceInfo) => void; + /** Emitted when the UPS device is closed or lost */ + 'disconnected': () => void; + /** Emitted on any HID communication error */ + 'error': (error: Error) => void; +} +export declare class ApcUpsHid extends EventEmitter { + private device; + private deviceInfo; + private pollInterval; + private lastAcPresent; + private lastBatteryCharge; + private readonly vendorId; + private readonly productId; + constructor(options?: ApcUpsHidOptions); + on(event: K, listener: ApcUpsHidEvents[K]): this; + emit(event: K, ...args: Parameters): boolean; + /** Open the HID connection to the UPS */ + open(): void; + /** Close the HID connection and stop polling */ + close(): void; + /** Whether the device is currently open */ + get isConnected(): boolean; + /** Device information (null if not connected) */ + get info(): UpsDeviceInfo | null; + /** Read a single-byte feature report value */ + private readU8; + /** Read a 16-bit unsigned LE feature report value */ + private readU16; + /** Read a 16-bit signed LE feature report value */ + private readS16; + /** Write a single-byte feature report */ + private writeU8; + /** Write a 16-bit LE feature report */ + private writeU16; + private assertOpen; + /** Get the full UPS status in a single call */ + getStatus(): UpsStatus; + /** Get battery charge percentage (0–100) */ + getBatteryCharge(): number; + /** Get estimated runtime remaining in seconds */ + getRuntimeSeconds(): number; + /** Check if AC power is present */ + isOnAC(): boolean; + /** Get input voltage */ + getInputVoltage(): number; + /** + * Arm the UPS shutdown timer. + * + * **IMPORTANT**: APC UPS devices ignore this command while on AC power. + * The UPS must be running on battery for the shutdown to take effect. + * + * @param delaySeconds - Seconds before the UPS cuts output power (min: 1) + */ + shutdown(delaySeconds: number): void; + /** + * Cancel a pending shutdown. + * Writes -1 (0xFFFF) to the shutdown timer, which disarms it. + */ + cancelShutdown(): void; + /** + * Check whether a shutdown is currently armed. + * @returns The remaining seconds, or -1 if not armed. + */ + getShutdownTimer(): number; + /** + * Set the beeper state. + * @param state - 'enabled' (beep on events), 'muted' (silence), or 'disabled' + */ + setBeeper(state: 'enabled' | 'muted' | 'disabled'): void; + /** Get the current beeper state */ + getBeeperStatus(): BeeperState; + /** + * Set the startup delay (seconds the UPS waits before restoring power + * after AC returns following a shutdown). + */ + setStartupDelay(seconds: number): void; + /** Get the current startup delay in seconds */ + getStartupDelay(): number; + /** + * Start periodic status polling. + * Emits 'status' on every tick, plus 'power-lost', 'power-restored', + * 'battery-low', and 'battery-critical' events on state transitions. + * + * @param intervalMs - Poll interval in milliseconds (default: 5000) + */ + startPolling(intervalMs?: number): void; + /** Stop periodic polling */ + stopPolling(): void; + /** Whether polling is currently active */ + get isPolling(): boolean; + /** + * List all connected APC UPS devices without opening them. + */ + static listDevices(vendorId?: number, productId?: number): UpsDeviceInfo[]; +} +export default ApcUpsHid; +//# sourceMappingURL=apc-ups-hid.d.ts.map \ No newline at end of file diff --git a/src/api/infra/ups/apc-ups-hid.ts b/src/api/infra/ups/apc-ups-hid.ts new file mode 100644 index 0000000..5449429 --- /dev/null +++ b/src/api/infra/ups/apc-ups-hid.ts @@ -0,0 +1,490 @@ +/** + * apc-ups-hid — Direct HID control of APC UPS devices on macOS (and Linux) + * + * Bypasses NUT/libusb entirely by using node-hid (hidapi/IOKit), which + * coexists with macOS's native DriverKit UPS drivers instead of fighting them. + * + * Works on stock macOS — no SIP, AMFI, or entitlement signing required. + * + * Tested on: APC Back-UPS BX2200MI (vendorId 0x051d, productId 0x0002) + * + * @example + * ```ts + * import { ApcUpsHid } from './apc-ups-hid'; + * + * const ups = new ApcUpsHid(); + * ups.on('status', (status) => console.log(status)); + * ups.on('power-lost', () => console.log('On battery!')); + * ups.on('power-restored', () => console.log('AC is back!')); + * ups.startPolling(5000); + * + * // Mute the beeper + * ups.setBeeper('mute'); + * + * // Shutdown UPS in 60 seconds (only works on battery!) + * ups.shutdown(60); + * + * // Cancel a pending shutdown + * ups.cancelShutdown(); + * + * // Cleanup + * ups.close(); + * ``` + */ + +import { EventEmitter } from 'events'; +import HID from 'node-hid'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface UpsDeviceInfo { + manufacturer: string; + product: string; + serialNumber: string; + path: string; +} + +export interface UpsStatus { + /** Battery charge percentage (0–100) */ + batteryCharge: number; + /** Estimated runtime remaining in seconds */ + runtimeSeconds: number; + /** Estimated runtime remaining in minutes (convenience) */ + runtimeMinutes: number; + /** Current input voltage (0 when on battery) */ + inputVoltage: number; + /** Precise input voltage from the UPS ADC */ + inputVoltagePrecise: number; + /** Whether AC power is currently present */ + acPresent: boolean; + /** Whether the UPS is currently on battery */ + onBattery: boolean; + /** Shutdown timer state: -1 = not armed, >0 = seconds until shutdown */ + shutdownTimer: number; + /** Beeper state: 'disabled' | 'enabled' | 'muted' | 'unknown' */ + beeperStatus: BeeperState; + /** Nominal output power in watts */ + nominalPowerWatts: number; + /** Low voltage transfer point */ + lowVoltageTransfer: number; + /** High voltage transfer point */ + highVoltageTransfer: number; + /** Full charge capacity percentage */ + fullChargeCapacity: number; + /** Raw timestamp of this reading */ + timestamp: number; +} + +export type BeeperState = 'disabled' | 'enabled' | 'muted' | 'unknown'; + +export interface ApcUpsHidOptions { + /** USB Vendor ID (default: 0x051d for APC) */ + vendorId?: number; + /** USB Product ID (default: 0x0002) */ + productId?: number; + /** Auto-open device on construction (default: true) */ + autoOpen?: boolean; +} + +export interface ApcUpsHidEvents { + /** Emitted on every poll with the full status */ + status: (status: UpsStatus) => void; + /** Emitted when AC power is lost (UPS switches to battery) */ + 'power-lost': () => void; + /** Emitted when AC power is restored */ + 'power-restored': () => void; + /** Emitted when battery charge drops below 20% */ + 'battery-low': (charge: number) => void; + /** Emitted when battery charge drops below 10% */ + 'battery-critical': (charge: number) => void; + /** Emitted when the UPS device is opened */ + 'connected': (info: UpsDeviceInfo) => void; + /** Emitted when the UPS device is closed or lost */ + 'disconnected': () => void; + /** Emitted on any HID communication error */ + 'error': (error: Error) => void; +} + +// ─── HID Report Map (APC Back-UPS BXnnnnMI) ───────────────────────────────── +// +// Mapped from NUT apc-hid.c source + empirical discovery on BX2200MI. +// Report IDs are specific to this device family. Other APC models may differ. + +const REPORT = { + // ── Read-only status ── + REMAINING_CAPACITY: 0x0c, // 1 byte, 0–100% + FULL_CHARGE_CAPACITY: 0x0d, // 1 byte, 0–100% + DESIGN_CAPACITY: 0x0e, // 1 byte, 0–100% + RUNTIME_TO_EMPTY: 0x0f, // 2 bytes LE, seconds + AC_PRESENT: 0x13, // 1 byte, 1=AC present, 0=on battery + INPUT_VOLTAGE: 0x30, // 1 byte, volts (approximate) + INPUT_VOLTAGE_PRECISE: 0x31, // 2 bytes LE, volts (drops to 0 on battery) + LOW_VOLTAGE_TRANSFER: 0x32, // 2 bytes LE, volts + HIGH_VOLTAGE_TRANSFER: 0x33, // 2 bytes LE, volts + CONFIG_ACTIVE_POWER: 0x52, // 2 bytes LE, watts + + // ── Writable timers ── + DELAY_BEFORE_SHUTDOWN: 0x15, // 2 bytes LE, signed: -1=not armed, >0=seconds + DELAY_BEFORE_STARTUP: 0x16, // 2 bytes LE, seconds + DELAY_BEFORE_REBOOT: 0x17, // 2 bytes LE, seconds + + // ── Beeper control ── + // HID descriptor: Usage Page 0x84, Usage 0x5A (AudibleAlarmControl) + // Values: 1=disabled, 2=enabled, 3=muted + AUDIBLE_ALARM_CONTROL: 0x78, // 1 byte + + // ── Status bitfield ── + PRESENT_STATUS: 0x07, // variable-length bitfield +} as const; + +// Beeper values per USB HID Power Device spec +const BEEPER_VALUES: Record = { + disabled: 1, + enabled: 2, + muted: 3, + unknown: 0, +}; + +const BEEPER_LABELS: Record = { + 1: 'disabled', + 2: 'enabled', + 3: 'muted', +}; + +// ─── Main Class ────────────────────────────────────────────────────────────── + +export class ApcUpsHid extends EventEmitter { + private device: HID.HID | null = null; + private deviceInfo: UpsDeviceInfo | null = null; + private pollInterval: ReturnType | null = null; + private lastAcPresent: boolean | null = null; + private lastBatteryCharge: number | null = null; + + private readonly vendorId: number; + private readonly productId: number; + + constructor(options: ApcUpsHidOptions = {}) { + super(); + this.vendorId = options.vendorId ?? 0x051d; + this.productId = options.productId ?? 0x0002; + + if (options.autoOpen !== false) { + this.open(); + } + } + + // ── Typed event emitter overrides ── + + on(event: K, listener: ApcUpsHidEvents[K]): this { + return super.on(event, listener); + } + + emit(event: K, ...args: Parameters): boolean { + return super.emit(event, ...args); + } + + // ── Connection ── + + /** Open the HID connection to the UPS */ + open(): void { + if (this.device) return; + + const devices = HID.devices().filter( + (d) => d.vendorId === this.vendorId && d.productId === this.productId + ); + + if (devices.length === 0) { + throw new Error( + `No APC UPS found (vendorId=0x${this.vendorId.toString(16)}, ` + + `productId=0x${this.productId.toString(16)}). Is it plugged in via USB?` + ); + } + + const target = devices[0]; + this.device = new HID.HID(target.path!); + this.deviceInfo = { + manufacturer: target.manufacturer ?? 'Unknown', + product: target.product ?? 'Unknown', + serialNumber: target.serialNumber ?? 'Unknown', + path: target.path!, + }; + + this.emit('connected', this.deviceInfo); + } + + /** Close the HID connection and stop polling */ + close(): void { + this.stopPolling(); + if (this.device) { + try { this.device.close(); } catch { /* ignore */ } + this.device = null; + this.deviceInfo = null; + this.lastAcPresent = null; + this.lastBatteryCharge = null; + this.emit('disconnected'); + } + } + + /** Whether the device is currently open */ + get isConnected(): boolean { + return this.device !== null; + } + + /** Device information (null if not connected) */ + get info(): UpsDeviceInfo | null { + return this.deviceInfo; + } + + // ── Reading ── + + /** Read a single-byte feature report value */ + private readU8(reportId: number): number { + this.assertOpen(); + const buf = this.device!.getFeatureReport(reportId, 2); + return buf.length >= 2 ? buf[1] : buf[0]; + } + + /** Read a 16-bit unsigned LE feature report value */ + private readU16(reportId: number): number { + this.assertOpen(); + const buf = this.device!.getFeatureReport(reportId, 3); + if (buf.length >= 3) return buf[1] | (buf[2] << 8); + if (buf.length === 2) return buf[0] | (buf[1] << 8); + return buf[0]; + } + + /** Read a 16-bit signed LE feature report value */ + private readS16(reportId: number): number { + const val = this.readU16(reportId); + return val > 32767 ? val - 65536 : val; + } + + /** Write a single-byte feature report */ + private writeU8(reportId: number, value: number): void { + this.assertOpen(); + this.device!.sendFeatureReport([reportId, value & 0xff]); + } + + /** Write a 16-bit LE feature report */ + private writeU16(reportId: number, value: number): void { + this.assertOpen(); + const unsigned = value < 0 ? value + 65536 : value; + this.device!.sendFeatureReport([ + reportId, + unsigned & 0xff, + (unsigned >> 8) & 0xff, + ]); + } + + private assertOpen(): void { + if (!this.device) { + throw new Error('UPS device is not open. Call open() first.'); + } + } + + /** Get the full UPS status in a single call */ + getStatus(): UpsStatus { + const batteryCharge = this.readU8(REPORT.REMAINING_CAPACITY); + const runtimeSeconds = this.readU16(REPORT.RUNTIME_TO_EMPTY); + const acPresentRaw = this.readU8(REPORT.AC_PRESENT); + const inputVoltage = this.readU8(REPORT.INPUT_VOLTAGE); + const inputVoltagePrecise = this.readU16(REPORT.INPUT_VOLTAGE_PRECISE); + const shutdownTimer = this.readS16(REPORT.DELAY_BEFORE_SHUTDOWN); + const beeperRaw = this.readU8(REPORT.AUDIBLE_ALARM_CONTROL); + const nominalPower = this.readU16(REPORT.CONFIG_ACTIVE_POWER); + const lowVoltage = this.readU16(REPORT.LOW_VOLTAGE_TRANSFER); + const highVoltage = this.readU16(REPORT.HIGH_VOLTAGE_TRANSFER); + const fullCharge = this.readU8(REPORT.FULL_CHARGE_CAPACITY); + + const acPresent = acPresentRaw === 1; + + return { + batteryCharge, + runtimeSeconds, + runtimeMinutes: Math.round(runtimeSeconds / 60 * 10) / 10, + inputVoltage, + inputVoltagePrecise, + acPresent, + onBattery: !acPresent, + shutdownTimer, + beeperStatus: BEEPER_LABELS[beeperRaw] ?? 'unknown', + nominalPowerWatts: nominalPower, + lowVoltageTransfer: lowVoltage, + highVoltageTransfer: highVoltage, + fullChargeCapacity: fullCharge, + timestamp: Date.now(), + }; + } + + /** Get battery charge percentage (0–100) */ + getBatteryCharge(): number { + return this.readU8(REPORT.REMAINING_CAPACITY); + } + + /** Get estimated runtime remaining in seconds */ + getRuntimeSeconds(): number { + return this.readU16(REPORT.RUNTIME_TO_EMPTY); + } + + /** Check if AC power is present */ + isOnAC(): boolean { + return this.readU8(REPORT.AC_PRESENT) === 1; + } + + /** Get input voltage */ + getInputVoltage(): number { + return this.readU8(REPORT.INPUT_VOLTAGE); + } + + // ── Commands ── + + /** + * Arm the UPS shutdown timer. + * + * **IMPORTANT**: APC UPS devices ignore this command while on AC power. + * The UPS must be running on battery for the shutdown to take effect. + * + * @param delaySeconds - Seconds before the UPS cuts output power (min: 1) + */ + shutdown(delaySeconds: number): void { + if (delaySeconds < 1) { + throw new Error('Shutdown delay must be at least 1 second.'); + } + if (delaySeconds > 32767) { + throw new Error('Shutdown delay must be at most 32767 seconds (~9 hours).'); + } + this.writeU16(REPORT.DELAY_BEFORE_SHUTDOWN, delaySeconds); + } + + /** + * Cancel a pending shutdown. + * Writes -1 (0xFFFF) to the shutdown timer, which disarms it. + */ + cancelShutdown(): void { + this.writeU16(REPORT.DELAY_BEFORE_SHUTDOWN, -1); + } + + /** + * Check whether a shutdown is currently armed. + * @returns The remaining seconds, or -1 if not armed. + */ + getShutdownTimer(): number { + return this.readS16(REPORT.DELAY_BEFORE_SHUTDOWN); + } + + /** + * Set the beeper state. + * @param state - 'enabled' (beep on events), 'muted' (silence), or 'disabled' + */ + setBeeper(state: 'enabled' | 'muted' | 'disabled'): void { + const value = BEEPER_VALUES[state]; + if (!value) throw new Error(`Invalid beeper state: ${state}`); + this.writeU8(REPORT.AUDIBLE_ALARM_CONTROL, value); + } + + /** Get the current beeper state */ + getBeeperStatus(): BeeperState { + const raw = this.readU8(REPORT.AUDIBLE_ALARM_CONTROL); + return BEEPER_LABELS[raw] ?? 'unknown'; + } + + /** + * Set the startup delay (seconds the UPS waits before restoring power + * after AC returns following a shutdown). + */ + setStartupDelay(seconds: number): void { + this.writeU16(REPORT.DELAY_BEFORE_STARTUP, seconds); + } + + /** Get the current startup delay in seconds */ + getStartupDelay(): number { + return this.readU16(REPORT.DELAY_BEFORE_STARTUP); + } + + // ── Polling ── + + /** + * Start periodic status polling. + * Emits 'status' on every tick, plus 'power-lost', 'power-restored', + * 'battery-low', and 'battery-critical' events on state transitions. + * + * @param intervalMs - Poll interval in milliseconds (default: 5000) + */ + startPolling(intervalMs: number = 5000): void { + this.stopPolling(); + + const poll = () => { + try { + const status = this.getStatus(); + this.emit('status', status); + + // AC power transition events + if (this.lastAcPresent !== null) { + if (this.lastAcPresent && !status.acPresent) { + this.emit('power-lost'); + } else if (!this.lastAcPresent && status.acPresent) { + this.emit('power-restored'); + } + } + this.lastAcPresent = status.acPresent; + + // Battery level events + if (status.onBattery) { + if (status.batteryCharge <= 10 && + (this.lastBatteryCharge === null || this.lastBatteryCharge > 10)) { + this.emit('battery-critical', status.batteryCharge); + } else if (status.batteryCharge <= 20 && + (this.lastBatteryCharge === null || this.lastBatteryCharge > 20)) { + this.emit('battery-low', status.batteryCharge); + } + } + this.lastBatteryCharge = status.batteryCharge; + + } catch (err) { + this.emit('error', err instanceof Error ? err : new Error(String(err))); + } + }; + + // Initial poll immediately + poll(); + this.pollInterval = setInterval(poll, intervalMs); + } + + /** Stop periodic polling */ + stopPolling(): void { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + } + + /** Whether polling is currently active */ + get isPolling(): boolean { + return this.pollInterval !== null; + } + + // ── Static helpers ── + + /** + * List all connected APC UPS devices without opening them. + */ + static listDevices(vendorId = 0x051d, productId = 0x0002): UpsDeviceInfo[] { + const seen = new Set(); + return HID.devices() + .filter((d) => d.vendorId === vendorId && d.productId === productId) + .filter((d) => { + // Deduplicate by path (multiple collections share the same device) + if (seen.has(d.path!)) return false; + seen.add(d.path!); + return true; + }) + .map((d) => ({ + manufacturer: d.manufacturer ?? 'Unknown', + product: d.product ?? 'Unknown', + serialNumber: d.serialNumber ?? 'Unknown', + path: d.path!, + })); + } +} + +export default ApcUpsHid; \ No newline at end of file diff --git a/test/test-ups.ts b/test/test-ups.ts new file mode 100644 index 0000000..37f7223 --- /dev/null +++ b/test/test-ups.ts @@ -0,0 +1,173 @@ +#!/usr/bin/env node +/** + * Test script for apc-ups-hid library + * + * Run: npx tsx test/test-ups.ts + * + * Tests proceed in order. The shutdown test is COMMENTED OUT by default. + */ + +import { ApcUpsHid } from '../src/api/infra/ups/apc-ups-hid'; + +async function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function main() { + console.log('╔══════════════════════════════════════════════════════╗'); + console.log('║ apc-ups-hid Library Test Suite ║'); + console.log('╚══════════════════════════════════════════════════════╝\n'); + + // ── Test 1: Device discovery ── + console.log('── Test 1: Device Discovery ──'); + const devices = ApcUpsHid.listDevices(); + console.log(` Found ${devices.length} APC UPS device(s):`); + devices.forEach(d => { + console.log(d.product); + console.log(d.serialNumber); + console.log(d.manufacturer); + console.log(` ${d.product} (S/N: ${d.serialNumber}) — ${d.manufacturer}`); + }); + if (devices.length === 0) { + console.error(' FAIL: No UPS found. Is it plugged in via USB?'); + process.exit(1); + } + console.log(' PASS ✓\n'); + + // ── Test 2: Open connection ── + console.log('── Test 2: Open Connection ──'); + const ups = new ApcUpsHid(); + console.log(` Connected: ${ups.isConnected}`); + console.log(` Device: ${ups.info?.product}`); + console.log(` Serial: ${ups.info?.serialNumber}`); + console.log(' PASS ✓\n'); + + // ── Test 3: Read full status ── + console.log('── Test 3: Read Full Status ──'); + const status = ups.getStatus(); + console.log(` Battery charge: ${status.batteryCharge}%`); + console.log(` Runtime remaining: ${status.runtimeSeconds}s (${status.runtimeMinutes} min)`); + console.log(` AC present: ${status.acPresent}`); + console.log(` On battery: ${status.onBattery}`); + console.log(` Input voltage: ${status.inputVoltage}V`); + console.log(` Input voltage (fine): ${status.inputVoltagePrecise}V`); + console.log(` Shutdown timer: ${status.shutdownTimer} (${status.shutdownTimer === -1 ? 'not armed' : 'ARMED!'})`); + console.log(` Beeper status: ${status.beeperStatus}`); + console.log(` Nominal power: ${status.nominalPowerWatts}W`); + console.log(` Low transfer: ${status.lowVoltageTransfer}V`); + console.log(` High transfer: ${status.highVoltageTransfer}V`); + console.log(` Full charge capacity: ${status.fullChargeCapacity}%`); + + // Sanity checks + const checks = [ + { name: 'battery 0–100', ok: status.batteryCharge >= 0 && status.batteryCharge <= 100 }, + { name: 'runtime > 0', ok: status.runtimeSeconds > 0 }, + { name: 'voltage > 0', ok: status.inputVoltage > 0 || !status.acPresent }, + { name: 'power > 0', ok: status.nominalPowerWatts > 0 }, + { name: 'shutdown = -1', ok: status.shutdownTimer === -1 }, + ]; + const allPass = checks.every(c => c.ok); + checks.forEach(c => console.log(` Check ${c.name}: ${c.ok ? 'PASS ✓' : 'FAIL ✗'}`)); + console.log(` ${allPass ? 'ALL PASS ✓' : 'SOME FAILED ✗'}\n`); + + // ── Test 4: Individual readers ── + console.log('── Test 4: Individual Readers ──'); + console.log(` getBatteryCharge(): ${ups.getBatteryCharge()}%`); + console.log(` getRuntimeSeconds(): ${ups.getRuntimeSeconds()}s`); + console.log(` isOnAC(): ${ups.isOnAC()}`); + console.log(` getInputVoltage(): ${ups.getInputVoltage()}V`); + console.log(` getShutdownTimer(): ${ups.getShutdownTimer()}`); + console.log(` getBeeperStatus(): ${ups.getBeeperStatus()}`); + console.log(` getStartupDelay(): ${ups.getStartupDelay()}s`); + console.log(' PASS ✓\n'); + + // ── Test 5: Beeper control ── + console.log('── Test 5: Beeper Control ──'); + const originalBeeper = ups.getBeeperStatus(); + console.log(` Current beeper state: ${originalBeeper}`); + + console.log(' Setting beeper to "muted"...'); + ups.setBeeper('muted'); + await sleep(500); + console.log(` Beeper state after mute: ${ups.getBeeperStatus()}`); + + console.log(' Setting beeper to "enabled"...'); + ups.setBeeper('enabled'); + await sleep(500); + console.log(` Beeper state after enable: ${ups.getBeeperStatus()}`); + + console.log(' Setting beeper to "disabled"...'); + ups.setBeeper('disabled'); + await sleep(500); + console.log(` Beeper state after disable: ${ups.getBeeperStatus()}`); + + // Restore original + if (originalBeeper !== 'unknown') { + console.log(` Restoring to: ${originalBeeper}`); + ups.setBeeper(originalBeeper as 'enabled' | 'muted' | 'disabled'); + await sleep(500); + console.log(` Restored: ${ups.getBeeperStatus()}`); + } + console.log(' DONE (verify beeper behavior matches expected states)\n'); + + // ── Test 6: Polling with events ── + console.log('── Test 6: Polling (5 ticks at 2s interval) ──'); + + let tickCount = 0; + ups.on('status', (s) => { + tickCount++; + console.log(` [tick ${tickCount}] charge=${s.batteryCharge}% runtime=${s.runtimeMinutes}min ac=${s.acPresent} voltage=${s.inputVoltage}V`); + }); + ups.on('power-lost', () => console.log(' ⚡ EVENT: power-lost')); + ups.on('power-restored', () => console.log(' ⚡ EVENT: power-restored')); + ups.on('battery-low', (c) => console.log(` ⚡ EVENT: battery-low (${c}%)`)); + ups.on('battery-critical',(c) => console.log(` ⚡ EVENT: battery-critical (${c}%)`)); + ups.on('error', (e) => console.log(` ⚡ EVENT: error — ${e.message}`)); + + ups.startPolling(2000); + + // Wait for 5 ticks + while (tickCount < 5) { + await sleep(500); + } + ups.stopPolling(); + console.log(` Polling stopped after ${tickCount} ticks.`); + console.log(' PASS ✓\n'); + + // ── Test 7: Shutdown / Cancel (COMMENTED OUT FOR SAFETY) ── + console.log('── Test 7: Shutdown Test (COMMENTED OUT) ──'); + console.log(' To test shutdown, uncomment the block below and'); + console.log(' UNPLUG THE UPS FROM THE WALL before running.'); + console.log(''); + console.log(' // ups.shutdown(30); // arm shutdown in 30 seconds'); + console.log(' // await sleep(3000);'); + console.log(' // console.log("Timer:", ups.getShutdownTimer());'); + console.log(' // ups.cancelShutdown(); // cancel before it fires'); + console.log(' // console.log("Timer after cancel:", ups.getShutdownTimer());'); + console.log(''); + + /* + // ── UNCOMMENT TO TEST (unplug UPS from wall first!) ── + console.log(' Arming shutdown in 30 seconds...'); + ups.shutdown(30); + await sleep(1000); + console.log(` Shutdown timer: ${ups.getShutdownTimer()}s`); + + console.log(' Cancelling shutdown...'); + ups.cancelShutdown(); + await sleep(500); + console.log(` Shutdown timer after cancel: ${ups.getShutdownTimer()}`); + console.log(' PASS ✓'); + */ + + // ── Cleanup ── + ups.close(); + console.log('\n══════════════════════════════════════════════════════'); + console.log(' All tests completed. UPS connection closed.'); + console.log('══════════════════════════════════════════════════════'); +} + +main().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +}); \ No newline at end of file