Skip to content

Fix Ftst key handling for Apple Silicon fan control#2924

Open
agoodkind wants to merge 10 commits intoexelban:masterfrom
agoodkind:agoodkind/apple-silicon-fan-control
Open

Fix Ftst key handling for Apple Silicon fan control#2924
agoodkind wants to merge 10 commits intoexelban:masterfrom
agoodkind:agoodkind/apple-silicon-fan-control

Conversation

@agoodkind
Copy link

@agoodkind agoodkind commented Jan 23, 2026

Fixes #2928

Summary

On Apple Silicon, thermalmonitord immediately overrides writes to F0Md (mode) and F0Tg (target) unless Ftst=1 is written first. The existing fan control implementation lacks this coordination, causing manual fan control to fail silently on M1/M2/M3 processors. This PR adds the Ftst unlock/reset logic directly to smc.swift, enabling fan control on Apple Silicon while preserving Intel compatibility.

The implementation signals manual control intent via the Ftst diagnostic key, retries mode writes until thermalmonitord releases control, and properly resets Ftst when returning to automatic mode. Multi-fan scenarios are handled by only resetting Ftst when all fans return to automatic.

Demo

Before After
Fan control unavailable on Apple silicon ✅ Full manual control on M-series processors
Screen.Recording.2026-01-23.at.13.33.26.mov
Screen.Recording.2026-01-23.at.13.32.06.mov

Testing

  • Validated on Apple Silicon (Mac16,6, MacBook Pro M4 Max) - fan speed changes take effect
  • Intel Mac backward compatibility verified (iMac19,1 - FS! bitmask and fpe2 format work correctly)

References

Implementation based on macos-smc-fan, which provides detailed technical analysis of SMC fan control mechanisms on Apple Silicon.

@agoodkind agoodkind marked this pull request as draft January 23, 2026 20:10
@agoodkind agoodkind changed the title Add Apple Silicon Fan Control Support [wip]Add Apple Silicon Fan Control Support Jan 23, 2026
@agoodkind agoodkind changed the title [wip]Add Apple Silicon Fan Control Support Add Apple Silicon Fan Control Support Jan 23, 2026
@agoodkind agoodkind force-pushed the agoodkind/apple-silicon-fan-control branch from 52e5f19 to 40540da Compare January 24, 2026 02:04
@agoodkind agoodkind marked this pull request as ready for review January 24, 2026 02:12
@agoodkind
Copy link
Author

@exelban Here is a PR to add support for changing fan speeds on apple silicon

@agoodkind agoodkind force-pushed the agoodkind/apple-silicon-fan-control branch from d69e726 to 4adab44 Compare January 24, 2026 02:22
@exelban
Copy link
Owner

exelban commented Jan 24, 2026

Its very fanny to read PR called Add Apple Silicon Fan Control Support from the apple silicon mac where fan control already working fine)

Please take a moment to review the project’s history. Much of what you’re implementing was already done 3–4 years ago, and the project has moved on since then because the original SMC implementation in C was not functioning correctly.
The old approach simply cannot work reliably—you need root privileges to access SMC, and that’s not possible without the XPC daemon.
Instead of re-implementing everything from scratch, you could restore the existing C-based SMC code from the earliest versions of Stats. It’s better to discuss these things ahead of time and save yourself unnecessary effort.

@agoodkind agoodkind changed the title Add Apple Silicon Fan Control Support Add Ftst key handling for Apple Silicon fan control Jan 24, 2026
@agoodkind agoodkind force-pushed the agoodkind/apple-silicon-fan-control branch from 4adab44 to 6d54758 Compare January 24, 2026 16:42
@agoodkind agoodkind changed the title Add Ftst key handling for Apple Silicon fan control Fix Ftst key handling for Apple Silicon fan control Jan 24, 2026
@agoodkind agoodkind force-pushed the agoodkind/apple-silicon-fan-control branch from 4bad4f6 to 39be0a7 Compare January 24, 2026 18:18
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump is needed to make fan helper reinstall

@agoodkind agoodkind force-pushed the agoodkind/apple-silicon-fan-control branch from 39be0a7 to 15fcbcf Compare January 24, 2026 18:45
@agoodkind
Copy link
Author

agoodkind commented Jan 24, 2026

@exelban

Its very fanny to read PR called Add Apple Silicon Fan Control Support from the apple silicon mac where fan control already working fine)

Please take a moment to review the project’s history. Much of what you’re implementing was already done 3–4 years ago, and the project has moved on since then because the original SMC implementation in C was not functioning correctly. The old approach simply cannot work reliably—you need root privileges to access SMC, and that’s not possible without the XPC daemon. Instead of re-implementing everything from scratch, you could restore the existing C-based SMC code from the earliest versions of Stats. It’s better to discuss these things ahead of time and save yourself unnecessary effort.

Hey thanks for the honest feedback, I appreciate you taking the time to review my PR. I reviewed the git history including the original C-based SMC code from 2020 (commit 413674d) and the Swift rewrite.

You're right that I overcomplicated this. I tested the Swift struct layout using MemoryLayout.offset and the existing SMCKeyData_t struct in smc.swift has correct field offsets that match the C ABI:

  • keyInfo.dataSize at offset 28
  • data8 (command byte) at offset 42
  • bytes at offset 48

So the C wrapper files I added were unnecessary and I've removed them. The existing Helper architecture (shelling out to the SMC CLI) works fine, and the refactor to move it into the program itself was out of scope.

However, there's one piece that is new and required for Apple Silicon: the Ftst (Fan Test) key handling. I searched the git history and this key is not used anywhere in the existing codebase. On Apple Silicon, thermalmonitord aggressively overrides F0Md/F0Tg writes unless you first write Ftst=1 to unlock fan control. This is why fan control was silently failing on M-series. The existing code was technically writing to the correct keys, but the OS was immediately overriding them. (Created an issue that describes this: #2928)

I've refactored the PR to be minimal:

  1. Removed smc.c, smc.h, bridge.h, SMCConnection.swift, SMCDataConversion.swift
  2. Added the Ftst unlock/reset logic directly to the existing smc.swift (guarded with #if arch(arm64))
  3. Kept the existing Helper architecture unchanged

Does this approach work for you?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The modified code was not rebuilding when I built the entire Stats target because Helper and SMC were not dependency targets

@exelban
Copy link
Owner

exelban commented Jan 24, 2026

thx, looks much better now. Sorry but it will take some time to check everything. Please ping me if I will not provide any feedback to the end of next week.

@agoodkind
Copy link
Author

thx, looks much better now. Sorry but it will take some time to check everything. Please ping me if I will not provide any feedback to the end of next week.

Thanks sounds good

@agoodkind agoodkind force-pushed the agoodkind/apple-silicon-fan-control branch from 940988a to 1452aab Compare January 25, 2026 02:24
Signed-off-by: Alex Goodkind <alex@goodkind.io>
Signed-off-by: Alex Goodkind <alex@goodkind.io>
@exelban
Copy link
Owner

exelban commented Jan 25, 2026

set fan mode: Error writing Ftst=0: (iokit/common) general error
Last manual fan - Ftst reset, thermalmonitord has control

M1 Max/Ultra

@agoodkind
Copy link
Author


set fan mode: Error writing Ftst=0: (iokit/common) general error

Last manual fan - Ftst reset, thermalmonitord has control

M1 Max/Ultra

What happens if you try manually uninstalling and reinstalling the fan helper?

@exelban
Copy link
Owner

exelban commented Jan 25, 2026

will try later

@agoodkind
Copy link
Author

agoodkind commented Jan 25, 2026 via email

@exelban
Copy link
Owner

exelban commented Feb 1, 2026

[SMCHelper] setFanMode: fan=0, mode=1
[SMCHelper] setFanSpeed: fan=0, speed=3500
[SMCHelper] setFanMode result: [setFanMode] fan 0 -> manual
[Ftst] current value: 0, alreadyUnlocked: false
[Ftst] unlock (set to 1) failed after 10 attempts
[fan 0] FAILED to unlock fan control

[SMCHelper] setFanSpeed result: [setFanSpeed] fan 0 -> 3500 RPM
[Ftst] current value: 0, alreadyUnlocked: false
[Ftst] unlock (set to 1) failed after 10 attempts
Error: Failed to unlock fan 0 for speed change

[SMCHelper] setFanMode: fan=1, mode=1
[SMCHelper] setFanSpeed: fan=1, speed=3500
[SMCHelper] setFanMode result: [setFanMode] fan 1 -> manual
[Ftst] current value: 0, alreadyUnlocked: false
[Ftst] unlock (set to 1) failed after 10 attempts
[fan 1] FAILED to unlock fan control

[SMCHelper] setFanSpeed result: [setFanSpeed] fan 1 -> 3500 RPM
[Ftst] current value: 0, alreadyUnlocked: false
[Ftst] unlock (set to 1) failed after 10 attempts
Error: Failed to unlock fan 1 for speed change

@agoodkind
Copy link
Author

agoodkind commented Feb 1, 2026 via email

@exelban
Copy link
Owner

exelban commented Feb 1, 2026

yes, clean install

@agoodkind
Copy link
Author

agoodkind commented Feb 1, 2026 via email

@agoodkind
Copy link
Author

agoodkind commented Feb 1, 2026 via email

Signed-off-by: Alex Goodkind <alex@goodkind.io>
- Helper: serial queue for setFanMode/setFanSpeed/resetFanControl
- smc.swift: 3s wait after Ftst=1, longer mode retry (100ms), SMC result logging
- helpers: per-fan verification with cancel-on-supersede, clearer logs
- smc.swift: neutral write logs (no 'succeeded'), FAILED on error
… (codes 4097 and 4099) during helper updates/restarts.

- Removed unnecessary debug print statement in ModeButtons for improved log clarity.
@agoodkind
Copy link
Author

agoodkind commented Feb 2, 2026

@exelban Hey I dug into this a bit more. A few things were going on:

  1. Ftst write was only retried 10 times.: The original code used the default maxAttempts: 10 for the Ftst=1 write. I’ve bumped that to 100 and added a **3s wait after Ftst=1 before trying the mode write, since thermalmonitord needs a few seconds to release control (see macos-smc-fan).

  2. Race when clicking mode + speed.: The UI was firing setFanMode and setFanSpeed back-to-back, so two SMC CLI processes were running at once and both trying to unlock. I added a serial queue in the helper so SMC operations run one after another.

  3. Diagnostics.: Added logging of the actual SMC result code (e.g. 0x82) so we can see what the firmware is returning when something fails.

If you can try again with the latest changes (serialized ops, longer Ftst retry, 3s wait before mode write), that would help. If it still fails, the new logs should show the exact SMC result code so we can narrow it down further.

Here is an example log of what my Mac does when I bump the helper (and the auto-reinstall occurs when I already had a manual speed set)

[SMCHelper] setFanMode: fan=0, mode=1 (queued)
[ModeButtons.setMode] fan 0: called with mode=forced
[ModeButtons.callback] invoked for fan 0, mode=forced
[ModeButtons.callback] skipped: mode=forced, fan.mode=forced
[SMCHelper] setFanMode: fan=1, mode=1 (queued)
[ModeButtons.setMode] fan 1: called with mode=forced
[ModeButtons.callback] invoked for fan 1, mode=forced
[ModeButtons.callback] skipped: mode=forced, fan.mode=forced
[SMCHelper] setFanMode result: [setFanMode] fan 0 -> manual
[Ftst] current value: 1, alreadyUnlocked: true
[F0Md] set fan 0 to manual mode
[fan 0] successfully set to manual mode

[SMCHelper] setFanMode result: [setFanMode] fan 1 -> manual
[Ftst] current value: 1, alreadyUnlocked: true
[F1Md] set fan 1 to manual mode
[fan 1] successfully set to manual mode

2026-02-01 16:32:15 reader.swift:90 DBG [<Net.UsageReader: 0x8b1c25800>] Successfully initialize reader
2026-02-01 16:32:15 reader.swift:90 DBG [<Net.ProcessReader: 0x8b1d745a0>] Successfully initialize reader
2026-02-01 16:32:15 reader.swift:164 DBG [<Net.ConnectivityReader: 0x8b6d12880>] Set update interval: 1 sec
2026-02-01 16:32:15 reader.swift:90 DBG [<Net.ConnectivityReader: 0x8b6d12880>] Successfully initialize reader
2026-02-01 16:32:15 reader.swift:90 DBG [<Battery.UsageReader: 0x8b69e5a00>] Successfully initialize reader
2026-02-01 16:32:15 reader.swift:90 DBG [<Battery.ProcessReader: 0x8b6efcf80>] Successfully initialize reader
2026-02-01 16:32:15 reader.swift:90 DBG [<Bluetooth.DevicesReader: 0x8b6eb2100>] Successfully initialize reader
2026-02-01 16:32:15 reader.swift:90 DBG [<Clock.ClockReader: 0x8b1455ae0>] Successfully initialize reader
2026-02-01 16:32:15 Remote.swift:120 INF [Remote] Found auth credentials for remote monitoring, starting Remote...
2026-02-01 16:32:15 reader.swift:133 DBG [<Net.UsageReader: 0x8b1c25800>] Set up update interval: 1 sec
2026-02-01 16:32:15 reader.swift:133 DBG [<Net.ConnectivityReader: 0x8b6d12880>] Set up update interval: 1 sec
2026-02-01 16:32:15 widget.swift:294 DBG [Network] widget label enabled
2026-02-01 16:32:15 widget.swift:294 DBG [Network] widget speed enabled
2026-02-01 16:32:15 reader.swift:133 DBG [<Sensors.SensorsReader: 0x8b31310a0>] Set up update interval: 1 sec
2026-02-01 16:32:15 widget.swift:294 DBG [Sensors] widget label enabled
2026-02-01 16:32:15 widget.swift:294 DBG [Sensors] widget sensors enabled
2026-02-01 16:32:15 reader.swift:133 DBG [<Disk.CapacityReader: 0x8b308d7c0>] Set up update interval: 1 sec
2026-02-01 16:32:15 reader.swift:133 DBG [<Disk.ActivityReader: 0x8b308d9a0>] Set up update interval: 1 sec
2026-02-01 16:32:15 widget.swift:294 DBG [Disk] widget label enabled
2026-02-01 16:32:15 widget.swift:294 DBG [Disk] widget speed enabled
2026-02-01 16:32:15 reader.swift:133 DBG [<RAM.UsageReader: 0x8b3a9ec00>] Set up update interval: 1 sec
2026-02-01 16:32:15 widget.swift:294 DBG [RAM] widget label enabled
2026-02-01 16:32:15 widget.swift:294 DBG [RAM] widget memory enabled
2026-02-01 16:32:15 reader.swift:133 DBG [<CPU.LoadReader: 0x8b5b59e00>] Set up update interval: 5 sec
2026-02-01 16:32:15 reader.swift:133 DBG [<CPU.AverageLoadReader: 0x8b5be4180>] Set up update interval: 15 sec
2026-02-01 16:32:15 widget.swift:294 DBG [CPU] widget label enabled
2026-02-01 16:32:15 widget.swift:294 DBG [CPU] widget line_chart enabled
new version of SMC helper is detected, going to update...
[SMCHelper] found 2 fan(s) to reset
[SMCHelper] resetting fan 0 to automatic mode
[SMCHelper] setFanMode: fan=0, mode=0 (queued)
[SMCHelper] resetting fan 1 to automatic mode
[SMCHelper] setFanMode: fan=1, mode=0 (queued)
2026-02-01 16:32:15 helpers.swift:112 DBG Application update interval is 'Silent'
2026-02-01 16:32:15 helpers.swift:153 DBG error updater.check(): last check was 3 minutes ago, stopping...
2026-02-01 16:32:15 AppDelegate.swift:84 INF Stats started in 0.0077 seconds
[SMCHelper] setFanSpeed: fan=0, speed=7826 (queued)
[SMCHelper] setFanSpeed: fan=1, speed=7826 (queued)
2026-02-01 16:32:15 Remote.swift:792 DBG [Remote MQTT] MQTT WebSocket connecting...
[Fan 0] verifying target 7826 RPM...
[Fan 1] verifying target 7826 RPM...
2026-02-01 16:32:16 Remote.swift:1119 DBG [Remote MQTT] MQTT WebSocket opened, sending CONNECT
2026-02-01 16:32:16 Remote.swift:1039 DBG [Remote MQTT] MQTT connected successfully
2026-02-01 16:32:17 Remote.swift:241 DBG [Remote] Registered device: 9F935DD8-9C66-4B91-894F-B197A7F05E70
the new version of SMC helper was successfully installed
[SMCHelper] setFanMode result: [setFanMode] fan 0 -> automatic
[F0Tg] write target=0
[fan 0] target set to 0, automatic mode requested

[SMCHelper] setFanMode result: [setFanMode] fan 1 -> automatic
[F1Tg] write target=0
[fan 1] target set to 0, automatic mode requested

Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named eu.exelban.Stats.SMC.Helper" UserInfo={NSDebugDescription=connection to service named eu.exelban.Stats.SMC.Helper}
Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named eu.exelban.Stats.SMC.Helper" UserInfo={NSDebugDescription=connection to service named eu.exelban.Stats.SMC.Helper}
[changeHelperState] fan 0: state=true, customMode=Optional(Sensors.FanMode.forced), customSpeed=Optional(7826)
[changeHelperState] fan 0: restoring mode=forced, speed=7826
[SMCHelper] setFanMode: fan=0, mode=1 (queued)
[ModeButtons.setMode] fan 0: called with mode=forced
[ModeButtons.callback] invoked for fan 0, mode=forced
[ModeButtons.callback] skipped: mode=forced, fan.mode=forced
Error Domain=NSCocoaErrorDomain Code=4099 "The connection to service named eu.exelban.Stats.SMC.Helper was invalidated: Failed to check-in, peer may have been unloaded: mach_error=10000003." UserInfo={NSDebugDescription=The connection to service named eu.exelban.Stats.SMC.Helper was invalidated: Failed to check-in, peer may have been unloaded: mach_error=10000003.}
[changeHelperState] fan 1: state=true, customMode=Optional(Sensors.FanMode.forced), customSpeed=Optional(7826)
[changeHelperState] fan 1: restoring mode=forced, speed=7826
[SMCHelper] setFanMode: fan=1, mode=1 (queued)
[ModeButtons.setMode] fan 1: called with mode=forced
[ModeButtons.callback] invoked for fan 1, mode=forced
[ModeButtons.callback] skipped: mode=forced, fan.mode=forced
Error Domain=NSCocoaErrorDomain Code=4099 "The connection to service named eu.exelban.Stats.SMC.Helper was invalidated: Failed to check-in, peer may have been unloaded: mach_error=10000003." UserInfo={NSDebugDescription=The connection to service named eu.exelban.Stats.SMC.Helper was invalidated: Failed to check-in, peer may have been unloaded: mach_error=10000003.}
[SMCHelper] setFanSpeed: fan=0, speed=7826 (queued)
[SMCHelper] setFanSpeed: fan=1, speed=7826 (queued)
[Fan 0] cancelled previous verification
[Fan 1] cancelled previous verification
[Fan 0] verifying target 7826 RPM...
[Fan 1] verifying target 7826 RPM...
[SMCHelper] setFanSpeed result: [setFanSpeed] fan 0 -> 7826 RPM
[F0Tg] SMC write target=7826

[SMCHelper] setFanSpeed result: [setFanSpeed] fan 1 -> 7826 RPM
[F1Tg] SMC write target=7826

[Fan 1] verification superseded by newer request
[Fan 0] verification superseded by newer request
[Fan 0] verified: 7553 RPM (target: 7826) after 12.3s
[Fan 1] verified: 7520 RPM (target: 7826) after 12.3s
[syncFanMode] fan 0: received notification from fan -1, fansSyncState=true
[syncFanMode] fan 0: guard failed: mode=nil
[syncFanMode] fan 1: received notification from fan -1, fansSyncState=true
[syncFanMode] fan 1: guard failed: mode=nil
[syncFanMode] fan 0: received notification from fan -1, fansSyncState=true
[syncFanMode] fan 0: guard failed: mode=nil
[syncFanMode] fan 1: received notification from fan -1, fansSyncState=true
[syncFanMode] fan 1: guard failed: mode=nil
[syncFanMode] fan 0: received notification from fan -1, fansSyncState=true
[syncFanMode] fan 0: guard failed: mode=nil
[syncFanMode] fan 1: received notification from fan -1, fansSyncState=true
[syncFanMode] fan 1: guard failed: mode=nil
[SMCHelper] setFanSpeed: fan=1, speed=3694 (queued)
[Fan 1] cancelled previous verification
[SMCHelper] setFanSpeed result: [setFanSpeed] fan 1 -> 3694 RPM
[F1Tg] SMC write target=3694

[SMCHelper] setFanSpeed: fan=0, speed=3694 (queued)
[Fan 0] cancelled previous verification
[SMCHelper] setFanSpeed result: [setFanSpeed] fan 0 -> 3694 RPM
[F0Tg] SMC write target=3694

[Fan 1] verifying target 3694 RPM...
[Fan 0] verifying target 3694 RPM...
[Fan 0] verified: 4006 RPM (target: 3694) after 2.0s
[Fan 1] verified: 3730 RPM (target: 3694) after 4.1s
[syncFanMode] fan 0: received notification from fan -1, fansSyncState=true
[syncFanMode] fan 0: guard failed: mode=nil
[syncFanMode] fan 1: received notification from fan -1, fansSyncState=true
[syncFanMode] fan 1: guard failed: mode=nil
[SMCHelper] setFanSpeed: fan=0, speed=2317 (queued)
[SMCHelper] setFanSpeed: fan=1, speed=2317 (queued)
[Fan 0] cancelled previous verification
[Fan 1] cancelled previous verification
[SMCHelper] setFanSpeed result: [setFanSpeed] fan 1 -> 2317 RPM
[F1Tg] SMC write target=2317

[SMCHelper] setFanSpeed result: [setFanSpeed] fan 0 -> 2317 RPM
[F0Tg] SMC write target=2317

[Fan 0] verifying target 2317 RPM...
[Fan 1] verifying target 2317 RPM...
[Fan 0] verified: 2331 RPM (target: 2317) after 2.1s
[Fan 1] verified: 2334 RPM (target: 2317) after 2.1s

@agoodkind
Copy link
Author

@exelban I managed to acquire an M1 MacBook pro and figured out the issue, been swamped with work but I'll put it up when I have a free moment this week

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.

Fan control doesn't work on Apple Silicon (M1/M2/M3/M4)

2 participants