Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions src/api/android/adb/AdbManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,20 @@ export class AdbManager {
return isReady;
}

/** Send reboot -p to every currently streaming ADB-connected headset */
async shutdownAllHeadsets(): Promise<void> {
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<void> {
const index = this.clientCurrentlyStreaming.findIndex(d => d.serial === serial);
if (index > -1) this.clientCurrentlyStreaming.splice(index, 1);
Expand Down
36 changes: 36 additions & 0 deletions src/api/core/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import { JsonPlayerAsk, JsonOutput } from "./Constants.ts";
// import {mDnsService} from "../infra/mDnsService.ts";
import { getLogger } from "@logtape/logtape";
import { spawnSync } from 'child_process';

Check warning on line 10 in src/api/core/Controller.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:child_process` over `child_process`.

See more on https://sonarcloud.io/project/issues?id=project-SIMPLE_simple.webplatform&issues=AZ0e8L_mshhUnMiqp00h&open=AZ0e8L_mshhUnMiqp00h&pullRequest=135
import { UpsManager } from '../infra/ups/UpsManager.ts';

const logger = getLogger(["core", "Controller"]);

Expand All @@ -17,6 +19,7 @@
gama_connector: GamaConnector | undefined;

adb_manager: AdbManager | undefined;
ups_service: UpsManager;
// mDnsService: mDnsService;


Expand All @@ -43,12 +46,45 @@
} 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<void> {
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() {
Expand Down
188 changes: 188 additions & 0 deletions src/api/infra/ups/UpsManager.ts
Original file line number Diff line number Diff line change
@@ -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;

Check warning on line 13 in src/api/infra/ups/UpsManager.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'ups' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=project-SIMPLE_simple.webplatform&issues=AZ0e8MCVshhUnMiqp00n&open=AZ0e8MCVshhUnMiqp00n&pullRequest=135
private _closed = false;
private reconnectTimer: ReturnType<typeof setTimeout> | 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<boolean> {
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<void> {
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;
Loading
Loading