Skip to content

Repository files navigation

fancontrol

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.

Install

From a release

Download the latest zip from releases, unpack it, and run the install script inside:

unzip fancontrol-*-macos-arm64.zip
cd fancontrol-*-macos-arm64
./install.sh

The binary is signed with a Developer ID and notarized by Apple, so it needs no toolchain and no security exception. Apple Silicon only.

From source

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.sh

It 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/

Use

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 auto

After 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)

Typical benchmarking wrap

sudo fancontrol max
trap 'sudo fancontrol auto' EXIT
./run-my-benchmark.sh

fancontrol 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.

Safety

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.

References

  • hholtmann/smcFanControl — the original reverse-engineering of the AppleSMC user-client, from which the SMCParamStruct layout, selector 2, 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 lowercase md spelling was verified against the live SMC key table, where smcFanControl's F<n>Md does not exist.
  • beltex/SMCKit — a modern Swift port of the same interface.

Driving it from an agent

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.

Releases

The version in Sources/fancontrol/FanControl.swift decides what ships. To cut a release:

  1. Add a ## <version> section to docs/changelog.md.
  2. Bump the version: field in Sources/fancontrol/FanControl.swift.
  3. 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.

Development

swift build
swift test

Source 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.

About

Swift CLI for reading and overriding Mac fan speeds via AppleSMC. Designed for benchmarking scripts and agents.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages