diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index d57ab4fd43..62b93a524f 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,9 +1,7 @@ { - "version": "4.29.2", + "version": "4.31.1", "extraOrigins": [], "sandbox": false, "ssoSubIds": [], - "plugins": [ - "unraid-api-plugin-connect" - ] -} + "plugins": [] +} \ No newline at end of file diff --git a/api/src/unraid-api/graph/resolvers/metrics/fancontrol/controllers/controller.interface.ts b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/controllers/controller.interface.ts new file mode 100644 index 0000000000..8605539d14 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/controllers/controller.interface.ts @@ -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; + + readAll(): Promise; + + setPwm(devicePath: string, pwmNumber: number, value: number): Promise; + + setMode(devicePath: string, pwmNumber: number, mode: number): Promise; + + restoreAutomatic(devicePath: string, pwmNumber: number, originalEnable: number): Promise; +} + +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; + } +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/fancontrol/controllers/hwmon.service.ts b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/controllers/hwmon.service.ts new file mode 100644 index 0000000000..8faac36ceb --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/controllers/hwmon.service.ts @@ -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 | null = null; + + async isAvailable(): Promise { + try { + const entries = await readdir(HWMON_PATH); + return entries.length > 0; + } catch { + return false; + } + } + + async readAll(): Promise { + 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 { + 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 { + await this.writeSysfs(devicePath, `pwm${pwmNumber}_enable`, mode.toString()); + } + + async restoreAutomatic( + devicePath: string, + pwmNumber: number, + originalEnable: number + ): Promise { + const restoreValue = originalEnable >= 2 ? originalEnable : 2; + await this.writeSysfs(devicePath, `pwm${pwmNumber}_enable`, restoreValue.toString()); + } + + private async detectDevices(): Promise { + 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 { + this.initialized = false; + this.initPromise = null; + await this.detectDevices(); + this.initialized = true; + } + + private async readSysfsInt(devicePath: string, filename: string): Promise { + 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 { + const filePath = join(devicePath, filename); + await writeFile(filePath, value, 'utf-8'); + this.logger.debug(`Wrote ${value} to ${filePath}`); + } +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/fancontrol/controllers/ipmi_fan.service.ts b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/controllers/ipmi_fan.service.ts new file mode 100644 index 0000000000..6df480c5c8 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/controllers/ipmi_fan.service.ts @@ -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 { + try { + await execa('ipmitool', ['-V'], { timeout: this.timeoutMs }); + return true; + } catch { + return false; + } + } + + async readAll(): Promise { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fan-curve.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fan-curve.service.spec.ts new file mode 100644 index 0000000000..79d85bb2e5 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fan-curve.service.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { FanCurveService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fan-curve.service.js'; +import { FanCurvePointConfig } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.model.js'; + +const interpolateSpeed = FanCurveService.prototype.interpolateSpeed; + +describe('Fan Curve Interpolation', () => { + const balancedCurve: FanCurvePointConfig[] = [ + { temp: 30, speed: 30 }, + { temp: 45, speed: 45 }, + { temp: 60, speed: 65 }, + { temp: 70, speed: 80 }, + { temp: 80, speed: 100 }, + ]; + + it('should return minimum speed below lowest temp', () => { + expect(interpolateSpeed(balancedCurve, 20)).toBe(30); + }); + + it('should return maximum speed above highest temp', () => { + expect(interpolateSpeed(balancedCurve, 90)).toBe(100); + }); + + it('should return exact value at curve point', () => { + expect(interpolateSpeed(balancedCurve, 45)).toBe(45); + }); + + it('should interpolate linearly between points', () => { + const speed = interpolateSpeed(balancedCurve, 37.5); + expect(speed).toBe(37.5); + }); + + it('should handle single point curve', () => { + expect(interpolateSpeed([{ temp: 50, speed: 60 }], 40)).toBe(60); + expect(interpolateSpeed([{ temp: 50, speed: 60 }], 70)).toBe(60); + }); + + it('should handle empty curve', () => { + expect(interpolateSpeed([], 50)).toBe(100); + }); + + it('should handle unsorted curve points', () => { + const unsorted = [ + { temp: 60, speed: 65 }, + { temp: 30, speed: 30 }, + { temp: 80, speed: 100 }, + ]; + expect(interpolateSpeed(unsorted, 45)).toBe(47.5); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fan-curve.service.ts b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fan-curve.service.ts new file mode 100644 index 0000000000..a1e3b8ec61 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fan-curve.service.ts @@ -0,0 +1,214 @@ +import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; + +import { HwmonService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/controllers/hwmon.service.js'; +import { FanSafetyService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fan-safety.service.js'; +import { + FanCurvePointConfig, + FanProfileConfig, + FanZoneConfig, +} from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.model.js'; +import { FanControlConfigService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.service.js'; +import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; + +const DEFAULT_PROFILES: Record = { + silent: { + description: 'Low noise, higher temperatures', + curve: [ + { temp: 30, speed: 20 }, + { temp: 50, speed: 35 }, + { temp: 65, speed: 55 }, + { temp: 75, speed: 75 }, + { temp: 85, speed: 100 }, + ], + }, + balanced: { + description: 'Balance between noise and cooling', + curve: [ + { temp: 30, speed: 30 }, + { temp: 45, speed: 45 }, + { temp: 60, speed: 65 }, + { temp: 70, speed: 80 }, + { temp: 80, speed: 100 }, + ], + }, + performance: { + description: 'Maximum cooling, higher noise', + curve: [ + { temp: 30, speed: 50 }, + { temp: 40, speed: 65 }, + { temp: 55, speed: 80 }, + { temp: 65, speed: 90 }, + { temp: 75, speed: 100 }, + ], + }, +}; + +@Injectable() +export class FanCurveService implements OnModuleDestroy { + private readonly logger = new Logger(FanCurveService.name); + private curveInterval: ReturnType | null = null; + private activeZones: FanZoneConfig[] = []; + private profiles: Record = { ...DEFAULT_PROFILES }; + private isApplyingCurves = false; + + constructor( + private readonly hwmonService: HwmonService, + private readonly temperatureService: TemperatureService, + private readonly safetyService: FanSafetyService, + private readonly configService: FanControlConfigService + ) {} + + async onModuleDestroy(): Promise { + await this.stop(); + } + + getProfiles(): Record { + return { ...this.profiles }; + } + + getDefaultProfiles(): Record { + return { ...DEFAULT_PROFILES }; + } + + interpolateSpeed(curve: FanCurvePointConfig[], temperature: number): number { + if (curve.length === 0) { + return 100; + } + + const sorted = [...curve].sort((a, b) => a.temp - b.temp); + + if (temperature <= sorted[0].temp) { + return sorted[0].speed; + } + if (temperature >= sorted[sorted.length - 1].temp) { + return sorted[sorted.length - 1].speed; + } + + for (let i = 0; i < sorted.length - 1; i++) { + const lower = sorted[i]; + const upper = sorted[i + 1]; + if (temperature >= lower.temp && temperature <= upper.temp) { + if (upper.temp === lower.temp) { + return upper.speed; + } + const ratio = (temperature - lower.temp) / (upper.temp - lower.temp); + return lower.speed + ratio * (upper.speed - lower.speed); + } + } + + return 100; + } + + async start(zones: FanZoneConfig[]): Promise { + await this.stop(); + this.activeZones = zones; + + const config = this.configService.getConfig(); + const interval = config.polling_interval ?? 2000; + + if (config.profiles) { + this.profiles = { ...DEFAULT_PROFILES, ...config.profiles }; + } + + await this.applyCurves(); + + this.curveInterval = setInterval(async () => { + if (this.isApplyingCurves) { + return; + } + try { + await this.applyCurves(); + } catch (err) { + this.logger.error(`Error applying fan curves: ${err}`); + } + }, interval); + + this.logger.log(`Fan curve engine started with ${zones.length} zone(s)`); + } + + async stop(): Promise { + if (this.curveInterval) { + clearInterval(this.curveInterval); + this.curveInterval = null; + this.activeZones = []; + await this.safetyService.restoreAllFans(); + this.logger.log('Fan curve engine stopped'); + } + } + + isRunning(): boolean { + return this.curveInterval !== null; + } + + private async applyCurves(): Promise { + if (this.safetyService.isInEmergencyMode()) { + return; + } + + this.isApplyingCurves = true; + try { + const tempMetrics = await this.temperatureService.getMetrics(); + if (!tempMetrics) { + return; + } + + const readings = await this.hwmonService.readAll(); + + const isOvertemp = await this.safetyService.checkTemperatureSafety(tempMetrics.sensors); + if (isOvertemp) { + return; + } + + const isFanFailure = await this.safetyService.checkFanFailure(readings); + if (isFanFailure || this.safetyService.isInEmergencyMode()) { + return; + } + + for (const zone of this.activeZones) { + const profile = this.profiles[zone.profile]; + if (!profile) { + this.logger.warn(`Profile ${zone.profile} not found, skipping zone`); + continue; + } + + const sensor = tempMetrics.sensors.find( + (s) => s.id === zone.sensor || s.name === zone.sensor + ); + if (!sensor) { + this.logger.debug(`Sensor ${zone.sensor} not found, skipping zone`); + continue; + } + + const targetSpeed = this.interpolateSpeed(profile.curve, sensor.current.value); + const targetPwm = Math.round((targetSpeed / 100) * 255); + + for (const fanId of zone.fans) { + try { + const fan = readings.find((r) => r.id === fanId); + if (!fan || !fan.hasPwmControl) { + this.logger.debug(`Fan ${fanId} not found or not controllable, skipping`); + continue; + } + + await this.safetyService.captureState(fanId, fan.devicePath, fan.pwmNumber, fan); + + const isCpuFan = fan.fanNumber === 1 || fan.name.toLowerCase().includes('cpu'); + const safePwm = isCpuFan + ? this.safetyService.validateCpuFanPwm(targetPwm) + : this.safetyService.validatePwmValue(targetPwm); + + if (fan.pwmEnable !== 1) { + await this.hwmonService.setMode(fan.devicePath, fan.pwmNumber, 1); + } + + await this.hwmonService.setPwm(fan.devicePath, fan.pwmNumber, safePwm); + } catch (err) { + this.logger.error(`Failed to apply curve to fan ${fanId}: ${err}`); + } + } + } + } finally { + this.isApplyingCurves = false; + } + } +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fan-safety.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fan-safety.service.spec.ts new file mode 100644 index 0000000000..9457632877 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fan-safety.service.spec.ts @@ -0,0 +1,229 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { RawFanReading } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/controllers/controller.interface.js'; +import { HwmonService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/controllers/hwmon.service.js'; +import { FanSafetyService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fan-safety.service.js'; +import { FanControlConfigService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.service.js'; +import { FanControlMode } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.model.js'; +import { TemperatureSensor } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; + +describe('FanSafetyService', () => { + let service: FanSafetyService; + let hwmon: Partial; + let configService: FanControlConfigService; + + beforeEach(() => { + hwmon = { + readAll: vi.fn().mockResolvedValue([ + { + id: 'nct6793:fan1', + name: 'nct6793 Fan 1', + rpm: 800, + pwmValue: 168, + pwmEnable: 5, + pwmMode: 1, + hasPwmControl: true, + devicePath: '/sys/class/hwmon/hwmon4', + fanNumber: 1, + pwmNumber: 1, + }, + ]), + setMode: vi.fn().mockResolvedValue(undefined), + setPwm: vi.fn().mockResolvedValue(undefined), + restoreAutomatic: vi.fn().mockResolvedValue(undefined), + }; + + configService = Object.create(FanControlConfigService.prototype); + configService.getConfig = vi.fn().mockReturnValue({ + enabled: true, + control_enabled: true, + safety: { + min_speed_percent: 20, + cpu_min_speed_percent: 30, + max_temp_before_full: 85, + fan_failure_threshold: 0, + }, + }); + + service = new FanSafetyService(hwmon as unknown as HwmonService, configService); + }); + + describe('validatePwmValue', () => { + it('should enforce minimum speed percentage', () => { + const result = service.validatePwmValue(10); + const minPwm = Math.round((20 / 100) * 255); + expect(result).toBe(minPwm); + }); + + it('should clamp to 255 max', () => { + expect(service.validatePwmValue(300)).toBe(255); + }); + + it('should pass through valid values above minimum', () => { + expect(service.validatePwmValue(200)).toBe(200); + }); + }); + + describe('validateCpuFanPwm', () => { + it('should enforce higher minimum for CPU fans', () => { + const result = service.validateCpuFanPwm(10); + const minPwm = Math.round((30 / 100) * 255); + expect(result).toBe(minPwm); + }); + }); + + describe('validateModeTransition', () => { + it('should prevent transition to OFF', () => { + const result = service.validateModeTransition(FanControlMode.OFF); + expect(result).toBe(false); + }); + + it('should allow transition to MANUAL', () => { + const result = service.validateModeTransition(FanControlMode.MANUAL); + expect(result).toBe(true); + }); + + it('should block all transitions in emergency mode', async () => { + await service.emergencyFullSpeed(); + const result = service.validateModeTransition(FanControlMode.AUTOMATIC); + expect(result).toBe(false); + }); + }); + + describe('emergencyFullSpeed', () => { + it('should set emergency mode flag', async () => { + expect(service.isInEmergencyMode()).toBe(false); + await service.emergencyFullSpeed(); + expect(service.isInEmergencyMode()).toBe(true); + }); + }); + + describe('captureState', () => { + it('should store the original fan state', async () => { + await service.captureState('nct6793:fan1', '/sys/class/hwmon/hwmon4', 1); + expect(hwmon.readAll).toHaveBeenCalled(); + }); + + it('should not re-capture already captured state', async () => { + await service.captureState('nct6793:fan1', '/sys/class/hwmon/hwmon4', 1); + await service.captureState('nct6793:fan1', '/sys/class/hwmon/hwmon4', 1); + expect(hwmon.readAll).toHaveBeenCalledTimes(1); + }); + }); + + describe('restoreAllFans', () => { + it('should restore all captured fan states', async () => { + await service.captureState('nct6793:fan1', '/sys/class/hwmon/hwmon4', 1); + await service.restoreAllFans(); + expect(hwmon.restoreAutomatic).toHaveBeenCalled(); + }); + + it('should clear emergency mode after restore', async () => { + await service.emergencyFullSpeed(); + expect(service.isInEmergencyMode()).toBe(true); + await service.restoreAllFans(); + expect(service.isInEmergencyMode()).toBe(false); + }); + }); + + describe('checkTemperatureSafety', () => { + it('should trigger emergency when temp exceeds max_temp_before_full', async () => { + const sensors = [{ id: 'cpu', name: 'CPU', current: { value: 90 } }] as TemperatureSensor[]; + + const result = await service.checkTemperatureSafety(sensors); + expect(result).toBe(true); + expect(service.isInEmergencyMode()).toBe(true); + }); + + it('should not trigger emergency when temps are safe', async () => { + const sensors = [{ id: 'cpu', name: 'CPU', current: { value: 60 } }] as TemperatureSensor[]; + + const result = await service.checkTemperatureSafety(sensors); + expect(result).toBe(false); + expect(service.isInEmergencyMode()).toBe(false); + }); + + it('should trigger at exactly max_temp_before_full', async () => { + const sensors = [{ id: 'cpu', name: 'CPU', current: { value: 85 } }] as TemperatureSensor[]; + + const result = await service.checkTemperatureSafety(sensors); + expect(result).toBe(true); + }); + + it('should skip implausible sensor readings above 150°C', async () => { + const sensors = [ + { id: 'bogus', name: 'Bogus Sensor', current: { value: 3892313 } }, + { id: 'cpu', name: 'CPU', current: { value: 60 } }, + ] as TemperatureSensor[]; + + const result = await service.checkTemperatureSafety(sensors); + expect(result).toBe(false); + expect(service.isInEmergencyMode()).toBe(false); + }); + }); + + describe('checkFanFailure', () => { + it('should not trigger when fan_failure_threshold is 0', async () => { + const readings = [ + { id: 'fan1', name: 'Fan 1', hasPwmControl: true, pwmEnable: 1, pwmValue: 128, rpm: 0 }, + ] as RawFanReading[]; + + const result = await service.checkFanFailure(readings); + expect(result).toBe(false); + }); + + it('should count failures and trigger at threshold', async () => { + vi.mocked(configService.getConfig).mockReturnValue({ + enabled: true, + control_enabled: true, + safety: { + min_speed_percent: 20, + cpu_min_speed_percent: 30, + max_temp_before_full: 85, + fan_failure_threshold: 3, + }, + }); + + const readings = [ + { id: 'fan1', name: 'Fan 1', hasPwmControl: true, pwmEnable: 1, pwmValue: 128, rpm: 0 }, + ] as RawFanReading[]; + + expect(await service.checkFanFailure(readings)).toBe(false); + expect(await service.checkFanFailure(readings)).toBe(false); + expect(await service.checkFanFailure(readings)).toBe(true); + }); + + it('should reset failure count when all fans are ok', async () => { + vi.mocked(configService.getConfig).mockReturnValue({ + enabled: true, + control_enabled: true, + safety: { + min_speed_percent: 20, + cpu_min_speed_percent: 30, + max_temp_before_full: 85, + fan_failure_threshold: 3, + }, + }); + + const failedReadings = [ + { id: 'fan1', name: 'Fan 1', hasPwmControl: true, pwmEnable: 1, pwmValue: 128, rpm: 0 }, + ] as RawFanReading[]; + + const okReadings = [ + { + id: 'fan1', + name: 'Fan 1', + hasPwmControl: true, + pwmEnable: 1, + pwmValue: 128, + rpm: 800, + }, + ] as RawFanReading[]; + + await service.checkFanFailure(failedReadings); + await service.checkFanFailure(failedReadings); + await service.checkFanFailure(okReadings); + expect(await service.checkFanFailure(failedReadings)).toBe(false); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fan-safety.service.ts b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fan-safety.service.ts new file mode 100644 index 0000000000..ee5b86a9a7 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fan-safety.service.ts @@ -0,0 +1,186 @@ +import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; + +import { RawFanReading } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/controllers/controller.interface.js'; +import { HwmonService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/controllers/hwmon.service.js'; +import { FanControlConfigService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.service.js'; +import { FanControlMode } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.model.js'; +import { TemperatureSensor } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; + +interface OriginalState { + devicePath: string; + pwmNumber: number; + pwmEnable: number; + pwmValue: number; +} + +@Injectable() +export class FanSafetyService implements OnModuleDestroy { + private readonly logger = new Logger(FanSafetyService.name); + private originalStates = new Map(); + private isEmergencyMode = false; + private fanFailureCount = 0; + + constructor( + private readonly hwmonService: HwmonService, + private readonly configService: FanControlConfigService + ) {} + + async onModuleDestroy(): Promise { + if (this.originalStates.size > 0) { + this.logger.warn('Module destroying — restoring fans to original states'); + await this.restoreAllFans(); + } + } + + async captureState( + fanId: string, + devicePath: string, + pwmNumber: number, + existingReading?: Pick + ): Promise { + if (this.originalStates.has(fanId)) { + return; + } + + const reading = + existingReading ?? (await this.hwmonService.readAll()).find((r) => r.id === fanId); + if (reading) { + this.originalStates.set(fanId, { + devicePath, + pwmNumber, + pwmEnable: reading.pwmEnable, + pwmValue: reading.pwmValue, + }); + this.logger.debug( + `Captured original state for ${fanId}: enable=${reading.pwmEnable}, pwm=${reading.pwmValue}` + ); + } + } + + private static readonly MAX_PLAUSIBLE_TEMP_C = 150; + + async checkTemperatureSafety(sensors: TemperatureSensor[]): Promise { + const config = this.configService.getConfig(); + const maxTemp = config.safety?.max_temp_before_full ?? 85; + + for (const sensor of sensors) { + if (sensor.current.value > FanSafetyService.MAX_PLAUSIBLE_TEMP_C) { + continue; + } + if (sensor.current.value >= maxTemp) { + this.logger.error( + `SAFETY: Sensor "${sensor.name}" at ${sensor.current.value}°C exceeds max_temp_before_full (${maxTemp}°C)` + ); + await this.emergencyFullSpeed(); + return true; + } + } + return false; + } + + async checkFanFailure(readings: RawFanReading[]): Promise { + const config = this.configService.getConfig(); + const threshold = config.safety?.fan_failure_threshold ?? 0; + if (threshold === 0) { + return false; + } + + const controllableStopped = readings.filter( + (r) => r.hasPwmControl && r.pwmEnable === 1 && r.pwmValue > 0 && r.rpm === 0 + ); + + if (controllableStopped.length > 0) { + this.fanFailureCount++; + this.logger.warn( + `Fan failure detected: ${controllableStopped.map((r) => r.name).join(', ')} (count: ${this.fanFailureCount}/${threshold})` + ); + if (this.fanFailureCount >= threshold) { + this.logger.error('Fan failure threshold exceeded — triggering emergency'); + try { + await this.emergencyFullSpeed(); + } catch (err) { + this.logger.error(`Failed to set emergency full speed: ${err}`); + } + return true; + } + } else { + this.fanFailureCount = 0; + } + return false; + } + + validatePwmValue(value: number): number { + const config = this.configService.getConfig(); + const minPercent = config.safety?.min_speed_percent ?? 20; + const minPwm = Math.round((minPercent / 100) * 255); + return Math.max(minPwm, Math.min(255, Math.round(value))); + } + + validateCpuFanPwm(value: number): number { + const config = this.configService.getConfig(); + const minPercent = config.safety?.cpu_min_speed_percent ?? 30; + const minPwm = Math.round((minPercent / 100) * 255); + return Math.max(minPwm, Math.min(255, Math.round(value))); + } + + async restoreAllFans(): Promise { + this.logger.warn('Restoring all fans to original states'); + const failedFans: string[] = []; + for (const [fanId, state] of this.originalStates.entries()) { + try { + if (state.pwmEnable === 1) { + await this.hwmonService.setPwm(state.devicePath, state.pwmNumber, state.pwmValue); + } + await this.hwmonService.restoreAutomatic( + state.devicePath, + state.pwmNumber, + state.pwmEnable + ); + this.logger.log( + `Restored fan ${fanId} to enable=${state.pwmEnable}, pwm=${state.pwmValue}` + ); + this.originalStates.delete(fanId); + } catch (err) { + this.logger.error(`Failed to restore fan ${fanId}: ${err}`); + failedFans.push(fanId); + } + } + if (failedFans.length === 0) { + this.isEmergencyMode = false; + } else { + this.logger.warn(`${failedFans.length} fan(s) failed to restore, staying in emergency mode`); + } + } + + async emergencyFullSpeed(): Promise { + this.logger.error('EMERGENCY: Setting all controllable fans to full speed'); + this.isEmergencyMode = true; + + const readings = await this.hwmonService.readAll(); + for (const reading of readings) { + if (reading.hasPwmControl) { + try { + await this.captureState(reading.id, reading.devicePath, reading.pwmNumber, reading); + await this.hwmonService.setMode(reading.devicePath, reading.pwmNumber, 1); + await this.hwmonService.setPwm(reading.devicePath, reading.pwmNumber, 255); + } catch (err) { + this.logger.error(`Failed emergency full-speed for ${reading.id}: ${err}`); + } + } + } + } + + isInEmergencyMode(): boolean { + return this.isEmergencyMode; + } + + validateModeTransition(targetMode: FanControlMode): boolean { + if (this.isEmergencyMode) { + return false; + } + if (targetMode === FanControlMode.OFF) { + return false; + } + return true; + } +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.model.ts b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.model.ts new file mode 100644 index 0000000000..566e0595b5 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.model.ts @@ -0,0 +1,278 @@ +import { Field, Float, InputType, Int, ObjectType } from '@nestjs/graphql'; + +import { plainToInstance, Type } from 'class-transformer'; +import { + IsBoolean, + IsInt, + IsNumber, + IsOptional, + IsString, + Max, + Min, + Validate, + ValidateNested, + validateSync, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; +import { GraphQLJSON } from 'graphql-scalars'; + +@ObjectType() +export class FanControlSafetyConfig { + @Field(() => Float, { nullable: true }) + @IsNumber() + @IsOptional() + @Min(0) + @Max(100) + min_speed_percent?: number; + + @Field(() => Float, { nullable: true }) + @IsNumber() + @IsOptional() + @Min(0) + @Max(100) + cpu_min_speed_percent?: number; + + @Field(() => Float, { nullable: true }) + @IsNumber() + @IsOptional() + @Min(0) + @Max(150) + max_temp_before_full?: number; + + @Field(() => Int, { nullable: true }) + @IsNumber() + @IsOptional() + @Min(0) + fan_failure_threshold?: number; +} + +@ObjectType() +export class FanCurvePointConfig { + @Field(() => Float) + @IsNumber() + temp!: number; + + @Field(() => Float) + @IsNumber() + @Min(0) + @Max(100) + speed!: number; +} + +@ObjectType() +export class FanProfileConfig { + @Field(() => String, { nullable: true }) + @IsString() + @IsOptional() + description?: string; + + @Field(() => [FanCurvePointConfig]) + @ValidateNested({ each: true }) + @Type(() => FanCurvePointConfig) + curve!: FanCurvePointConfig[]; + + @Field(() => Float, { nullable: true, description: 'Minimum fan speed percentage' }) + @IsNumber() + @IsOptional() + @Min(0) + @Max(100) + minSpeed?: number; + + @Field(() => Float, { nullable: true, description: 'Maximum fan speed percentage' }) + @IsNumber() + @IsOptional() + @Min(0) + @Max(100) + maxSpeed?: number; +} + +@ObjectType() +export class FanZoneConfig { + @Field(() => [String]) + @IsString({ each: true }) + fans!: string[]; + + @Field(() => String) + @IsString() + sensor!: string; + + @Field(() => String) + @IsString() + profile!: string; +} + +@ValidatorConstraint({ name: 'ValidateProfiles', async: false }) +class ValidateProfiles implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + if (value === null || value === undefined) { + return true; + } + if (typeof value !== 'object' || Array.isArray(value)) { + return false; + } + for (const [, entry] of Object.entries(value as Record)) { + const instance = plainToInstance(FanProfileConfig, entry); + const errors = validateSync(instance); + if (errors.length > 0) { + return false; + } + } + return true; + } + + defaultMessage(): string { + return 'Each profile must be a valid FanProfileConfig with a curve array of {temp, speed} points'; + } +} + +@ValidatorConstraint({ name: 'ValidateUniqueFanIdsAcrossZones', async: false }) +class ValidateUniqueFanIdsAcrossZones implements ValidatorConstraintInterface { + validate(zones: unknown): boolean { + if (!Array.isArray(zones)) { + return true; + } + const seen = new Set(); + for (const zone of zones) { + if (!zone || !Array.isArray(zone.fans)) { + continue; + } + for (const fanId of zone.fans as string[]) { + if (seen.has(fanId)) { + return false; + } + seen.add(fanId); + } + } + return true; + } + + defaultMessage(): string { + return 'A fan ID must not appear in more than one zone'; + } +} + +@ObjectType() +export class FanControlConfig { + @Field({ nullable: true }) + @IsBoolean() + @IsOptional() + enabled?: boolean; + + @Field({ nullable: true }) + @IsBoolean() + @IsOptional() + control_enabled?: boolean; + + @Field(() => Int, { nullable: true }) + @IsInt() + @IsOptional() + @Min(1) + polling_interval?: number; + + @Field(() => String, { nullable: true }) + @IsString() + @IsOptional() + control_method?: string; + + @Field(() => FanControlSafetyConfig, { nullable: true }) + @ValidateNested() + @Type(() => FanControlSafetyConfig) + @IsOptional() + safety?: FanControlSafetyConfig; + + @Field(() => [FanZoneConfig], { + nullable: true, + description: 'Fan zone configurations for automatic curve control', + }) + @ValidateNested({ each: true }) + @Type(() => FanZoneConfig) + @Validate(ValidateUniqueFanIdsAcrossZones) + @IsOptional() + zones?: FanZoneConfig[]; + + @Field(() => GraphQLJSON, { nullable: true, description: 'Custom fan profiles (name -> config)' }) + @Validate(ValidateProfiles) + @IsOptional() + profiles?: Record; +} + +@InputType() +export class FanControlSafetyInput { + @Field(() => Float, { nullable: true }) + @IsNumber() + @IsOptional() + @Min(0) + @Max(100) + min_speed_percent?: number; + + @Field(() => Float, { nullable: true }) + @IsNumber() + @IsOptional() + @Min(0) + @Max(100) + cpu_min_speed_percent?: number; + + @Field(() => Float, { nullable: true }) + @IsNumber() + @IsOptional() + @Min(0) + @Max(150) + max_temp_before_full?: number; + + @Field(() => Int, { nullable: true }) + @IsNumber() + @IsOptional() + @Min(0) + fan_failure_threshold?: number; +} + +@InputType() +export class FanZoneConfigInput { + @Field(() => [String], { description: 'Fan IDs in this zone' }) + @IsString({ each: true }) + fans!: string[]; + + @Field(() => String, { description: 'Temperature sensor ID' }) + @IsString() + sensor!: string; + + @Field(() => String, { description: 'Profile name to use' }) + @IsString() + profile!: string; +} + +@InputType() +export class UpdateFanControlConfigInput { + @Field({ nullable: true }) + @IsBoolean() + @IsOptional() + enabled?: boolean; + + @Field({ nullable: true }) + @IsBoolean() + @IsOptional() + control_enabled?: boolean; + + @Field(() => Int, { nullable: true }) + @IsInt() + @IsOptional() + @Min(1) + polling_interval?: number; + + @Field(() => FanControlSafetyInput, { nullable: true }) + @ValidateNested() + @Type(() => FanControlSafetyInput) + @IsOptional() + safety?: FanControlSafetyInput; + + @Field(() => [FanZoneConfigInput], { + nullable: true, + description: 'Zone configurations for automatic curve control', + }) + @ValidateNested({ each: true }) + @Type(() => FanZoneConfigInput) + @Validate(ValidateUniqueFanIdsAcrossZones) + @IsOptional() + zones?: FanZoneConfigInput[]; +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.service.ts b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.service.ts new file mode 100644 index 0000000000..c2febd51f9 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { ConfigFilePersister } from '@unraid/shared/services/config-file.js'; + +import { FanControlConfig } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.model.js'; +import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js'; + +@Injectable() +export class FanControlConfigService extends ConfigFilePersister { + constructor(configService: ConfigService) { + super(configService); + } + + enabled(): boolean { + return true; + } + + configKey(): string { + return 'fanControl'; + } + + fileName(): string { + return 'fancontrol.json'; + } + + defaultConfig(): FanControlConfig { + return { + enabled: true, + control_enabled: false, + polling_interval: 2000, + control_method: 'auto', + safety: { + min_speed_percent: 20, + cpu_min_speed_percent: 30, + max_temp_before_full: 85, + fan_failure_threshold: 0, + }, + zones: [], + profiles: {}, + }; + } + + async validate(config: object): Promise { + return validateObject(FanControlConfig, config); + } +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.input.ts b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.input.ts new file mode 100644 index 0000000000..5da188d8e8 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.input.ts @@ -0,0 +1,48 @@ +import { Field, InputType, Int } from '@nestjs/graphql'; + +import { IsEnum, IsInt, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; + +import { FanControlMode } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.model.js'; + +@InputType() +export class SetFanSpeedInput { + @Field(() => String, { description: 'Fan ID to control' }) + @IsString() + fanId!: string; + + @Field(() => Int, { description: 'PWM value (0-255)' }) + @IsInt() + @Min(0) + @Max(255) + pwmValue!: number; +} + +@InputType() +export class SetFanModeInput { + @Field(() => String, { description: 'Fan ID to control' }) + @IsString() + fanId!: string; + + @Field(() => FanControlMode, { description: 'Target control mode' }) + @IsEnum(FanControlMode) + mode!: FanControlMode; +} + +@InputType() +export class SetFanProfileInput { + @Field(() => String, { description: 'Fan ID to assign profile to' }) + @IsString() + fanId!: string; + + @Field(() => String, { description: 'Profile name to apply' }) + @IsString() + profileName!: string; + + @Field(() => String, { + nullable: true, + description: 'Temperature sensor ID for the curve', + }) + @IsOptional() + @IsString() + temperatureSensorId?: string; +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.model.ts b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.model.ts new file mode 100644 index 0000000000..a660988380 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.model.ts @@ -0,0 +1,260 @@ +import { Field, Float, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql'; + +import { Node } from '@unraid/shared/graphql.model.js'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsBoolean, + IsEnum, + IsNumber, + IsOptional, + IsString, + Max, + Min, + ValidateNested, +} from 'class-validator'; + +export enum FanControlMode { + MANUAL = 'MANUAL', + AUTOMATIC = 'AUTOMATIC', + FIXED = 'FIXED', + OFF = 'OFF', +} + +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', + DC_3PIN = 'DC_3PIN', + MOLEX = 'MOLEX', + 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' }) + @ValidateNested({ each: true }) + @Type(() => FanCurvePoint) + 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 fan headers reported by hardware' }) + @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: 'Names of fans that may need attention (stopped, failing)', + }) + @IsOptional() + fansNeedingAttention?: string[]; +} + +@ObjectType({ implements: () => Node }) +export class FanControlMetrics extends Node { + @Field(() => [Fan], { + description: + 'All fans reported by hardware, including entries that may be undetected or disconnected', + }) + fans!: Fan[]; + + @Field(() => [FanProfile], { description: 'Available fan profiles' }) + profiles!: FanProfile[]; + + @Field(() => FanControlSummary, { description: 'Fan control summary' }) + summary!: FanControlSummary; +} + +@InputType() +export class FanCurvePointInput { + @Field(() => Float, { description: 'Temperature in Celsius' }) + @IsNumber() + temperature!: number; + + @Field(() => Float, { description: 'Fan speed percentage (0-100)' }) + @IsNumber() + @Min(0) + @Max(100) + speed!: number; +} + +@InputType() +export class CreateFanProfileInput { + @Field(() => String, { description: 'Profile name' }) + @IsString() + name!: string; + + @Field(() => String, { nullable: true, description: 'Profile description' }) + @IsOptional() + @IsString() + description?: string; + + @Field(() => [FanCurvePointInput], { description: 'Temperature/speed curve points' }) + @ValidateNested({ each: true }) + @ArrayMinSize(1) + @Type(() => FanCurvePointInput) + curvePoints!: FanCurvePointInput[]; + + @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() + @Min(0) + @Max(100) + minSpeed!: number; + + @Field(() => Float, { description: 'Maximum fan speed percentage', defaultValue: 100 }) + @IsNumber() + @Min(0) + @Max(100) + maxSpeed!: number; +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.module.ts b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.module.ts new file mode 100644 index 0000000000..30c3e71347 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { HwmonService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/controllers/hwmon.service.js'; +import { IpmiFanService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/controllers/ipmi_fan.service.js'; +import { FanCurveService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fan-curve.service.js'; +import { FanSafetyService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fan-safety.service.js'; +import { FanControlConfigService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.service.js'; +import { FanControlResolver } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.resolver.js'; +import { FanControlService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.service.js'; +import { TemperatureModule } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.module.js'; + +@Module({ + imports: [TemperatureModule], + providers: [ + { + provide: FanControlConfigService, + useFactory: async (configService: ConfigService) => { + const service = new FanControlConfigService(configService); + await service.onModuleInit(); + return service; + }, + inject: [ConfigService], + }, + FanControlService, + FanControlResolver, + FanSafetyService, + FanCurveService, + HwmonService, + IpmiFanService, + ], + exports: [FanControlService, FanControlConfigService], +}) +export class FanControlModule {} diff --git a/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.resolver.ts b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.resolver.ts new file mode 100644 index 0000000000..63a701933c --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.resolver.ts @@ -0,0 +1,286 @@ +import { Logger } from '@nestjs/common'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; + +import { pwmEnableToControlMode } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/controllers/controller.interface.js'; +import { HwmonService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/controllers/hwmon.service.js'; +import { FanCurveService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fan-curve.service.js'; +import { FanSafetyService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fan-safety.service.js'; +import { + FanControlConfig, + UpdateFanControlConfigInput, +} from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.model.js'; +import { FanControlConfigService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.service.js'; +import { + SetFanModeInput, + SetFanProfileInput, + SetFanSpeedInput, +} from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.input.js'; +import { + CreateFanProfileInput, + FanControlMetrics, + FanControlMode, +} from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.model.js'; + +@Resolver(() => FanControlMetrics) +export class FanControlResolver { + private readonly logger = new Logger(FanControlResolver.name); + + constructor( + private readonly hwmonService: HwmonService, + private readonly safetyService: FanSafetyService, + private readonly configService: FanControlConfigService, + private readonly fanCurveService: FanCurveService + ) {} + + @Mutation(() => Boolean, { description: 'Set fan PWM speed' }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.CONFIG, + }) + async setFanSpeed( + @Args('input', { type: () => SetFanSpeedInput }) input: SetFanSpeedInput + ): Promise { + const config = this.configService.getConfig(); + if (!config.control_enabled) { + throw new Error('Fan control is not enabled. Enable it in config first.'); + } + + if (this.safetyService.isInEmergencyMode()) { + throw new Error('System is in emergency mode. Fan control is locked.'); + } + + const readings = await this.hwmonService.readAll(); + const fan = readings.find((r) => r.id === input.fanId); + if (!fan) { + throw new Error(`Fan ${input.fanId} not found`); + } + if (!fan.hasPwmControl) { + throw new Error(`Fan ${input.fanId} does not support PWM control`); + } + + await this.safetyService.captureState(fan.id, fan.devicePath, fan.pwmNumber); + + const isCpuFan = fan.fanNumber === 1 || fan.name.toLowerCase().includes('cpu'); + if (isCpuFan) { + this.logger.debug( + `Fan ${input.fanId} identified as CPU fan via heuristic (fanNumber=${fan.fanNumber}, name=${fan.name})` + ); + } + const safePwm = isCpuFan + ? this.safetyService.validateCpuFanPwm(input.pwmValue) + : this.safetyService.validatePwmValue(input.pwmValue); + + const currentMode = pwmEnableToControlMode(fan.pwmEnable); + if (currentMode !== FanControlMode.MANUAL) { + await this.hwmonService.setMode(fan.devicePath, fan.pwmNumber, 1); + } + + await this.hwmonService.setPwm(fan.devicePath, fan.pwmNumber, safePwm); + this.logger.log(`Set fan ${input.fanId} PWM to ${safePwm} (requested: ${input.pwmValue})`); + + return true; + } + + @Mutation(() => Boolean, { description: 'Set fan control mode' }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.CONFIG, + }) + async setFanMode( + @Args('input', { type: () => SetFanModeInput }) input: SetFanModeInput + ): Promise { + const config = this.configService.getConfig(); + if (!config.control_enabled) { + throw new Error('Fan control is not enabled. Enable it in config first.'); + } + + if (this.safetyService.isInEmergencyMode()) { + throw new Error('System is in emergency mode. Fan control is locked.'); + } + + const readings = await this.hwmonService.readAll(); + const fan = readings.find((r) => r.id === input.fanId); + if (!fan) { + throw new Error(`Fan ${input.fanId} not found`); + } + if (!fan.hasPwmControl) { + throw new Error(`Fan ${input.fanId} does not support PWM control`); + } + + const currentMode = pwmEnableToControlMode(fan.pwmEnable); + if (!this.safetyService.validateModeTransition(input.mode)) { + throw new Error(`Cannot transition from ${currentMode} to ${input.mode}`); + } + + await this.safetyService.captureState(fan.id, fan.devicePath, fan.pwmNumber); + + const enableValue = this.modeToEnable(input.mode); + await this.hwmonService.setMode(fan.devicePath, fan.pwmNumber, enableValue); + this.logger.log(`Set fan ${input.fanId} mode to ${input.mode} (enable=${enableValue})`); + + return true; + } + + @Mutation(() => Boolean, { description: 'Restore all fans to their original/automatic state' }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.CONFIG, + }) + async restoreAllFans(): Promise { + await this.safetyService.restoreAllFans(); + return true; + } + + @Mutation(() => Boolean, { description: 'Update fan control configuration' }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.CONFIG, + }) + async updateFanControlConfig( + @Args('input', { type: () => UpdateFanControlConfigInput }) input: UpdateFanControlConfigInput + ): Promise { + const current = this.configService.getConfig(); + + const updated = { + ...current, + ...Object.fromEntries(Object.entries(input).filter(([_, v]) => v !== undefined)), + }; + + if (input.safety) { + updated.safety = { + ...current.safety, + ...Object.fromEntries(Object.entries(input.safety).filter(([_, v]) => v !== undefined)), + }; + } + + this.configService.replaceConfig(updated); + + if (updated.control_enabled && updated.zones && updated.zones.length > 0) { + await this.fanCurveService.start(updated.zones); + this.logger.log('Fan curve engine started with zone config'); + } else if (!updated.control_enabled || !updated.zones?.length) { + await this.fanCurveService.stop(); + this.logger.log('Fan curve engine stopped'); + } + + return true; + } + + @Mutation(() => Boolean, { description: 'Assign a fan profile to a specific fan' }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.CONFIG, + }) + async setFanProfile( + @Args('input', { type: () => SetFanProfileInput }) input: SetFanProfileInput + ): Promise { + const config = this.configService.getConfig(); + if (!config.control_enabled) { + throw new Error('Fan control is not enabled. Enable it in config first.'); + } + + const allProfiles = { ...this.fanCurveService.getProfiles(), ...(config.profiles ?? {}) }; + if (!allProfiles[input.profileName]) { + throw new Error( + `Profile "${input.profileName}" not found. Available: ${Object.keys(allProfiles).join(', ')}` + ); + } + + const readings = await this.hwmonService.readAll(); + const fan = readings.find((r) => r.id === input.fanId); + if (!fan) { + throw new Error(`Fan ${input.fanId} not found`); + } + if (!fan.hasPwmControl) { + throw new Error(`Fan ${input.fanId} does not support PWM control`); + } + + const zones = config.zones ?? []; + const existingZoneIdx = zones.findIndex((z) => z.fans.includes(input.fanId)); + if (existingZoneIdx >= 0) { + zones[existingZoneIdx].profile = input.profileName; + if (input.temperatureSensorId) { + zones[existingZoneIdx].sensor = input.temperatureSensorId; + } + } else { + if (!input.temperatureSensorId) { + throw new Error( + 'temperatureSensorId is required when assigning a profile to a fan not already in a zone' + ); + } + zones.push({ + fans: [input.fanId], + sensor: input.temperatureSensorId, + profile: input.profileName, + }); + } + + const updated: FanControlConfig = { ...config, zones }; + this.configService.replaceConfig(updated); + + if (updated.control_enabled && zones.length > 0) { + await this.fanCurveService.start(zones); + } + + this.logger.log(`Assigned profile "${input.profileName}" to fan ${input.fanId}`); + return true; + } + + @Mutation(() => Boolean, { description: 'Create a custom fan profile' }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.CONFIG, + }) + async createFanProfile( + @Args('input', { type: () => CreateFanProfileInput }) input: CreateFanProfileInput + ): Promise { + const config = this.configService.getConfig(); + const profiles = config.profiles ?? {}; + + const builtInProfiles = this.fanCurveService.getDefaultProfiles(); + if (builtInProfiles[input.name]) { + throw new Error( + `Cannot overwrite built-in profile "${input.name}". Choose a different name.` + ); + } + + if (profiles[input.name]) { + throw new Error( + `A custom profile named "${input.name}" already exists. Delete it first or choose a different name.` + ); + } + + profiles[input.name] = { + description: input.description, + curve: input.curvePoints.map((p) => ({ temp: p.temperature, speed: p.speed })), + minSpeed: input.minSpeed, + maxSpeed: input.maxSpeed, + }; + + const updated: FanControlConfig = { ...config, profiles }; + this.configService.replaceConfig(updated); + + this.logger.log(`Created custom fan profile "${input.name}"`); + return true; + } + + private modeToEnable(mode: FanControlMode): number { + switch (mode) { + case FanControlMode.MANUAL: + return 1; + case FanControlMode.AUTOMATIC: + return 2; + case FanControlMode.FIXED: + throw new Error( + 'FIXED mode cannot be set directly — hardware maps it identically to MANUAL. Use MANUAL instead.' + ); + case FanControlMode.OFF: + return 0; + default: + return 2; + } + } +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.service.spec.ts new file mode 100644 index 0000000000..86037ca64a --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.service.spec.ts @@ -0,0 +1,289 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + FanControllerProvider, + pwmEnableToControlMode, + pwmModeToConnectorType, + RawFanReading, +} from '@app/unraid-api/graph/resolvers/metrics/fancontrol/controllers/controller.interface.js'; +import { HwmonService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/controllers/hwmon.service.js'; +import { IpmiFanService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/controllers/ipmi_fan.service.js'; +import { FanCurveService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fan-curve.service.js'; +import { FanControlConfigService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.service.js'; +import { + FanConnectorType, + FanControlMode, +} from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.model.js'; +import { FanControlService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.service.js'; + +describe('FanControlService', () => { + let service: FanControlService; + let hwmon: Partial; + let ipmi: Partial; + let configService: FanControlConfigService; + let fanCurveService: Partial; + + const mockReading: RawFanReading = { + id: 'nct6793:fan1', + name: 'nct6793 Fan 1', + rpm: 800, + pwmValue: 168, + pwmEnable: 5, + pwmMode: 1, + hasPwmControl: true, + devicePath: '/sys/class/hwmon/hwmon4', + fanNumber: 1, + pwmNumber: 1, + }; + + const mockReading2: RawFanReading = { + id: 'nct6793:fan2', + name: 'nct6793 Fan 2', + rpm: 1200, + pwmValue: 144, + pwmEnable: 5, + pwmMode: 1, + hasPwmControl: true, + devicePath: '/sys/class/hwmon/hwmon4', + fanNumber: 2, + pwmNumber: 2, + }; + + const mockReadingDisconnected: RawFanReading = { + id: 'nct6793:fan3', + name: 'nct6793 Fan 3', + rpm: 0, + pwmValue: 77, + pwmEnable: 1, + pwmMode: 0, + hasPwmControl: true, + devicePath: '/sys/class/hwmon/hwmon4', + fanNumber: 3, + pwmNumber: 3, + }; + + beforeEach(() => { + hwmon = { + id: 'HwmonService', + isAvailable: vi.fn().mockResolvedValue(true), + readAll: vi.fn().mockResolvedValue([mockReading, mockReading2, mockReadingDisconnected]), + setPwm: vi.fn().mockResolvedValue(undefined), + setMode: vi.fn().mockResolvedValue(undefined), + restoreAutomatic: vi.fn().mockResolvedValue(undefined), + }; + + ipmi = { + id: 'IpmiFanService', + isAvailable: vi.fn().mockResolvedValue(false), + readAll: vi.fn().mockResolvedValue([]), + setPwm: vi.fn().mockResolvedValue(undefined), + setMode: vi.fn().mockResolvedValue(undefined), + restoreAutomatic: vi.fn().mockResolvedValue(undefined), + }; + + configService = Object.create(FanControlConfigService.prototype); + configService.getConfig = vi.fn().mockReturnValue({ + enabled: true, + control_enabled: false, + polling_interval: 2000, + control_method: 'auto', + safety: { + min_speed_percent: 20, + cpu_min_speed_percent: 30, + max_temp_before_full: 85, + fan_failure_threshold: 0, + }, + zones: [], + profiles: {}, + }); + + fanCurveService = { + getProfiles: vi.fn().mockReturnValue({ + silent: { + description: 'Low noise, higher temperatures', + curve: [ + { temp: 30, speed: 20 }, + { temp: 85, speed: 100 }, + ], + }, + balanced: { + description: 'Balance between noise and cooling', + curve: [ + { temp: 30, speed: 30 }, + { temp: 80, speed: 100 }, + ], + }, + performance: { + description: 'Maximum cooling, higher noise', + curve: [ + { temp: 30, speed: 50 }, + { temp: 75, speed: 100 }, + ], + }, + }), + }; + + service = new FanControlService( + hwmon as unknown as HwmonService, + ipmi as unknown as IpmiFanService, + configService, + fanCurveService as unknown as FanCurveService + ); + }); + + describe('initialization', () => { + it('should detect available providers', async () => { + await service.onModuleInit(); + expect(hwmon.isAvailable).toHaveBeenCalled(); + expect(ipmi.isAvailable).toHaveBeenCalled(); + }); + + it('should handle unavailable providers gracefully', async () => { + vi.mocked(hwmon.isAvailable!).mockResolvedValue(false); + vi.mocked(ipmi.isAvailable!).mockResolvedValue(false); + + await service.onModuleInit(); + + const metrics = await service.getMetrics(); + expect(metrics.fans).toHaveLength(0); + }); + }); + + describe('getMetrics', () => { + it('should return fan metrics from available providers', async () => { + await service.onModuleInit(); + const metrics = await service.getMetrics(); + + expect(metrics.fans).toHaveLength(3); + expect(metrics.fans[0].name).toBe('nct6793 Fan 1'); + expect(metrics.fans[0].current.rpm).toBe(800); + }); + + it('should calculate PWM percentage correctly', async () => { + await service.onModuleInit(); + const metrics = await service.getMetrics(); + + const fan1 = metrics.fans[0]; + expect(fan1.current.pwm).toBeCloseTo((168 / 255) * 100, 1); + }); + + it('should identify detected vs disconnected fans', async () => { + await service.onModuleInit(); + const metrics = await service.getMetrics(); + + expect(metrics.fans[0].detected).toBe(true); + expect(metrics.fans[2].detected).toBe(false); + }); + + it('should build correct summary', async () => { + await service.onModuleInit(); + const metrics = await service.getMetrics(); + + expect(metrics.summary.totalFans).toBe(3); + expect(metrics.summary.averageRpm).toBeGreaterThan(0); + }); + + it('should return empty metrics when disabled', async () => { + vi.mocked(configService.getConfig).mockReturnValue({ + enabled: false, + control_enabled: false, + polling_interval: 2000, + }); + + await service.onModuleInit(); + const metrics = await service.getMetrics(); + + expect(metrics.fans).toHaveLength(0); + }); + + it('should cache results within TTL', async () => { + await service.onModuleInit(); + + await service.getMetrics(); + await service.getMetrics(); + + expect(hwmon.readAll).toHaveBeenCalledTimes(1); + }); + + it('should filter NaN RPM readings', async () => { + vi.mocked(hwmon.readAll!).mockResolvedValue([{ ...mockReading, rpm: NaN }, mockReading2]); + + await service.onModuleInit(); + const metrics = await service.getMetrics(); + + expect(metrics.fans).toHaveLength(1); + }); + + it('should populate profiles from FanCurveService', async () => { + await service.onModuleInit(); + const metrics = await service.getMetrics(); + + expect(metrics.profiles).toHaveLength(3); + expect(metrics.profiles.map((p) => p.name)).toContain('silent'); + expect(metrics.profiles.map((p) => p.name)).toContain('balanced'); + expect(metrics.profiles.map((p) => p.name)).toContain('performance'); + }); + + it('should merge custom profiles from config', async () => { + vi.mocked(configService.getConfig).mockReturnValue({ + enabled: true, + control_enabled: false, + polling_interval: 2000, + control_method: 'auto', + safety: { + min_speed_percent: 20, + cpu_min_speed_percent: 30, + max_temp_before_full: 85, + fan_failure_threshold: 0, + }, + zones: [], + profiles: { + custom: { + description: 'My custom profile', + curve: [ + { temp: 30, speed: 40 }, + { temp: 80, speed: 90 }, + ], + }, + }, + }); + + await service.onModuleInit(); + const metrics = await service.getMetrics(); + + expect(metrics.profiles).toHaveLength(4); + expect(metrics.profiles.map((p) => p.name)).toContain('custom'); + }); + }); +}); + +describe('pwmEnableToControlMode', () => { + it('should map enable=0 to OFF', () => { + expect(pwmEnableToControlMode(0)).toBe(FanControlMode.OFF); + }); + + it('should map enable=1 to MANUAL', () => { + expect(pwmEnableToControlMode(1)).toBe(FanControlMode.MANUAL); + }); + + it('should map enable=2-5 to AUTOMATIC', () => { + expect(pwmEnableToControlMode(2)).toBe(FanControlMode.AUTOMATIC); + expect(pwmEnableToControlMode(3)).toBe(FanControlMode.AUTOMATIC); + expect(pwmEnableToControlMode(4)).toBe(FanControlMode.AUTOMATIC); + expect(pwmEnableToControlMode(5)).toBe(FanControlMode.AUTOMATIC); + }); +}); + +describe('pwmModeToConnectorType', () => { + it('should map mode=0 to DC_3PIN', () => { + expect(pwmModeToConnectorType(0)).toBe(FanConnectorType.DC_3PIN); + }); + + it('should map mode=1 to PWM_4PIN', () => { + expect(pwmModeToConnectorType(1)).toBe(FanConnectorType.PWM_4PIN); + }); + + it('should map unknown mode to UNKNOWN', () => { + expect(pwmModeToConnectorType(99)).toBe(FanConnectorType.UNKNOWN); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.service.ts b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.service.ts new file mode 100644 index 0000000000..987fa685b4 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.service.ts @@ -0,0 +1,197 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; + +import { + FanControllerProvider, + pwmEnableToControlMode, + pwmModeToConnectorType, +} from '@app/unraid-api/graph/resolvers/metrics/fancontrol/controllers/controller.interface.js'; +import { HwmonService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/controllers/hwmon.service.js'; +import { IpmiFanService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/controllers/ipmi_fan.service.js'; +import { FanCurveService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fan-curve.service.js'; +import { FanControlConfigService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.service.js'; +import { + Fan, + FanControlMetrics, + FanControlSummary, + FanProfile, + FanSpeed, + FanType, +} from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.model.js'; + +@Injectable() +export class FanControlService implements OnModuleInit { + private readonly logger = new Logger(FanControlService.name); + private providers: FanControllerProvider[] = []; + private availableProviders: FanControllerProvider[] = []; + private cache: FanControlMetrics | null = null; + private cacheTimestamp = 0; + private readonly CACHE_TTL_MS = 1000; + + constructor( + private readonly hwmonService: HwmonService, + private readonly ipmiFanService: IpmiFanService, + private readonly configService: FanControlConfigService, + private readonly fanCurveService: FanCurveService + ) {} + + async onModuleInit(): Promise { + this.providers = [this.hwmonService, this.ipmiFanService]; + + for (const provider of this.providers) { + const available = await provider.isAvailable(); + if (available) { + this.availableProviders.push(provider); + this.logger.log(`Fan controller available: ${provider.id}`); + } else { + this.logger.debug(`Fan controller not available: ${provider.id}`); + } + } + + if (this.availableProviders.length === 0) { + this.logger.warn('No fan controllers detected'); + } + } + + async getMetrics(): Promise { + const isCacheValid = this.cache && Date.now() - this.cacheTimestamp < this.CACHE_TTL_MS; + if (isCacheValid && this.cache) { + return this.cache; + } + + const config = this.configService.getConfig(); + if (!config.enabled) { + return this.emptyMetrics(); + } + + const fans: Fan[] = []; + + for (const provider of this.availableProviders) { + try { + const readings = await provider.readAll(); + + for (const reading of readings) { + if (!Number.isFinite(reading.rpm)) { + continue; + } + + const pwmPercent = + reading.hasPwmControl && reading.pwmValue >= 0 + ? (reading.pwmValue / 255) * 100 + : 0; + + const speed: FanSpeed = { + rpm: reading.rpm, + pwm: Math.round(pwmPercent * 100) / 100, + timestamp: new Date(), + }; + + const fan = Object.assign(new Fan(), { + id: reading.id, + name: reading.name, + type: this.inferFanType(reading.name, reading.fanNumber), + connectorType: pwmModeToConnectorType(reading.pwmMode), + header: `Fan Header ${reading.fanNumber}`, + current: speed, + mode: pwmEnableToControlMode(reading.pwmEnable), + controllable: reading.hasPwmControl, + detected: reading.rpm > 0, + }); + + fans.push(fan); + } + } catch (err) { + this.logger.error(`Error reading from ${provider.id}: ${err}`); + } + } + + const summary = this.buildSummary(fans); + + const profiles = this.getProfiles(); + + const metrics = Object.assign(new FanControlMetrics(), { + id: 'fanControl', + fans, + profiles, + summary, + }); + + this.cache = metrics; + this.cacheTimestamp = Date.now(); + + return metrics; + } + + private getProfiles(): FanProfile[] { + const defaultProfiles = this.fanCurveService.getProfiles(); + const config = this.configService.getConfig(); + const customProfiles = config.profiles ?? {}; + + const allProfiles = { ...defaultProfiles, ...customProfiles }; + + return Object.entries(allProfiles).map(([name, profileConfig]) => + Object.assign(new FanProfile(), { + name, + description: profileConfig.description, + curvePoints: (profileConfig.curve ?? []).map((point) => ({ + temperature: point.temp ?? 0, + speed: point.speed ?? 0, + })), + minSpeed: 20, + maxSpeed: 100, + }) + ); + } + + private buildSummary(fans: Fan[]): FanControlSummary { + const detectedFans = fans.filter((f) => f.detected); + const controllableFans = fans.filter((f) => f.controllable && f.detected); + const rpms = detectedFans.map((f) => f.current.rpm).filter((r) => r > 0); + const speeds = detectedFans.map((f) => f.current.pwm).filter((s) => s > 0); + + const fansNeedingAttention = fans + .filter((f) => f.controllable && !f.detected) + .map((f) => f.name); + + return { + totalFans: fans.length, + controllableFans: controllableFans.length, + averageRpm: rpms.length > 0 ? Math.round(rpms.reduce((a, b) => a + b, 0) / rpms.length) : 0, + averageSpeed: + speeds.length > 0 + ? Math.round((speeds.reduce((a, b) => a + b, 0) / speeds.length) * 100) / 100 + : 0, + fansNeedingAttention: fansNeedingAttention.length > 0 ? fansNeedingAttention : undefined, + }; + } + + private inferFanType(name: string, fanNumber: number): FanType { + const lower = name.toLowerCase(); + if (lower.includes('cpu') || fanNumber === 1) { + return FanType.CPU; + } + if (lower.includes('gpu')) { + return FanType.GPU; + } + if (lower.includes('chassis') || lower.includes('rear') || lower.includes('exhaust')) { + return FanType.CASE_EXHAUST; + } + if (lower.includes('front') || lower.includes('intake')) { + return FanType.CASE_INTAKE; + } + return FanType.CUSTOM; + } + + private emptyMetrics(): FanControlMetrics { + return Object.assign(new FanControlMetrics(), { + id: 'fanControl', + fans: [], + profiles: [], + summary: { + totalFans: 0, + controllableFans: 0, + averageSpeed: 0, + averageRpm: 0, + }, + }); + } +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts index 438d800fbb..fe056b5eff 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts @@ -4,6 +4,7 @@ import { Node } from '@unraid/shared/graphql.model.js'; import { CpuUtilization } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js'; import { MemoryUtilization } from '@app/unraid-api/graph/resolvers/info/memory/memory.model.js'; +import { FanControlMetrics } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.model.js'; import { TemperatureMetrics } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; @ObjectType({ @@ -25,4 +26,10 @@ export class Metrics extends Node { description: 'Temperature metrics', }) temperature?: TemperatureMetrics; + + @Field(() => FanControlMetrics, { + nullable: true, + description: 'Fan control metrics', + }) + fanControl?: FanControlMetrics; } diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts index 00ef567969..4ee6d03451 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts @@ -2,12 +2,13 @@ import { Module } from '@nestjs/common'; import { CpuModule } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.module.js'; import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js'; +import { FanControlModule } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.module.js'; import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js'; import { TemperatureModule } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.module.js'; import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js'; @Module({ - imports: [ServicesModule, CpuModule, TemperatureModule], + imports: [ServicesModule, CpuModule, TemperatureModule, FanControlModule], providers: [MetricsResolver, MemoryService], exports: [MetricsResolver], }) diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts index 1269629e53..2fda56e3a9 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts @@ -10,6 +10,8 @@ import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js'; import { MemoryUtilization } from '@app/unraid-api/graph/resolvers/info/memory/memory.model.js'; import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js'; +import { FanControlConfigService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.service.js'; +import { FanControlService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.service.js'; import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js'; import { TemperatureConfigService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-config.service.js'; import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; @@ -50,6 +52,18 @@ describe('MetricsResolver Integration Tests', () => { getConfig: vi.fn().mockReturnValue({ enabled: true, polling_interval: 5000 }), }, }, + { + provide: FanControlService, + useValue: { + getMetrics: vi.fn().mockResolvedValue(null), + }, + }, + { + provide: FanControlConfigService, + useValue: { + getConfig: vi.fn().mockReturnValue({ enabled: false }), + }, + }, ], }).compile(); diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts index 488c303a31..c19d6d089d 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts @@ -8,6 +8,8 @@ import { pubsub } from '@app/core/pubsub.js'; import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js'; import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js'; import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js'; +import { FanControlConfigService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.service.js'; +import { FanControlService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.service.js'; import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js'; import { TemperatureConfigService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-config.service.js'; import { @@ -119,6 +121,18 @@ describe('MetricsResolver', () => { getConfig: vi.fn().mockReturnValue({ enabled: true, polling_interval: 5000 }), }, }, + { + provide: FanControlService, + useValue: { + getMetrics: vi.fn().mockResolvedValue(null), + }, + }, + { + provide: FanControlConfigService, + useValue: { + getConfig: vi.fn().mockReturnValue({ enabled: false }), + }, + }, ], }).compile(); @@ -217,15 +231,25 @@ describe('MetricsResolver', () => { getConfig: vi.fn().mockReturnValue({ enabled: true, polling_interval: 5000 }), }; + const fanControlServiceMock = { + getMetrics: vi.fn().mockResolvedValue(null), + }; + + const fanControlConfigServiceMock = { + getConfig: vi.fn().mockReturnValue({ enabled: false }), + }; + const testModule = new MetricsResolver( cpuService, cpuTopologyServiceMock as unknown as CpuTopologyService, memoryService, temperatureServiceMock as unknown as TemperatureService, + fanControlServiceMock as unknown as FanControlService, subscriptionTracker as unknown as SubscriptionTrackerService, {} as unknown as SubscriptionHelperService, configServiceMock as unknown as ConfigService, - temperatureConfigServiceMock as unknown as TemperatureConfigService + temperatureConfigServiceMock as unknown as TemperatureConfigService, + fanControlConfigServiceMock as unknown as FanControlConfigService ); testModule.onModuleInit(); @@ -262,10 +286,14 @@ describe('MetricsResolver', () => { {} as CpuTopologyService, {} as MemoryService, temperatureServiceMock, + { getMetrics: vi.fn().mockResolvedValue(null) } as unknown as FanControlService, subscriptionTracker, {} as SubscriptionHelperService, {} as ConfigService, - temperatureConfigServiceMock + temperatureConfigServiceMock, + { + getConfig: vi.fn().mockReturnValue({ enabled: false }), + } as unknown as FanControlConfigService ); testModule.onModuleInit(); @@ -305,10 +333,14 @@ describe('MetricsResolver', () => { {} as CpuTopologyService, {} as MemoryService, temperatureServiceMock, + { getMetrics: vi.fn().mockResolvedValue(null) } as unknown as FanControlService, subscriptionTracker, {} as SubscriptionHelperService, {} as ConfigService, - temperatureConfigServiceMock + temperatureConfigServiceMock, + { + getConfig: vi.fn().mockReturnValue({ enabled: false }), + } as unknown as FanControlConfigService ); testModule.onModuleInit(); diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts index 794e3f4cea..159a72c5e9 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -11,6 +11,9 @@ import { CpuPackages, CpuUtilization } from '@app/unraid-api/graph/resolvers/inf import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js'; import { MemoryUtilization } from '@app/unraid-api/graph/resolvers/info/memory/memory.model.js'; import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js'; +import { FanControlConfigService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.service.js'; +import { FanControlMetrics } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.model.js'; +import { FanControlService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.service.js'; import { Metrics } from '@app/unraid-api/graph/resolvers/metrics/metrics.model.js'; import { TemperatureConfigInput } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-config.input.js'; import { TemperatureConfigService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-config.service.js'; @@ -27,10 +30,12 @@ export class MetricsResolver implements OnModuleInit { private readonly cpuTopologyService: CpuTopologyService, private readonly memoryService: MemoryService, private readonly temperatureService: TemperatureService, + private readonly fanControlService: FanControlService, private readonly subscriptionTracker: SubscriptionTrackerService, private readonly subscriptionHelper: SubscriptionHelperService, private readonly configService: ConfigService, - private readonly temperatureConfigService: TemperatureConfigService + private readonly temperatureConfigService: TemperatureConfigService, + private readonly fanControlConfigService: FanControlConfigService ) {} onModuleInit() { @@ -102,6 +107,22 @@ export class MetricsResolver implements OnModuleInit { polling_interval ); } + + const fanConfig = this.fanControlConfigService.getConfig(); + if (fanConfig.enabled) { + this.subscriptionTracker.registerTopic( + PUBSUB_CHANNEL.FAN_METRICS, + async () => { + const payload = await this.fanControlService.getMetrics(); + if (payload) { + pubsub.publish(PUBSUB_CHANNEL.FAN_METRICS, { + systemMetricsFanControl: payload, + }); + } + }, + fanConfig.polling_interval ?? 2000 + ); + } } @Query(() => Metrics) @@ -165,6 +186,12 @@ export class MetricsResolver implements OnModuleInit { public async temperature(): Promise { return this.temperatureService.getMetrics(); } + + @ResolveField(() => FanControlMetrics, { nullable: true }) + public async fanControl(): Promise { + return this.fanControlService.getMetrics(); + } + @Subscription(() => TemperatureMetrics, { name: 'systemMetricsTemperature', resolve: (value) => value.systemMetricsTemperature, @@ -178,6 +205,19 @@ export class MetricsResolver implements OnModuleInit { return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.TEMPERATURE_METRICS); } + @Subscription(() => FanControlMetrics, { + name: 'systemMetricsFanControl', + resolve: (value) => value.systemMetricsFanControl, + nullable: true, + }) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) + public async systemMetricsFanControlSubscription() { + return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.FAN_METRICS); + } + @Mutation(() => Boolean) @UsePermissions({ action: AuthAction.UPDATE_ANY, diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts index 4d1c5a6575..7018becbe0 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts @@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js'; import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js'; import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js'; +import { FanControlConfigService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol-config.service.js'; +import { FanControlService } from '@app/unraid-api/graph/resolvers/metrics/fancontrol/fancontrol.service.js'; import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js'; import { TemperatureConfigService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-config.service.js'; import { @@ -144,6 +146,18 @@ describe('Temperature GraphQL Integration', () => { getConfig: vi.fn().mockReturnValue({ enabled: true, polling_interval: 5000 }), }, }, + { + provide: FanControlService, + useValue: { + getMetrics: vi.fn().mockResolvedValue(null), + }, + }, + { + provide: FanControlConfigService, + useValue: { + getConfig: vi.fn().mockReturnValue({ enabled: false }), + }, + }, ], }).compile(); diff --git a/packages/unraid-shared/src/pubsub/graphql.pubsub.ts b/packages/unraid-shared/src/pubsub/graphql.pubsub.ts index 0359617f96..148186a7c8 100644 --- a/packages/unraid-shared/src/pubsub/graphql.pubsub.ts +++ b/packages/unraid-shared/src/pubsub/graphql.pubsub.ts @@ -17,6 +17,7 @@ export enum GRAPHQL_PUBSUB_CHANNEL { OWNER = "OWNER", SERVERS = "SERVERS", TEMPERATURE_METRICS = "TEMPERATURE_METRICS", + FAN_METRICS = "FAN_METRICS", VMS = "VMS", DOCKER_STATS = "DOCKER_STATS", LOG_FILE = "LOG_FILE",