From 818276969bf05d96fdee4226f1adf348bfa79ace Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:06:45 +0000 Subject: [PATCH] feat: add UPS monitoring integration using nut-client * Install modern `nut-client` library. * Add default `.env` configuration (NUT_HOST, NUT_PORT, NUT_UPS_NAME). * Create `NutManager` to handle UPS connections and monitor `ONBATT`, `ONLINE`, and `LOWBATT` events. * Integrate `NutManager` into `Controller` initialization and restarts. * Add list of possible features to implement inside the stub handle methods of `NutManager`. * Gracefully warn and cleanup the class when no UPS is available. Co-authored-by: RoiArthurB <16764085+RoiArthurB@users.noreply.github.com> --- .env.example | 6 ++ package-lock.json | 66 +++++++--------- package.json | 1 + src/api/core/Controller.ts | 9 +++ src/api/index.ts | 6 ++ src/api/infra/nut/NutManager.ts | 136 ++++++++++++++++++++++++++++++++ 6 files changed, 185 insertions(+), 39 deletions(-) create mode 100644 src/api/infra/nut/NutManager.ts diff --git a/.env.example b/.env.example index fb8baa7..be9e7de 100644 --- a/.env.example +++ b/.env.example @@ -26,4 +26,10 @@ LEARNING_PACKAGE_PATH="./learning-packages" # Default to `false` #AGGRESSIVE_DISCONNECT=false +# ------------------------------- +# NUT (Network UPS Tools) Settings +# ------------------------------- +# NUT_HOST=localhost +# NUT_PORT=3493 +# NUT_UPS_NAME=myUps diff --git a/package-lock.json b/package-lock.json index ebeb561..24064d7 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", + "nut-client": "^0.0.9", "postcss": "^8.4.40", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -1937,9 +1938,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1953,9 +1951,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1969,9 +1964,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1985,9 +1977,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2001,9 +1990,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2017,9 +2003,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2033,9 +2016,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2049,9 +2029,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2065,9 +2042,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2081,9 +2055,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2097,9 +2068,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2113,9 +2081,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2129,9 +2094,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9568,6 +9530,26 @@ "node": ">=0.10.0" } }, + "node_modules/nut-client": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/nut-client/-/nut-client-0.0.9.tgz", + "integrity": "sha512-5gfTTHWoSp8P7flwRU0q4osmVtyPP6BsEZZI2c0YKQa4+iI+sSyD5i2zVrfosTgkqJc5JGIdDnyYQxBJLJbMgg==", + "license": "MIT", + "dependencies": { + "async": "^3.2.6", + "debug": "^4.3.7", + "tiny-typed-emitter": "2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/nut-client/node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -12299,6 +12281,12 @@ "node": ">=0.10.0" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/package.json b/package.json index bab55ad..2bca0f4 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "express": "^4.21.2", "geoip-lite": "^1.4.10", "i18next": "^23.15.1", + "nut-client": "^0.0.9", "postcss": "^8.4.40", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/api/core/Controller.ts b/src/api/core/Controller.ts index 8725af1..3e63bc1 100644 --- a/src/api/core/Controller.ts +++ b/src/api/core/Controller.ts @@ -6,6 +6,7 @@ import { AdbManager } from "../android/adb/AdbManager.ts"; import { useAdb, ENV_GAMALESS } from "../index.ts"; import { JsonPlayerAsk, JsonOutput } from "./Constants.ts"; // import {mDnsService} from "../infra/mDnsService.ts"; +import { NutManager } from "../infra/nut/NutManager.ts"; import { getLogger } from "@logtape/logtape"; const logger = getLogger(["core", "Controller"]); @@ -18,6 +19,7 @@ export class Controller { adb_manager: AdbManager | undefined; // mDnsService: mDnsService; + nut_manager: NutManager; constructor(useAdb: boolean) { @@ -36,12 +38,16 @@ export class Controller { } else { logger.warn("Couldn't find ADB working or started, cancelling ADB management") } + + this.nut_manager = new NutManager(); } // Allow running init functions for some components needing it async initialize() { if (this.adb_manager) await this.adb_manager.init(); + + await this.nut_manager.init(); } async restart() { @@ -67,6 +73,9 @@ export class Controller { if (useAdb) this.adb_manager = new AdbManager(this); + this.nut_manager.close(); + this.nut_manager = new NutManager(); + await this.initialize(); } diff --git a/src/api/index.ts b/src/api/index.ts index 36f6f38..19c9663 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -56,6 +56,12 @@ process.env.EXTRA_LEARNING_PACKAGE_PATH = process.env.EXTRA_LEARNING_PACKAGE_P const ENV_AGGRESSIVE_DISCONNECT: boolean = process.env.AGGRESSIVE_DISCONNECT !== undefined ? ['true', '1', 'yes'].includes(process.env.AGGRESSIVE_DISCONNECT.toLowerCase()) : false; // ! GAMA ===== +// NUT (Network UPS Tools) ===== +process.env.NUT_HOST = process.env.NUT_HOST || 'localhost'; +process.env.NUT_PORT = process.env.NUT_PORT || '3493'; +process.env.NUT_UPS_NAME = process.env.NUT_UPS_NAME || 'myUps'; +// ! NUT ===== + // Headsets ===== process.env.HEADSET_WS_PORT = process.env.HEADSET_WS_PORT || '8080'; // ! Headsets ===== diff --git a/src/api/infra/nut/NutManager.ts b/src/api/infra/nut/NutManager.ts new file mode 100644 index 0000000..6fe319b --- /dev/null +++ b/src/api/infra/nut/NutManager.ts @@ -0,0 +1,136 @@ +import { NUTClient, Monitor } from 'nut-client'; +import { getLogger } from '@logtape/logtape'; + +const logger = getLogger(["infra", "nut", "NutManager"]); + +export class NutManager { + private client: NUTClient; + private monitor: Monitor | null = null; + private upsName: string; + + constructor() { + const host = process.env.NUT_HOST || 'localhost'; + const port = parseInt(process.env.NUT_PORT || '3493', 10); + this.upsName = process.env.NUT_UPS_NAME || 'myUps'; + + logger.info(`Initializing NUT Manager for UPS '${this.upsName}' at ${host}:${port}`); + + this.client = new NUTClient(host, port); + } + + public async init(): Promise { + try { + // First check if UPS is available + const upses = await this.client.listUPS(); + logger.debug(`Available UPSes: {upses}`, {upses}); + + if (!upses || Object.keys(upses).length === 0) { + logger.warn('No UPS available or listed. Disabling NUT integration and cleaning up.'); + this.close(); + return; + } + + // Check if our configured UPS is in the list + if (!upses[this.upsName]) { + logger.warn(`Configured UPS '${this.upsName}' not found in the available list. Disabling NUT integration and cleaning up.`); + this.close(); + return; + } + + // Valid UPS found. Setup Monitor + logger.info(`UPS '${this.upsName}' found, setting up monitor...`); + this.monitor = new Monitor(this.client, this.upsName); + + // Setup UPS Events + + // Fired when UPS switches to battery power (e.g. unplugged) + this.monitor.on('ONBATT', () => { + logger.warn(`UPS '${this.upsName}' is ON BATTERY. Power has been lost!`); + this.handlePowerLoss(); + }); + + // Fired when UPS is back on utility power (e.g. plugged back in) + this.monitor.on('ONLINE', () => { + logger.info(`UPS '${this.upsName}' is ONLINE. Utility power has been restored!`); + this.handlePowerRestored(); + }); + + // Fired when UPS battery is low + this.monitor.on('LOWBATT', () => { + logger.warn(`UPS '${this.upsName}' has LOW BATTERY! Preparing for shutdown sequence...`); + this.handleLowBattery(); + }); + + // Fired when battery needs replacement + this.monitor.on('REPLBATT', () => { + logger.warn(`UPS '${this.upsName}' needs battery replacement!`); + }); + + // Fired on UPS forced shutdown (FSD) + this.monitor.on('FSD', () => { + logger.warn(`UPS '${this.upsName}' forced shutdown initiated!`); + }); + + // (Optional) Catch-all for any event or specific variable changes + // this.monitor.on('*', (event: string, ...args) => { + // logger.trace(`UPS Event ${event}: {args}`, { args }); + // }); + + await this.monitor.start(); + logger.info('NUT Monitor started successfully.'); + + } catch (error) { + logger.error(`Failed to initialize NUT connection: {error}`, {error}); + this.close(); + } + } + + private handlePowerLoss() { + // TODO: Handle Power Loss + // Possible Features: + // - Broadcast warning to all connected clients. + // - Save state of the GAMA Simulation if supported. + // - Consider scheduling a graceful shutdown if power isn't restored within X minutes. + } + + private handlePowerRestored() { + // TODO: Handle Power Restored + // Possible Features: + // - Cancel any pending shutdowns. + // - Broadcast recovery message to clients. + } + + private handleLowBattery() { + // TODO: Handle Low Battery Shutdown Sequence + // Possible Features: + // - Turn off VR Headsets to avoid useless battery drain: + // - Retrieve `AdbManager` instance. + // - For each connected device, execute `adb shell reboot -p` to turn off the headset. + // - Turn off the main server (Mac Mini): + // - Execute system `shutdown` command (e.g., `sudo shutdown -h now` on macOS/Linux). + // - Log all actions meticulously. + } + + public close() { + logger.debug('Cleaning up NUT Manager...'); + if (this.monitor) { + try { + this.monitor.stop(); + } catch (e) { + logger.warn('Failed to stop monitor smoothly: {e}', {e}); + } + this.monitor = null; + } + + try { + // We can't close client cleanly as per docs, but we can drop ref + // nut-client manages connections automatically + } catch (e) { + logger.trace('Error closing client {e}', {e}); + } + + this.client = null as any; // Allow GC + } +} + +export default NutManager;