Skip to content

feat(fans): add temperature-driven fan profile engine#3191

Draft
marxo126 wants to merge 3 commits intoexelban:masterfrom
marxo126:feat/fan-profiles-curves
Draft

feat(fans): add temperature-driven fan profile engine#3191
marxo126 wants to merge 3 commits intoexelban:masterfrom
marxo126:feat/fan-profiles-curves

Conversation

@marxo126
Copy link
Copy Markdown

@marxo126 marxo126 commented May 7, 2026

Heads up before reading: this is a feature add and I expect it may be out of scope for the project. Filing as Draft so you can quickly close it if so — would rather get a fast "no, scope" than burn your review time. Discussion thread: #3162.

What this adds

Opt-in fan profiles that drive setFanSpeed based on temperature sensor readings using a user-defined curve with hysteresis. When no profile is enabled the engine is inert and the existing manual/automatic fan UI is unchanged.

  • 3 starter presets: Silent, Balanced, Performance (editable)
  • Per-profile linear curve over CPU temp → target RPM
  • Hysteresis: skip SMC write if new target differs from last by < 100 RPM (avoids SMC thrash)
  • 1Hz tick gate: re-evaluation runs at most once per second
  • Profile JSON persisted to ~/Library/Application Support/Stats/fan-profiles.json; enable flags via Store.shared
  • Sensors popup slider for a profile-controlled fan is greyed out with tooltip pointing to the profile name

Architecture

  • Modules/Sensors/fanProfile.swiftCurvePoint + FanProfile Codable, presets, FanProfileStore JSON persistence (~141 LOC)
  • Modules/Sensors/fanProfileEngine.swiftFanProfileEngine.shared singleton, hysteresis, RPM clamping (~131 LOC)
  • Modules/Sensors/fanProfileSettings.swift — settings panel with preset picker + enable toggle (~155 LOC)
  • Modules/Sensors/main.swift — registers fans + drives processTick from existing usageCallback
  • Modules/Sensors/popup.swiftsetProfileControlled(_:) on FanView to grey out manual slider when a profile is active
  • Modules/Sensors/settings.swift — appends profile section to existing settings under #if arch(arm64)

Scope decisions / known gaps

(All open to redirection from your review)

  • Apple Silicon only — gated behind #if arch(arm64). Intel fan control uses a different SMC path; not touched.
  • Single sensor source — averages all CPU temperature sensors as the curve input. A dedicated GPU fan with no GPU temp signal won't ramp from this. Per-profile sensor-source picking is a follow-up if accepted (TODO marker in fanProfileEngine.swift).
  • No curve point editor in UI yet — settings panel only allows enable/disable + preset selection + delete. Users can edit the JSON directly. A graphical curve editor is a follow-up.
  • Engine inert when no profile enabled — zero overhead in the default config. The usageCallback already runs; the engine returns early before any SMC interaction.

Testing

Logic-reviewed, not yet hardware-tested. Setting up an Apple Silicon validation pass once the approach has your sign-off.

Diff

3 new files (~427 LOC)
3 modified files (+61 LOC)
project.pbxproj: 12 new entries for the new files

Adds opt-in fan profiles that drive setFanSpeed based on temperature
sensor readings using a user-defined curve with hysteresis. Three
built-in starter presets (Silent, Balanced, Performance) are editable
through a new settings panel. When no profile is enabled the engine is
inert and existing manual/automatic fan control is unchanged.

Profiles are stored as JSON in ~/Library/Application Support/Stats/.
The Sensors popup slider is disabled for fans currently driven by a
profile, with a tooltip pointing to the profile name.

Closes exelban#3162

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@exelban
Copy link
Copy Markdown
Owner

exelban commented May 7, 2026

so just me to understand, you vibe code and spam me with 3 PR and do not test even one of them?

@marxo126
Copy link
Copy Markdown
Author

marxo126 commented May 7, 2026

so just me to understand, you vibe code and spam me with 3 PR and do not test even one of them?

I tested these on my MacBook and they seem to have worked. I'd like to discuss with you if they would be a good fit for your app.

for reason that I did not want to create another app when Stats with Fans already existed then improving it.

@exelban
Copy link
Copy Markdown
Owner

exelban commented May 7, 2026

Zrzut ekranu 2026-05-7 o 11 31 17

@marxo126
Copy link
Copy Markdown
Author

marxo126 commented May 7, 2026

Zrzut ekranu 2026-05-7 o 11 31 17

Ah, okay. I'll update this. When I tested locally recently. But first, I noticed that you commented in another post that you're planning to drop this feature Fans from the stats, right?

marxo126 added 2 commits May 7, 2026 11:40
Build fix after initial commit. Store, Constants, localizedString,
PreferencesSection, and PreferencesRow are all defined in the Kit
framework target.
Two issues found during runtime testing on M4 Max:

1. The CPU temperature group includes per-core sensors. Apple Silicon
   parks efficiency cores under low load and parked cores report very
   low values (~1.5°C). Averaging across all CPU sensors pulled the
   driving temperature 30-40°C below the actual hot-core temperature,
   so the curve evaluated at idle even when active P-cores were hot.
   Use max() instead — drives off the worst-case sensor, which is what
   real thermal control actually responds to. Also add a >5°C floor so
   any genuinely-stuck-at-zero sensor is ignored.

2. The Sensors module surfaces a 'Fastest fan' pseudo-fan with id < 0
   (an aggregate, not a real SMC channel). The engine was writing curve
   targets to it. Filter to id >= 0 so only real fans receive writes.

Verified end-to-end on Apple Silicon: at 67.86°C CPU, Silent curve
correctly drives both real fans to 2324 RPM (linear interp between
60°C/1800 and 75°C/2800).
@marxo126
Copy link
Copy Markdown
Author

marxo126 commented May 7, 2026

Update — runtime verified end-to-end on macOS 26.4.1, Apple Silicon (M4 Max).

Test setup:

  1. Launched dev build, enabled the Silent preset profile in Settings.
  2. Measured fan RPMs before profile via independent SMC tool.
  3. Waited 8s for the engine tick.
  4. Re-measured.

Result:
```
Before profile After profile (8s)
Fan 0 RPM 1331 2323
Fan 1 RPM 1460 2293
Curve target — 2324 (linear interp at 67.86°C between 60°C/1800 and 75°C/2800 on Silent curve)
```

Both fans ramped to within ±5 RPM of the curve target. Engine writes were correctly hysteresis-skipped on subsequent ticks once the target stabilised.

Two real bugs surfaced during testing — both fixed in commit `c71ea3b9`:

  1. The CPU temperature group includes per-core sensors. Apple Silicon parks efficiency cores under low load and parked cores report ~1.5°C. Averaging across all CPU sensors pulled the driving temperature 30-40°C below the actual hot-core temperature. Switched to `max()` of CPU temps (>5°C floor) — drives off the worst-case sensor, which is what real thermal control responds to. Without this fix the curve evaluated at idle even when active P-cores were hot, so fans never ramped.
  2. The Sensors module surfaces a "Fastest fan" pseudo-fan with id < 0 (an aggregate, not a real SMC channel). The engine was writing curve targets to it. Filter to `id >= 0` so only real fans receive writes.

Caveat: tested with the same unsigned-dev-build helper bypass as #3190 (necessary because SMAuthorizedClients cert match is required and dev builds are unsigned). Behaviour in a Developer ID-signed production build is identical — the engine code path is the same.

Minor unfixed corner case: first tick has `bounds=nil` because `registerFans` runs at module init and the reader callback can fire before that completes. Engine still writes the unclamped target on first tick, then clamps correctly from second tick on. Worth noting but not a blocker — happy to swap `registerFans` to a synchronous call inside `init` if you'd prefer it bullet-proof on the first tick.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants