241 lines
6.9 KiB
C#
241 lines
6.9 KiB
C#
namespace ChromeboxFanControl;
|
|
|
|
/// <summary>
|
|
/// Background loop: LHM temp → curve → ectool fanduty; ectool RPM; fail-safe handling.
|
|
/// </summary>
|
|
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;
|
|
|
|
public FanController(AppConfig initialConfig)
|
|
{
|
|
_config = initialConfig;
|
|
}
|
|
|
|
public event EventHandler<FanSampleEventArgs>? 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)
|
|
{
|
|
// Do not send fanduty while paused; EC keeps last hardware state.
|
|
targetDuty = null;
|
|
ectoolMsg = "paused";
|
|
}
|
|
else if (_failSafeActive)
|
|
{
|
|
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;
|
|
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)
|
|
{
|
|
targetDuty = null;
|
|
ectoolMsg = "no CPU temperature (holding EC state)";
|
|
}
|
|
else
|
|
{
|
|
var d = FanCurve.CalculateFanPercent(tempC.Value, cfg.CurvePoints);
|
|
targetDuty = d;
|
|
var (okD, _, eD) = await EctoolRunner
|
|
.RunAsync(cfg.EctoolPath, ["fanduty", d.ToString()], ct)
|
|
.ConfigureAwait(false);
|
|
ectoolMsg = okD ? $"fanduty {d}%" : 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}");
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
StopInternal(waitForLoop: true);
|
|
}
|
|
}
|