[Feature Bounty] Fan Control System
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 113
- Forks
- 22
- Avg merge
- 10h 40m
- Merged PRs (30d)
- 13
Description
Fan Control System Feature Request
Is your feature request related to a problem?
Currently, the Unraid API has no capability to monitor or control system fans. Users cannot:
- View current fan speeds (RPM)
- Adjust fan curves based on temperature zones
- Set custom PWM values for case, CPU, or GPU fans
- Create temperature-based fan profiles
- Monitor fan health and detect failures
- Optimize noise levels while maintaining cooling
Without API-level fan control, users must rely on BIOS settings or manual interventions, lacking the dynamic control needed for optimal cooling and noise management. This is especially problematic for users who want to integrate fan control with home automation systems or create custom cooling profiles based on workload.
Describe the solution you'd like
A comprehensive fan control system integrated into the Unraid API that:
-
Provides real-time fan monitoring for all detected fans
-
Supports multiple fan types:
- CPU fans (PWM and DC control)
- Case fans (intake/exhaust)
- GPU fans (when accessible)
- HDD cage fans
- Custom/additional fans via fan controllers
-
Features GraphQL queries, mutations, and subscriptions for:
- Current fan speeds (RPM) and PWM values
- Fan control modes (manual, automatic, curve-based)
- Custom fan curves linked to temperature sensors
- Real-time fan speed updates via subscriptions
- Mutations to adjust fan speeds and profiles
-
Supports multiple control methods:
- PWM control via motherboard headers
- DC voltage control for 3-pin fans
- Smart fan controllers (if present)
- IPMI fan control (for server boards)
Describe alternatives you've considered
- IPMI tools - Limited to server motherboards, no universal solution
- fancontrol (lm-sensors) - Command-line only, no API integration
- BIOS fan control - Static profiles, no runtime adjustments
- Dynamix Auto Fan Control plugin - WebGUI only, no API access
- Custom scripts - No standardized API, difficult to maintain
Additional context
Binary Management Strategy
IMPORTANT: Fan control binaries should be downloaded from safe sources during the plugin build process and included in the plugin's TXZ package:
// Enhancement to the plugin build process
// Location: plugin/builder/build-txz.ts
// Add function to download fan control tools during build
const downloadFanControlTools = async (targetDir: string) => {
console.log("Downloading fan control tools from safe sources...");
const tools = [
{
name: 'pwmconfig',
url: 'https://github.com/lm-sensors/lm-sensors/releases/download/v3.6.0/pwmconfig-3.6.0-x86_64',
sha256: 'abc123...', // Verify integrity
},
{
name: 'fancontrol',
url: 'https://github.com/lm-sensors/lm-sensors/releases/download/v3.6.0/fancontrol-3.6.0-x86_64',
sha256: 'def456...', // Verify integrity
},
{
name: 'ipmitool',
url: 'https://github.com/ipmitool/ipmitool/releases/download/v1.8.19/ipmitool-1.8.19-x86_64',
sha256: 'ghi789...', // Verify integrity
}
];
const fanControlDir = join(targetDir, 'usr/local/emhttp/plugins/unraid-api/fancontrol');
await fs.mkdir(fanControlDir, { recursive: true });
for (const tool of tools) {
console.log(`Downloading ${tool.name}...`);
const response = await fetch(tool.url);
const buffer = await response.arrayBuffer();
// Verify SHA256 checksum
const hash = crypto.createHash('sha256');
hash.update(Buffer.from(buffer));
if (hash.digest('hex') !== tool.sha256) {
throw new Error(`Checksum verification failed for ${tool.name}`);
}
// Save binary
const toolPath = join(fanControlDir, tool.name);
await fs.writeFile(toolPath, Buffer.from(buffer));
await fs.chmod(toolPath, 0o755);
console.log(`✓ ${tool.name} downloaded and verified`);
}
};
// Call during TXZ build process
await downloadFanControlTools(sourceDir);
This approach:
- Downloads binaries from trusted, official sources during build time
- Verifies integrity using SHA256 checksums
- Includes binaries in the plugin TXZ package
- Ensures consistent versions across all installations
- Avoids runtime downloads or system package dependencies
- Maintains security by verifying all downloaded binaries
Integration with Existing Metrics Module
Fan control should be integrated into the existing MetricsResolver alongside temperature monitoring:
// Extend existing MetricsResolver
// Location: api/src/unraid-api/graph/resolvers/metrics/
metrics/
├── metrics.module.ts # Update to include fan service
├── metrics.resolver.ts # Extend with fan control fields
├── metrics.model.ts # Add fan control types
├── fancontrol/
│ ├── fancontrol.service.ts # Core fan control service
│ ├── fancontrol.model.ts # Fan control models
│ └── controllers/
│ ├── controller.interface.ts
│ ├── pwm.service.ts # PWM control
│ ├── ipmi.service.ts # IPMI fan control
│ └── smart-fan.service.ts # Smart fan controllers
└── __tests__/
└── fancontrol.service.spec.ts
NestJS Model Extensions (Integrated with Metrics)
// Location: api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.model.ts
import { Field, Float, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
import { Node } from '@unraid/shared/graphql.model.js';
import { IsEnum, IsNumber, IsOptional, IsString, IsBoolean } from 'class-validator';
export enum FanControlMode {
MANUAL = 'MANUAL', // Direct PWM/voltage control
AUTOMATIC = 'AUTOMATIC', // Temperature-based curves
FIXED = 'FIXED', // Fixed speed
OFF = 'OFF', // Fan disabled
}
registerEnumType(FanControlMode, {
name: 'FanControlMode',
description: 'Fan control operation mode',
});
export enum FanType {
CPU = 'CPU',
CASE_INTAKE = 'CASE_INTAKE',
CASE_EXHAUST = 'CASE_EXHAUST',
GPU = 'GPU',
HDD_CAGE = 'HDD_CAGE',
RADIATOR = 'RADIATOR',
CHIPSET = 'CHIPSET',
PSU = 'PSU',
CUSTOM = 'CUSTOM',
}
registerEnumType(FanType, {
name: 'FanType',
description: 'Type of fan',
});
export enum FanConnectorType {
PWM_4PIN = 'PWM_4PIN', // 4-pin PWM control
DC_3PIN = 'DC_3PIN', // 3-pin voltage control
MOLEX = 'MOLEX', // Direct power, no control
UNKNOWN = 'UNKNOWN',
}
registerEnumType(FanConnectorType, {
name: 'FanConnectorType',
description: 'Fan connector type',
});
@ObjectType()
export class FanSpeed {
@Field(() => Int, { description: 'Current RPM' })
@IsNumber()
rpm!: number;
@Field(() => Float, { description: 'Current PWM duty cycle (0-100%)' })
@IsNumber()
pwm!: number;
@Field(() => Float, { nullable: true, description: 'Target RPM if set' })
@IsOptional()
@IsNumber()
targetRpm?: number;
@Field(() => Date, { description: 'Timestamp of reading' })
timestamp!: Date;
}
@ObjectType()
export class FanCurvePoint {
@Field(() => Float, { description: 'Temperature in Celsius' })
@IsNumber()
temperature!: number;
@Field(() => Float, { description: 'Fan speed percentage (0-100)' })
@IsNumber()
speed!: number;
}
@ObjectType()
export class FanProfile {
@Field(() => String, { description: 'Profile name' })
@IsString()
name!: string;
@Field(() => String, { nullable: true, description: 'Profile description' })
@IsOptional()
@IsString()
description?: string;
@Field(() => [FanCurvePoint], { description: 'Temperature/speed curve points' })
curvePoints!: FanCurvePoint[];
@Field(() => String, {
nullable: true,
description: 'Temperature sensor ID to use for this profile'
})
@IsOptional()
@IsString()
temperatureSensorId?: string;
@Field(() => Float, {
description: 'Minimum fan speed percentage',
defaultValue: 20
})
@IsNumber()
minSpeed!: number;
@Field(() => Float, {
description: 'Maximum fan speed percentage',
defaultValue: 100
})
@IsNumber()
maxSpeed!: number;
}
@ObjectType({ implements: () => Node })
export class Fan extends Node {
@Field(() => String, { description: 'Fan name/label' })
@IsString()
name!: string;
@Field(() => FanType, { description: 'Type of fan' })
@IsEnum(FanType)
type!: FanType;
@Field(() => FanConnectorType, { description: 'Connector type' })
@IsEnum(FanConnectorType)
connectorType!: FanConnectorType;
@Field(() => String, { nullable: true, description: 'Physical header location' })
@IsOptional()
@IsString()
header?: string;
@Field(() => FanSpeed, { description: 'Current fan speed' })
current!: FanSpeed;
@Field(() => FanControlMode, { description: 'Current control mode' })
@IsEnum(FanControlMode)
mode!: FanControlMode;
@Field(() => FanProfile, { nullable: true, description: 'Active profile if in automatic mode' })
@IsOptional()
activeProfile?: FanProfile;
@Field(() => Int, { nullable: true, description: 'Minimum RPM (hardware limit)' })
@IsOptional()
@IsNumber()
minRpm?: number;
@Field(() => Int, { nullable: true, description: 'Maximum RPM (hardware limit)' })
@IsOptional()
@IsNumber()
maxRpm?: number;
@Field(() => Boolean, { description: 'Whether fan is controllable' })
@IsBoolean()
controllable!: boolean;
@Field(() => Boolean, { description: 'Whether fan is detected/connected' })
@IsBoolean()
detected!: boolean;
}
@ObjectType()
export class FanControlSummary {
@Field(() => Int, { description: 'Total number of fans detected' })
@IsNumber()
totalFans!: number;
@Field(() => Int, { description: 'Number of controllable fans' })
@IsNumber()
controllableFans!: number;
@Field(() => Float, { description: 'Average fan speed percentage' })
@IsNumber()
averageSpeed!: number;
@Field(() => Float, { description: 'Average RPM across all fans' })
@IsNumber()
averageRpm!: number;
@Field(() => [String], {
nullable: true,
description: 'IDs of fans that may need attention (stopped, failing)'
})
@IsOptional()
fansNeedingAttention?: string[];
}
@ObjectType({ implements: () => Node })
export class FanControlMetrics extends Node {
@Field(() => [Fan], { description: 'All detected fans' })
fans!: Fan[];
@Field(() => [FanProfile], { description: 'Available fan profiles' })
profiles!: FanProfile[];
@Field(() => FanControlSummary, { description: 'Fan control summary' })
summary!: FanControlSummary;
}
// Extend existing Metrics model
// Location: api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts
import { FanControlMetrics } from './fancontrol/fancontrol.model.js';
@ObjectType({ implements: () => Node })
export class Metrics extends Node {
// ... existing fields ...
@Field(() => FanControlMetrics, {
nullable: true,
description: 'Fan control metrics'
})
fanControl?: FanControlMetrics;
}
Binary/Tool Setup Requirements
// fancontrol.service.ts - Use plugin-bundled binaries
import { join } from 'path';
import { ConfigService } from '@nestjs/config';
export class FanControlService implements OnModuleInit {
private readonly binPath: string;
private availableTools: Map<string, string> = new Map();
private fanDevices: Map<string, string> = new Map(); // hwmon paths
constructor(private readonly configService: ConfigService) {
// Use binaries bundled with the plugin
this.binPath = this.configService.get(
'API_FANCONTROL_BIN_PATH',
'/usr/local/emhttp/plugins/unraid-api/fancontrol'
);
}
async onModuleInit() {
// Initialize bundled tools
await this.initializeBundledTools();
// Detect fan control devices
await this.detectFanDevices();
// Initialize control methods
if (this.availableTools.has('pwmconfig')) {
await this.initializePWMControl();
}
if (this.availableTools.has('ipmitool')) {
await this.initializeIPMIControl();
}
}
private async detectFanDevices(): Promise<void> {
// Scan /sys/class/hwmon for fan control devices
const hwmonPath = '/sys/class/hwmon';
const devices = await fs.readdir(hwmonPath);
for (const device of devices) {
const devicePath = join(hwmonPath, device);
const nameFile = join(devicePath, 'name');
try {
const name = await fs.readFile(nameFile, 'utf-8');
// Check for PWM controls
const files = await fs.readdir(devicePath);
const hasPWM = files.some(f => f.startsWith('pwm'));
const hasFan = files.some(f => f.startsWith('fan'));
if (hasPWM || hasFan) {
this.fanDevices.set(name.trim(), devicePath);
this.logger.log(`Found fan device: ${name.trim()} at ${devicePath}`);
}
} catch {
// Device doesn't have necessary files
}
}
}
// Read fan RPM from sysfs
private async readFanRPM(devicePath: string, fanNumber: number): Promise<number> {
const fanFile = join(devicePath, `fan${fanNumber}_input`);
try {
const rpm = await fs.readFile(fanFile, 'utf-8');
return parseInt(rpm.trim(), 10);
} catch {
return 0;
}
}
// Set PWM value (0-255)
private async setPWMValue(devicePath: string, pwmNumber: number, value: number): Promise<void> {
const pwmFile = join(devicePath, `pwm${pwmNumber}`);
const enableFile = join(devicePath, `pwm${pwmNumber}_enable`);
// Enable manual PWM control (1 = manual, 2 = automatic)
await fs.writeFile(enableFile, '1');
// Set PWM value (0-255)
const pwmValue = Math.round((value / 100) * 255);
await fs.writeFile(pwmFile, pwmValue.toString());
}
}
Integration with Existing MetricsResolver
// Extend existing MetricsResolver with mutations for fan control
// Location: api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts
@Resolver(() => Metrics)
export class MetricsResolver implements OnModuleInit {
constructor(
private readonly cpuService: CpuService,
private readonly memoryService: MemoryService,
private readonly temperatureService: TemperatureService,
private readonly fanControlService: FanControlService, // Add fan control service
private readonly subscriptionTracker: SubscriptionTrackerService,
private readonly subscriptionHelper: SubscriptionHelperService
) {}
onModuleInit() {
// Existing CPU, Memory, Temperature polling...
// Add fan speed polling with 2 second interval
this.subscriptionTracker.registerTopic(
PUBSUB_CHANNEL.FAN_METRICS,
async () => {
const payload = await this.fanControlService.getMetrics();
pubsub.publish(PUBSUB_CHANNEL.FAN_METRICS, {
systemMetricsFans: payload
});
},
2000
);
}
// Add fan control field to Metrics type
@ResolveField(() => FanControlMetrics, { nullable: true })
public async fanControl(): Promise<FanControlMetrics> {
return this.fanControlService.getMetrics();
}
// Add fan speed subscription
@Subscription(() => FanControlMetrics, {
name: 'systemMetricsFans',
resolve: (value) => value.systemMetricsFans,
})
@UsePermissions({
action: AuthActionVerb.READ,
resource: Resource.INFO,
possession: AuthPossession.ANY,
})
public async systemMetricsFansSubscription() {
return this.subscriptionHelper.createTrackedSubscription(
PUBSUB_CHANNEL.FAN_METRICS
);
}
}
// Add mutations resolver for fan control
@Resolver()
export class FanControlResolver {
constructor(private readonly fanControlService: FanControlService) {}
@Mutation(() => Fan)
@UsePermissions({
action: AuthActionVerb.UPDATE,
resource: Resource.CONFIG,
possession: AuthPossession.ANY,
})
async setFanSpeed(
@Args('fanId') fanId: string,
@Args('speed', { type: () => Float }) speed: number
): Promise<Fan> {
return this.fanControlService.setFanSpeed(fanId, speed);
}
@Mutation(() => Fan)
@UsePermissions({
action: AuthActionVerb.UPDATE,
resource: Resource.CONFIG,
possession: AuthPossession.ANY,
})
async setFanMode(
@Args('fanId') fanId: string,
@Args('mode', { type: () => FanControlMode }) mode: FanControlMode
): Promise<Fan> {
return this.fanControlService.setFanMode(fanId, mode);
}
@Mutation(() => Fan)
@UsePermissions({
action: AuthActionVerb.UPDATE,
resource: Resource.CONFIG,
possession: AuthPossession.ANY,
})
async setFanProfile(
@Args('fanId') fanId: string,
@Args('profileId') profileId: string
): Promise<Fan> {
return this.fanControlService.setFanProfile(fanId, profileId);
}
@Mutation(() => FanProfile)
@UsePermissions({
action: AuthActionVerb.CREATE,
resource: Resource.CONFIG,
possession: AuthPossession.ANY,
})
async createFanProfile(
@Args('input') input: CreateFanProfileInput
): Promise<FanProfile> {
return this.fanControlService.createProfile(input);
}
}
Configuration Options
// api/dev/configs/api.json additions
{
"fanControl": {
"enabled": true,
"polling_interval": 2000,
"control_method": "auto", // auto, pwm, ipmi
"safety": {
"min_speed_percent": 20, // Never go below 20%
"max_temp_before_full": 85, // Full speed if any sensor hits 85°C
"fan_failure_threshold": 0 // RPM below this = failure
},
"profiles": {
"quiet": {
"description": "Quiet operation",
"curve": [
{ "temp": 30, "speed": 20 },
{ "temp": 50, "speed": 35 },
{ "temp": 70, "speed": 60 },
{ "temp": 85, "speed": 100 }
]
},
"balanced": {
"description": "Balanced cooling and noise",
"curve": [
{ "temp": 30, "speed": 30 },
{ "temp": 50, "speed": 50 },
{ "temp": 70, "speed": 75 },
{ "temp": 80, "speed": 100 }
]
},
"performance": {
"description": "Maximum cooling",
"curve": [
{ "temp": 30, "speed": 40 },
{ "temp": 45, "speed": 60 },
{ "temp": 60, "speed": 80 },
{ "temp": 75, "speed": 100 }
]
}
},
"fan_zones": {
"cpu": {
"fans": ["cpu_fan1", "cpu_fan2"],
"sensor": "cpu_package_temp",
"profile": "balanced"
},
"case": {
"fans": ["front_intake1", "front_intake2", "rear_exhaust"],
"sensor": "motherboard_temp",
"profile": "quiet"
},
"hdd": {
"fans": ["hdd_cage_fan"],
"sensor": "max_disk_temp",
"profile": "balanced"
}
}
}
}
Environment (if relevant)
Unraid OS Version: 6.12+ (requires pwm/fan control support in kernel)
Pre-submission Checklist
- I have searched existing issues to ensure this feature hasn't already been requested
- This is not an Unraid Connect related feature
- I have provided clear examples and implementation details for the feature
Bounty Development Guidelines
For developers interested in implementing this feature:
- Download binaries during plugin build - Use build-txz.ts to fetch from safe sources
- Integrate with existing MetricsResolver - Don't create a separate fan control module
- Implement safety features first - Never allow fans to stop completely unless explicitly requested
- Start with PWM control - Most universal method for modern systems
- Add IPMI support for server motherboards as secondary method
- Create temperature-linked profiles - Integrate with temperature monitoring feature
- Extend MetricsResolver with fan fields, subscriptions, and mutations
- Add comprehensive unit tests for all services
- Document the API endpoints and configuration options
- Test thoroughly - Fan control can affect system stability if done incorrectly
- Follow existing patterns in the codebase (especially systemMetrics*)
- Implement gradual speed changes - Avoid sudden fan speed jumps
Binary Management
The plugin build process (build-txz.ts) should:
- Download fan control tools from official, trusted sources
- Verify SHA256 checksums for security
- Include binaries in the plugin TXZ at
/usr/local/emhttp/plugins/unraid-api/fancontrol/ - Set proper executable permissions
- Ensure compatibility across different Unraid versions
Safety Considerations
- Never stop CPU fans completely - Maintain minimum 20% speed
- Implement temperature override - Full speed if any sensor exceeds critical threshold
- Gradual speed changes - Ramp up/down over time to reduce wear
- Fan failure detection - Alert if fan RPM drops to 0
- Fallback to BIOS control - If service fails, restore hardware defaults
Testing Requirements
- Unit tests for all services
- Integration tests for GraphQL resolvers and mutations
- Test PWM control on various motherboards
- Test safety features (temperature override, minimum speeds)
- Performance tests to ensure polling doesn't impact system
- Test with different fan types (3-pin, 4-pin, smart controllers)
Deliverables
- Fan control service implementation within metrics module
- Enhancement to build-txz.ts for downloading fan control tools
- NestJS models with GraphQL decorators for fan control types
- GraphQL mutations for fan control operations
- Unit and integration tests
- Documentation (API docs and configuration guide)
- Example GraphQL queries and mutations for fan control
- Safety documentation and best practices guide
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts, metrics.resolver.ts, and metrics.model.ts, then inspect plugin/builder/build-txz.ts. The scope needs to be narrowed before implementation; done would require an agreed fan-control design, bundled-tool handling, GraphQL integration, and the named fancontrol.service.spec.ts coverage.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- graphql, typescript
- Domain
- api, backend-api-design, devops
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 15/100