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 NumericUpDown _nudRampUpSteps = new(); private readonly NumericUpDown _nudRampUpMinDelta = new(); private readonly NotifyIcon _tray = new(); private readonly ContextMenuStrip _trayMenu = new(); private readonly ToolStripMenuItem _miPause; private readonly object _seriesLock = new(); private readonly List _tSec = new(); private readonly List _temps = new(); private readonly List _duties = new(); private readonly List _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 = 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); _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; 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); 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 | AnchorStyles.Top }, 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; _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(); } 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, RampUpSteps = (int)_nudRampUpSteps.Value, RampUpMinDeltaPercent = (int)_nudRampUpMinDelta.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; } } }