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 { public func isActive() -> Bool {
return self.connection != nil return self.connection != nil
} }

View File

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

View File

@@ -71,8 +71,24 @@ internal class Popup: PopupWrapper {
selected: self.fanValueState.rawValue 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) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
@@ -455,7 +471,7 @@ internal class ChartSensorView: NSStackView {
internal class FanView: NSStackView { internal class FanView: NSStackView {
public var sizeCallback: (() -> Void) public var sizeCallback: (() -> Void)
private var fan: Fan internal var fan: Fan
private var ready: Bool = false private var ready: Bool = false
private var helperView: NSView? = nil private var helperView: NSView? = nil
@@ -638,7 +654,7 @@ internal class FanView: NSStackView {
height: view.frame.height - 8 height: view.frame.height - 8
), mode: self.fan.mode) ), mode: self.fan.mode)
buttons.callback = { [weak self] (mode: FanMode) in 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.mode = mode
self?.fan.customMode = mode self?.fan.customMode = mode
SMCHelper.shared.setFanMode(fan.id, mode: mode.rawValue) SMCHelper.shared.setFanMode(fan.id, mode: mode.rawValue)
@@ -1072,6 +1088,7 @@ private class ModeButtons: NSStackView {
self.callback(.automatic) self.callback(.automatic)
NotificationCenter.default.post(name: .syncFansControl, object: nil, userInfo: ["mode": "automatic"]) NotificationCenter.default.post(name: .syncFansControl, object: nil, userInfo: ["mode": "automatic"])
NotificationCenter.default.post(name: .checkFanModes, object: nil)
} }
@objc private func manualMode(_ sender: NSButton) { @objc private func manualMode(_ sender: NSButton) {

View File

@@ -352,6 +352,14 @@ extension SensorsReader {
} }
private func getFanMode(_ id: Int) -> FanMode { 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) let fansMode: Int = Int(SMC.shared.getValue("FS! ") ?? 0)
var mode: FanMode = .automatic var mode: FanMode = .automatic
@@ -366,6 +374,7 @@ extension SensorsReader {
} }
return mode return mode
#endif
} }
} }

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ enum CMDType: String {
case set case set
case fan case fan
case fans case fans
case reset
case help case help
case unknown case unknown
@@ -25,6 +26,7 @@ enum CMDType: String {
case "set": self = .set case "set": self = .set
case "fan": self = .fan case "fan": self = .fan
case "fans": self = .fans case "fans": self = .fans
case "reset": self = .reset
case "help": self = .help case "help": self = .help
default: self = .unknown default: self = .unknown
} }
@@ -136,6 +138,16 @@ func main() {
print() 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: case .help, .unknown:
print("SMC tool\n") print("SMC tool\n")
print("Usage:") print("Usage:")
@@ -145,6 +157,7 @@ func main() {
print(" set set value to a key") print(" set set value to a key")
print(" fan set fan speed") print(" fan set fan speed")
print(" fans list of fans") print(" fans list of fans")
print(" reset reset Ftst (Apple Silicon only)")
print(" help help menu\n") print(" help help menu\n")
print("Available Flags:") print("Available Flags:")
print(" -t list temperature sensors") print(" -t list temperature sensors")

View File

@@ -46,6 +46,11 @@ internal enum SMCKeys: UInt8 {
public enum FanMode: Int, Codable { public enum FanMode: Int, Codable {
case automatic = 0 case automatic = 0
case forced = 1 case forced = 1
case auto3 = 3
public var isAutomatic: Bool {
self == .automatic || self == .auto3
}
} }
internal struct SMCKeyData_t { internal struct SMCKeyData_t {
@@ -352,6 +357,43 @@ public class SMC {
// MARK: - fans // MARK: - fans
public func setFanMode(_ id: Int, mode: FanMode) { 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 { if self.getValue("F\(id)Md") != nil {
var result: kern_return_t = 0 var result: kern_return_t = 0
var value = SMCVal_t("F\(id)Md") 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")) print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
return return
} }
#endif
} }
public func setFanSpeed(_ id: Int, speed: Int) { 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 { #if arch(arm64)
print("new fan speed (\(speed)) is more than maximum speed (\(maxSpeed))") 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 return
} }
if modeVal.bytes[0] != 1 {
if !unlockFanControl(fanId: id) { return }
}
#endif
var result: kern_return_t = 0 var result: kern_return_t = 0
var value = SMCVal_t("F\(id)Tg") var value = SMCVal_t("F\(id)Tg")
@@ -454,11 +507,17 @@ public class SMC {
value.bytes[3] = UInt8(0) value.bytes[3] = UInt8(0)
} }
#if arch(arm64)
if !writeWithRetry(value) {
return
}
#else
result = write(value) result = write(value)
if result != kIOReturnSuccess { if result != kIOReturnSuccess {
print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
return return
} }
#endif
} }
public func resetFans() { 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 // MARK: - internal functions
private func read(_ value: UnsafeMutablePointer<SMCVal_t>) -> kern_return_t { private func read(_ value: UnsafeMutablePointer<SMCVal_t>) -> kern_return_t {
@@ -520,6 +674,12 @@ public class SMC {
return result 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 return kIOReturnSuccess
} }