feat: added better fan control for M3/M4 Apple Silicon (#2924)

* Fix Ftst key handling for Apple Silicon fan control

* Update CFBundleVersion to 747 in Info.plist

Signed-off-by: Alex Goodkind <alex@goodkind.io>

* Update TeamId and SMC.Helper certificate identifier in Info.plist

Signed-off-by: Alex Goodkind <alex@goodkind.io>

* Add debug logging to SMC fan control functions

* Use writeWithRetry for Apple Silicon fan control writes and bump helper version to 1.0.3

* SMC fan control: serialize ops, Ftst timing, verification, logging

- 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

* - Updated error handling in SMCHelper to suppress expected XPC errors (codes 4097 and 4099) during helper updates/restarts.
- Removed unnecessary debug print statement in ModeButtons for improved log clarity.

* Update version numbers in Info.plist files to 752 and change TeamId for SMC.Helper

* Add FanMode.auto3 and isAutomatic, re-add F%dMd write in automatic path, use isAutomatic in countManualFans; bump SMC Helper to 1.0.24

* Apple Silicon fan control: direct-first writes, strip diagnostic bloat

Try direct F%dMd=1 write before Ftst unlock (instant on M1, fallback
on M3/M4). Remove verification system, diagnostic prints, dead code.

* Apple Silicon fan control: direct-first writes

Try direct F%dMd=1 write before Ftst unlock (instant on M1,
fallback on M3/M4).

Bump helper to 1.0.2/3, Stats/Widgets to 751.

---------

Signed-off-by: Alex Goodkind <alex@goodkind.io>
This commit is contained in:
Alex Goodkind
2026-02-22 06:17:23 -08:00
committed by GitHub
parent 28395c6dfd
commit 20030a2a1c
9 changed files with 258 additions and 31 deletions

View File

@@ -894,6 +894,11 @@ public class SMCHelper {
}
}
public func resetFanControl() {
guard let helper = self.helper(nil) else { return }
helper.resetFanControl { _ in }
}
public func isActive() -> Bool {
return self.connection != nil
}

View File

@@ -271,6 +271,7 @@ public extension Notification.Name {
static let refreshPublicIP = Notification.Name("refreshPublicIP")
static let resetTotalNetworkUsage = Notification.Name("resetTotalNetworkUsage")
static let syncFansControl = Notification.Name("syncFansControl")
static let checkFanModes = Notification.Name("checkFanModes")
static let fanHelperState = Notification.Name("fanHelperState")
static let toggleOneView = Notification.Name("toggleOneView")
static let widgetRearrange = Notification.Name("widgetRearrange")

View File

@@ -71,8 +71,24 @@ internal class Popup: PopupWrapper {
selected: self.fanValueState.rawValue
))
]))
#if arch(arm64)
NotificationCenter.default.addObserver(self, selector: #selector(self.checkFanModesAndResetFtst), name: .checkFanModes, object: nil)
#endif
}
deinit {
NotificationCenter.default.removeObserver(self)
}
#if arch(arm64)
@objc private func checkFanModesAndResetFtst() {
let fanViews = self.list.values.compactMap { $0 as? FanView }
guard !fanViews.isEmpty else { return }
guard fanViews.allSatisfy({ $0.fan.mode == .automatic }) else { return }
SMCHelper.shared.resetFanControl()
}
#endif
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@@ -455,7 +471,7 @@ internal class ChartSensorView: NSStackView {
internal class FanView: NSStackView {
public var sizeCallback: (() -> Void)
private var fan: Fan
internal var fan: Fan
private var ready: Bool = false
private var helperView: NSView? = nil
@@ -638,7 +654,7 @@ internal class FanView: NSStackView {
height: view.frame.height - 8
), mode: self.fan.mode)
buttons.callback = { [weak self] (mode: FanMode) in
if let fan = self?.fan, fan.mode != mode {
if let fan = self?.fan, mode == .automatic || fan.mode != mode {
self?.fan.mode = mode
self?.fan.customMode = mode
SMCHelper.shared.setFanMode(fan.id, mode: mode.rawValue)
@@ -1072,6 +1088,7 @@ private class ModeButtons: NSStackView {
self.callback(.automatic)
NotificationCenter.default.post(name: .syncFansControl, object: nil, userInfo: ["mode": "automatic"])
NotificationCenter.default.post(name: .checkFanModes, object: nil)
}
@objc private func manualMode(_ sender: NSButton) {

View File

@@ -352,6 +352,14 @@ extension SensorsReader {
}
private func getFanMode(_ id: Int) -> FanMode {
#if arch(arm64)
// Apple Silicon: Read F%dMd directly
// Mode values: 0 = auto, 1 = manual, 3 = system (treated as auto for UI)
let modeValue = Int(SMC.shared.getValue("F\(id)Md") ?? 0)
return modeValue == 1 ? .forced : .automatic
#else
// Legacy Intel: Use FS! bitmask
// Bitmask: 0 = all auto, 1 = fan 0 forced, 2 = fan 1 forced, 3 = both forced
let fansMode: Int = Int(SMC.shared.getValue("FS! ") ?? 0)
var mode: FanMode = .automatic
@@ -366,6 +374,7 @@ extension SensorsReader {
}
return mode
#endif
}
}

View File

@@ -7,9 +7,9 @@
<key>CFBundleName</key>
<string>eu.exelban.Stats.SMC.Helper</string>
<key>CFBundleShortVersionString</key>
<string>1.0.1</string>
<string>1.1.0</string>
<key>CFBundleVersion</key>
<string>2</string>
<string>3</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>SMAuthorizedClients</key>

View File

@@ -16,6 +16,7 @@ helper.run()
class Helper: NSObject, NSXPCListenerDelegate, HelperProtocol {
private let listener: NSXPCListener
private let smcQueue = DispatchQueue(label: "eu.exelban.Stats.SMC.Helper.smcQueue")
private var connections = [NSXPCConnection]()
private var shouldQuit = false
@@ -115,36 +116,56 @@ extension Helper {
}
func setFanMode(id: Int, mode: Int, completion: (String?) -> Void) {
guard let smc = self.smc else {
completion("missing smc tool")
return
smcQueue.sync {
guard let smc = self.smc else {
completion("missing smc tool")
return
}
let result = syncShell("\(smc) fan \(id) -m \(mode)")
if let error = result.error, !error.isEmpty {
NSLog("error set fan mode: \(error)")
completion(nil)
return
}
completion(result.output)
}
let result = syncShell("\(smc) fan \(id) -m \(mode)")
if let error = result.error, !error.isEmpty {
NSLog("error set fan mode: \(error)")
completion(nil)
return
}
completion(result.output)
}
func setFanSpeed(id: Int, value: Int, completion: (String?) -> Void) {
guard let smc = self.smc else {
completion("missing smc tool")
return
smcQueue.sync {
guard let smc = self.smc else {
completion("missing smc tool")
return
}
let result = syncShell("\(smc) fan \(id) -v \(value)")
if let error = result.error, !error.isEmpty {
NSLog("error set fan speed: \(error)")
completion(nil)
return
}
completion(result.output)
}
let result = syncShell("\(smc) fan \(id) -v \(value)")
if let error = result.error, !error.isEmpty {
NSLog("error set fan speed: \(error)")
completion(nil)
return
}
func resetFanControl(completion: (String?) -> Void) {
smcQueue.sync {
guard let smc = self.smc else {
completion("missing smc tool")
return
}
let result = syncShell("\(smc) reset")
if let error = result.error, !error.isEmpty {
NSLog("error reset fan control: \(error)")
completion(nil)
return
}
completion(result.output)
}
completion(result.output)
}
func powermetrics(_ samplers: [String], completion: @escaping (String?) -> Void) {

View File

@@ -17,6 +17,7 @@ import Foundation
func setFanMode(id: Int, mode: Int, completion: @escaping (String?) -> Void)
func setFanSpeed(id: Int, value: Int, completion: @escaping (String?) -> Void)
func resetFanControl(completion: @escaping (String?) -> Void)
func powermetrics(_ samplers: [String], completion: @escaping (String?) -> Void)
func uninstall()

View File

@@ -16,6 +16,7 @@ enum CMDType: String {
case set
case fan
case fans
case reset
case help
case unknown
@@ -25,6 +26,7 @@ enum CMDType: String {
case "set": self = .set
case "fan": self = .fan
case "fans": self = .fans
case "reset": self = .reset
case "help": self = .help
default: self = .unknown
}
@@ -136,6 +138,16 @@ func main() {
print()
}
case .reset:
#if arch(arm64)
if SMC.shared.resetFanControl() {
print("[reset] Ftst reset to 0, thermalmonitord has control")
} else {
print("[reset] Ftst reset FAILED")
}
#else
print("[reset] not needed on Intel Macs")
#endif
case .help, .unknown:
print("SMC tool\n")
print("Usage:")
@@ -145,6 +157,7 @@ func main() {
print(" set set value to a key")
print(" fan set fan speed")
print(" fans list of fans")
print(" reset reset Ftst (Apple Silicon only)")
print(" help help menu\n")
print("Available Flags:")
print(" -t list temperature sensors")

View File

@@ -46,6 +46,11 @@ internal enum SMCKeys: UInt8 {
public enum FanMode: Int, Codable {
case automatic = 0
case forced = 1
case auto3 = 3
public var isAutomatic: Bool {
self == .automatic || self == .auto3
}
}
internal struct SMCKeyData_t {
@@ -352,6 +357,43 @@ public class SMC {
// MARK: - fans
public func setFanMode(_ id: Int, mode: FanMode) {
#if arch(arm64)
if mode == .forced {
if !unlockFanControl(fanId: id) { return }
} else {
let modeKey = "F\(id)Md"
let targetKey = "F\(id)Tg"
if self.getValue(modeKey) != nil {
var modeVal = SMCVal_t(modeKey)
let readResult = read(&modeVal)
guard readResult == kIOReturnSuccess else {
print(smcError("read", key: modeKey, result: readResult))
return
}
if modeVal.bytes[0] != 0 {
modeVal.bytes[0] = 0
if !writeWithRetry(modeVal) { return }
}
}
var targetValue = SMCVal_t(targetKey)
let result = read(&targetValue)
guard result == kIOReturnSuccess else {
print(smcError("read", key: targetKey, result: result))
return
}
let bytes = Float(0).bytes
targetValue.bytes[0] = bytes[0]
targetValue.bytes[1] = bytes[1]
targetValue.bytes[2] = bytes[2]
targetValue.bytes[3] = bytes[3]
if !writeWithRetry(targetValue) { return }
}
#else
// Intel
if self.getValue("F\(id)Md") != nil {
var result: kern_return_t = 0
var value = SMCVal_t("F\(id)Md")
@@ -422,15 +464,26 @@ public class SMC {
print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
return
}
#endif
}
public func setFanSpeed(_ id: Int, speed: Int) {
let maxSpeed = Int(self.getValue("F\(id)Mx") ?? 4000)
if let maxSpeed = self.getValue("F\(id)Mx"),
speed > Int(maxSpeed) {
return setFanSpeed(id, speed: Int(maxSpeed))
}
if speed > maxSpeed {
print("new fan speed (\(speed)) is more than maximum speed (\(maxSpeed))")
#if arch(arm64)
var modeVal = SMCVal_t("F\(id)Md")
let modeResult = read(&modeVal)
guard modeResult == kIOReturnSuccess else {
print("Error read fan mode: " + (String(cString: mach_error_string(modeResult), encoding: String.Encoding.ascii) ?? "unknown error"))
return
}
if modeVal.bytes[0] != 1 {
if !unlockFanControl(fanId: id) { return }
}
#endif
var result: kern_return_t = 0
var value = SMCVal_t("F\(id)Tg")
@@ -454,11 +507,17 @@ public class SMC {
value.bytes[3] = UInt8(0)
}
#if arch(arm64)
if !writeWithRetry(value) {
return
}
#else
result = write(value)
if result != kIOReturnSuccess {
print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
return
}
#endif
}
public func resetFans() {
@@ -471,6 +530,101 @@ public class SMC {
}
}
// MARK: - Apple Silicon Fan Control
#if arch(arm64)
/// Format SMC error for logging with context
private func smcError(_ operation: String, key: String, result: kern_return_t) -> String {
let errorDesc = String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"
return "[\(key)] \(operation) failed: \(errorDesc) (0x\(String(result, radix: 16)))"
}
private func writeWithRetry(_ value: SMCVal_t, maxAttempts: Int = 10, delayMicros: UInt32 = 50_000) -> Bool {
let mutableValue = value
var lastResult: kern_return_t = kIOReturnSuccess
for attempt in 0..<maxAttempts {
lastResult = write(mutableValue)
if lastResult == kIOReturnSuccess {
return true
}
if attempt < maxAttempts - 1 {
usleep(delayMicros)
}
}
print(smcError("write", key: value.key, result: lastResult))
return false
}
private func unlockFanControl(fanId: Int) -> Bool {
var ftstCheck = SMCVal_t("Ftst")
let ftstResult = read(&ftstCheck)
guard ftstResult == kIOReturnSuccess else {
print(smcError("read", key: "Ftst", result: ftstResult))
return false
}
let ftstActive = ftstCheck.bytes[0] == 1
if ftstActive {
return retryModeWrite(fanId: fanId, maxAttempts: 20)
}
// Try direct write first (works on M1 without Ftst)
let modeKey = "F\(fanId)Md"
var modeVal = SMCVal_t(modeKey)
let modeRead = read(&modeVal)
guard modeRead == kIOReturnSuccess else {
print(smcError("read", key: modeKey, result: modeRead))
return false
}
modeVal.bytes[0] = 1
if write(modeVal) == kIOReturnSuccess {
return true
}
// Direct failed; fall back to Ftst unlock
var ftstVal = SMCVal_t("Ftst")
let ftstRead = read(&ftstVal)
guard ftstRead == kIOReturnSuccess else {
print(smcError("read", key: "Ftst", result: ftstRead))
return false
}
ftstVal.bytes[0] = 1
if !writeWithRetry(ftstVal, maxAttempts: 100) {
return false
}
// Wait for thermalmonitord to yield control
usleep(3_000_000)
return retryModeWrite(fanId: fanId, maxAttempts: 300)
}
private func retryModeWrite(fanId: Int, maxAttempts: Int) -> Bool {
let modeKey = "F\(fanId)Md"
var modeVal = SMCVal_t(modeKey)
let result = read(&modeVal)
guard result == kIOReturnSuccess else {
print(smcError("read", key: modeKey, result: result))
return false
}
modeVal.bytes[0] = 1
return writeWithRetry(modeVal, maxAttempts: maxAttempts, delayMicros: 100_000)
}
public func resetFanControl() -> Bool {
var value = SMCVal_t("Ftst")
let result = read(&value)
guard result == kIOReturnSuccess else {
print(smcError("read", key: "Ftst", result: result))
return false
}
if value.bytes[0] == 0 { return true }
value.bytes[0] = 0
return writeWithRetry(value)
}
#endif
// MARK: - internal functions
private func read(_ value: UnsafeMutablePointer<SMCVal_t>) -> kern_return_t {
@@ -520,6 +674,12 @@ public class SMC {
return result
}
// IOKit can return kIOReturnSuccess but SMC firmware may still reject the write.
// Check SMC-level result code (0x00 = success, non-zero = error)
if output.result != 0x00 {
return kIOReturnError
}
return kIOReturnSuccess
}