Files
chromebox-fan-control-win/ChromeboxFanControl/MainForm.cs
2026-03-21 05:47:58 +08:00

551 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ScottPlot;
using ScottPlot.WinForms;
namespace ChromeboxFanControl;
public sealed class MainForm : Form
{
private AppConfig _config = AppConfig.LoadMerged();
private FanController _fan = null!;
private readonly FormsPlot _chart = new() { Dock = DockStyle.Fill };
private readonly FormsPlot _curveChart = new() { Dock = DockStyle.Fill };
private readonly System.Windows.Forms.Label _lblStatus = new();
private readonly DataGridView _gridCurve = new();
private readonly TextBox _txtEctool = new();
private readonly NumericUpDown _nudPoll = new();
private readonly NumericUpDown _nudRpmEvery = new();
private readonly TextBox _txtFanRpmArgs = new();
private readonly TextBox _txtFanDutyArgs = new();
private readonly TextBox _txtAutoFanArgs = new();
private readonly ComboBox _cmbTempSource = new();
private readonly NumericUpDown _nudFailCount = new();
private readonly NumericUpDown _nudFailPct = new();
private readonly CheckBox _chkFailAuto = new();
private readonly NumericUpDown _nudChartMin = new();
private readonly NumericUpDown _nudChartPoints = new();
private readonly NotifyIcon _tray = new();
private readonly ContextMenuStrip _trayMenu = new();
private readonly ToolStripMenuItem _miPause = new("暂停控制");
private readonly object _seriesLock = new();
private readonly List<double> _tSec = new();
private readonly List<double> _temps = new();
private readonly List<double> _duties = new();
private readonly List<double> _rpms = new();
private DateTime _chartAnchor = DateTime.UtcNow;
private bool _minimizeToTray = true;
private ScottPlot.IYAxis? _yRpmAxis;
public MainForm()
{
Text = "Chromebox 风扇温控";
Size = new Size(960, 700);
StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(800, 500);
BuildUi();
WireTray();
Load += (_, _) =>
{
_config = AppConfig.LoadMerged();
_chartAnchor = DateTime.UtcNow;
ApplyConfigToUi();
_fan = new FanController(CloneConfigFromUi());
_fan.Sample += FanOnSample;
_fan.Start();
};
FormClosing += MainForm_FormClosing;
}
private void BuildUi()
{
var tabs = new TabControl { Dock = DockStyle.Fill };
var tabMonitor = new TabPage("监控");
var tabCurve = new TabPage("曲线");
var tabAdv = new TabPage("高级");
tabs.TabPages.Add(tabMonitor);
tabs.TabPages.Add(tabCurve);
tabs.TabPages.Add(tabAdv);
_lblStatus.AutoSize = false;
_lblStatus.Height = 28;
_lblStatus.Dock = DockStyle.Bottom;
_lblStatus.Padding = new Padding(8, 4, 8, 4);
_lblStatus.AutoEllipsis = true;
_lblStatus.Text = "启动中…";
var panelChart = new Panel { Dock = DockStyle.Fill };
panelChart.Controls.Add(_chart);
panelChart.Controls.Add(_lblStatus);
tabMonitor.Controls.Add(panelChart);
_gridCurve.Dock = DockStyle.Top;
_gridCurve.Height = 260;
_gridCurve.AutoGenerateColumns = false;
_gridCurve.AllowUserToAddRows = false;
_gridCurve.RowHeadersVisible = false;
_gridCurve.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "colTemp",
HeaderText = "温度 (°C)",
ReadOnly = true,
Width = 80
});
_gridCurve.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "colDuty",
HeaderText = "占空比 0100",
Width = 100
});
for (var i = 0; i < 14; i++)
_gridCurve.Rows.Add(FanCurve.TempBreakpoints[i].ToString(), "0");
_gridCurve.CellValueChanged += (_, _) => RebuildCurveChart();
var panelCurveBtns = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
AutoSize = true,
FlowDirection = FlowDirection.LeftToRight,
Padding = new Padding(8)
};
var btnSave = new Button { Text = "保存并应用", AutoSize = true };
btnSave.Click += (_, _) => SaveFromUi();
var btnDefault = new Button { Text = "恢复默认曲线", AutoSize = true };
btnDefault.Click += (_, _) =>
{
var def = new AppConfig();
for (var i = 0; i < 14; i++)
_gridCurve.Rows[i].Cells[1].Value = def.CurvePoints[i].ToString();
RebuildCurveChart();
};
panelCurveBtns.Controls.Add(btnSave);
panelCurveBtns.Controls.Add(btnDefault);
var panelCurveTop = new Panel { Dock = DockStyle.Top, Height = 360 };
panelCurveTop.Controls.Add(_gridCurve);
panelCurveTop.Controls.Add(panelCurveBtns);
var panelCurve = new Panel { Dock = DockStyle.Fill };
panelCurve.Controls.Add(_curveChart);
panelCurve.Controls.Add(panelCurveTop);
tabCurve.Controls.Add(panelCurve);
var flp = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 2,
RowCount = 12,
Padding = new Padding(12)
};
flp.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 35));
flp.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 65));
var row = 0;
AddRow(flp, row++, "ectool 路径", _txtEctool);
AddRow(flp, row++, "轮询间隔 (ms)", _nudPoll);
_nudPoll.Minimum = 250;
_nudPoll.Maximum = 60_000;
_nudPoll.Increment = 250;
AddRow(flp, row++, "每 N 轮读 RPM", _nudRpmEvery);
_nudRpmEvery.Minimum = 1;
_nudRpmEvery.Maximum = 100;
AddRow(flp, row++, "读转速参数 (空格分隔)", _txtFanRpmArgs);
AddRow(flp, row++, "读占空比参数 (空=跳过)", _txtFanDutyArgs);
AddRow(flp, row++, "恢复自动风扇参数", _txtAutoFanArgs);
AddRow(flp, row++, "温度来源", _cmbTempSource);
_cmbTempSource.DropDownStyle = ComboBoxStyle.DropDownList;
_cmbTempSource.Items.AddRange(["AverageCore", "MaxCore"]);
AddRow(flp, row++, "连续失败进入安全模式 (次)", _nudFailCount);
_nudFailCount.Minimum = 1;
_nudFailCount.Maximum = 100;
AddRow(flp, row++, "安全模式固定占空比 (%)", _nudFailPct);
_nudFailPct.Minimum = 0;
_nudFailPct.Maximum = 100;
_chkFailAuto.Text = "安全模式使用 autofanctrl不用固定占空比";
_chkFailAuto.AutoSize = true;
var failPanel = new FlowLayoutPanel
{
FlowDirection = FlowDirection.LeftToRight,
AutoSize = true,
Dock = DockStyle.Fill,
WrapContents = false
};
failPanel.Controls.Add(_chkFailAuto);
flp.Controls.Add(new System.Windows.Forms.Label { Text = "安全模式策略", AutoSize = true, Anchor = AnchorStyles.Left }, 0, row);
flp.Controls.Add(failPanel, 1, row++);
AddRow(flp, row++, "图表保留时长 (分钟)", _nudChartMin);
_nudChartMin.Minimum = 1;
_nudChartMin.Maximum = 120;
AddRow(flp, row++, "图表最大点数", _nudChartPoints);
_nudChartPoints.Minimum = 100;
_nudChartPoints.Maximum = 10_000;
_nudChartPoints.Increment = 100;
var btnSaveAdv = new Button { Text = "保存高级设置", AutoSize = true, Dock = DockStyle.Bottom };
btnSaveAdv.Click += (_, _) => SaveFromUi();
tabAdv.Controls.Add(btnSaveAdv);
tabAdv.Controls.Add(flp);
Controls.Add(tabs);
}
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);
editor.Dock = DockStyle.Fill;
t.Controls.Add(editor, 1, row);
}
private void WireTray()
{
_tray.Text = "Chromebox 风扇温控";
_tray.Visible = true;
_tray.Icon = SystemIcons.Application;
_tray.DoubleClick += (_, _) => ShowFromTray();
_miPause.Click += (_, _) =>
{
TogglePause();
};
_trayMenu.Items.Add("打开主窗口", null, (_, _) => ShowFromTray());
_trayMenu.Items.Add(_miPause);
_trayMenu.Items.Add("关于", null, (_, _) =>
MessageBox.Show(this,
"Chromebox 风扇温控\n\n使用 LibreHardwareMonitor 读取 CPU 温度,通过 crosec ectool 控制风扇。\n\n请以管理员身份运行。",
Text, MessageBoxButtons.OK, MessageBoxIcon.Information));
_trayMenu.Items.Add("退出", null, (_, _) => Close());
_tray.ContextMenuStrip = _trayMenu;
}
private void ShowFromTray()
{
Show();
WindowState = FormWindowState.Normal;
ShowInTaskbar = true;
Activate();
}
private void TogglePause()
{
if (_fan == null)
return;
_fan.Paused = !_fan.Paused;
_miPause.Text = _fan.Paused ? "恢复控制" : "暂停控制";
}
private void ApplyConfigToUi()
{
_txtEctool.Text = _config.EctoolPath;
_nudPoll.Value = _config.PollIntervalMs;
_nudRpmEvery.Value = _config.FanRpmPollEveryNCycles;
_txtFanRpmArgs.Text = string.Join(' ', _config.FanRpmArgs);
_txtFanDutyArgs.Text = _config.FanDutyArgs is { Length: > 0 } da ? string.Join(' ', da) : "";
_txtAutoFanArgs.Text = string.Join(' ', _config.AutoFanCtrlArgs);
_cmbTempSource.SelectedItem = _config.TempSource;
if (_cmbTempSource.SelectedIndex < 0)
_cmbTempSource.SelectedIndex = 0;
_nudFailCount.Value = _config.FailSafeAfterConsecutiveErrors;
_nudFailPct.Value = _config.FailSafeFanPercent;
_chkFailAuto.Checked = _config.FailSafeRestoreAutoFan;
_nudChartMin.Value = _config.ChartHistoryMinutes;
_nudChartPoints.Value = _config.ChartMaxPoints;
for (var i = 0; i < 14; i++)
_gridCurve.Rows[i].Cells[1].Value = _config.CurvePoints[i].ToString();
RebuildCurveChart();
}
private AppConfig CloneConfigFromUi()
{
var c = new AppConfig
{
EctoolPath = _txtEctool.Text.Trim(),
PollIntervalMs = (int)_nudPoll.Value,
FanRpmPollEveryNCycles = (int)_nudRpmEvery.Value,
FanRpmArgs = SplitArgs(_txtFanRpmArgs.Text),
FanDutyArgs = SplitArgs(_txtFanDutyArgs.Text) is { Length: > 0 } fa ? fa : null,
AutoFanCtrlArgs = SplitArgs(_txtAutoFanArgs.Text),
TempSource = _cmbTempSource.SelectedItem?.ToString() ?? "AverageCore",
FailSafeAfterConsecutiveErrors = (int)_nudFailCount.Value,
FailSafeFanPercent = (int)_nudFailPct.Value,
FailSafeRestoreAutoFan = _chkFailAuto.Checked,
ChartHistoryMinutes = (int)_nudChartMin.Value,
ChartMaxPoints = (int)_nudChartPoints.Value,
CurvePoints = ReadCurveFromGrid()
};
return c;
}
private static string[] SplitArgs(string line) =>
string.IsNullOrWhiteSpace(line)
? []
: line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
private int[] ReadCurveFromGrid()
{
var pts = new int[14];
for (var i = 0; i < 14; i++)
{
var cell = _gridCurve.Rows[i].Cells[1].Value?.ToString();
if (!int.TryParse(cell, out var v))
v = 0;
pts[i] = Math.Clamp(v, 0, 100);
}
return pts;
}
private void RebuildCurveChart()
{
var temps = FanCurve.TempBreakpoints.Select(t => (double)t).ToArray();
var duties = ReadCurveFromGrid().Select(d => (double)d).ToArray();
var plt = _curveChart.Plot;
plt.Clear();
plt.Axes.Left.Label.Text = "占空比 (%)";
plt.Axes.Bottom.Label.Text = "温度 (°C)";
plt.Axes.SetLimitsX(0, 100);
plt.Axes.SetLimitsY(0, 100);
if (temps.Length > 0 && duties.Length > 0)
{
var scatter = plt.Add.Scatter(temps, duties);
scatter.Color = Colors.SteelBlue;
scatter.LineWidth = 2;
scatter.MarkerSize = 6;
}
_curveChart.Refresh();
}
private void SaveFromUi()
{
try
{
var rpmArgs = SplitArgs(_txtFanRpmArgs.Text);
if (rpmArgs.Length == 0)
{
MessageBox.Show(this, "读转速参数不能为空。", Text, MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
var autoArgs = SplitArgs(_txtAutoFanArgs.Text);
if (autoArgs.Length == 0)
{
MessageBox.Show(this, "恢复自动风扇参数不能为空。", Text, MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
_config = CloneConfigFromUi();
_config.SaveUser();
_fan?.UpdateConfig(_config);
MessageBox.Show(this, "已保存。", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
private void FanOnSample(object? sender, FanSampleEventArgs e)
{
if (IsDisposed)
return;
if (InvokeRequired)
BeginInvoke(new MethodInvoker(() => OnSampleUi(e)));
else
OnSampleUi(e);
}
private void OnSampleUi(FanSampleEventArgs e)
{
var sec = (e.Time.ToUniversalTime() - _chartAnchor).TotalSeconds;
var temp = e.TempC ?? double.NaN;
var duty = (e.ActualDutyPercent ?? e.TargetDutyPercent) is { } d ? d : double.NaN;
var rpm = e.Rpm.HasValue ? e.Rpm.Value : double.NaN;
lock (_seriesLock)
{
_tSec.Add(sec);
_temps.Add(temp);
_duties.Add(duty);
_rpms.Add(rpm);
TrimSeries();
}
var tStr = e.TempC.HasValue ? $"{e.TempC:F1} °C" : "—";
var dVal = e.ActualDutyPercent ?? e.TargetDutyPercent;
var dStr = dVal.HasValue ? $"{dVal} %" : "—";
var rStr = e.Rpm.HasValue ? $"{e.Rpm} RPM" : "—";
var state = e.Paused ? "已暂停" : e.FailSafe ? "安全模式" : "运行中";
_lblStatus.Text =
$"{state} 温度: {tStr} 目标占空比: {dStr} 转速: {rStr} ectool: {e.LastEctoolMessage ?? ""}";
_tray.Text = $"Chromebox {tStr} {dStr}";
RebuildChart();
}
private void TrimSeries()
{
var maxPts = _config.ChartMaxPoints;
var winSec = Math.Max(60, _config.ChartHistoryMinutes * 60.0);
while (_tSec.Count > 0 && _tSec.Count > maxPts)
{
_tSec.RemoveAt(0);
_temps.RemoveAt(0);
_duties.RemoveAt(0);
_rpms.RemoveAt(0);
}
if (_tSec.Count == 0)
return;
var last = _tSec[^1];
while (_tSec.Count > 0 && last - _tSec[0] > winSec)
{
_tSec.RemoveAt(0);
_temps.RemoveAt(0);
_duties.RemoveAt(0);
_rpms.RemoveAt(0);
}
}
private void RebuildChart()
{
double[] x, t, d, r;
lock (_seriesLock)
{
x = _tSec.ToArray();
t = _temps.ToArray();
d = _duties.ToArray();
r = _rpms.ToArray();
}
var plt = _chart.Plot;
plt.Clear();
plt.Axes.Left.Label.Text = "Temperature (°C)";
plt.Axes.Bottom.Label.Text = "Time (s)";
plt.Axes.Right.IsVisible = true;
plt.Axes.Right.Label.Text = "Duty (%)";
if (_yRpmAxis == null)
_yRpmAxis = plt.Axes.AddRightAxis();
_yRpmAxis.Label.Text = "RPM";
if (x.Length > 0)
{
var st = plt.Add.Scatter(x, t);
st.Axes.YAxis = plt.Axes.Left;
st.LegendText = string.Empty;
st.Color = Colors.Blue;
st.LineWidth = 2;
st.MarkerSize = 0;
var sd = plt.Add.Scatter(x, d);
sd.Axes.YAxis = plt.Axes.Right;
sd.LegendText = string.Empty;
sd.Color = Colors.Orange;
sd.LineWidth = 2;
sd.MarkerSize = 0;
var sr = plt.Add.Scatter(x, r);
sr.Axes.YAxis = _yRpmAxis;
sr.LegendText = string.Empty;
sr.Color = Colors.Green;
sr.LineWidth = 2;
sr.MarkerSize = 0;
var tempValid = t.Where(v => !double.IsNaN(v)).ToArray();
if (tempValid.Length > 0)
{
var tMin = tempValid.Min();
var tMax = tempValid.Max();
var pad = Math.Max(5, (tMax - tMin) * 0.1);
plt.Axes.SetLimitsY(Math.Max(0, tMin - pad), tMax + pad, plt.Axes.Left);
}
else
{
plt.Axes.SetLimitsY(0, 100, plt.Axes.Left);
}
plt.Axes.SetLimitsY(0, 100, plt.Axes.Right);
var rpmValid = r.Where(v => !double.IsNaN(v) && v > 0).ToArray();
if (rpmValid.Length > 0)
{
var rMin = rpmValid.Min();
var rMax = rpmValid.Max();
var rpmRange = rMax - rMin;
var pad = Math.Max(500, Math.Max(rpmRange * 0.2, rMax * 0.15));
plt.Axes.SetLimitsY(Math.Max(0, rMin - pad * 0.2), rMax + pad, _yRpmAxis);
}
else
{
plt.Axes.SetLimitsY(0, 6000, _yRpmAxis);
}
var xMin = x[0];
var xMax = x[^1];
var range = xMax - xMin;
var xMargin = Math.Min(10, Math.Max(2, range * 0.03));
plt.Axes.SetLimitsX(Math.Max(0, xMin - xMargin * 0.5), xMax + xMargin);
}
else
{
plt.Axes.SetLimitsY(0, 6000, _yRpmAxis);
}
LegendItem[] leg =
[
new() { LineColor = Colors.Blue, LineWidth = 2, LabelText = "°C" },
new() { LineColor = Colors.Orange, LineWidth = 2, LabelText = "Duty %" },
new() { LineColor = Colors.Green, LineWidth = 2, LabelText = "RPM" }
];
plt.ShowLegend(leg, Alignment.UpperRight);
plt.Legend.Orientation = ScottPlot.Orientation.Horizontal;
_chart.Refresh();
}
private void MainForm_FormClosing(object? sender, FormClosingEventArgs e)
{
_tray.Visible = false;
_tray.Dispose();
try
{
_fan?.Stop();
var cfg = _config;
if (File.Exists(cfg.EctoolPath))
EctoolRunner.RunSync(cfg.EctoolPath, cfg.AutoFanCtrlArgs);
}
catch
{
/* best effort */
}
_fan?.Dispose();
}
protected override void OnResize(EventArgs e)
{
base.OnResize(e);
if (WindowState == FormWindowState.Minimized && _minimizeToTray)
{
Hide();
ShowInTaskbar = false;
}
}
}