A small Swift CLI that reads and overrides Mac fan speeds through AppleSMC. It exists so a script or an agent can pin the fans at maximum for a benchmark run and hand control back to macOS when the run ends.
Only ever built and run on one machine: a MacBook Pro (M5 Max, 128 GB, macOS 26.6.2). Other Apple Silicon Macs use the same AppleSMC key IDs and should work; Intel Macs share the same interface and read without sudo. Neither is exercised. Reports from other hardware are welcome; nothing here is claimed for machines this repo has not seen.
Download the latest zip from releases, unpack it, and run the install script inside:
unzip fancontrol-*-macos-arm64.zip
cd fancontrol-*-macos-arm64
./install.shThe binary is signed with a Developer ID and notarized by Apple, so it needs no toolchain and no security exception. Apple Silicon only.
Requires macOS 13 or newer and a Swift 5.9+ toolchain (Xcode 15 or a matching swift.org toolchain).
git clone https://github.com/evanwtf/fancontrol.git
cd fancontrol
Scripts/install.shIt is the same script either way: it uses the binary next to it if there is one, and builds a fresh one if there is not.
That builds and signs the binary, installs it to /usr/local/bin/fancontrol
(root-owned), and writes /etc/sudoers.d/fancontrol granting passwordless
sudo for the fan-write subcommands only: max, auto and reset, with or
without flags. set stays outside the grant — slowing the fans keeps the
password prompt — and status never needed sudo. Re-running the script is
idempotent; delete /etc/sudoers.d/fancontrol to revoke passwordless sudo.
To install without the sudoers entry, build and copy by hand:
swift build -c release
sudo cp .build/release/fancontrol /usr/local/bin/Reads work as any user. Writes (max, set, auto) need root because the
AppleSMC driver enforces it on the write commands, not on the transport.
fancontrol status # print every fan, mode and RPMs
sudo fancontrol max # force every fan to its max RPM
sudo fancontrol set 4500 # force every fan to 4500 RPM (clamped)
sudo fancontrol set 4500 --fan 0 # one fan only
sudo fancontrol auto # hand control back to macOS
sudo fancontrol reset # synonym for autoAfter Scripts/install.sh, max, auto and reset run under sudo without
a password prompt.
On a fanless Mac (all Apple Silicon MacBook Airs, for example) status
prints a "no fans found" message with a search URL for that model, and
the write subcommands refuse rather than pretending to succeed.
Every subcommand accepts --json, which is the intended path for scripted use.
fancontrol status --json
{"fans":[{"actual_rpm":1230,"index":0,"max_rpm":6800,"min_rpm":1200,"mode":"auto","target_rpm":1230}]}
sudo fancontrol max --json
{"action":"max","results":[{"fan":0,"max_rpm":6800,"min_rpm":1200,"mode":"forced","target_rpm":6800}]}On a fanless Mac, fancontrol status --json returns {"fans":[]}.
Errors are also JSON when --json is set, on stderr:
fancontrol max --json
{"detail":null,"error":"this action needs root; re-run under sudo"}Exit codes:
| code | meaning |
|---|---|
| 0 | success |
| 1 | runtime error (SMC I/O, bad key, unexpected size) |
| 2 | needs root |
| 64 | usage error (argument parsing) |
sudo fancontrol max
trap 'sudo fancontrol auto' EXIT
./run-my-benchmark.shfancontrol auto (or reset) sets F<n>md back to 0; macOS's own thermal policy resumes
immediately. If the process dies without running auto, a reboot also restores
default behaviour — the mode override is not persisted across boots.
Forcing fans changes airflow, not thermal capacity. The Mac's own overtemp
protection stays in place; you can burn out fan bearings faster by pinning them
at max for long stretches, but the SoC does not lose its thermal cutout. Do not
use set to run fans slower than macOS wants — that is what the Mac already
does when it can.
hholtmann/smcFanControl— the original reverse-engineering of the AppleSMC user-client, from which theSMCParamStructlayout, selector2, key IDs (FNum,F<n>Ac,F<n>Mn,F<n>Mx,F<n>Tg) and the fpe2 encoding all descend. The mode key's lowercasemdspelling was verified against the live SMC key table, where smcFanControl'sF<n>Mddoes not exist.beltex/SMCKit— a modern Swift port of the same interface.
Paste this into whatever your coding agent reads for rules — a Claude Code
skill, a Cursor rule, an AGENTS.md section, a system prompt:
fancontrol reads and overrides Mac fan speeds via AppleSMC on Apple Silicon.
Every subcommand takes --json. Exit codes: 0 ok, 1 error, 2 needs root,
64 usage error.
Use only these three:
fancontrol status --json read RPM and mode; needs no sudo
sudo -n fancontrol max --json pin every fan to maximum
sudo -n fancontrol auto --json hand control back to macOS
("reset" is a synonym for "auto".)
Never run `fancontrol set <rpm>`. Do not run it, suggest it, or put it in a
script. max and auto are safe in one direction only: they add cooling or
return control to macOS. set is the one command that can hold fans below what
thermal policy is asking for, which is how a machine overheats or throttles
under load. That call needs a human who can see the machine. If a specific
RPM is genuinely wanted, say so and let the user run it.
Always use `sudo -n`. Plain sudo waits on a password prompt that is invisible
in a non-interactive session, so the command hangs until it times out. If
sudo -n reports "a password is required", this machine has no passwordless
grant: say so and let the user run it. Do not retry without -n, and do not
edit /etc/sudoers.d/.
Always restore the fans. max holds until something sets it back — nothing
expires it; only auto or a reboot does. Pair every max with a trap in the
same shell so the restore survives a failing command or a Ctrl-C:
sudo -n fancontrol max
trap 'sudo -n fancontrol auto' EXIT
./run-benchmark.sh
Never end a session with the fans forced. Check status afterwards and confirm
mode came back to "auto".
Fan count and RPM ranges vary by machine, so probe status rather than
assuming. A fan reporting actual_rpm 0 is stopped, which is normal when idle
and not a failure.
Scripts/install.sh grants passwordless sudo for max, auto and reset
alone, so the machine enforces the same boundary the prompt describes.
The version in Sources/fancontrol/FanControl.swift decides what ships.
To cut a release:
- Add a
## <version>section todocs/changelog.md. - Bump the
version:field inSources/fancontrol/FanControl.swift. - Push to
main.
The release workflow sees a version with no tag, builds and signs the binary,
notarizes the zip with Apple, tags v<version>, and publishes the release
with the notes from the changelog. A push whose version is already tagged
does nothing, so ordinary commits do not cut releases. A version with no
changelog section is refused rather than published with empty notes.
Scripts/package.sh does the build-sign-zip-notarize part on its own, so a
release can be reproduced locally exactly as CI cut it.
Signing and notarization are configured by two repository variables, not secrets, since both are only names:
gh variable set SIGN_IDENTITY --body "Developer ID Application: NAME (TEAMID)"
gh variable set NOTARY_PROFILE --body "monitor-notary"NOTARY_PROFILE names a notarytool credential profile in the keychain of
the macOS runner. The profile holds an app-specific password and
authenticates to the team rather than to a product, so the one the monitor
repo already uses notarizes this too. No credential material goes into GitHub.
Rebuilding that Mac means recreating the profile with xcrun notarytool store-credentials; the password for it is in 1Password.
swift build
swift testSource layout: Sources/fancontrol/SMC.swift wraps the AppleSMC user-client
(SMCParamStruct, fpe2 codec, key I/O); Sources/fancontrol/FanControl.swift
defines the ArgumentParser subcommands and JSON envelopes.
Tests/fancontrolTests covers the wire format (no hardware needed).
CI runs on the evanwtf self-hosted macOS ARM64 runner
(.github/workflows/ci.yml): swift build -c release, swift test, and a
fancontrol --help smoke run.
Notes for agents and scripts calling the binary — including the JSON schema and exit-code contract, sudo patterns, and what is deliberately out of scope — live in AGENTS.md.