-
Notifications
You must be signed in to change notification settings - Fork 19
feat(fancontrol): Implement fan control system #1954
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ruaan-deysel
wants to merge
5
commits into
unraid:main
Choose a base branch
from
ruaan-deysel:feat/fan-control-system
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
6e5f0ec
feat(fancontrol): implement fan safety service and configuration mana…
ruaan-deysel 18f5e42
feat(fan-control): enhance fan control system with safety checks and …
ruaan-deysel 3f6eaf6
feat(fan-control): enhance fan control logic with safety checks and c…
ruaan-deysel d1b0d38
feat(fan-control): refactor fan control service and validation logic …
ruaan-deysel 66f0d92
feat(fan-control): add validation for speed limits and prevent duplic…
ruaan-deysel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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": [] | ||
| } |
58 changes: 58 additions & 0 deletions
58
api/src/unraid-api/graph/resolvers/metrics/fancontrol/controllers/controller.interface.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
173 changes: 173 additions & 0 deletions
173
api/src/unraid-api/graph/resolvers/metrics/fancontrol/controllers/hwmon.service.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}`); | ||
| } | ||
| } |
119 changes: 119 additions & 0 deletions
119
api/src/unraid-api/graph/resolvers/metrics/fancontrol/controllers/ipmi_fan.service.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
For Supermicro fan control withipmitool 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 -60Repository: unraid/api
Length of output: 2543
Use a zone ID instead of
pwmNumberin the Supermicro IPMI zone command.The command
0x30 0x70 0x66 0x01is 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. PassingpwmNumberdirectly 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