diff --git a/Kit/helpers.swift b/Kit/helpers.swift index b4d5f592..ca147654 100644 --- a/Kit/helpers.swift +++ b/Kit/helpers.swift @@ -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 } diff --git a/Kit/types.swift b/Kit/types.swift index 1c25c979..bc4e3d45 100644 --- a/Kit/types.swift +++ b/Kit/types.swift @@ -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") diff --git a/Modules/Sensors/popup.swift b/Modules/Sensors/popup.swift index 862e9c0b..dace08b6 100644 --- a/Modules/Sensors/popup.swift +++ b/Modules/Sensors/popup.swift @@ -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) { diff --git a/Modules/Sensors/readers.swift b/Modules/Sensors/readers.swift index 9615edd3..2495a8a2 100644 --- a/Modules/Sensors/readers.swift +++ b/Modules/Sensors/readers.swift @@ -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 } } diff --git a/SMC/Helper/Info.plist b/SMC/Helper/Info.plist index e64e4b39..a24a1561 100644 --- a/SMC/Helper/Info.plist +++ b/SMC/Helper/Info.plist @@ -7,9 +7,9 @@ CFBundleName eu.exelban.Stats.SMC.Helper CFBundleShortVersionString - 1.0.1 + 1.1.0 CFBundleVersion - 2 + 3 CFBundleInfoDictionaryVersion 6.0 SMAuthorizedClients diff --git a/SMC/Helper/main.swift b/SMC/Helper/main.swift index d94fdef2..fc744e30 100644 --- a/SMC/Helper/main.swift +++ b/SMC/Helper/main.swift @@ -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) { diff --git a/SMC/Helper/protocol.swift b/SMC/Helper/protocol.swift index e22e52d2..5cb3a115 100644 --- a/SMC/Helper/protocol.swift +++ b/SMC/Helper/protocol.swift @@ -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() diff --git a/SMC/main.swift b/SMC/main.swift index 6f53d711..f61932c5 100644 --- a/SMC/main.swift +++ b/SMC/main.swift @@ -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") diff --git a/SMC/smc.swift b/SMC/smc.swift index 53fca68d..179fda50 100644 --- a/SMC/smc.swift +++ b/SMC/smc.swift @@ -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.. 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) -> 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 }