Files
chromebox-fan-control-win/ChromeboxFanControl/MainForm.cs
2026-03-22 12:33:04 +08:00

571 lines
20 KiB
C#

using ChromeboxFanControl.Properties;
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 ComboBox _cmbLanguage = 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;
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()
{
_miPause = new ToolStripMenuItem(Resources.TrayPause);
Text = Resources.AppTitle;
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(Resources.TabMonitor);
var tabCurve = new TabPage(Resources.TabCurve);
var tabAdv = new TabPage(Resources.TabAdvanced);
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 = Resources.StatusStarting;
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 = Resources.GridColTemp,
ReadOnly = true,
Width = 80
});
_gridCurve.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "colDuty",
HeaderText = Resources.GridColDuty,
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 = Resources.BtnSaveApply, AutoSize = true };
btnSave.Click += (_, _) => SaveFromUi();
var btnDefault = new Button { Text = Resources.BtnRestoreDefault, 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 = 13,
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++, Resources.LblLanguage, _cmbLanguage);
_cmbLanguage.DropDownStyle = ComboBoxStyle.DropDownList;
_cmbLanguage.Items.AddRange([Resources.LangAuto, Resources.LangEn, Resources.LangZhHans, Resources.LangZhHant]);
AddRow(flp, row++, Resources.LblEctoolPath, _txtEctool);
AddRow(flp, row++, Resources.LblPollInterval, _nudPoll);
_nudPoll.Minimum = 250;
_nudPoll.Maximum = 60_000;
_nudPoll.Increment = 250;
AddRow(flp, row++, Resources.LblRpmEvery, _nudRpmEvery);
_nudRpmEvery.Minimum = 1;
_nudRpmEvery.Maximum = 100;
AddRow(flp, row++, Resources.LblFanRpmArgs, _txtFanRpmArgs);
AddRow(flp, row++, Resources.LblFanDutyArgs, _txtFanDutyArgs);
AddRow(flp, row++, Resources.LblAutoFanArgs, _txtAutoFanArgs);
AddRow(flp, row++, Resources.LblTempSource, _cmbTempSource);
_cmbTempSource.DropDownStyle = ComboBoxStyle.DropDownList;
_cmbTempSource.Items.AddRange(["AverageCore", "MaxCore"]);
AddRow(flp, row++, Resources.LblFailCount, _nudFailCount);
_nudFailCount.Minimum = 1;
_nudFailCount.Maximum = 100;
AddRow(flp, row++, Resources.LblFailPct, _nudFailPct);
_nudFailPct.Minimum = 0;
_nudFailPct.Maximum = 100;
_chkFailAuto.Text = Resources.ChkFailAuto;
_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 = Resources.LblFailStrategy, AutoSize = true, Anchor = AnchorStyles.Left }, 0, row);
flp.Controls.Add(failPanel, 1, row++);
AddRow(flp, row++, Resources.LblChartMin, _nudChartMin);
_nudChartMin.Minimum = 1;
_nudChartMin.Maximum = 120;
AddRow(flp, row++, Resources.LblChartPoints, _nudChartPoints);
_nudChartPoints.Minimum = 100;
_nudChartPoints.Maximum = 10_000;
_nudChartPoints.Increment = 100;
var btnSaveAdv = new Button { Text = Resources.BtnSaveAdvanced, 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 = Resources.AppTitle;
_tray.Visible = true;
_tray.Icon = SystemIcons.Application;
_tray.DoubleClick += (_, _) => ShowFromTray();
_miPause.Click += (_, _) =>
{
TogglePause();
};
_trayMenu.Items.Add(Resources.TrayOpen, null, (_, _) => ShowFromTray());
_trayMenu.Items.Add(_miPause);
_trayMenu.Items.Add(Resources.TrayAbout, null, (_, _) =>
MessageBox.Show(this, Resources.AboutMessage, Text, MessageBoxButtons.OK, MessageBoxIcon.Information));
_trayMenu.Items.Add(Resources.TrayExit, 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 ? Resources.TrayResume : Resources.TrayPause;
}
private static readonly string[] LanguageValues = ["auto", "en", "zh-Hans", "zh-Hant"];
private void ApplyConfigToUi()
{
var langIdx = Array.IndexOf(LanguageValues, _config.Language);
_cmbLanguage.SelectedIndex = langIdx >= 0 ? langIdx : 0;
_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 langIdx = _cmbLanguage.SelectedIndex;
var lang = langIdx >= 0 && langIdx < LanguageValues.Length ? LanguageValues[langIdx] : "auto";
var c = new AppConfig
{
Language = lang,
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 = Resources.CurveChartDuty;
plt.Axes.Bottom.Label.Text = Resources.CurveChartTemp;
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;
}
plt.Font.Automatic();
_curveChart.Refresh();
}
private void SaveFromUi()
{
try
{
var rpmArgs = SplitArgs(_txtFanRpmArgs.Text);
if (rpmArgs.Length == 0)
{
MessageBox.Show(this, Resources.ErrRpmArgsEmpty, Text, MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
var autoArgs = SplitArgs(_txtAutoFanArgs.Text);
if (autoArgs.Length == 0)
{
MessageBox.Show(this, Resources.ErrAutoFanArgsEmpty, Text, MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
var langBefore = _config.Language;
_config = CloneConfigFromUi();
_config.SaveUser();
_fan?.UpdateConfig(_config);
var msg = Resources.MsgSaved;
if (langBefore != _config.Language)
msg = Resources.MsgRestartToApplyLanguage;
MessageBox.Show(this, msg, 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 ? Resources.StatusPaused : e.FailSafe ? Resources.StatusFailSafe : Resources.StatusRunning;
_lblStatus.Text =
$"{state} {Resources.LabelTemp} {tStr} {Resources.LabelTargetDuty} {dStr} {Resources.LabelRpm} {rStr} ectool: {e.LastEctoolMessage ?? ""}";
_tray.Text = $"{Resources.AppTitle} {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 = Resources.ChartTemp;
plt.Axes.Bottom.Label.Text = Resources.ChartTime;
plt.Axes.Right.IsVisible = true;
plt.Axes.Right.Label.Text = Resources.ChartDuty;
if (_yRpmAxis == null)
_yRpmAxis = plt.Axes.AddRightAxis();
_yRpmAxis.Label.Text = Resources.ChartRpm;
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 = Resources.LegendCelsius },
new() { LineColor = Colors.Orange, LineWidth = 2, LabelText = Resources.LegendDuty },
new() { LineColor = Colors.Green, LineWidth = 2, LabelText = Resources.ChartRpm }
];
plt.ShowLegend(leg, Alignment.UpperRight);
plt.Legend.Orientation = ScottPlot.Orientation.Horizontal;
plt.Font.Automatic();
_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;
}
}
}