A lightweight fan control daemon for 2015 Intel MacBook Pro (MacBookPro11,5).
Should be adaptable to other Intel MacBook models as well — the probe tool will identify your specific SMC keys.
Built as a minimal replacement for Macs Fan Control, which caused severe system slowdowns on this hardware. This project uses less than 1% of CPU and a few MB of RAM.
Macs Fan Control is a great app, but on older MacBook Pros it can cause the system to become nearly unusable due to CPU and RAM overhead. This project does exactly one thing well: read sensor temperatures and adjust fan speed accordingly, with almost zero system impact.
Apple's built-in auto fan control on the 2015 MacBook Pro is overly aggressive — fans ramp up fast and stay high long after temperatures drop. This daemon uses a smoother curve that keeps the machine quiet at idle while still pushing hard enough under load to avoid macOS's thermal throttling (CPU_Speed_Limit). See CPU Throttling & Speed Limit Watchdog below for why this matters more than it sounds.
- Machine: MacBookPro11,5 (MacBook Pro Retina 15-inch, Mid 2015)
- CPU: Quad-Core Intel Core i7 2.8 GHz
- GPU: AMD Radeon R9 M370X + Intel Iris Pro
- OS: macOS Monterey 12.7.6
- SMC: Version 2.30f2
May work on other 2013–2015 Intel MacBook Pros with similar SMC key layouts. The probe tool will tell you what keys your machine exposes.
- Configurable target sensor — drive the fan curve off whichever SMC key runs hottest on your machine (TC0F by default)
- Exponential fan curve — more aggressive ramp in the mid-range where throttling tends to start, gentle at idle
- Independent RPM control for left and right fans
- Configurable curve, floor RPM, hysteresis, and ramp-down cooldown
- Emergency override at configurable temperature ceiling
- CPU_Speed_Limit watchdog — live background listener on
pmset -g thermlog. Forces max fan speed on early throttling and hands off to Apple auto control if throttling becomes severe - Manual override modes — Auto, Manual (fixed RPM), and Max, controllable from the menu bar
- Automatic restore to Apple control on crash, exit, or emergency
- Runs as a login daemon via launchd — daemon does not auto-start by default; start/stop from the menu bar or
macfanalias - Menu bar display showing target sensor temp, GPU temp, CPU speed limit %, fan RPM, and current mode
- Dry-run mode for testing without touching the fans
- Single install script handles all setup automatically
- Extremely low resource usage
MacFanControl/
├── native/
│ ├── smc.c # SMC implementation (from smcFanControl, GPL v2)
│ ├── smc.h
│ ├── macfan_smc.c # Lightweight CLI helper wrapping smc.c
│ └── Makefile
│
├── sensors.py # Subprocess wrapper around macfan_smc
├── fan_curve.py # Fan curve calculation (pure, no I/O)
├── speed_watcher.py # Background pmset thermlog listener (daemon-side)
├── override.py # Shared override.json read/write (Auto/Manual/Max)
├── daemon.py # Main daemon loop
├── menubar.py # Menu bar status display + manual controls
├── config.json # User configuration
├── macfan.sh # Process management shortcuts
├── install.sh # Automated install script
│
├── com.macfancontrol.daemon.plist # launchd template (daemon, RunAtLoad=false)
├── com.macfancontrol.menubar.plist # launchd template (menu bar, RunAtLoad=true)
│
└── tests/
└── probe.py # Read all sensors and fan info (no writes)
- macOS (tested on Monterey 12.7.6)
- Xcode Command Line Tools
- Python 3
Install Xcode Command Line Tools if needed:
xcode-select --installgit clone https://github.com/91kinks/MacFanControl.git
cd MacFanControlcd native
make check # verify your build environment
make
cd ..python3 tests/probe.pyThis dumps all temperature keys and fan info from your SMC with no writes. Look for the hottest-running sensor under load — on the MacBookPro11,5 this is TC0F. The GPU die sensor TG0D is also useful as a secondary/display value. If your machine reports different keys, update config.json before proceeding.
{
"binary": "native/macfan_smc",
"sensor": {
"gpu_temp_key": "TG0D",
"cpu_temp_key": "TC0F",
"target_sensor_key": "cpu"
},
"poll_interval_seconds": 3,
"fan0": {
"label": "Left side",
"floor_rpm": 3100,
"max_rpm": 6156
},
"fan1": {
"label": "Right side",
"floor_rpm": 3000,
"max_rpm": 5700
},
"curve": {
"start_temp": 60,
"max_temp": 85,
"hysteresis": 2,
"exponent": 0.6,
"cooldown_seconds": 45
},
"safety": {
"emergency_temp": 85,
"sensor_fail_action": "auto",
"speed_limit_warn": 70,
"speed_limit_emergency": 60
},
"log_threshold_temp": 80
}Config notes:
target_sensor_key—"cpu"or"gpu". Selects which ofcpu_temp_key/gpu_temp_keyactually drives the fan curve and emergency logic. The other one is read for display only. On the MacBookPro11,5,cpu_temp_keyis set toTC0F(hottest sensor on the board, not strictly "CPU-only") and used as the target — this gives the curve visibility into combined CPU+GPU load rather than GPU die temp alone.floor_rpm— the RPM both fans hold at rest. Raising this (e.g. 3000-3100 vs. the original 2600) keeps the machine running cooler at idle, which gives the curve less ground to make up once load hits.start_temp/max_temp— the active ramp range.max_tempis also where fans hit hardware maximum. Loweringmax_temp(85°C vs. the original 95°C) means fans reach full speed before macOS's thermal governor starts throttling, not after.hysteresis— degrees belowstart_tempthe temp must drop before ramp-down is even considered. Kept low (2°C) becausecooldown_secondsalready handles oscillation prevention — the two together being too conservative caused fans to never ramp down in testing.exponent— shapes the curve.1.0= linear (original behavior). Values below1.0(e.g.0.6) push more RPM into the middle of the temperature range — exactly the 70-80°C zone where throttling tends to start — without being loud at idle. Values above1.0do the opposite (quiet until nearmax_temp, then aggressive).cooldown_seconds— once temp drops belowstart_temp - hysteresis, fans must stay there for this many seconds (andCPU_Speed_Limitmust be healthy) before RPM is allowed to decrease. This is what prevents fans from backing off the moment they've made progress, only to let temps creep back up.speed_limit_warn/speed_limit_emergency— see CPU Throttling & Speed Limit Watchdog.log_threshold_temp— daemon only logs to stdout above this temperature, keeping the log file manageable during normal operation.
python3 -m venv venv
source venv/bin/activate
pip install rumpssudo python3 daemon.py --dry-runThis runs the full loop — reading sensors, computing targets, checking the speed limit watchdog — without writing anything to the SMC. Verify the startup banner shows the values you expect (target sensor, curve range, exponent, cooldown, speed limit thresholds), then Ctrl+C to exit.
sudo python3 daemon.pyIn a separate terminal, verify the fans are responding:
native/macfan_smc fansConfirm that Mode shows forced and the target RPMs align with your config. Ctrl+C to exit — this cleanly restores Apple auto control before shutting down.
chmod +x install.sh
./install.shThe install script will:
- Verify all requirements are met
- Create the logs directory
- Write both launchd plists with correct paths for your system
- Add a passwordless sudo rule for the
macfan_smcbinary only - Lock the binary to root ownership to prevent privilege escalation
- Add a
macfanshell alias for process management - Load the menu bar launch agent
After install, the menu bar app starts automatically on every login — the daemon does not. Start the daemon from the menu bar (Daemon → Start Daemon) or run macfan start daemon once you're ready. This was changed deliberately so the daemon can be started/stopped on demand without digging into the terminal, and so a fresh login doesn't immediately start writing to the SMC before you've had a chance to check things.
The menu bar dropdown includes a Mode section with three options, written to a shared override.json file that the daemon checks at the top of every poll cycle:
| Mode | Behavior |
|---|---|
| Auto | Default. The daemon runs the full curve, cooldown, and watchdog logic described above. |
| Manual | Both fans are held at a fixed RPM you set. Curve, cooldown, and emergency temp logic are bypassed — only the speed limit emergency watchdog still applies as a final safety net. |
| Max | Both fans are forced to hardware maximum, same as a temperature emergency. |
Manual mode controls:
Manual RPM: 4000
[ – 500 ] [ – 100 ] [ + 100 ] [ + 500 ]
[ Enter RPM... ]
→ Set Manual
The +/- buttons nudge the held RPM value. Enter RPM... opens a native dialog for typing an exact value. Values are clamped to the lowest floor_rpm and highest max_rpm across both fans. Set Manual writes the override — the daemon picks it up on its next poll (within poll_interval_seconds).
Switching back to Auto clears the override. The daemon resets its ramp state and starts fresh from the floor, so there's no leftover cooldown hold from before the override was applied.
This exists as a manual fallback for situations the curve doesn't handle well — sustained heavy load where you'd rather just pin the fans yourself, or quickly silencing them for a video call regardless of temperature.
This is the most important addition since the original release, and the reason for several of the config changes above.
On the MacBookPro11,5, macOS's thermal governor can assert CPU_Speed_Limit (visible via pmset -g therm / pmset -g thermlog) well before any temperature emergency threshold is reached. If the machine sits at a "warm but not alarming" temperature (e.g. 75-78°C) for too long, macOS throttles the CPU to shed heat — sometimes down to its floor value, from which it does not recover without a reboot.
A fan curve that only reacts to temperature can miss this entirely: the temperature looks fine, but the duration at that temperature was the problem. The original linear curve with max_temp: 95 and start_temp: 66 left too much headroom — fans never got aggressive enough in the 70-80°C range to actually bring temps back down, so the machine would hover there indefinitely under load.
-
Lower
max_temp(85°C) + exponential curve (exponent: 0.6) — fans ramp harder in the 70-80°C range specifically, the zone where throttling starts. This is enough on its own for most sustained loads (tested against 4K video playback) — temps that hover at 73-78°C get pushed back down into the 60s instead of holding steady. -
Cooldown (
cooldown_seconds) — once fans ramp up, they hold that RPM until temp has been back belowstart_temp - hysteresisfor the full cooldown period. Without this, fans would back off the moment temp dipped slightly, temps would creep back up, and the cycle would repeat without ever making real progress. -
Speed limit watchdog (
speed_watcher.py) — a background thread runspmset -g thermlogfor the life of the daemon and updates instantly wheneverCPU_Speed_Limitchanges (no polling delay). Every tick checks this value:speed_limit < speed_limit_warn(default 70%) — fans forced to max immediately, cooldown timer reset. The curve's response was too slow; override it.speed_limit <= speed_limit_emergency(default 60%) — daemon restores Apple auto control and exits. At this point Apple's own (much more aggressive) auto fan response recovers the speed limit faster than our curve can, and 60% gives comfortable margin above the unrecoverable floor (~20%).
The menu bar always displays the current CPU_Speed_Limit percentage (via pmset -g therm on its normal 3-second refresh), with a ⚠ warning indicator when it drops below speed_limit_warn — so you have an at-a-glance signal of throttling state without needing a terminal open.
If you're adapting this for your own hardware, the values above (max_temp: 85, exponent: 0.6, cooldown_seconds: 45, hysteresis: 2, floor_rpm raised to 3000-3100) were arrived at through iterative testing against real throttling events. Start with these as a baseline and adjust exponent first if fans feel too aggressive or not aggressive enough in the mid-range — it has the biggest effect on day-to-day noise level without touching the safety thresholds.
MacFanControl requires passwordless sudo access for the macfan_smc binary in order to write to the SMC without prompting on every fan update.
The sudoers rule is scoped as narrowly as possible — it grants passwordless sudo for that one specific binary path only, nothing else.
To prevent privilege escalation via binary replacement, install.sh automatically locks the binary to root ownership after install:
-rwxr-xr-x root wheel native/macfan_smc
This means even if an attacker gains user-level access to your machine, they cannot replace the binary with a malicious one without already having root — which breaks the escalation chain.
If you ever rebuild the binary (make), re-run ./install.sh to reapply the ownership lock.
./install.sh --uninstallThis unloads both launch agents (if running), removes the plists, removes the sudoers rule, restores the binary to user ownership, and returns fans to Apple auto control.
The menu bar app includes daemon controls directly in the dropdown:
Daemon: ● running
→ Start Daemon
→ Stop Daemon
Start Daemon / Stop Daemon call launchctl load / launchctl unload on the daemon's launch agent — no terminal needed for everyday use. Stopping the daemon triggers its normal shutdown path, which restores Apple auto fan control before exiting.
Use the macfan alias (added automatically by install.sh):
macfan status # show whether daemon and menu bar are running
macfan start # start both
macfan stop # stop both
macfan restart # restart both
macfan start daemon # start daemon only
macfan stop daemon # stop daemon only
macfan start menubar # start menu bar only
macfan stop menubar # stop menu bar only
macfan restart daemon # restart daemon onlyIf the alias isn't active yet in your current terminal:
source ~/.zshrcTo start/stop manually without the alias:
launchctl load ~/Library/LaunchAgents/com.macfancontrol.menubar.plist
launchctl unload ~/Library/LaunchAgents/com.macfancontrol.menubar.plist
launchctl load ~/Library/LaunchAgents/com.macfancontrol.daemon.plist
launchctl unload ~/Library/LaunchAgents/com.macfancontrol.daemon.plistNote: the menu bar app starts automatically on login (RunAtLoad: true); the daemon does not (RunAtLoad: false). This is intentional — see Installation step 8.
The macfan_smc binary can be used directly without the daemon:
# Read all temperature sensors
./native/macfan_smc temps
# Read fan info (RPM, min, max, mode)
./native/macfan_smc fans
# Set fan 0 and fan 1 to independent RPM targets
sudo ./native/macfan_smc set-rpm 3000 2800
# Return both fans to Apple auto control
sudo ./native/macfan_smc set-autoRPM
^
max ─────────────────────────────/──────
| /
| /
| ╱
floor─────────────────────╱────
| ─────╱
| ────╱
+─────────────────────────────> Target Sensor Temp (C)
58 60 85
↑ ↑ ↑
ramp-down ramp-up emergency
threshold threshold override
- Below
start_temp— fans hold atfloor_rpm(quiet) start_temptomax_temp— exponential ramp fromfloor_rpmtomax_rpm. Withexponent < 1.0, the curve bulges upward in the middle of the range — fans get noticeably more RPM in the 70-80°C zone than a straight line would give, without being loud right atstart_temp- At
max_temp— immediate override to hardware maximum - Hysteresis — once ramping, fans won't even be considered for ramp-down until temp drops below
start_temp - hysteresis - Cooldown — after crossing that threshold, temp must stay below it for
cooldown_seconds(andCPU_Speed_Limitmust be healthy) before RPM is actually allowed to decrease. Until then, fans hold their current RPM regardless of what the curve would otherwise calculate.
Each fan scales against its own hardware min/max independently. See CPU Throttling & Speed Limit Watchdog for why the curve is shaped this way.
logs/daemon.log # daemon stdout (temps, RPM decisions)
logs/daemon.err # daemon stderr (errors)
logs/menubar.log # menu bar stdout
logs/menubar.err # menu bar stderr
| Key | Description | Notes |
|---|---|---|
| TC0F | Hottest system sensor | Primary control sensor (target_sensor_key) |
| TG0D | GPU die temperature | Secondary / display only |
| TC1C | CPU core 1 temperature | Secondary / informational |
| F0Tg | Fan 0 target RPM | fpe2 encoded, big-endian |
| F1Tg | Fan 1 target RPM | fpe2 encoded, big-endian |
| FS! | Forced mode bitmask | bit 0 = fan 0, bit 1 = fan 1 |
CPU_Speed_Limit is read separately via pmset -g thermlog (daemon, live-streamed) and pmset -g therm (menu bar, polled) — it is not an SMC key.
SMC communication is based on smcFanControl by hholtmann. The smc.c and smc.h files are used under the GNU GPL v2 license.
GNU General Public License v2.0
This project uses code from smcFanControl (GPL v2), so the same license applies here. See GPL v2.