Skip to content

feat(sensors): temperature-driven fan controller with per-fan AC/Battery profiles#3198

Closed
Morteza-Rastgoo wants to merge 7 commits into
exelban:masterfrom
Morteza-Rastgoo:feat/fan-target-temp-power-source
Closed

feat(sensors): temperature-driven fan controller with per-fan AC/Battery profiles#3198
Morteza-Rastgoo wants to merge 7 commits into
exelban:masterfrom
Morteza-Rastgoo:feat/fan-target-temp-power-source

Conversation

@Morteza-Rastgoo
Copy link
Copy Markdown

@Morteza-Rastgoo Morteza-Rastgoo commented May 9, 2026

What this adds

An opt-in Fan Temperature Controller built into the Sensors popup. Instead of manually setting RPM, you pick a target exhaust temperature and the controller keeps fans as fast as needed to stay cool — with separate targets for AC Adapter and Battery, configurable per fan.

When disabled the engine is completely inert; existing manual/automatic fan behaviour is unchanged.

Key differences from #3191

#3191 (curve engine) This PR
UX Define multi-point RPM curve Single target temp slider
Power source One profile for all conditions Separate AC / Battery targets
Control sensor CPU temperature Exhaust airflow (lap-facing vents)
Algorithm Linear interpolation on user curve Bang-bang: instant max → slow step-down

How it works

Each fan in the Sensors popup gains a third mode button: Temp. Clicking it reveals two sliders (⚡ AC and 🔋 Battery), each controlling a target exhaust temperature from 30–85 °C.

Control signal — exhaust airflow vents (not CPU core temp):

  • Primary: TaLW / TaRW — left/right wing vent sensors (~32–34 °C at idle on M4 Max)
  • Fallback: TaLP / TaRF then max CPU core temp
  • Popup header shows live L: 32°C R: 33°C readout (turns orange when either exceeds target)
  • Using vent temp means the controller responds to what actually heats the lap, not die temps that run 60–110 °C above perceived surface heat

Algorithm:

  • controlTemp > target → instantly set max RPM (forced mode via SMC)
  • controlTemp ≤ target → step down 200 RPM every 500 ms until automatic mode is restored
  • 500 ms tick gate + newRPM != prev guard prevent flooding the helper's serial smcQueue
  • Mode transitions (Auto/Manual) are handled exclusively by popup buttons; controller does NOT call setFanMode for fans not in Temp mode — eliminates a race that would silently override manual speed
  • willTerminate restores automatic mode on all controlled fans

Files changed

File What changed
Modules/Sensors/fanTempController.swift New singleton: power-source detection, airflow sensor selection, bang-bang controller, smcQueue flood prevention (~172 LOC)
Modules/Sensors/popup.swift Auto/Manual/Temp mode buttons per fan; Temp panel with ⚡/🔋 sliders; live L/R airflow readout in header (~+207 LOC)
Modules/Sensors/main.swift Wire processTick / releaseAll into Sensors module lifecycle (+8 LOC)
Modules/Sensors/values.swift Add TaLW / TaRW (Airflow left/right wing) to Apple Silicon sensor list (+2 lines)
Stats.xcodeproj/project.pbxproj 4 new build entries for fanTempController.swift

Testing

Verified on macOS 26.4.1, Apple Silicon M2:

  • Temp mode, target 40 °C, laptop on lap: vent sensors ~32–34 °C at rest → fans stay in automatic; under load (vent temps ~45 °C) → fans blast to 6800 RPM immediately; load drops → step-down over ~20 s back to automatic
  • AC vs Battery profiles: switching power source instantly applies the matching slider target
  • Manual mode after Temp mode: clicking Manual exits Temp mode cleanly; slider controls fan speed without controller interference
  • Auto mode: always restored correctly on quit and on explicit Auto click
  • Disabled (Auto or Manual): zero SMC calls from controller; behaviour identical to unmodified Stats

Morteza Rastgoo and others added 7 commits May 9, 2026 18:36
…ource profiles

Adds a proportional fan speed controller that keeps CPU temperature
near a user-defined target, with separate settings for AC adapter
and battery power sources.

Architecture:
- fanTempController.swift  – FanTempController singleton (proportional
  ramp, IOKit power-source detection, 1 s tick gate, 100 RPM hysteresis,
  per-fan min/max clamping).  Engine is inert when disabled — zero
  overhead in default config.
- fanTempControllerSettings.swift – Settings panel with two sections
  (AC Adapter / Battery), each offering an enable toggle and a
  target-temperature slider (30–85 °C).
- main.swift  – registerFans() after reader init; processTick() in
  usageCallback; releaseAll() in willTerminate (restores automatic mode).
- settings.swift – appends FanTempControllerSettingsView below existing
  sensor prefs.
- popup.swift – FanView.setControlledByTempController() greys out
  manual slider and mode buttons while the engine is active, with a
  tooltip pointing to the controller.

Compared to the curve-based approach in exelban#3191 this feature uses a
single target-temperature knob (simpler UX) and adds power-source
awareness: a cooler target on battery preserves charge while a higher
threshold on AC lets users push performance without the fans running
harder than necessary when idle.

Tested on macOS 26.4.1, Apple Silicon M4 Max.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix SensorGroup.cpu → .CPU (enum case is uppercase)
- Change acSettings/battSettings to var to allow mutation
- Guard mode11be with swift(>=6.0) for SDK compatibility

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds FanTempControllerPopupView directly in the Sensors popup, below
the fans section. The panel shows:
- A header row '🌡 Fan Temp Control' with live CPU temp (e.g. 'CPU 72°C')
- An '⚡ AC Adapter' row: enable toggle + target temp stepper
- A '🔋 Battery' row: enable toggle + target temp stepper

Steppers cover 30–85°C in 1-degree steps and write directly to
FanTempController.shared, which ramps fan speed proportionally
when the CPU exceeds the target. No navigation to Settings needed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the separate popup panel with a proper 'Temp' mode button
integrated directly into each fan's [Automatic | Manual | Temp] row.

When Temp is selected:
- SMC is set to automatic; FanTempController takes over
- Two temperature sliders appear (⚡ AC Adapter, 🔋 Battery), 30–85°C
- Controller ramps fan proportionally: at target temp = min RPM,
  25°C above target = max RPM; hysteresis 100 RPM
- Settings persist across launches per-fan in UserDefaults
- Switching back to Auto or Manual releases SMC control immediately

Also removed FanTempControllerSettings (settings panel) since
all controls are now inline in the popup.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Root cause: isActive() returns false until a setFanSpeed/setFanMode call
has been made (XPC connection is lazy). Replaced with isInstalled which
checks the helper binary on disk directly.

Also:
- Show 'CPU: XX°C' in the temp slider view so users know what target to set
- CPU temp turns orange when above target (fans will ramp)
- Default targets lowered from 55/60°C to 50°C for both profiles

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
New behavior:
- HOT (CPU > target): fans blast to max within 3°C overshoot — immediate
- COOL (CPU ≤ target): reduce by max 200 RPM per 500ms tick (~20s to full idle)
- Tick interval halved to 500ms for faster thermal response

This replaces the linear 25°C ramp with an asymmetric strategy:
fast thermal protection, comfortable gradual wind-down.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…race, aggressive ramp

- Control signal changed from Airport (TW0P) to exhaust airflow wing sensors
  (TaLW/TaRW), falling back to TaLP/TaRF then CPU max. These directly measure
  the air that heats the user's lap instead of a single internal chip.
- Add TaLW / TaRW ('Airflow left/right wing') to values.swift sensor list.
- Popup temp-mode header now shows 'L: 32°C  R: 32°C' (both vent temps) instead
  of a single Airport readout; label turns orange when either side exceeds target.
- Remove proportional RPM ramp — controller now blasts fans to max the instant
  temp > target (aggressive cool-down), then steps down 200 RPM per 500 ms tick.
- Fix critical race condition: controller no longer calls setFanMode(automatic)
  for non-temp fans, which was silently overriding popup's Manual/Auto buttons.
- Use live fan.maxSpeed on every tick (never cached); matches turbo-button behaviour.
- 500 ms tick gate with newRPM != prev guard prevents flooding the helper's
  serial smcQueue and keeps popup button latency near zero.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@Morteza-Rastgoo Morteza-Rastgoo changed the title feat(sensors): temperature-driven fan controller with per-power-source profiles feat(sensors): temperature-driven fan controller with per-fan AC/Battery profiles May 9, 2026
@Morteza-Rastgoo Morteza-Rastgoo marked this pull request as ready for review May 9, 2026 19:22
@exelban exelban closed this May 10, 2026
@Morteza-Rastgoo
Copy link
Copy Markdown
Author

🙂

marxo126 added a commit to marxo126/stats that referenced this pull request May 11, 2026
Adds the two pieces from Morteza-Rastgoo's PR exelban#3198 most relevant to
laptop use: airflow vent sensors + power-source detection. Telemetry
only — driver still die-temp by default.

- values.swift: TaLW + TaRW (Airflow left/right wing) added alongside
  existing TaLP/TaRF. Apple Silicon platforms.
- engine: vent_max derived from sensors with group=.sensor and name
  containing 'Airflow'. Power source detected via IOPSCopyPowerSourcesInfo.
- telemetry CSV gains vent_max_temp, power_source columns.
- Driver logic unchanged — die temp (max CPU/GPU) still primary.
  Vent telemetry gathered first to evaluate whether vent-driven mode
  is worth implementing for this hardware (M4 Mac, Mac16,5).
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