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

@@ -16,6 +16,8 @@ public sealed class AppConfig
public string[]? FanDutyArgs { get; set; }
public string[] AutoFanCtrlArgs { get; set; } = ["autofanctrl"];
public string TempSource { get; set; } = "AverageCore";
public int RampUpSteps { get; set; } = 3;
public int RampUpMinDeltaPercent { get; set; } = 20;
public int FailSafeAfterConsecutiveErrors { get; set; } = 5;
public int FailSafeFanPercent { get; set; } = 100;
public bool FailSafeRestoreAutoFan { get; set; }
@@ -81,6 +83,8 @@ public sealed class AppConfig
if (src.AutoFanCtrlArgs is { Length: > 0 })
dst.AutoFanCtrlArgs = src.AutoFanCtrlArgs;
dst.TempSource = string.Equals(src.TempSource, "MaxCore", StringComparison.OrdinalIgnoreCase) ? "MaxCore" : "AverageCore";
dst.RampUpSteps = Math.Clamp(src.RampUpSteps, 1, 10);
dst.RampUpMinDeltaPercent = Math.Clamp(src.RampUpMinDeltaPercent, 0, 50);
dst.FailSafeAfterConsecutiveErrors = Math.Max(1, src.FailSafeAfterConsecutiveErrors);
dst.FailSafeFanPercent = Math.Clamp(src.FailSafeFanPercent, 0, 100);
dst.FailSafeRestoreAutoFan = src.FailSafeRestoreAutoFan;

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);

View File

@@ -25,6 +25,8 @@ public sealed class MainForm : Form
private readonly CheckBox _chkFailAuto = new();
private readonly NumericUpDown _nudChartMin = new();
private readonly NumericUpDown _nudChartPoints = new();
private readonly NumericUpDown _nudRampUpSteps = new();
private readonly NumericUpDown _nudRampUpMinDelta = new();
private readonly NotifyIcon _tray = new();
private readonly ContextMenuStrip _trayMenu = new();
private readonly ToolStripMenuItem _miPause;
@@ -141,11 +143,13 @@ public sealed class MainForm : Form
{
Dock = DockStyle.Fill,
ColumnCount = 2,
RowCount = 13,
RowCount = 15,
Padding = new Padding(12)
};
flp.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 35));
flp.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 65));
for (var i = 0; i < 15; i++)
flp.RowStyles.Add(new RowStyle(SizeType.AutoSize));
var row = 0;
AddRow(flp, row++, Resources.LblLanguage, _cmbLanguage);
@@ -201,6 +205,14 @@ public sealed class MainForm : Form
_nudChartPoints.Maximum = 10_000;
_nudChartPoints.Increment = 100;
AddRow(flp, row++, Resources.LblRampUpSteps, _nudRampUpSteps);
_nudRampUpSteps.Minimum = 1;
_nudRampUpSteps.Maximum = 10;
AddRow(flp, row++, Resources.LblRampUpMinDeltaPercent, _nudRampUpMinDelta);
_nudRampUpMinDelta.Minimum = 0;
_nudRampUpMinDelta.Maximum = 50;
var btnSaveAdv = new Button { Text = Resources.BtnSaveAdvanced, AutoSize = true, Dock = DockStyle.Bottom };
btnSaveAdv.Click += (_, _) => SaveFromUi();
tabAdv.Controls.Add(btnSaveAdv);
@@ -211,7 +223,7 @@ public sealed class MainForm : Form
private static void AddRow(TableLayoutPanel t, int row, string label, Control editor)
{
t.Controls.Add(new System.Windows.Forms.Label { Text = label, AutoSize = true, Anchor = AnchorStyles.Left }, 0, row);
t.Controls.Add(new System.Windows.Forms.Label { Text = label, AutoSize = true, Anchor = AnchorStyles.Left | AnchorStyles.Top }, 0, row);
editor.Dock = DockStyle.Fill;
t.Controls.Add(editor, 1, row);
}
@@ -271,6 +283,8 @@ public sealed class MainForm : Form
_chkFailAuto.Checked = _config.FailSafeRestoreAutoFan;
_nudChartMin.Value = _config.ChartHistoryMinutes;
_nudChartPoints.Value = _config.ChartMaxPoints;
_nudRampUpSteps.Value = _config.RampUpSteps;
_nudRampUpMinDelta.Value = _config.RampUpMinDeltaPercent;
for (var i = 0; i < 14; i++)
_gridCurve.Rows[i].Cells[1].Value = _config.CurvePoints[i].ToString();
RebuildCurveChart();
@@ -296,6 +310,8 @@ public sealed class MainForm : Form
FailSafeRestoreAutoFan = _chkFailAuto.Checked,
ChartHistoryMinutes = (int)_nudChartMin.Value,
ChartMaxPoints = (int)_nudChartPoints.Value,
RampUpSteps = (int)_nudRampUpSteps.Value,
RampUpMinDeltaPercent = (int)_nudRampUpMinDelta.Value,
CurvePoints = ReadCurveFromGrid()
};
return c;

View File

@@ -33,6 +33,8 @@ internal static class Resources
public static string LblFailStrategy => _rm.GetString("LblFailStrategy") ?? "Fail-safe strategy";
public static string LblChartMin => _rm.GetString("LblChartMin") ?? "Chart minutes";
public static string LblChartPoints => _rm.GetString("LblChartPoints") ?? "Chart points";
public static string LblRampUpSteps => _rm.GetString("LblRampUpSteps") ?? "Ramp-up steps";
public static string LblRampUpMinDeltaPercent => _rm.GetString("LblRampUpMinDeltaPercent") ?? "Min ramp delta (%)";
public static string BtnSaveAdvanced => _rm.GetString("BtnSaveAdvanced") ?? "Save Advanced";
public static string TrayOpen => _rm.GetString("TrayOpen") ?? "Show window";
public static string TrayPause => _rm.GetString("TrayPause") ?? "Pause";

View File

@@ -83,6 +83,8 @@
<data name="LblFailStrategy" xml:space="preserve"><value>Fail-safe strategy</value></data>
<data name="LblChartMin" xml:space="preserve"><value>Chart history (minutes)</value></data>
<data name="LblChartPoints" xml:space="preserve"><value>Chart max points</value></data>
<data name="LblRampUpSteps" xml:space="preserve"><value>Ramp-up steps</value></data>
<data name="LblRampUpMinDeltaPercent" xml:space="preserve"><value>Min ramp delta (%)</value></data>
<data name="BtnSaveAdvanced" xml:space="preserve"><value>Save Advanced Settings</value></data>
<data name="TrayOpen" xml:space="preserve"><value>Show window</value></data>
<data name="TrayPause" xml:space="preserve"><value>Pause control</value></data>

View File

@@ -83,6 +83,8 @@
<data name="LblFailStrategy" xml:space="preserve"><value>安全模式策略</value></data>
<data name="LblChartMin" xml:space="preserve"><value>图表保留时长 (分钟)</value></data>
<data name="LblChartPoints" xml:space="preserve"><value>图表最大点数</value></data>
<data name="LblRampUpSteps" xml:space="preserve"><value>升速步数</value></data>
<data name="LblRampUpMinDeltaPercent" xml:space="preserve"><value>最小 ramp 增幅 (%)</value></data>
<data name="BtnSaveAdvanced" xml:space="preserve"><value>保存高级设置</value></data>
<data name="TrayOpen" xml:space="preserve"><value>打开主窗口</value></data>
<data name="TrayPause" xml:space="preserve"><value>暂停控制</value></data>

View File

@@ -83,6 +83,8 @@
<data name="LblFailStrategy" xml:space="preserve"><value>安全模式策略</value></data>
<data name="LblChartMin" xml:space="preserve"><value>圖表保留時長 (分鐘)</value></data>
<data name="LblChartPoints" xml:space="preserve"><value>圖表最大點數</value></data>
<data name="LblRampUpSteps" xml:space="preserve"><value>升速步數</value></data>
<data name="LblRampUpMinDeltaPercent" xml:space="preserve"><value>最小 ramp 增幅 (%)</value></data>
<data name="BtnSaveAdvanced" xml:space="preserve"><value>儲存進階設定</value></data>
<data name="TrayOpen" xml:space="preserve"><value>開啟主視窗</value></data>
<data name="TrayPause" xml:space="preserve"><value>暫停控制</value></data>