Skip to content
Open
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
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)],
Comment on lines +45 to +47
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For Supermicro fan control with ipmitool raw 0x30 0x70 0x66 0x01 , what values are valid for ? Does the command target fan zones instead of individual fan headers?

💡 Result:

Valid values for <zone> in the Supermicro IPMI command ipmitool raw 0x30 0x70 0x66 0x01 <zone> <duty> are motherboard-specific integers starting from 0x00 (typically 0 in decimal), such as 0x00 for CPU fans (e.g., FAN1-FAN4) and 0x01 for peripheral/system fans (e.g., FANA-FANH). Higher values like 0x02-0x04 may exist on boards with more zones. The exact mapping depends on the motherboard model; users should test zones incrementally (0,1,2,...) while monitoring fan speeds via ipmitool sdr to identify active zones. Yes, the command targets fan zones (groups of fan headers) rather than individual fan headers. Supermicro IPMI groups fans into zones for unified control (e.g., Zone 0: CPU area fans; Zone 1: peripheral fans). Individual header control is not supported via this command.

Citations:


🏁 Script executed:

cat -n api/src/unraid-api/graph/resolvers/metrics/fancontrol/controllers/ipmi_fan.service.ts | head -60

Repository: unraid/api

Length of output: 2543


Use a zone ID instead of pwmNumber in the Supermicro IPMI zone command.

The command 0x30 0x70 0x66 0x01 is a zone-level Supermicro IPMI command where the fifth parameter must be a fan zone ID (e.g., 0 for CPU fans, 1 for peripheral fans), not a per-fan PWM number. Passing pwmNumber directly will either fail with invalid zone values or control the wrong fan group. The zone mapping should be handled before calling this command.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@api/src/unraid-api/graph/resolvers/metrics/fancontrol/controllers/ipmi_fan.service.ts`
around lines 45 - 47, The IPMI raw command is using pwmNumber where a Supermicro
fan zone ID is required; update the code around the execa call in
ipmi_fan.service.ts so that you map the incoming pwmNumber to the correct zone
ID (e.g., 0 for CPU, 1 for peripheral) before invoking execa, and pass that
zoneId as the fifth argument instead of pwmNumber; adjust the function signature
or add a helper (e.g., mapPwmToZone or computeZoneId) and use zoneId in the
execa call along with the existing percent parameter.

{
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