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
}