From 84346089caf7cb1b163e9279bdf9fe4cf0519651 Mon Sep 17 00:00:00 2001 From: weslson <95448131+weslson@users.noreply.github.com> Date: Wed, 4 Mar 2026 04:26:29 -1000 Subject: [PATCH] fix(fancontroller): support more than 2 steps in fan curve (#156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * Apply suggestions from code review --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Cedric Specht --- pkg/fancontroller/fancontroller.go | 31 +++++++++++----- pkg/fancontroller/fancontroller_test.go | 49 +++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/pkg/fancontroller/fancontroller.go b/pkg/fancontroller/fancontroller.go index 299ef08..c384f0a 100644 --- a/pkg/fancontroller/fancontroller.go +++ b/pkg/fancontroller/fancontroller.go @@ -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 { diff --git a/pkg/fancontroller/fancontroller_test.go b/pkg/fancontroller/fancontroller_test.go index d534767..49f1f62 100644 --- a/pkg/fancontroller/fancontroller_test.go +++ b/pkg/fancontroller/fancontroller_test.go @@ -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()