Skip to content
Merged
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,29 @@ reappear after serial ambiguity is resolved.
The identity identifies the controller's header, not the physical fan attached
to it; replacing the fan does not change the saved assignment.

## MSI boards with the nct6687 driver

On some MSI boards the nct6687 driver accepts speed changes for the system
fans (`pwm3` and up) but does not apply them, so those fans stay at about 60%.
The driver documents this and fixes it with its `msi_fan_brute_force` module
option. When a configured fan uses one of those channels and the option is not
detected, the settings page shows a notice with the workaround. The option's
loaded state is read from the driver's `fan_control_watchdog` attribute or its
"MSI fan brute force mode" kernel log line, falling back to the modprobe
configuration. While a fan on one of those channels is set above 60% but reads
back 60% or less for 10 seconds, the plugin also sends one Unraid
notification per fan per boot. On Unraid, set the option on the flash drive and
reboot:

```bash
mkdir -p /boot/config/modprobe.d
echo "options nct6687 msi_fan_brute_force=1" >> /boot/config/modprobe.d/nct6687.conf
```

The option was added to the driver in December 2025; `modinfo nct6687` lists
it when the installed build supports it. See the
[driver documentation](https://github.com/Fred78290/nct6687d#loading-msi_fan_brute_force-parameter-fails).

Support / Issues
- https://github.com/andrebrait/fanctrlplus/issues

Expand Down
2 changes: 2 additions & 0 deletions src/usr/local/emhttp/plugins/fanctrlplus2/css/fcp.base.css
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,8 @@
margin: 0;
white-space: nowrap;
}
.fcp-nct6687-notice { justify-content: flex-start; }
.fcp-nct6687-notice code.fcp-cmd { display: block; margin: 4px 0; user-select: all; }

/* ===== Sort toggle ===== */
.fanctrl-sort-toggle {
Expand Down
13 changes: 13 additions & 0 deletions src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ flock($page_config_lock, LOCK_UN);
fclose($page_config_lock);
$cpu_sensors = detect_cpu_sensors();
$aux_sensors = detect_aux_sensors();
$fcp_nct6687_notice = fcp_nct6687_brute_force_missing(array_column($all_cfg, 'controller'));
?>

<div class="fanctrl-header">
Expand All @@ -173,6 +174,18 @@ $aux_sensors = detect_aux_sensors();
<span>FanCtrl Plus 2 files changed on the server. Reload to use matching interface resources.</span>
<button type="button" id="fcp-asset-reload">Reload page</button>
</div>
<?php if ($fcp_nct6687_notice): ?>
<div class="fcp-asset-update-notice fcp-nct6687-notice">
<span>
<strong>MSI board detected:</strong> the nct6687 driver may ignore speed changes on system fans, which then stay at about 60%.
The driver's documented workaround is its <code>msi_fan_brute_force</code> option. Run the commands below from an Unraid terminal, then reboot:
<code class="fcp-cmd">mkdir -p /boot/config/modprobe.d</code>
<code class="fcp-cmd">echo "options nct6687 msi_fan_brute_force=1" &gt;&gt; /boot/config/modprobe.d/nct6687.conf</code>
If <code>modinfo nct6687 | grep msi_fan_brute_force</code> prints nothing, the installed driver predates the option and needs an update.
<a href="https://github.com/Fred78290/nct6687d#loading-msi_fan_brute_force-parameter-fails" target="_blank" rel="noopener">Driver documentation</a>
</span>
</div>
<?php endif; ?>

<script>
const DEBUG = false; // Enables diagnostic console output.
Expand Down
57 changes: 57 additions & 0 deletions src/usr/local/emhttp/plugins/fanctrlplus2/include/Common.php
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,63 @@ function list_pwm(string $hwmon_glob = '/sys/class/hwmon/hwmon*') {
return $out;
}

// On MSI boards the nct6687 driver uses its msi_alt1 register layout, where
// writes to the system-fan channels (pwm3 and up) are accepted but ignored
// unless the msi_fan_brute_force module option is set; those fans then stay
// at about 60%. The option is not readable from sysfs, so its state is taken,
// in order, from the fan_control_watchdog attribute (created only in that
// mode, by newer builds), the driver's "MSI fan brute force mode" line in the
// kernel log (printed on auto-detected MSI boards; the latest load wins), and
// finally the modprobe configuration.
// https://github.com/Fred78290/nct6687d#loading-msi_fan_brute_force-parameter-fails
function fcp_nct6687_brute_force_missing(
array $controllers,
string $module_dir = '/sys/module/nct6687',
string $modprobe_glob = '/etc/modprobe.d/*.conf',
?string $kernel_log = null
): bool {
$fan_config = @file_get_contents("$module_dir/parameters/fan_config");
if ($fan_config === false || trim($fan_config) !== 'msi_alt1') return false;

$hwmon = null;
foreach ($controllers as $controller) {
if (!preg_match('/^pwm(\d+)$/', basename((string)$controller), $m) || (int)$m[1] < 3) continue;
$dir = dirname($controller);
// The driver names its hwmon device after the chip it found.
if (preg_match('/^nct668[367]$/', trim((string)@file_get_contents("$dir/name")))) {
$hwmon = $dir;
break;
}
}
if ($hwmon === null || file_exists("$hwmon/fan_control_watchdog")) return false;

// syslog first, dmesg last: dmesg holds the most recent load, but its ring
// buffer may have dropped the line on a long uptime. Only the last 4 MiB of
// syslog are read, so a huge log cannot exhaust PHP's memory limit.
$syslog = '/var/log/syslog';
$kernel_log ??= (string)@file_get_contents($syslog, false, null, -min(4 << 20, (int)@filesize($syslog)))
. "\n" . (string)@shell_exec('dmesg 2>/dev/null');
if (preg_match_all('/nct668\d[^\n]*MSI fan brute force mode: (enabled|disabled)/', $kernel_log, $m)) {
return end($m[1]) === 'disabled';
}

// modprobe reads the files in name order and passes every option along, and
// the kernel applies them in turn, so the last setting wins. Kernel bools
// accept 1/y/t/on (optionally quoted), or the bare name; '-' and '_' are
// interchangeable.
$files = glob($modprobe_glob) ?: [];
usort($files, fn($a, $b) => strcmp(basename($a), basename($b)));
$last = null;
foreach ($files as $conf) {
preg_match_all('/^\s*options\s+nct6687\s+(.*)$/m', (string)@file_get_contents($conf), $lines);
foreach ($lines[1] as $args) {
preg_match_all('/(?:^|\s)msi[-_]fan[-_]brute[-_]force(?:="?([^\s"]*)"?)?(?=\s|$)/', $args, $vals);
foreach ($vals[1] as $val) $last = $val;
}
}
return $last === null || !preg_match('/^(?:$|[1yYtT]|[oO][nN])/', $last);
}

// Find storcli binary (storcli64, storcli2, storcli) in common paths
function find_storcli(): ?string {
$candidates = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,50 @@ interval_seconds() {
(( secs > fcp_interval_max_seconds )) && secs=$fcp_interval_max_seconds
printf '%s' "$secs"
}

# ===== MSI nct6687 stuck-fan check (#13) =====
# On MSI boards in the driver's msi_alt1 layout, system-fan channels (pwm3 and
# up) ignore speed changes unless the msi_fan_brute_force option is set, and
# stay at about 60%. Reading pwmN back returns the chip's actual output, so a
# fan driven above 60% that keeps reading back lower is caught here. Drivers
# that expose fan_control_watchdog run with the option set and are skipped.
fcp_notify_bin="${fcp_notify_bin:-/usr/local/emhttp/webGui/scripts/notify}"
fcp_nct6687_module="${fcp_nct6687_module:-/sys/module/nct6687}"

fcp_nct6687_msi_channel() {
local controller="$1" chip
[[ "$controller" =~ /pwm([0-9]+)$ ]] && (( BASH_REMATCH[1] >= 3 )) || return 1
chip=$(cat "$(dirname "$controller")/name" 2>/dev/null)
[[ "$chip" =~ ^nct668[367]$ ]] || return 1
[[ ! -e "$(dirname "$controller")/fan_control_watchdog" ]] || return 1
[[ "$(cat "$fcp_nct6687_module/parameters/fan_config" 2>/dev/null)" == "msi_alt1" ]]
}

# A PWM value as a whole percentage, rounded the way the dashboard shows it.
fcp_pwm_percent() {
echo $(( ($1 * 100 + 127) / 255 ))
}

# Echoes when the current stuck stretch began (the given time, or the start
# carried over), or nothing when the fan is not stuck. Stuck means set above
# 60% but reading back 60% or less: the speed a fan ignoring its writes holds.
# The caller warns once a stretch has lasted 10 seconds, which a chip still
# ramping up would not.
fcp_stuck_since() {
local since="$1" now="$2" target="$3" actual="$4"
if [[ "$actual" =~ ^[0-9]+$ ]] &&
(( $(fcp_pwm_percent "$target") > 60 && $(fcp_pwm_percent "$actual") <= 60 )); then
echo "${since:-$now}"
fi
}

# One Unraid notification per fan per boot; the flag lives on tmpfs. Without
# a flag every tick would notify again, so no flag means no notification.
fcp_notify_stuck() {
local name="$1" target="$2" actual="$3" flag="$4"
[[ -e "$flag" ]] && return 0
touch "$flag" 2>/dev/null || return 0
"$fcp_notify_bin" -e "FanCtrl Plus 2" -i warning -l "/Settings/fanctrlplus2" \
-s "Fan $name is not reaching its set speed" \
-d "Set to $(fcp_pwm_percent "$target")% but the nct6687 controller reports $(fcp_pwm_percent "$actual")%. On MSI boards this driver ignores system-fan speed changes unless its msi_fan_brute_force option is set. See the FanCtrl Plus 2 settings page."
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ aux_locate_bins "${aux_sensor:-}"
source "/usr/local/emhttp/plugins/fanctrlplus2/scripts/disk_group_control.sh"

plugin="fanctrlplus2"
# The dashboard updater also creates this, but may start after the loops.
mkdir -p "/var/tmp/${plugin}"
custom="${custom:-$(basename "$cfg_file" .cfg)}"
controller_enable="${controller}_enable"

Expand All @@ -44,6 +46,10 @@ else
fan_path=""
fi

stuck_since=""
nct6687_watch=0
fcp_nct6687_msi_channel "$controller" && nct6687_watch=1

prev_pwm=-1
hist_file="/var/tmp/${plugin}/history_${plugin}_${custom}"

Expand Down Expand Up @@ -184,5 +190,16 @@ while true; do
fi
fi

# Fans the nct6687 MSI layout may ignore: warn once one has stayed at 60% or
# less for 10 seconds while set above it. The target is the last value
# written, which a change under 5 leaves in place.
if (( nct6687_watch )); then
actual_pwm=$(cat "$controller" 2>/dev/null)
stuck_since=$(fcp_stuck_since "$stuck_since" "$SECONDS" "$prev_pwm" "$actual_pwm")
if [[ -n "$stuck_since" ]] && (( SECONDS - stuck_since >= 10 )); then
Comment on lines +198 to +199

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check stuck fans at the 10-second deadline

When interval_sec exceeds 10 seconds (the default is 120 and the supported maximum is 3600), this condition is evaluated only once per control-loop iteration and is followed by the full interval sleep. A stuck stretch first observed here therefore cannot notify after 10 seconds; it notifies only on the next tick—up to an hour later—or not at all if the requested PWM falls below 60% before that tick. Schedule a follow-up read after the remaining 10-second window or poll this check independently of the temperature-control interval.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intended. The check follows the control loop's cadence by design: it warns after 10 seconds' worth of ticks plus the tick that set the speed, so at a 120 s interval that is the second tick. A fan stuck at 60% isn't urgent, and the condition persists, so a later tick still catches it. Users who choose a long interval accept slower reaction from every part of the loop, this check included.

fcp_notify_stuck "$custom" "$prev_pwm" "$actual_pwm" "/var/tmp/${plugin}/nct6687_stuck_${custom}"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fi
fi

sleep "$(interval_seconds)"
done
101 changes: 101 additions & 0 deletions tests/nct6687_notice_test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

require_once __DIR__ . '/../src/usr/local/emhttp/plugins/fanctrlplus2/include/Common.php';

$failures = [];
$root = sys_get_temp_dir() . '/fcp_nct6687_notice_' . getmypid();
$module = "$root/module";
$hwmon = "$root/devices/platform/nct6687.2592/hwmon/hwmon7";
$other = "$root/devices/platform/it87.552/hwmon/hwmon3";
$modprobe = "$root/modprobe.d";

@mkdir("$module/parameters", 0777, true);
@mkdir($hwmon, 0777, true);
@mkdir($other, 0777, true);
@mkdir($modprobe, 0777, true);
file_put_contents("$hwmon/name", "nct6687\n");
file_put_contents("$other/name", "it8728\n");
file_put_contents("$modprobe/nct6683.conf", "blacklist nct6683\ninstall nct6683 /bin/false\n");

$log = '';
$check = function (string $label, bool $expected, array $controllers) use (&$failures, &$log, $module, $modprobe): void {
$actual = fcp_nct6687_brute_force_missing($controllers, $module, "$modprobe/*.conf", $log);
if ($actual !== $expected) {
$failures[] = "$label: expected " . var_export($expected, true) . ', got ' . var_export($actual, true);
}
};

$sys_fan = ["$other/pwm3", "$hwmon/pwm5"];

$check('driver not loaded', false, $sys_fan);

file_put_contents("$module/parameters/fan_config", "default\n");
$check('default register layout', false, $sys_fan);

file_put_contents("$module/parameters/fan_config", "msi_alt1\n");
$check('msi_alt1 without the option', true, $sys_fan);
$check('first system channel', true, ["$hwmon/pwm3"]);
$nct6686 = "$root/devices/platform/nct6687.2593/hwmon/hwmon8";
@mkdir($nct6686, 0777, true);
file_put_contents("$nct6686/name", "nct6686\n");
$check('nct6686 chip', true, ["$other/pwm3", "$nct6686/pwm3"]);
$check('only CPU and pump channels', false, ["$hwmon/pwm1", "$hwmon/pwm2", "$hwmon/pwm1_enable", "$hwmon/pwm3_enable"]);
$check('system channel on another chip', false, ["$other/pwm5"]);
$check('no fans configured', false, []);

foreach ([
'options nct6687 msi_fan_brute_force=0' => true,
'options nct6687 msi_fan_brute_force=n' => true,
'# options nct6687 msi_fan_brute_force=1' => true,
'options nct6683 msi_fan_brute_force=1' => true,
'options nct6687x msi_fan_brute_force=1' => true,
'options nct6687 not_msi_fan_brute_force=1' => true,
'options nct6687 msi_fan_brute_force=1' => false,
"# Set by hand\noptions nct6687 msi_fan_brute_force=1" => false,
"options nct6687 fan_config=msi_alt1 msi-fan-brute-force=Y\n" => false,
'options nct6687 msi_fan_brute_force' => false,
'options nct6687 msi_fan_brute_force=on' => false,
'options nct6687 msi_fan_brute_force="true"' => false,
"options nct6687 msi_fan_brute_force=1\noptions nct6687 msi_fan_brute_force=0" => true,
'options nct6687 msi_fan_brute_force=0 msi_fan_brute_force=1' => false,
] as $line => $expected) {
file_put_contents("$modprobe/nct6687.conf", "$line\n");
$check("modprobe line '$line'", $expected, $sys_fan);
}

// Across files, modprobe reads them in name order, so the later file wins.
file_put_contents("$modprobe/zz-off.conf", "options nct6687 msi_fan_brute_force=0\n");
$check('a later file disables the option', true, $sys_fan);
rename("$modprobe/zz-off.conf", "$modprobe/00-off.conf");
$check('an earlier file is overridden', false, $sys_fan);
unlink("$modprobe/00-off.conf");
file_put_contents("$modprobe/nct6687.conf", "options nct6687 msi_fan_brute_force=1\n");

// The kernel log reports the loaded value, so it overrides the modprobe files
// (which the loop above left setting the option).
$enabled = "Oct 6 06:20:01 Tower kernel: nct6687 nct6687.2592: MSI fan brute force mode: enabled\n";
$disabled = "[ 7.912345] nct6687 nct6687.2592: MSI fan brute force mode: disabled\n";
$log = $disabled;
$check('kernel log: disabled, modprobe option set after boot', true, $sys_fan);
$log = "$disabled$enabled";
$check('kernel log: latest load enabled', false, $sys_fan);
$log = "$enabled$disabled";
$check('kernel log: latest load disabled', true, $sys_fan);
file_put_contents("$modprobe/nct6687.conf", "options nct6687 msi_fan_brute_force=0\n");
$log = $enabled;
$check('kernel log: enabled, option passed outside modprobe.d', false, $sys_fan);
$log = '';
unlink("$modprobe/nct6687.conf");

touch("$hwmon/fan_control_watchdog");
$check('watchdog attribute present', false, $sys_fan);
$log = $disabled;
$check('watchdog attribute outranks the kernel log', false, $sys_fan);

exec('rm -rf ' . escapeshellarg($root));

if ($failures) {
fwrite(STDERR, implode("\n", $failures) . "\n");
exit(1);
}
echo "nct6687 notice: OK\n";
67 changes: 67 additions & 0 deletions tests/nct6687_stuck_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/bin/bash

set -u

root=$(cd "$(dirname "$0")/.." && pwd)
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

fcp_nct6687_module="$tmp/module"
fcp_notify_bin="$tmp/notify"
source "$root/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/aux_sensors.sh"
source "$root/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/disk_group_control.sh"

failures=0
expect_equal() {
local expected="$1" actual="$2" message="$3"
if [[ "$expected" == "$actual" ]]; then
return
fi
printf '%s\nExpected: %s\nActual: %s\n' "$message" "$expected" "$actual" >&2
failures=$((failures + 1))
}

# ===== fcp_nct6687_msi_channel =====
nct="$tmp/hwmon7" other="$tmp/hwmon3"
mkdir -p "$nct" "$other" "$fcp_nct6687_module/parameters"
echo nct6687 > "$nct/name"
echo it8728 > "$other/name"
channel() { fcp_nct6687_msi_channel "$1" && echo yes || echo no; }

expect_equal no "$(channel "$nct/pwm5")" "Without the driver loaded, no channel is watched."
echo default > "$fcp_nct6687_module/parameters/fan_config"
expect_equal no "$(channel "$nct/pwm5")" "The default register layout applies speed changes."
echo msi_alt1 > "$fcp_nct6687_module/parameters/fan_config"
expect_equal yes "$(channel "$nct/pwm3")" "The first system-fan channel is watched."
expect_equal no "$(channel "$nct/pwm2")" "The pump channel is not affected."
expect_equal no "$(channel "$other/pwm5")" "Another chip's channel is not watched."
echo nct6683 > "$other/name"
expect_equal yes "$(channel "$other/pwm5")" "The driver's other chip names are watched too."
touch "$nct/fan_control_watchdog"
expect_equal no "$(channel "$nct/pwm5")" "A driver running with brute force on is not watched."

# ===== fcp_stuck_since =====
expect_equal 100 "$(fcp_stuck_since '' 100 255 154)" "Set to 100%, stuck at 60%: a stretch starts now."
expect_equal 100 "$(fcp_stuck_since 100 110 166 154)" "A new target above 60% keeps the stretch going."
expect_equal 100 "$(fcp_stuck_since 100 110 155 154)" "61% is above 60%; a reading of 154 rounds to 60%."
expect_equal '' "$(fcp_stuck_since 100 110 154 120)" "A target of 60% or less ends the stretch."
expect_equal '' "$(fcp_stuck_since 100 110 255 155)" "A reading above 60% ends the stretch."
expect_equal '' "$(fcp_stuck_since 100 110 255 '')" "An unreadable value ends the stretch."

# ===== fcp_notify_stuck =====
printf '#!/bin/bash\nprintf "%%s\\n" "$@" >> "%s/calls"\n' "$tmp" > "$fcp_notify_bin"
chmod +x "$fcp_notify_bin"
fcp_notify_stuck HardDriveFans 255 154 "$tmp/flag"
fcp_notify_stuck HardDriveFans 255 154 "$tmp/flag"
expect_equal 1 "$(grep -c '^-s$' "$tmp/calls")" "One notification per fan until the flag is cleared at boot."
expect_equal 1 "$(grep -c '^Set to 100% but the nct6687 controller reports 60%' "$tmp/calls")" \
"The notification reports the set and actual speeds as percentages."
expect_equal 1 "$(grep -cx 'warning' "$tmp/calls")" "The notification is a warning."
fcp_notify_stuck HardDriveFans 255 154 "$tmp/missing/flag"
expect_equal 1 "$(grep -c '^-s$' "$tmp/calls")" "Without a flag file, no notification is sent, so ticks cannot repeat it."

if (( failures > 0 )); then
exit 1
fi

echo "nct6687 stuck-fan tests passed"
Loading