fix(fancontroller): support more than 2 steps in fan curve (#156)

* fix(fancontroller): support more than 2 steps in fan curve

The GetFanSpeedPercent function only used the first 2 steps from the
config, ignoring all subsequent steps. This caused the fan to cap at
step[1]'s percent value regardless of actual temperature.

For example, with a typical 5-step curve:
  - 40°C → 30%
  - 50°C → 50%
  - 60°C → 70%
  - 70°C → 90%
  - 75°C → 100%

Any temperature ≥50°C would return 50% instead of the correct value.
At 77°C the fan should be at 100%, but was stuck at 50%.

The fix properly iterates through all configured steps to find the
correct temperature bracket and interpolates within it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Apply suggestions from code review

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Cedric Specht <cedric@specht-labs.de>
This commit is contained in:
weslson
2026-03-04 04:26:29 -10:00
committed by GitHub
parent 9477cc71c2
commit 84346089ca
2 changed files with 70 additions and 10 deletions

View File

@@ -86,20 +86,31 @@ func (f *fanControllerLinear) GetFanSpeedPercent(temperature float64) uint8 {
return f.overrideOpts.Percent
}
if temperature <= f.config.Steps[0].Temperature {
return f.config.Steps[0].Percent
}
if temperature >= f.config.Steps[1].Temperature {
return f.config.Steps[1].Percent
steps := f.config.Steps
// Below minimum temperature: use minimum fan speed
if temperature <= steps[0].Temperature {
return steps[0].Percent
}
// Calculate slope
slope := float64(f.config.Steps[1].Percent-f.config.Steps[0].Percent) / (f.config.Steps[1].Temperature - f.config.Steps[0].Temperature)
// Above maximum temperature: use maximum fan speed
lastIdx := len(steps) - 1
if temperature >= steps[lastIdx].Temperature {
return steps[lastIdx].Percent
}
// Calculate speed
speed := float64(f.config.Steps[0].Percent) + slope*(temperature-f.config.Steps[0].Temperature)
// Find the bracket where steps[i].Temperature <= temperature < steps[i+1].Temperature
for i := 0; i < lastIdx; i++ {
if temperature >= steps[i].Temperature && temperature < steps[i+1].Temperature {
// Linear interpolation between steps[i] and steps[i+1]
slope := float64(steps[i+1].Percent-steps[i].Percent) / (steps[i+1].Temperature - steps[i].Temperature)
speed := float64(steps[i].Percent) + slope*(temperature-steps[i].Temperature)
return uint8(speed)
}
}
return uint8(speed)
// Fallback (should not reach here due to above checks)
return steps[lastIdx].Percent
}
func (f *fanControllerLinear) IsAutomaticSpeed() bool {

View File

@@ -45,6 +45,55 @@ func TestFanControllerLinear_GetFanSpeed(t *testing.T) {
}
}
func TestFanControllerLinear_GetFanSpeedMultipleSteps(t *testing.T) {
t.Parallel()
// Typical 5-step fan curve configuration
config := fancontroller.Config{
Steps: []fancontroller.Step{
{Temperature: 40, Percent: 30},
{Temperature: 50, Percent: 50},
{Temperature: 60, Percent: 70},
{Temperature: 70, Percent: 90},
{Temperature: 75, Percent: 100},
},
}
controller, err := fancontroller.NewLinearFanController(config)
if err != nil {
t.Fatalf("Failed to create fan controller: %v", err)
}
testCases := []struct {
name string
temperature float64
expected uint8
}{
{"below minimum", 30, 30}, // Below 40°C: use minimum 30%
{"at step 0", 40, 30}, // At 40°C: 30%
{"between step 0-1", 45, 40}, // Midpoint 40-50°C: 40%
{"at step 1", 50, 50}, // At 50°C: 50%
{"between step 1-2", 55, 60}, // Midpoint 50-60°C: 60%
{"at step 2", 60, 70}, // At 60°C: 70%
{"between step 2-3", 65, 80}, // Midpoint 60-70°C: 80%
{"at step 3", 70, 90}, // At 70°C: 90%
{"between step 3-4", 72, 94}, // 70 + (100-90)*(72-70)/(75-70) = 90 + 4 = 94%
{"at step 4", 75, 100}, // At 75°C: 100%
{"above maximum", 80, 100}, // Above 75°C: use maximum 100%
{"well above maximum", 90, 100}, // Well above: still 100%
}
for _, tc := range testCases {
expected := tc.expected
temperature := tc.temperature
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
speed := controller.GetFanSpeedPercent(temperature)
assert.Equal(t, expected, speed, "Temperature %.1f°C should yield %d%% fan speed", temperature, expected)
})
}
}
func TestFanControllerLinear_GetFanSpeedWithOverride(t *testing.T) {
t.Parallel()