Skip to content
8 changes: 3 additions & 5 deletions api/dev/configs/api.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
{
"version": "4.29.2",
"version": "4.31.1",
"extraOrigins": [],
"sandbox": false,
"ssoSubIds": [],
"plugins": [
"unraid-api-plugin-connect"
]
}
"plugins": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {
FanConnectorType,
FanControlMode,
} from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.model.js';

export interface RawFanReading {
id: string;
name: string;
rpm: number;
pwmValue: number;
pwmEnable: number;
pwmMode: number;
hasPwmControl: boolean;
devicePath: string;
fanNumber: number;
pwmNumber: number;
}

export interface FanControllerProvider {
readonly id: string;

isAvailable(): Promise<boolean>;

readAll(): Promise<RawFanReading[]>;

setPwm(devicePath: string, pwmNumber: number, value: number): Promise<void>;

setMode(devicePath: string, pwmNumber: number, mode: number): Promise<void>;

restoreAutomatic(devicePath: string, pwmNumber: number, originalEnable: number): Promise<void>;
}

export function pwmEnableToControlMode(enable: number): FanControlMode {
switch (enable) {
case 0:
return FanControlMode.OFF;
case 1:
return FanControlMode.MANUAL;
case 2:
case 3:
case 4:
case 5:
return FanControlMode.AUTOMATIC;
default:
return FanControlMode.AUTOMATIC;
}
}

export function pwmModeToConnectorType(mode: number): FanConnectorType {
switch (mode) {
case 0:
return FanConnectorType.DC_3PIN;
case 1:
return FanConnectorType.PWM_4PIN;
default:
return FanConnectorType.UNKNOWN;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { Injectable, Logger } from '@nestjs/common';
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

import {
FanControllerProvider,
RawFanReading,
} from '@app/unraid-api/graph/resolvers/metrics/fancontrol/controllers/controller.interface.js';

const HWMON_PATH = '/sys/class/hwmon';

interface HwmonDevice {
name: string;
path: string;
fans: number[];
pwms: number[];
}

@Injectable()
export class HwmonService implements FanControllerProvider {
readonly id = 'HwmonService';
private readonly logger = new Logger(HwmonService.name);
private devices: HwmonDevice[] = [];
private initialized = false;
private initPromise: Promise<void> | null = null;

async isAvailable(): Promise<boolean> {
try {
const entries = await readdir(HWMON_PATH);
return entries.length > 0;
} catch {
return false;
}
}

async readAll(): Promise<RawFanReading[]> {
if (!this.initialized) {
if (!this.initPromise) {
this.initPromise = this.detectDevices().then(
() => {
this.initialized = true;
},
(err) => {
this.initPromise = null;
throw err;
}
);
}
await this.initPromise;
}

const readings: RawFanReading[] = [];

for (const device of this.devices) {
for (const fanNumber of device.fans) {
const pwmNumber = fanNumber;
const hasPwm = device.pwms.includes(pwmNumber);

const rpm = await this.readSysfsInt(device.path, `fan${fanNumber}_input`);
const pwmValue = hasPwm ? await this.readSysfsInt(device.path, `pwm${pwmNumber}`) : 0;
const pwmEnable = hasPwm
? await this.readSysfsInt(device.path, `pwm${pwmNumber}_enable`)
: -1;
const pwmMode = hasPwm
? await this.readSysfsInt(device.path, `pwm${pwmNumber}_mode`)
: -1;

readings.push({
id: `${device.name}:fan${fanNumber}`,
name: `${device.name} Fan ${fanNumber}`,
rpm,
pwmValue,
pwmEnable,
pwmMode,
hasPwmControl: hasPwm,
devicePath: device.path,
fanNumber,
pwmNumber,
});
}
}

return readings;
}

async setPwm(devicePath: string, pwmNumber: number, value: number): Promise<void> {
const clamped = Math.max(0, Math.min(255, Math.round(value)));
await this.writeSysfs(devicePath, `pwm${pwmNumber}`, clamped.toString());
}

async setMode(devicePath: string, pwmNumber: number, mode: number): Promise<void> {
await this.writeSysfs(devicePath, `pwm${pwmNumber}_enable`, mode.toString());
}

async restoreAutomatic(
devicePath: string,
pwmNumber: number,
originalEnable: number
): Promise<void> {
const restoreValue = originalEnable >= 2 ? originalEnable : 2;
await this.writeSysfs(devicePath, `pwm${pwmNumber}_enable`, restoreValue.toString());
}

private async detectDevices(): Promise<void> {
this.devices = [];

try {
const entries = await readdir(HWMON_PATH);

for (const entry of entries) {
const devicePath = join(HWMON_PATH, entry);

try {
const name = (await readFile(join(devicePath, 'name'), 'utf-8')).trim();
const files = await readdir(devicePath);

const fans = files
.filter((f) => /^fan\d+_input$/.test(f))
.map((f) => {
const m = f.match(/^fan(\d+)_input$/);
return m ? parseInt(m[1], 10) : NaN;
})
.filter((n) => !Number.isNaN(n))
.sort((a, b) => a - b);

const pwms = files
.filter((f) => /^pwm\d+$/.test(f))
.map((f) => {
const m = f.match(/^pwm(\d+)$/);
return m ? parseInt(m[1], 10) : NaN;
})
.filter((n) => !Number.isNaN(n))
.sort((a, b) => a - b);

if (fans.length > 0) {
this.devices.push({ name, path: devicePath, fans, pwms });
this.logger.log(
`Detected hwmon device: ${name} at ${devicePath} (${fans.length} fans, ${pwms.length} PWM controls)`
);
}
} catch {
// Device doesn't have the necessary files — skip
}
}

this.initialized = true;
} catch (err) {
this.logger.warn(`Failed to scan hwmon devices: ${err}`);
}
}

async rescan(): Promise<void> {
this.initialized = false;
this.initPromise = null;
await this.detectDevices();
this.initialized = true;
}

private async readSysfsInt(devicePath: string, filename: string): Promise<number> {
try {
const content = await readFile(join(devicePath, filename), 'utf-8');
return parseInt(content.trim(), 10);
} catch {
return 0;
}
}

private async writeSysfs(devicePath: string, filename: string, value: string): Promise<void> {
const filePath = join(devicePath, filename);
await writeFile(filePath, value, 'utf-8');
this.logger.debug(`Wrote ${value} to ${filePath}`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { Injectable, Logger } from '@nestjs/common';

import { execa } from 'execa';

import {
FanControllerProvider,
RawFanReading,
} from '@app/unraid-api/graph/resolvers/metrics/fancontrol/controllers/controller.interface.js';

@Injectable()
export class IpmiFanService implements FanControllerProvider {
readonly id = 'IpmiFanService';
private readonly logger = new Logger(IpmiFanService.name);
private readonly timeoutMs = 5000;

async isAvailable(): Promise<boolean> {
try {
await execa('ipmitool', ['-V'], { timeout: this.timeoutMs });
return true;
} catch {
return false;
}
}

async readAll(): Promise<RawFanReading[]> {
try {
const { stdout } = await execa('ipmitool', ['sdr', 'type', 'Fan'], {
timeout: this.timeoutMs,
});

return this.parseSdrOutput(stdout);
} catch (err) {
this.logger.error(`Failed to read IPMI fan sensors: ${err}`);
return [];
}
}

async setPwm(devicePath: string, pwmNumber: number, value: number): Promise<void> {
if (!Number.isFinite(value) || value < 0 || value > 255) {
throw new Error(`Invalid PWM value: ${value}. Must be a number between 0 and 255.`);
}
const percent = Math.round((value / 255) * 100);
try {
// NOTE: raw command 0x30 0x70 0x66 is Supermicro-specific fan control
await execa(
'ipmitool',
['raw', '0x30', '0x70', '0x66', '0x01', String(pwmNumber), String(percent)],
{
timeout: this.timeoutMs,
}
);
this.logger.debug(`IPMI: Set fan zone ${pwmNumber} to ${percent}%`);
} catch (err) {
this.logger.error(`IPMI setPwm failed: ${err}`);
throw err;
}
}

async setMode(devicePath: string, pwmNumber: number, mode: number): Promise<void> {
const controlMode = mode === 1 ? '0x00' : '0x01';
try {
await execa('ipmitool', ['raw', '0x30', '0x45', controlMode], {
timeout: this.timeoutMs,
});
this.logger.debug(`IPMI: Set fan mode to ${mode === 1 ? 'manual' : 'automatic'}`);
} catch (err) {
this.logger.error(`IPMI setMode failed: ${err}`);
throw err;
}
}

async restoreAutomatic(
devicePath: string,
pwmNumber: number,
originalEnable: number
): Promise<void> {
await this.setMode(devicePath, pwmNumber, 2);
}

private parseSdrOutput(stdout: string): RawFanReading[] {
const readings: RawFanReading[] = [];
const lines = stdout.split('\n').filter((l) => l.trim().length > 0);
let fanIndex = 1;

for (const line of lines) {
const parts = line.split('|').map((s) => s.trim());
if (parts.length < 5) {
continue;
}

const [name, , , , reading] = parts;
if (!name || !reading) {
continue;
}

const rpmMatch = reading.match(/(\d+)\s*RPM/i);
if (!rpmMatch) {
continue;
}

const rpm = parseInt(rpmMatch[1], 10);
readings.push({
id: `ipmi:fan${fanIndex}`,
name: name.trim(),
rpm,
pwmValue: 0,
pwmEnable: -1,
pwmMode: -1,
hasPwmControl: false,
devicePath: 'ipmi',
fanNumber: fanIndex,
pwmNumber: fanIndex,
});
fanIndex++;
}

return readings;
}
}
Loading
Loading