diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 1f77de70..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,16 +20,6 @@ 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 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/README.md b/README.md index 36d5ada1..6cbca7cb 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`: @@ -584,6 +584,27 @@ curves: - 80: 255 ``` +#### Staircase + +To create a staircase speed curve, use a curve of type `staircase`. + +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: + - 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..93ac3ac9 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 { + 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..1a8a1714 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,25 @@ 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"` + // 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:"-"` + // 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 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/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..eb99b21e --- /dev/null +++ b/internal/curves/staircase.go @@ -0,0 +1,62 @@ +package curves + +import ( + "fmt" + "math" + + "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, 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 + + targetTemp := math.MinInt + 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.Hysteresis.Down { + 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..a42961fe --- /dev/null +++ b/internal/curves/staircase_test.go @@ -0,0 +1,219 @@ +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, + hysteresis configuration.HysteresisConfig, + steps map[int]float64, +) (curve configuration.CurveConfig) { + curve = configuration.CurveConfig{ + ID: id, + Staircase: &configuration.StaircaseCurveConfig{ + Sensor: sensorId, + Hysteresis: hysteresis, + 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(), + configuration.HysteresisConfig{ + Down: 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(), + configuration.HysteresisConfig{ + Down: 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(), + configuration.HysteresisConfig{ + Down: 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) +} + +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(), + configuration.HysteresisConfig{ + Down: 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(), + configuration.HysteresisConfig{ + Down: 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 +}