From 6136afdcd431e17f184b2a36369e3f4f2cbcd67e Mon Sep 17 00:00:00 2001 From: Markus Probst Date: Sun, 31 May 2026 18:47:30 +0200 Subject: [PATCH 1/8] Add staircase fan curve --- README.md | 19 ++++ internal/configuration/config.go | 71 ++++++++------ internal/configuration/curves.go | 21 +++- internal/configuration/validation.go | 20 ++++ internal/curves/curve.go | 9 ++ internal/curves/linear_test.go | 3 +- internal/curves/staircase.go | 60 ++++++++++++ internal/curves/staircase_test.go | 141 +++++++++++++++++++++++++++ 8 files changed, 310 insertions(+), 34 deletions(-) create mode 100644 internal/curves/staircase.go create mode 100644 internal/curves/staircase_test.go diff --git a/README.md b/README.md index 36d5ada1..737a83c0 100644 --- a/README.md +++ b/README.md @@ -584,6 +584,25 @@ curves: - 80: 255 ``` +#### Staircase + +To create a simple, staircase speed curve, use a curve of type `staircase`. + +It maintains a static fan speed and avoids constantly changing fan speeds. + +```yaml +curves: + - id: staircase_curve + staircase: + sensor: cpu_package + threshold: 6 + steps: + # Sensor value (in degrees Celsius) -> Speed (0-255) + - 40: 1 + - 50: 50 + - 80: 255 +``` + #### PID If you want to get your hands dirty and use a PID based curve, you can use `pid`: diff --git a/internal/configuration/config.go b/internal/configuration/config.go index 73592863..48b82557 100644 --- a/internal/configuration/config.go +++ b/internal/configuration/config.go @@ -189,40 +189,51 @@ func applyTransformations() { // convert steps in linear curves from strings (with plain numbers or percent values) to floats between 0 and 255 for _, curve := range CurrentConfig.Curves { if curve.Linear != nil && len(curve.Linear.InSteps) > 0 { - curve.Linear.Steps = make(map[int]float64) - - for temp, origstr := range curve.Linear.InSteps { - str := strings.TrimSpace(origstr) - l := len(str) - isPercent := false - if l > 1 && str[l-1] == '%' { - isPercent = true - str = str[:l-1] // cut off '%' because ParseFloat() wouldn't like it + transformCurveSteps(&curve.ID, &curve.Linear.Steps, &curve.Linear.InSteps) + } + if curve.Staircase != nil && len(curve.Staircase.InSteps) > 0 { + if len(curve.Staircase.InSteps) > 0 { + transformCurveSteps(&curve.ID, &curve.Staircase.Steps, &curve.Staircase.InSteps) + } else { + ui.Fatal("Missing steps in curve %s", curve.ID) + } + } + } +} + +func transformCurveSteps(ID *string, Steps *map[int]float64, InSteps *map[int]string) { + *Steps = make(map[int]float64) + + for temp, origstr := range *InSteps { + str := strings.TrimSpace(origstr) + l := len(str) + isPercent := false + if l > 1 && str[l-1] == '%' { + isPercent = true + str = str[:l-1] // cut off '%' because ParseFloat() wouldn't like it + } + speed, err := strconv.ParseFloat(str, 64) + if err != nil { + ui.Fatal("Invalid curve step value '%s' in %s - must be either just a number or a number followed by '%%'", origstr, *ID) + } else { + if isPercent { + if speed < 0 || speed > 100 { + ui.Fatal("invalid curve step value '%s' (=> %f) in %s - must be between 0%% and 100%%", origstr, speed, *ID) } - speed, err := strconv.ParseFloat(str, 64) - if err != nil { - ui.Fatal("Invalid curve step value '%s' in %s - must be either just a number or a number followed by '%%'", origstr, curve.ID) + // convert 0-100% into [0..255] + if speed < 1 { + // less than 1% always turns into 0 + speed = 0 } else { - if isPercent { - if speed < 0 || speed > 100 { - ui.Fatal("invalid curve step value '%s' (=> %f) in %s - must be between 0%% and 100%%", origstr, speed, curve.ID) - } - // convert 0-100% into [0..255] - if speed < 1 { - // less than 1% always turns into 0 - speed = 0 - } else { - // 1% turns into 1, 100% turns into 255 - // => convert 1..100% to 1..255 - // => 0..99 to 0..254 and then add 1 - speed = (speed-1)*(254.0/99.0) + 1 - } - } else if speed < 0 || speed > 255 { - ui.Fatal("invalid curve step value '%s' in %s - must be between 0 and 255", origstr, curve.ID) - } - curve.Linear.Steps[temp] = speed + // 1% turns into 1, 100% turns into 255 + // => convert 1..100% to 1..255 + // => 0..99 to 0..254 and then add 1 + speed = (speed-1)*(254.0/99.0) + 1 } + } else if speed < 0 || speed > 255 { + ui.Fatal("invalid curve step value '%s' in %s - must be between 0 and 255", origstr, *ID) } + (*Steps)[temp] = speed } } } diff --git a/internal/configuration/curves.go b/internal/configuration/curves.go index 1daa9795..6830e379 100644 --- a/internal/configuration/curves.go +++ b/internal/configuration/curves.go @@ -5,9 +5,10 @@ type CurveConfig struct { ID string `json:"id"` // can be any of the following: - Linear *LinearCurveConfig `json:"linear,omitempty"` - PID *PidCurveConfig `json:"pid,omitempty"` - Function *FunctionCurveConfig `json:"function,omitempty"` + Linear *LinearCurveConfig `json:"linear,omitempty"` + Staircase *StaircaseCurveConfig `json:"staircase,omitempty"` + PID *PidCurveConfig `json:"pid,omitempty"` + Function *FunctionCurveConfig `json:"function,omitempty"` } type LinearCurveConfig struct { @@ -26,6 +27,20 @@ type LinearCurveConfig struct { Steps map[int]float64 `json:"steps" mapstructure:"-"` } +type StaircaseCurveConfig struct { + // Sensor is the id of the sensor to use for this curve + Sensor string `json:"sensor"` + // Threshold is the temperature threshold in degrees + Threshold int `json:"threshold"` + // Steps is a map of temperature to relative speed value (in range of 0..255 or alternatively 0%..100%) + // InSteps contains the speed values as strings (like "42" or "11%"), as read from fan2go.yaml + InSteps map[int]string `mapstructure:"steps" json:"-"` + // Steps is created from InSteps on load (LoadConfig()), the strings are converted to floats + // between 0 and 255 (0% is 0, 1% is 1; from there on it's interpolated linearly so 100% is 255). + // If a string only contains a number (without "%"), it's just converted to float + Steps map[int]float64 `json:"steps" mapstructure:"-"` +} + type PidCurveConfig struct { // Sensor is the id of the sensor to use for this curve Sensor string `json:"sensor"` diff --git a/internal/configuration/validation.go b/internal/configuration/validation.go index 3fdd8dc5..2efed482 100644 --- a/internal/configuration/validation.go +++ b/internal/configuration/validation.go @@ -124,6 +124,9 @@ func isSensorConfigInUse(config SensorConfig, curves []CurveConfig) bool { if curveConfig.Linear != nil && curveConfig.Linear.Sensor == config.ID { return true } + if curveConfig.Staircase != nil && curveConfig.Staircase.Sensor == config.ID { + return true + } if curveConfig.PID != nil && curveConfig.PID.Sensor == config.ID { return true } @@ -146,6 +149,9 @@ func validateCurves(config *Configuration) error { if curveConfig.Linear != nil { subConfigs++ } + if curveConfig.Staircase != nil { + subConfigs++ + } if curveConfig.PID != nil { subConfigs++ } @@ -196,6 +202,20 @@ func validateCurves(config *Configuration) error { } } + if curveConfig.Staircase != nil { + if len(curveConfig.Staircase.Sensor) <= 0 { + return fmt.Errorf("curve %s: missing sensorId", curveConfig.ID) + } + + if !sensorIdExists(curveConfig.Staircase.Sensor, config) { + return fmt.Errorf("curve %s: no sensor definition with id '%s' found", curveConfig.ID, curveConfig.Staircase.Sensor) + } + + if len(curveConfig.Staircase.InSteps) <= 0 { + return fmt.Errorf("curve %s: missing steps", curveConfig.ID) + } + } + if curveConfig.PID != nil { if len(curveConfig.PID.Sensor) <= 0 { return fmt.Errorf("curve %s: missing sensorId", curveConfig.ID) diff --git a/internal/curves/curve.go b/internal/curves/curve.go index 28f9cc9c..229f1d9d 100644 --- a/internal/curves/curve.go +++ b/internal/curves/curve.go @@ -2,6 +2,7 @@ package curves import ( "fmt" + "math" "github.com/markusressel/fan2go/internal/configuration" "github.com/markusressel/fan2go/internal/util" @@ -30,6 +31,14 @@ func NewSpeedCurve(config configuration.CurveConfig) (SpeedCurve, error) { return ret, nil } + if config.Staircase != nil { + ret := &StaircaseSpeedCurve{ + Config: config, + LastTemp: math.MinInt, + } + return ret, nil + } + if config.PID != nil { pidLoop := util.NewPidLoop( config.PID.P, diff --git a/internal/curves/linear_test.go b/internal/curves/linear_test.go index 4c9c862f..0b0c05c1 100644 --- a/internal/curves/linear_test.go +++ b/internal/curves/linear_test.go @@ -1,10 +1,11 @@ package curves import ( + "testing" + "github.com/markusressel/fan2go/internal/configuration" "github.com/markusressel/fan2go/internal/sensors" "github.com/stretchr/testify/assert" - "testing" ) // helper function to create a linear curve configuration diff --git a/internal/curves/staircase.go b/internal/curves/staircase.go new file mode 100644 index 00000000..cb5f8aac --- /dev/null +++ b/internal/curves/staircase.go @@ -0,0 +1,60 @@ +package curves + +import ( + "github.com/markusressel/fan2go/internal/ui" + + "github.com/markusressel/fan2go/internal/configuration" + "github.com/markusressel/fan2go/internal/sensors" +) + +type StaircaseSpeedCurve struct { + Config configuration.CurveConfig `json:"config"` + Value float64 `json:"value"` + + LastTemp int +} + +func (c *StaircaseSpeedCurve) GetId() string { + return c.Config.ID +} + +func (c *StaircaseSpeedCurve) Evaluate() (value float64, err error) { + sensor, _ := sensors.GetSensor(c.Config.Staircase.Sensor) + var measured float64 + measured, err = sensor.GetValue() + if err != nil { + ui.Warning("Curve %s: Error getting sensor value: %v", c.Config.ID, err) + return c.Value, err + } + + steps := c.Config.Staircase.Steps + + var targetTemp int + for temp := range steps { + if measured >= float64(temp)*1000 { + targetTemp = max(targetTemp, temp) + } + } + if targetTemp < c.LastTemp && (c.LastTemp-int(measured/1000)) < c.Config.Staircase.Threshold { + targetTemp = c.LastTemp + } + + c.LastTemp = targetTemp + value = steps[targetTemp] + + ui.Debug("Evaluating curve '%s'. Sensor '%s' temp '%.0f°'. Desired speed: %.2f", c.Config.ID, sensor.GetId(), measured/1000, value) + c.SetValue(value) + return value, nil +} + +func (c *StaircaseSpeedCurve) SetValue(value float64) { + valueMu.Lock() + defer valueMu.Unlock() + c.Value = value +} + +func (c *StaircaseSpeedCurve) CurrentValue() float64 { + valueMu.Lock() + defer valueMu.Unlock() + return c.Value +} diff --git a/internal/curves/staircase_test.go b/internal/curves/staircase_test.go new file mode 100644 index 00000000..2847ecba --- /dev/null +++ b/internal/curves/staircase_test.go @@ -0,0 +1,141 @@ +package curves + +import ( + "testing" + + "github.com/markusressel/fan2go/internal/configuration" + "github.com/markusressel/fan2go/internal/sensors" + "github.com/stretchr/testify/assert" +) + +// helper function to create a staircase curve configuration +func createStaircaseCurveConfig( + id string, + sensorId string, + threshold int, + steps map[int]float64, +) (curve configuration.CurveConfig) { + curve = configuration.CurveConfig{ + ID: id, + Staircase: &configuration.StaircaseCurveConfig{ + Sensor: sensorId, + Threshold: threshold, + Steps: steps, + }, + } + return curve +} + +func TestStaircaseCurveWithStepsAtMin(t *testing.T) { + // GIVEN + avgTmp := 40000.0 + s := &MockSensor{ + Name: "sensor", + MovingAvg: avgTmp, + } + sensors.RegisterSensor(s) + + curveConfig := createStaircaseCurveConfig( + "curve", + s.GetId(), + 8, + map[int]float64{ + 50: 30, + 60: 100, + 70: 255, + }, + ) + curve, _ := NewSpeedCurve(curveConfig) + + // WHEN + result, err := curve.Evaluate() + if err != nil { + assert.Fail(t, err.Error()) + } + + // THEN + assert.Equal(t, 0.0, result) +} + +func TestStaircaseCurveWithStepsInMiddle(t *testing.T) { + // GIVEN + avgTmp := 60000.0 + s := &MockSensor{ + Name: "sensor", + MovingAvg: avgTmp, + } + sensors.RegisterSensor(s) + + curveConfig := createStaircaseCurveConfig( + "curve", + s.GetId(), + 8, + map[int]float64{ + 50: 30, + 60: 100, + 70: 255, + }, + ) + curve, _ := NewSpeedCurve(curveConfig) + + // WHEN + result, err := curve.Evaluate() + if err != nil { + assert.Fail(t, err.Error()) + } + + // THEN + assert.Equal(t, 100.0, result) + + // WHEN + s.MovingAvg = 55000.0 + result, err = curve.Evaluate() + if err != nil { + assert.Fail(t, err.Error()) + } + + // THEN + assert.Equal(t, 100.0, result) + + // WHEN + s.MovingAvg = 52000.0 + result, err = curve.Evaluate() + if err != nil { + assert.Fail(t, err.Error()) + } + + // THEN + assert.Equal(t, 30.0, result) + +} + +func TestStaircaseCurveWithStepsAtMax(t *testing.T) { + // GIVEN + avgTmp := 70000.0 + s := &MockSensor{ + Name: "sensor", + MovingAvg: avgTmp, + } + sensors.RegisterSensor(s) + + curveConfig := createStaircaseCurveConfig( + "curve", + s.GetId(), + 8, + map[int]float64{ + 50: 30, + 60: 100, + 70: 255, + }, + ) + curve, _ := NewSpeedCurve(curveConfig) + + // WHEN + result, err := curve.Evaluate() + if err != nil { + assert.Fail(t, err.Error()) + } + + // THEN + assert.Equal(t, 255.0, result) +} From 0a2539f631729d0d745d90aadaab4ee0db91595e Mon Sep 17 00:00:00 2001 From: Markus Ressel Date: Sat, 20 Jun 2026 23:05:08 +0200 Subject: [PATCH 2/8] =?UTF-8?q?1.=20Fixed=20Staircase=20Curve=20Evaluation?= =?UTF-8?q?=20&=20Bugs=20(staircase.go):=20=20=20=20=20=E2=80=A2=20Replace?= =?UTF-8?q?d=20raw=20=20sensor.GetValue()=20=20with=20the=20smoothed,=20ba?= =?UTF-8?q?ckground-polled=20=20sensor.GetMovingAvg()=20=20to=20utilize=20?= =?UTF-8?q?standard=20architecture=20patterns=20and=20avoid=20expensive,?= =?UTF-8?q?=20redundant=20active=20polling.=20=20=20=20=20=E2=80=A2=20Adde?= =?UTF-8?q?d=20checking=20for=20=20!exists=20||=20sensor=20=3D=3D=20nil=20?= =?UTF-8?q?=20to=20prevent=20runtime=20panics.=20=20=20=20=20=E2=80=A2=20R?= =?UTF-8?q?eplaced=20the=20=200=20-initialized=20=20targetTemp=20=20logic?= =?UTF-8?q?=20with=20a=20=20math.MinInt=20=20sentinel.=20This=20allows=20n?= =?UTF-8?q?egative=20step=20temperatures=20(e.g.,=20=20-10=C2=B0C=20)=20to?= =?UTF-8?q?=20evaluate=20correctly=20and=20allows=20step=20values=20at=20e?= =?UTF-8?q?xactly=20=200=C2=B0C=20=20=20=20=20=20=20without=20conflicts.?= =?UTF-8?q?=20=20=20=20=20=E2=80=A2=20Cleaned=20up=20tab=20indentation=20s?= =?UTF-8?q?tyling.=202.=20Simplified=20Configuration=20Verification=20(con?= =?UTF-8?q?fig.go):=20=20=20=20=20=E2=80=A2=20Removed=20the=20nested=20red?= =?UTF-8?q?undant=20length=20checks=20in=20=20applyTransformations()=20,?= =?UTF-8?q?=20making=20sure=20empty=20step=20configs=20trigger=20a=20fatal?= =?UTF-8?q?=20config=20error=20correctly.=203.=20Added=20Tests=20&=20Verif?= =?UTF-8?q?ied=20(staircase=5Ftest.go):=20=20=20=20=20=E2=80=A2=20Added=20?= =?UTF-8?q?=20TestStaircaseCurveWithNegativeTemperatures=20=20to=20assert?= =?UTF-8?q?=20proper=20hysteresis=20and=20matching=20of=20sub-zero=20steps?= =?UTF-8?q?.=20=20=20=20=20=E2=80=A2=20Added=20=20TestStaircaseCurveWithSt?= =?UTF-8?q?epAtZero=20=20to=20verify=20steps=20configured=20at=20exactly?= =?UTF-8?q?=20=200=C2=B0C=20=20function=20properly.=20=20=20=20=20?= =?UTF-8?q?=E2=80=A2=20Ran=20=20make=20test=20=20and=20=20go=20test=20=20o?= =?UTF-8?q?n=20the=20packages;=20all=20test=20suites=20compiled=20successf?= =?UTF-8?q?ully=20and=20passed.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/configuration/config.go | 2 +- internal/curves/staircase.go | 31 +++++++++----- internal/curves/staircase_test.go | 68 +++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 12 deletions(-) diff --git a/internal/configuration/config.go b/internal/configuration/config.go index 48b82557..93ac3ac9 100644 --- a/internal/configuration/config.go +++ b/internal/configuration/config.go @@ -191,7 +191,7 @@ func applyTransformations() { if curve.Linear != nil && len(curve.Linear.InSteps) > 0 { transformCurveSteps(&curve.ID, &curve.Linear.Steps, &curve.Linear.InSteps) } - if curve.Staircase != nil && len(curve.Staircase.InSteps) > 0 { + if curve.Staircase != nil { if len(curve.Staircase.InSteps) > 0 { transformCurveSteps(&curve.ID, &curve.Staircase.Steps, &curve.Staircase.InSteps) } else { diff --git a/internal/curves/staircase.go b/internal/curves/staircase.go index cb5f8aac..b341717b 100644 --- a/internal/curves/staircase.go +++ b/internal/curves/staircase.go @@ -1,6 +1,9 @@ package curves import ( + "fmt" + "math" + "github.com/markusressel/fan2go/internal/ui" "github.com/markusressel/fan2go/internal/configuration" @@ -19,28 +22,34 @@ func (c *StaircaseSpeedCurve) GetId() string { } func (c *StaircaseSpeedCurve) Evaluate() (value float64, err error) { - sensor, _ := sensors.GetSensor(c.Config.Staircase.Sensor) - var measured float64 - measured, err = sensor.GetValue() - if err != nil { - ui.Warning("Curve %s: Error getting sensor value: %v", c.Config.ID, err) - return c.Value, err - } + sensor, exists := sensors.GetSensor(c.Config.Staircase.Sensor) + if !exists || sensor == nil { + ui.Warning("Curve %s: Sensor not found with id '%s'", c.Config.ID, c.Config.Staircase.Sensor) + return c.Value, fmt.Errorf("sensor not found with id '%s'", c.Config.Staircase.Sensor) + } + measured := sensor.GetMovingAvg() steps := c.Config.Staircase.Steps - var targetTemp int + targetTemp := math.MinInt for temp := range steps { if measured >= float64(temp)*1000 { - targetTemp = max(targetTemp, temp) + if targetTemp == math.MinInt || temp > targetTemp { + targetTemp = temp + } } } - if targetTemp < c.LastTemp && (c.LastTemp-int(measured/1000)) < c.Config.Staircase.Threshold { + + if targetTemp < c.LastTemp && c.LastTemp != math.MinInt && (c.LastTemp-int(measured/1000)) < c.Config.Staircase.Threshold { targetTemp = c.LastTemp } c.LastTemp = targetTemp - value = steps[targetTemp] + if targetTemp == math.MinInt { + value = 0.0 + } else { + value = steps[targetTemp] + } ui.Debug("Evaluating curve '%s'. Sensor '%s' temp '%.0f°'. Desired speed: %.2f", c.Config.ID, sensor.GetId(), measured/1000, value) c.SetValue(value) diff --git a/internal/curves/staircase_test.go b/internal/curves/staircase_test.go index 2847ecba..97cb9c74 100644 --- a/internal/curves/staircase_test.go +++ b/internal/curves/staircase_test.go @@ -139,3 +139,71 @@ func TestStaircaseCurveWithStepsAtMax(t *testing.T) { // THEN assert.Equal(t, 255.0, result) } + +func TestStaircaseCurveWithNegativeTemperatures(t *testing.T) { + // GIVEN + s := &MockSensor{ + ID: "neg_sensor", + Name: "neg_sensor", + MovingAvg: -5000.0, // -5°C + } + sensors.RegisterSensor(s) + + curveConfig := createStaircaseCurveConfig( + "curve_neg", + s.GetId(), + 3, + map[int]float64{ + -10: 10, + 10: 50, + }, + ) + curve, _ := NewSpeedCurve(curveConfig) + + // WHEN + result, err := curve.Evaluate() + assert.NoError(t, err) + + // THEN + assert.Equal(t, 10.0, result) // -5°C matches -10°C threshold + + // WHEN: temperature drops to -12°C, which is within hysteresis threshold of 3 (from -10°C) + s.MovingAvg = -12000.0 + result, err = curve.Evaluate() + assert.NoError(t, err) + assert.Equal(t, 10.0, result) // Holds 10.0 due to hysteresis + + // WHEN: temperature drops to -14°C, which is beyond hysteresis threshold + s.MovingAvg = -14000.0 + result, err = curve.Evaluate() + assert.NoError(t, err) + assert.Equal(t, 0.0, result) // Drops to 0.0 +} + +func TestStaircaseCurveWithStepAtZero(t *testing.T) { + // GIVEN + s := &MockSensor{ + ID: "zero_sensor", + Name: "zero_sensor", + MovingAvg: 5000.0, // 5°C + } + sensors.RegisterSensor(s) + + curveConfig := createStaircaseCurveConfig( + "curve_zero", + s.GetId(), + 5, + map[int]float64{ + 0: 5, + 40: 50, + }, + ) + curve, _ := NewSpeedCurve(curveConfig) + + // WHEN + result, err := curve.Evaluate() + assert.NoError(t, err) + + // THEN + assert.Equal(t, 5.0, result) // 5°C matches 0°C threshold +} From 2766765ec79b1c2f6fa3656996a140289146844b Mon Sep 17 00:00:00 2001 From: Markus Ressel Date: Sat, 20 Jun 2026 23:26:22 +0200 Subject: [PATCH 3/8] disable release-drafter autolabeler functionality explicitly --- .github/workflows/release-drafter.yml | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 1f77de70..2415cf89 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -28,16 +28,9 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - # (Optional) GitHub Enterprise requires GHE_HOST variable set - #- name: Set GHE_HOST - # run: | - # echo "GHE_HOST=${GITHUB_SERVER_URL##https:\/\/}" >> $GITHUB_ENV - # Drafts your next Release notes as Pull Requests are merged into "master" - uses: release-drafter/release-drafter@v7 - # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml - # with: - # config-name: my-config.yml - # disable-autolabeler: true + with: + disable-autolabeler: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From 9bdf1b3ca650c9079d98dc9dfda6460de6601757 Mon Sep 17 00:00:00 2001 From: Markus Ressel Date: Sat, 20 Jun 2026 23:35:04 +0200 Subject: [PATCH 4/8] review fixes --- internal/curves/staircase.go | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/internal/curves/staircase.go b/internal/curves/staircase.go index b341717b..7c50576f 100644 --- a/internal/curves/staircase.go +++ b/internal/curves/staircase.go @@ -34,22 +34,15 @@ func (c *StaircaseSpeedCurve) Evaluate() (value float64, err error) { targetTemp := math.MinInt for temp := range steps { if measured >= float64(temp)*1000 { - if targetTemp == math.MinInt || temp > targetTemp { - targetTemp = temp - } + targetTemp = max(targetTemp, temp) } } - - if targetTemp < c.LastTemp && c.LastTemp != math.MinInt && (c.LastTemp-int(measured/1000)) < c.Config.Staircase.Threshold { + if targetTemp < c.LastTemp && (c.LastTemp-int(measured/1000)) < c.Config.Staircase.Threshold { targetTemp = c.LastTemp } c.LastTemp = targetTemp - if targetTemp == math.MinInt { - value = 0.0 - } else { - value = steps[targetTemp] - } + value = steps[targetTemp] ui.Debug("Evaluating curve '%s'. Sensor '%s' temp '%.0f°'. Desired speed: %.2f", c.Config.ID, sensor.GetId(), measured/1000, value) c.SetValue(value) From eb9c36dbb02f1dcfd145bb16c9d0f316536695a1 Mon Sep 17 00:00:00 2001 From: Markus Ressel Date: Sat, 20 Jun 2026 23:39:21 +0200 Subject: [PATCH 5/8] lets remove the trigger then --- .github/workflows/release-drafter.yml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 2415cf89..b204b831 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -7,14 +7,6 @@ on: - main - master - # pull_request event is required only for autolabeler - pull_request: - # Only following types are handled by the action, but one can default to all as well - types: [ opened, reopened, synchronize ] -# pull_request_target event is required for autolabeler to support PRs from forks -# pull_request_target: -# types: [ opened, reopened, synchronize ] - permissions: contents: read @@ -28,9 +20,6 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - # Drafts your next Release notes as Pull Requests are merged into "master" - uses: release-drafter/release-drafter@v7 - with: - disable-autolabeler: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From 3f5921be1404c0d066a038dc86fb05be1e9977ae Mon Sep 17 00:00:00 2001 From: Markus Ressel Date: Sat, 20 Jun 2026 23:41:56 +0200 Subject: [PATCH 6/8] drop the "simple" wording --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 737a83c0..a97da5d2 100644 --- a/README.md +++ b/README.md @@ -517,7 +517,7 @@ temperature sensors. #### Linear -To create a simple, linear speed curve, use a curve of type `linear`. +To create a linear speed curve, use a curve of type `linear`. This curve type can be used with a min/max sensor value, where the min temp will result in a curve value of `0` and the max temp will result in a curve value of `255`: @@ -586,7 +586,7 @@ curves: #### Staircase -To create a simple, staircase speed curve, use a curve of type `staircase`. +To create a staircase speed curve, use a curve of type `staircase`. It maintains a static fan speed and avoids constantly changing fan speeds. From 9b12fca4d5e971797cf93e0761673220ef96666e Mon Sep 17 00:00:00 2001 From: Markus Ressel Date: Sat, 20 Jun 2026 23:43:25 +0200 Subject: [PATCH 7/8] update docs --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a97da5d2..6cbca7cb 100644 --- a/README.md +++ b/README.md @@ -588,7 +588,9 @@ curves: To create a staircase speed curve, use a curve of type `staircase`. -It maintains a static fan speed and avoids constantly changing fan speeds. +It maintains static fan speeds at defined temperature ranges. +This prevents fans from constantly ramping up and down (oscillating) in response to minor, +rapid fluctuations in sensor temperature, offering a more consistent acoustic profile. ```yaml curves: From d8f5e93feb670d8b90120a54e865854d1772b0e4 Mon Sep 17 00:00:00 2001 From: Markus Probst Date: Sun, 21 Jun 2026 19:25:27 +0200 Subject: [PATCH 8/8] staircase: Rename threshold to hysteresis --- internal/configuration/curves.go | 9 +++++++-- internal/curves/staircase.go | 2 +- internal/curves/staircase_test.go | 28 +++++++++++++++++++--------- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/internal/configuration/curves.go b/internal/configuration/curves.go index 6830e379..1a8a1714 100644 --- a/internal/configuration/curves.go +++ b/internal/configuration/curves.go @@ -30,8 +30,8 @@ type LinearCurveConfig struct { type StaircaseCurveConfig struct { // Sensor is the id of the sensor to use for this curve Sensor string `json:"sensor"` - // Threshold is the temperature threshold in degrees - Threshold int `json:"threshold"` + // Hysteresis configuration + Hysteresis HysteresisConfig `json:"hysteresis"` // Steps is a map of temperature to relative speed value (in range of 0..255 or alternatively 0%..100%) // InSteps contains the speed values as strings (like "42" or "11%"), as read from fan2go.yaml InSteps map[int]string `mapstructure:"steps" json:"-"` @@ -41,6 +41,11 @@ type StaircaseCurveConfig struct { Steps map[int]float64 `json:"steps" mapstructure:"-"` } +type HysteresisConfig struct { + // Temperature drop threshold in degrees before reducing fan speed + Down int `json:"down,omitempty"` +} + type PidCurveConfig struct { // Sensor is the id of the sensor to use for this curve Sensor string `json:"sensor"` diff --git a/internal/curves/staircase.go b/internal/curves/staircase.go index 7c50576f..eb99b21e 100644 --- a/internal/curves/staircase.go +++ b/internal/curves/staircase.go @@ -37,7 +37,7 @@ func (c *StaircaseSpeedCurve) Evaluate() (value float64, err error) { targetTemp = max(targetTemp, temp) } } - if targetTemp < c.LastTemp && (c.LastTemp-int(measured/1000)) < c.Config.Staircase.Threshold { + if targetTemp < c.LastTemp && (c.LastTemp-int(measured/1000)) < c.Config.Staircase.Hysteresis.Down { targetTemp = c.LastTemp } diff --git a/internal/curves/staircase_test.go b/internal/curves/staircase_test.go index 97cb9c74..a42961fe 100644 --- a/internal/curves/staircase_test.go +++ b/internal/curves/staircase_test.go @@ -12,15 +12,15 @@ import ( func createStaircaseCurveConfig( id string, sensorId string, - threshold int, + hysteresis configuration.HysteresisConfig, steps map[int]float64, ) (curve configuration.CurveConfig) { curve = configuration.CurveConfig{ ID: id, Staircase: &configuration.StaircaseCurveConfig{ - Sensor: sensorId, - Threshold: threshold, - Steps: steps, + Sensor: sensorId, + Hysteresis: hysteresis, + Steps: steps, }, } return curve @@ -38,7 +38,9 @@ func TestStaircaseCurveWithStepsAtMin(t *testing.T) { curveConfig := createStaircaseCurveConfig( "curve", s.GetId(), - 8, + configuration.HysteresisConfig{ + Down: 8, + }, map[int]float64{ 50: 30, 60: 100, @@ -69,7 +71,9 @@ func TestStaircaseCurveWithStepsInMiddle(t *testing.T) { curveConfig := createStaircaseCurveConfig( "curve", s.GetId(), - 8, + configuration.HysteresisConfig{ + Down: 8, + }, map[int]float64{ 50: 30, 60: 100, @@ -121,7 +125,9 @@ func TestStaircaseCurveWithStepsAtMax(t *testing.T) { curveConfig := createStaircaseCurveConfig( "curve", s.GetId(), - 8, + configuration.HysteresisConfig{ + Down: 8, + }, map[int]float64{ 50: 30, 60: 100, @@ -152,7 +158,9 @@ func TestStaircaseCurveWithNegativeTemperatures(t *testing.T) { curveConfig := createStaircaseCurveConfig( "curve_neg", s.GetId(), - 3, + configuration.HysteresisConfig{ + Down: 3, + }, map[int]float64{ -10: 10, 10: 50, @@ -192,7 +200,9 @@ func TestStaircaseCurveWithStepAtZero(t *testing.T) { curveConfig := createStaircaseCurveConfig( "curve_zero", s.GetId(), - 5, + configuration.HysteresisConfig{ + Down: 5, + }, map[int]float64{ 0: 5, 40: 50,