namespace ChromeboxFanControl; /// /// Background loop: LHM temp → curve → ectool fanduty; ectool RPM; fail-safe handling. /// public sealed class FanController : IDisposable { private readonly object _cfgLock = new(); private AppConfig _config; private CancellationTokenSource? _cts; private Task? _loop; private CpuTempReader? _cpu; private volatile bool _paused; 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) { _config = initialConfig; } public event EventHandler? Sample; public bool Paused { get => _paused; set => _paused = value; } public void UpdateConfig(AppConfig config) { lock (_cfgLock) _config = config; } public void Start() { StopInternal(waitForLoop: true); _cpu = new CpuTempReader(); _cts = new CancellationTokenSource(); var token = _cts.Token; _loop = Task.Run(() => LoopAsync(token), token); } public void Stop() { StopInternal(waitForLoop: true); } private void StopInternal(bool waitForLoop) { try { _cts?.Cancel(); } catch { /* */ } if (waitForLoop && _loop != null) { try { _loop.Wait(8000); } catch { /* */ } } _cpu?.Dispose(); _cpu = null; _cts?.Dispose(); _cts = null; _loop = null; } public async Task RestoreAutoFanAsync() { AppConfig cfg; lock (_cfgLock) cfg = _config; if (!File.Exists(cfg.EctoolPath)) return; var (ok, _, err) = await EctoolRunner.RunAsync(cfg.EctoolPath, cfg.AutoFanCtrlArgs) .ConfigureAwait(false); if (!ok) DebugLog($"autofanctrl: {err}"); } private async Task LoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { AppConfig cfg; lock (_cfgLock) cfg = _config; var mode = string.Equals(cfg.TempSource, "MaxCore", StringComparison.OrdinalIgnoreCase) ? CpuTempAggregation.MaxCore : CpuTempAggregation.AverageCore; double? tempC = null; try { tempC = _cpu?.ReadCpuTemp(mode); } catch (Exception ex) { DebugLog($"LHM: {ex.Message}"); } if (tempC == null) _consecutiveTempErrors++; else _consecutiveTempErrors = 0; if (_consecutiveTempErrors >= cfg.FailSafeAfterConsecutiveErrors) _failSafeActive = true; else if (tempC != null) _failSafeActive = false; byte? targetDuty = null; string? ectoolMsg = null; int? rpm = null; int? actualDuty = null; if (_paused) { ClearRampState(); targetDuty = null; ectoolMsg = "paused"; } else if (_failSafeActive) { ClearRampState(); if (cfg.FailSafeRestoreAutoFan) { var (okA, _, eA) = await EctoolRunner.RunAsync(cfg.EctoolPath, cfg.AutoFanCtrlArgs, ct) .ConfigureAwait(false); targetDuty = null; ectoolMsg = okA ? "fail-safe: autofanctrl" : $"fail-safe autofanctrl: {eA}"; } else { 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); ectoolMsg = okF ? $"fail-safe fanduty {d}%" : $"fail-safe: {eF}"; } } else if (tempC == null) { ClearRampState(); targetDuty = null; ectoolMsg = "no CPU temperature (holding EC state)"; } else { 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", output.ToString()], ct) .ConfigureAwait(false); ectoolMsg = okD ? $"fanduty {output}%" : eD; } _cycleIndex++; if (cfg.FanRpmPollEveryNCycles > 0 && _cycleIndex % cfg.FanRpmPollEveryNCycles == 0 && File.Exists(cfg.EctoolPath)) { var (okR, stdoutR, errR) = await EctoolRunner.RunAsync(cfg.EctoolPath, cfg.FanRpmArgs, ct).ConfigureAwait(false); if (okR) rpm = EctoolRunner.TryParseFanRpm(stdoutR); else ectoolMsg = string.IsNullOrEmpty(ectoolMsg) ? $"rpm: {errR}" : $"{ectoolMsg}; rpm: {errR}"; if (cfg.FanDutyArgs is { Length: > 0 }) { var (okDuty, stdoutDuty, errDuty) = await EctoolRunner.RunAsync(cfg.EctoolPath, cfg.FanDutyArgs, ct).ConfigureAwait(false); if (okDuty) actualDuty = EctoolRunner.TryParseFanDuty(stdoutDuty); else ectoolMsg = string.IsNullOrEmpty(ectoolMsg) ? $"duty: {errDuty}" : $"{ectoolMsg}; duty: {errDuty}"; } } RaiseSample(new FanSampleEventArgs { Time = DateTime.Now, TempC = tempC, TargetDutyPercent = targetDuty, ActualDutyPercent = actualDuty, Rpm = rpm, LastEctoolMessage = ectoolMsg, Paused = _paused, FailSafe = _failSafeActive }); try { await Task.Delay(Math.Clamp(cfg.PollIntervalMs, 250, 60_000), ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; } } } private void RaiseSample(FanSampleEventArgs e) { try { Sample?.Invoke(this, e); } catch { /* UI must not kill loop */ } } private static void DebugLog(string msg) { 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); } }