Fix NCT6687DR fan control for system fans (index 9–15) (#2294)

* Add NCT6687DR fan control support with bit mapping and configuration phase handling

* NCT6687DR: emulate CC_Engine SetManualFAN protocol

- System fans (index > 8): skip manual mode bit (0xA00), use flat SmartFAN curve instead
- Change commit value from 0x40 to 0xC0 (REQ|DONE) matching CC_Engine
- Write 7-point temp curve (all 0x20/32C) alongside duty curve for system fans
- Add Sleep(10ms) after request (0x80) and commit (0xC0) per CC_Engine timing
- Restore: only clear manual mode bit for CPU/Pump/Chipset/EZ-Connect fans

* NCT6687DR: add INVALID bit check with retry after EC commit

- FinishFanCfgUpdate now returns bool: false if EC sets INVALID (0xCF8 & 0x10)
- SetControl and RestoreDefaultFanControl retry up to 3 times on INVALID
- Matches CC_Engine's post-commit validation behavior

* fix(NCT6687DR): bypass SmartFAN engine for system fans — use direct PWM

System fans (SYSFAN1-7) now use the same direct PWM mechanism as
CPU/Pump/Chipset fans, completely bypassing the SmartFAN curve engine
and its inherent ~2%/sec PWM smoothing.

Key changes:
- FAN_PWM_COMMAND_REG: system fans now point to direct PWM registers
  (SYSFAN7: 08:E9, SYSFAN1-6: 02:65..02:60) instead of SmartFAN
  curve base addresses (0xBE0..0xC70)
- FAN_CONTROL_MODE_REG: system fans use 08:0F (not 0xA00) for their
  manual-mode enable bits
- FAN_CONTROL_MODE_BIT: corrected per-channel bit positions derived
  from BIOS unk_104C0 table (sub_2F50 / sub_2BDC analysis)
- Set6687DRControl: simplified to single WriteByte for all fans
  (no more 7-point flat curve + step/rate writes)
- SetControl: unified path — all fans set manual-mode bit + direct PWM
- RestoreDefaultFanControl: clear manual-mode bit to return to SmartFAN

Register mapping discovered via IDA analysis of BIOS firmware:
- Extended unk_101E0 table at 0x103AC contains channels 10-15
- sub_2BDC configures 5 register operations per channel before curve write
- Entry[1] in unk_104C0 = manual-mode enable register + bit position

* review: Stopwatch, engineSts rename, CompleteFanConfigUpdate

* refactor: PreciseWait via SpinWait, DateTime to Stopwatch in ReadByte/WriteByte

* review: remove regions, revert PreciseWait to Thread.Sleep
This commit is contained in:
MadLongTom
2026-04-02 14:21:13 +02:00
committed by GitHub
parent 1c51643e7a
commit 9d9bb0084b
@@ -19,23 +19,62 @@ namespace LibreHardwareMonitor.Hardware.Motherboard.Lpc;
internal class Nct677X : ISuperIO
{
// ReSharper disable InconsistentNaming
private const ushort ADDRESS_REGISTER_OFFSET = 0x05;
private const byte BANK_SELECT_REGISTER = 0x4E;
private const uint DATA_REGISTER_OFFSET = 0x06;
// NCT668X EC space
private const uint EC_SPACE_PAGE_REGISTER_OFFSET = 0x04;
private const uint EC_SPACE_INDEX_REGISTER_OFFSET = 0x05;
private const uint EC_SPACE_DATA_REGISTER_OFFSET = 0x06;
private const byte EC_SPACE_PAGE_SELECT = 0xFF;
private const ushort NUVOTON_VENDOR_ID = 0x5CA3;
// NCT6687DR EC engine status register and flags (based on Linux nct6687d driver)
private const ushort NCT6687DR_REG_FAN_ENGINE_STS = 0xCF8;
private const byte NCT6687DR_FAN_CFG_LOCK = 1 << 6;
private const byte NCT6687DR_FAN_CFG_PHASE = 1 << 3;
private const byte NCT6687DR_FAN_CFG_INVALID = 1 << 4;
private const byte NCT6687DR_FAN_CFG_CHECK_DONE = 1 << 5;
private const byte NCT6687DR_FAN_CFG_REQ = 0x80;
private const byte NCT6687DR_FAN_CFG_DONE = 0x40;
// ReSharper restore InconsistentNaming
// Chip identity
private readonly LpcPort _lpcPort;
private readonly ushort _port;
private readonly byte _revision;
private readonly bool _isNuvotonVendor;
// Fan registers (chip-specific, initialized in constructor)
// ReSharper disable InconsistentNaming
private readonly ushort[] FAN_CONTROL_MODE_REG;
private readonly int[] FAN_CONTROL_MODE_BIT; // NCT6687DR only: maps array index → bit position in mode register
private readonly ushort[] FAN_PWM_COMMAND_REG;
private readonly ushort[] FAN_PWM_OUT_REG;
private readonly ushort[] FAN_PWM_REQUEST_REG;
private readonly ushort VENDOR_ID_HIGH_REGISTER;
private readonly ushort VENDOR_ID_LOW_REGISTER;
// ReSharper restore InconsistentNaming
// Sensor registers
private readonly ushort[] _fanCountRegister;
private readonly ushort[] _fanRpmRegister;
private readonly byte[] _initialFanControlMode = new byte[7];
private readonly byte[] _initialFanPwmCommand = new byte[7];
private readonly bool _isNuvotonVendor;
private readonly LpcPort _lpcPort;
private readonly int _maxFanCount;
private readonly int _minFanCount;
private readonly int _minFanRpm;
private readonly ushort _port;
private readonly bool[] _restoreDefaultFanControlRequired = new bool[7];
private readonly byte _revision;
private readonly TemperatureSourceData[] _temperaturesSource;
private readonly ushort _vBatMonitorControlRegister;
private readonly ushort[] _voltageRegisters;
private readonly ushort _voltageVBatRegister;
// Fan control state (save/restore)
private readonly byte[] _initialFanControlMode = new byte[7];
private readonly byte[] _initialFanPwmCommand = new byte[7];
private readonly bool[] _restoreDefaultFanControlRequired = new bool[7];
public Nct677X(LpcPort lpcPort, Chip chip, byte revision, ushort port)
{
Chip = chip;
@@ -67,9 +106,34 @@ internal class Nct677X : ISuperIO
// Each index in the below arrays represents a fan header
// ARRAY_KEY = new ushort[] { CPU FAN, PUMP, CHIPSET, EZ-CONNECT FAN, null, null, null, null, null, SYSFAN7, SYSFAN1, SYSFAN2, SYSFAN3, SYSFAN4, SYSFAN5, SYSFAN6 };
FAN_PWM_OUT_REG = [0x160, 0x161, 0x162, 0x163, 0x164, 0x165, 0x166, 0x167, 0xFFF, 0xC93, 0xE05, 0xE04, 0xE03, 0xE02, 0xE01, 0xE00]; // Duty Cycle Sensors
FAN_PWM_COMMAND_REG = [0xA28, 0xA29, 0xA2A, 0xA2B, 0xFFF, 0xFFF, 0xFFF, 0xFFF, 0xFFF, 0xBE0, 0xC70, 0xC58, 0xC40, 0xC28, 0xC10, 0xBF8]; // Control Registers for CPU/Pump/EZ-Connect Fan, Initial Fan Curve Registers for System Fans
FAN_CONTROL_MODE_REG = [0xA00, 0xA00, 0xA00, 0xA00, 0xA00, 0xA00, 0xA00, 0xA00, 0xA00, 0xA00, 0xA00, 0xA00, 0xA00, 0xA00, 0xA00, 0xA00];
// Direct PWM command registers ("pair 0" from extended unk_101E0 table in BIOS).
// CPU/Pump/Chipset/EZ-Connect: 0A:(28+ch). SYSFAN7(ch9): 08:E9. SYSFAN1-6(ch10-15): 02:(65..60).
// Setting the manual-mode bit in FAN_CONTROL_MODE_REG makes the EC use these for direct PWM,
// bypassing the SmartFAN curve engine and its inherent ~2%/sec smoothing.
FAN_PWM_COMMAND_REG = [0xA28, 0xA29, 0xA2A, 0xA2B, 0xFFF, 0xFFF, 0xFFF, 0xFFF, 0xFFF, 0x8E9, 0x265, 0x264, 0x263, 0x262, 0x261, 0x260];
// Manual-mode enable registers. Ch 0-7 use 0A:00 (one bit each); ch 8-15 use 08:0F.
FAN_CONTROL_MODE_REG = [0xA00, 0xA00, 0xA00, 0xA00, 0xA00, 0xA00, 0xA00, 0xA00, 0xA00, 0x80F, 0x80F, 0x80F, 0x80F, 0x80F, 0x80F, 0x80F];
FAN_PWM_REQUEST_REG = [0xA01, 0xA01, 0xA01, 0xA01, 0xA01, 0xA01, 0xA01, 0xA01, 0xA01, 0xA01, 0xA01, 0xA01, 0xA01, 0xA01, 0xA01, 0xA01];
// Mapping from array index to bit position in the FAN_CONTROL_MODE_REG (0xA00) register.
// The EC firmware uses bits 0-7 for 8 fan channels; LHM's sparse array indices must map to the correct bit.
// Bit 0: CPU Fan, Bit 1: Pump, Bit 2: Chipset/SYSFAN1, Bit 3: EZ-Connect/SYSFAN2, etc.
// Based on the Linux nct6687d driver's msi_alt1 config: index 0=CPU, 1=Pump, 2-7=System fans.
// -1 means no valid mapping (unused index slots).
FAN_CONTROL_MODE_BIT = [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1]; // Defaults
FAN_CONTROL_MODE_BIT[0] = 0; // CPU Fan → bit 0 of 0A:00
FAN_CONTROL_MODE_BIT[1] = 1; // Pump → bit 1 of 0A:00
FAN_CONTROL_MODE_BIT[2] = 2; // Chipset → bit 2 of 0A:00
FAN_CONTROL_MODE_BIT[3] = 3; // EZ-Connect → bit 3 of 0A:00
// System fans: manual-mode bits in 08:0F (derived from BIOS unk_104C0 entry[1]).
// EC channel mapping: LHM idx 9=ch9, 10=ch15, 11=ch14, 12=ch13, 13=ch12, 14=ch11, 15=ch10.
FAN_CONTROL_MODE_BIT[9] = 1; // SYSFAN7 (ch 9) → bit 1 of 08:0F
FAN_CONTROL_MODE_BIT[10] = 7; // SYSFAN1 (ch 15) → bit 7 of 08:0F
FAN_CONTROL_MODE_BIT[11] = 6; // SYSFAN2 (ch 14) → bit 6 of 08:0F
FAN_CONTROL_MODE_BIT[12] = 5; // SYSFAN3 (ch 13) → bit 5 of 08:0F
FAN_CONTROL_MODE_BIT[13] = 4; // SYSFAN4 (ch 12) → bit 4 of 08:0F
FAN_CONTROL_MODE_BIT[14] = 3; // SYSFAN5 (ch 11) → bit 3 of 08:0F
FAN_CONTROL_MODE_BIT[15] = 2; // SYSFAN6 (ch 10) → bit 2 of 08:0F
}
else
{
@@ -608,8 +672,35 @@ internal class Nct677X : ISuperIO
// set output value
WriteByte(FAN_PWM_COMMAND_REG[index], value.Value);
}
else if (Chip is Chip.NCT6687DR)
{
// NCT6687DR (MSI AM5/LGA1851): Direct PWM mode for ALL fans.
// Set the manual-mode bit to bypass the SmartFAN curve engine entirely.
// CPU/Pump/Chipset/EZ-Connect: bit in 0xA00. System fans: bit in 0x80F.
// Derived from BIOS unk_104C0 table — entry[1] = set manual-mode bit.
int bitPos = FAN_CONTROL_MODE_BIT[index];
if (bitPos >= 0)
{
byte mode = ReadByte(FAN_CONTROL_MODE_REG[index]);
byte bitMask = (byte)(0x01 << bitPos);
mode = (byte)(mode | bitMask);
WriteByte(FAN_CONTROL_MODE_REG[index], mode);
}
// Retry up to 3 times if EC rejects the configuration (INVALID bit)
for (int attempt = 0; attempt < 3; attempt++)
{
if (!StartFanCfgUpdate(index))
break;
Set6687DRControl(index, value.Value);
if (CompleteFanConfigUpdate(index))
break;
}
}
else
{
// NCT6683D / NCT6686D / NCT6687D (non-DR)
// Manual mode, bit(1 : set, 0 : unset)
// bit 0 : CPU Fan
// bit 1 : PUMP Fan
@@ -623,27 +714,12 @@ internal class Nct677X : ISuperIO
WriteByte(FAN_PWM_REQUEST_REG[index], 0x80);
Thread.Sleep(50);
if (Chip is Chip.NCT6687DR) // For MSI AM5/LGA1851 NCT6687D functionality
{
if (index < 8) // Control fans traditionally if part of the old control scheme. Applies to CPU/Pump/EZ-Conn
{
byte mode = ReadByte(FAN_CONTROL_MODE_REG[index]);
byte bitMask = (byte)(0x01 << index);
mode = (byte)(mode | bitMask);
WriteByte(FAN_CONTROL_MODE_REG[index], mode);
}
byte mode = ReadByte(FAN_CONTROL_MODE_REG[index]);
byte bitMask = (byte)(0x01 << index);
mode = (byte)(mode | bitMask);
WriteByte(FAN_CONTROL_MODE_REG[index], mode);
Set6687DRControl(index, value.Value);
}
else // All other Nuvoton SIO controllers and motherboards that use NCT6683/6686/6687
{
byte mode = ReadByte(FAN_CONTROL_MODE_REG[index]);
byte bitMask = (byte)(0x01 << index);
mode = (byte)(mode | bitMask);
WriteByte(FAN_CONTROL_MODE_REG[index], mode);
WriteByte(FAN_PWM_COMMAND_REG[index], value.Value);
}
WriteByte(FAN_PWM_COMMAND_REG[index], value.Value);
WriteByte(FAN_PWM_REQUEST_REG[index], 0x40);
Thread.Sleep(50);
@@ -1113,11 +1189,11 @@ internal class Nct677X : ISuperIO
//timeout: after 500ms, abort and force access
byte access;
DateTime timeout = DateTime.UtcNow.AddMilliseconds(500);
Stopwatch timeout = Stopwatch.StartNew();
while (true)
{
access = _lpcPort.ReadIoPort((ushort)(_port + EC_SPACE_PAGE_REGISTER_OFFSET));
if (access == EC_SPACE_PAGE_SELECT || DateTime.UtcNow > timeout)
if (access == EC_SPACE_PAGE_SELECT || timeout.Elapsed >= TimeSpan.FromMilliseconds(500))
break;
Thread.Sleep(1);
@@ -1159,11 +1235,11 @@ internal class Nct677X : ISuperIO
//timeout: after 500ms, abort and force access
byte access;
DateTime timeout = DateTime.UtcNow.AddMilliseconds(500);
Stopwatch timeout = Stopwatch.StartNew();
while (true)
{
access = _lpcPort.ReadIoPort((ushort)(_port + EC_SPACE_PAGE_REGISTER_OFFSET));
if (access == EC_SPACE_PAGE_SELECT || DateTime.UtcNow > timeout)
if (access == EC_SPACE_PAGE_SELECT || timeout.Elapsed >= TimeSpan.FromMilliseconds(500))
break;
Thread.Sleep(1);
@@ -1190,35 +1266,90 @@ internal class Nct677X : ISuperIO
((ReadByte(VENDOR_ID_HIGH_REGISTER) << 8) | ReadByte(VENDOR_ID_LOW_REGISTER)) == NUVOTON_VENDOR_ID;
}
private void Set6687DRControl(int index, byte? value)
/// <summary>
/// Request the EC to enter fan configuration phase and wait until registers are unlocked.
/// Based on the Linux nct6687d driver's start_fan_cfg_update() function.
/// </summary>
/// <returns>true if the EC entered config phase successfully; false on timeout.</returns>
private bool StartFanCfgUpdate(int index)
{
if (index > 8) // Brute Force System Fan Control
{
int initFanCurveReg = FAN_PWM_COMMAND_REG[index]; // Initial Register Address for the Fan Curve
int targetFanCurveAddr = initFanCurveReg; // Address of the Current Fan Curve Register we're writing to
ushort targetFanCurveReg; // Integer value of the current fan curve register address, not the value within
byte currentSpeed = ReadByte(FAN_PWM_OUT_REG[index]); // Current Speed of the target fan
byte engineSts = ReadByte(NCT6687DR_REG_FAN_ENGINE_STS);
// If current fan duty cycle matches requested duty cycle, skip re-writing the fan curve
if (currentSpeed == value.Value)
{
return;
}
else
{
// Write 7-point fan curve
for (int count = 0; count < 14; count += 2)
{
targetFanCurveAddr = initFanCurveReg+count;
targetFanCurveReg = Convert.ToUInt16(targetFanCurveAddr);
WriteByte(targetFanCurveReg, value.Value);
}
}
}
else // Control CPU, Pump, Chipset, or EZ-Connect Fan normally
// Already accessible
if ((engineSts & NCT6687DR_FAN_CFG_LOCK) == 0 && (engineSts & NCT6687DR_FAN_CFG_PHASE) != 0)
return true;
// Wait until any existing config phase is done and request flag is clear
Stopwatch sw = Stopwatch.StartNew();
while (sw.Elapsed < TimeSpan.FromSeconds(1))
{
WriteByte(FAN_PWM_COMMAND_REG[index], value.Value);
engineSts = ReadByte(NCT6687DR_REG_FAN_ENGINE_STS);
if ((engineSts & NCT6687DR_FAN_CFG_PHASE) == 0)
{
byte req = ReadByte(FAN_PWM_REQUEST_REG[index]);
if ((req & NCT6687DR_FAN_CFG_REQ) == 0)
break;
}
Thread.Sleep(1);
}
// Send config request
WriteByte(FAN_PWM_REQUEST_REG[index], NCT6687DR_FAN_CFG_REQ);
Thread.Sleep(10); // CC_Engine: fixed 10ms delay after request
// Wait until EC enters config phase and unlocks registers
sw.Restart();
while (sw.Elapsed < TimeSpan.FromSeconds(1))
{
engineSts = ReadByte(NCT6687DR_REG_FAN_ENGINE_STS);
if ((engineSts & NCT6687DR_FAN_CFG_LOCK) == 0 && (engineSts & NCT6687DR_FAN_CFG_PHASE) != 0)
return true;
Thread.Sleep(1);
}
return false; // Timeout: EC did not enter config phase
}
/// <summary>
/// Signal the EC that fan configuration is complete and wait for acknowledgment.
/// Returns false if the EC set the INVALID bit (configuration rejected).
/// </summary>
private bool CompleteFanConfigUpdate(int index)
{
byte engineSts = ReadByte(NCT6687DR_REG_FAN_ENGINE_STS);
// Already not accessible
if ((engineSts & NCT6687DR_FAN_CFG_LOCK) != 0 || (engineSts & NCT6687DR_FAN_CFG_PHASE) == 0)
return false;
// Signal done — CC_Engine uses 0xC0 (REQ|DONE) to commit atomically
WriteByte(FAN_PWM_REQUEST_REG[index], NCT6687DR_FAN_CFG_REQ | NCT6687DR_FAN_CFG_DONE);
Thread.Sleep(10); // CC_Engine: fixed 10ms delay after commit
// Wait until EC checks the new configuration
Stopwatch sw = Stopwatch.StartNew();
while (sw.Elapsed < TimeSpan.FromSeconds(1))
{
engineSts = ReadByte(NCT6687DR_REG_FAN_ENGINE_STS);
if ((engineSts & NCT6687DR_FAN_CFG_CHECK_DONE) != 0)
break;
Thread.Sleep(1);
}
// Check if EC rejected the configuration (INVALID bit)
engineSts = ReadByte(NCT6687DR_REG_FAN_ENGINE_STS);
return (engineSts & NCT6687DR_FAN_CFG_INVALID) == 0;
}
/// <summary>
/// Write fan control value for NCT6687DR. All fans use direct PWM command registers.
/// </summary>
private void Set6687DRControl(int index, byte value)
{
WriteByte(FAN_PWM_COMMAND_REG[index], value);
}
private void SaveDefaultFanControl(int index)
@@ -1229,6 +1360,18 @@ internal class Nct677X : ISuperIO
{
_initialFanControlMode[index] = ReadByte(FAN_CONTROL_MODE_REG[index]);
}
else if (Chip is Chip.NCT6687DR)
{
// Use the correct bit position mapping for NCT6687DR
int bitPos = FAN_CONTROL_MODE_BIT[index];
if (bitPos >= 0)
{
byte mode = ReadByte(FAN_CONTROL_MODE_REG[index]);
byte bitMask = (byte)(0x01 << bitPos);
_initialFanControlMode[index] = (byte)(mode & bitMask);
}
}
else
{
byte mode = ReadByte(FAN_CONTROL_MODE_REG[index]);
@@ -1250,8 +1393,32 @@ internal class Nct677X : ISuperIO
WriteByte(FAN_CONTROL_MODE_REG[index], _initialFanControlMode[index]);
WriteByte(FAN_PWM_COMMAND_REG[index], _initialFanPwmCommand[index]);
}
else if (Chip is Chip.NCT6687DR)
{
// NCT6687DR: Restore original manual-mode bit for all fans.
// Clear the bit we set in SetControl to return to SmartFAN curve mode.
int bitPos = FAN_CONTROL_MODE_BIT[index];
if (bitPos >= 0)
{
byte mode = ReadByte(FAN_CONTROL_MODE_REG[index]);
byte bitMask = (byte)(0x01 << bitPos);
mode = (byte)(mode & ~bitMask);
WriteByte(FAN_CONTROL_MODE_REG[index], mode);
}
for (int attempt = 0; attempt < 3; attempt++)
{
if (!StartFanCfgUpdate(index))
break;
Set6687DRControl(index, _initialFanPwmCommand[index]);
if (CompleteFanConfigUpdate(index))
break;
}
}
else
{
// NCT6683D / NCT6686D / NCT6687D (non-DR)
byte mode = ReadByte(FAN_CONTROL_MODE_REG[index]);
mode = (byte)(mode & ~_initialFanControlMode[index]);
WriteByte(FAN_CONTROL_MODE_REG[index], mode);
@@ -1259,16 +1426,7 @@ internal class Nct677X : ISuperIO
WriteByte(FAN_PWM_REQUEST_REG[index], 0x80);
Thread.Sleep(50);
if (Chip is Chip.NCT6687DR)
{
// for MSI AM5/LGA1851 boards using NCT6687D
Set6687DRControl(index, _initialFanPwmCommand[index]);
}
else
{
// All other motherboards that use NCT6683/6686/6687
WriteByte(FAN_PWM_COMMAND_REG[index], _initialFanPwmCommand[index]);
}
WriteByte(FAN_PWM_COMMAND_REG[index], _initialFanPwmCommand[index]);
WriteByte(FAN_PWM_REQUEST_REG[index], 0x40);
Thread.Sleep(50);
@@ -1390,27 +1548,4 @@ internal class Nct677X : ISuperIO
SYSTIN3 = 6,
PECI_0 = 12
}
// ReSharper disable InconsistentNaming
private const ushort ADDRESS_REGISTER_OFFSET = 0x05;
private const byte BANK_SELECT_REGISTER = 0x4E;
private const uint DATA_REGISTER_OFFSET = 0x06;
// NCT668X
private const uint EC_SPACE_PAGE_REGISTER_OFFSET = 0x04;
private const uint EC_SPACE_INDEX_REGISTER_OFFSET = 0x05;
private const uint EC_SPACE_DATA_REGISTER_OFFSET = 0x06;
private const byte EC_SPACE_PAGE_SELECT = 0xFF;
private const ushort NUVOTON_VENDOR_ID = 0x5CA3;
private readonly ushort[] FAN_CONTROL_MODE_REG;
private readonly ushort[] FAN_PWM_COMMAND_REG;
private readonly ushort[] FAN_PWM_OUT_REG;
private readonly ushort[] FAN_PWM_REQUEST_REG;
private readonly ushort VENDOR_ID_HIGH_REGISTER;
private readonly ushort VENDOR_ID_LOW_REGISTER;
// ReSharper restore InconsistentNaming
}