feat: ramp-up/down delay fan control

- Add RampUpSteps and RampUpMinDeltaPercent config
- Gradual ramp in both directions (up and down) to reduce fan oscillation
- Fix Advanced tab layout with RowStyles and label alignment

Made-with: Cursor
This commit is contained in:
2026-03-22 12:53:15 +08:00
parent 1de688b792
commit e8c8a759e0
7 changed files with 91 additions and 7 deletions

View File

@@ -14,6 +14,10 @@ public sealed class FanController : IDisposable
private int _consecutiveTempErrors;
private int _cycleIndex;
private bool _failSafeActive;
private byte? _lastCommandedDuty;
private byte _rampStartDuty;
private byte _rampTargetDuty;
private int _rampStepIndex;
public FanController(AppConfig initialConfig)
{
@@ -132,12 +136,13 @@ public sealed class FanController : IDisposable
if (_paused)
{
// Do not send fanduty while paused; EC keeps last hardware state.
ClearRampState();
targetDuty = null;
ectoolMsg = "paused";
}
else if (_failSafeActive)
{
ClearRampState();
if (cfg.FailSafeRestoreAutoFan)
{
var (okA, _, eA) = await EctoolRunner.RunAsync(cfg.EctoolPath, cfg.AutoFanCtrlArgs, ct)
@@ -149,6 +154,7 @@ public sealed class FanController : IDisposable
{
var d = (byte)Math.Clamp(cfg.FailSafeFanPercent, 0, 100);
targetDuty = d;
_lastCommandedDuty = d;
var (okF, _, eF) = await EctoolRunner
.RunAsync(cfg.EctoolPath, ["fanduty", d.ToString()], ct)
.ConfigureAwait(false);
@@ -157,17 +163,20 @@ public sealed class FanController : IDisposable
}
else if (tempC == null)
{
ClearRampState();
targetDuty = null;
ectoolMsg = "no CPU temperature (holding EC state)";
}
else
{
var d = FanCurve.CalculateFanPercent(tempC.Value, cfg.CurvePoints);
targetDuty = d;
var curveTarget = FanCurve.CalculateFanPercent(tempC.Value, cfg.CurvePoints);
var output = ComputeRampOutput(curveTarget, cfg);
targetDuty = output;
_lastCommandedDuty = output;
var (okD, _, eD) = await EctoolRunner
.RunAsync(cfg.EctoolPath, ["fanduty", d.ToString()], ct)
.RunAsync(cfg.EctoolPath, ["fanduty", output.ToString()], ct)
.ConfigureAwait(false);
ectoolMsg = okD ? $"fanduty {d}%" : eD;
ectoolMsg = okD ? $"fanduty {output}%" : eD;
}
_cycleIndex++;
@@ -233,6 +242,53 @@ public sealed class FanController : IDisposable
System.Diagnostics.Debug.WriteLine($"[ChromeboxFanControl] {msg}");
}
private void ClearRampState()
{
_rampStepIndex = 0;
}
private byte ComputeRampOutput(byte targetDuty, AppConfig cfg)
{
var n = Math.Clamp(cfg.RampUpSteps, 1, 10);
var minDelta = Math.Clamp(cfg.RampUpMinDeltaPercent, 0, 50);
if (_lastCommandedDuty == null)
return targetDuty;
var curr = _lastCommandedDuty.Value;
var delta = Math.Abs(targetDuty - curr);
if (targetDuty == curr)
{
ClearRampState();
return targetDuty;
}
if (delta < minDelta)
{
ClearRampState();
return targetDuty;
}
if (_rampStepIndex == 0 || targetDuty != _rampTargetDuty)
{
_rampStartDuty = curr;
_rampTargetDuty = targetDuty;
_rampStepIndex = 1;
}
if (_rampStepIndex >= n)
{
ClearRampState();
return _rampTargetDuty;
}
var step = (int)Math.Round((_rampTargetDuty - _rampStartDuty) * (double)_rampStepIndex / n);
var output = (byte)Math.Clamp(_rampStartDuty + step, 0, 100);
_rampStepIndex++;
return output;
}
public void Dispose()
{
StopInternal(waitForLoop: true);