mirror of
https://github.com/morgan9e/macos-stats
synced 2026-04-13 15:54:10 +09:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,6 +116,7 @@ extension Helper {
|
||||
}
|
||||
|
||||
func setFanMode(id: Int, mode: Int, completion: (String?) -> Void) {
|
||||
smcQueue.sync {
|
||||
guard let smc = self.smc else {
|
||||
completion("missing smc tool")
|
||||
return
|
||||
@@ -129,8 +131,10 @@ extension Helper {
|
||||
|
||||
completion(result.output)
|
||||
}
|
||||
}
|
||||
|
||||
func setFanSpeed(id: Int, value: Int, completion: (String?) -> Void) {
|
||||
smcQueue.sync {
|
||||
guard let smc = self.smc else {
|
||||
completion("missing smc tool")
|
||||
return
|
||||
@@ -146,6 +150,23 @@ extension Helper {
|
||||
|
||||
completion(result.output)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func powermetrics(_ samplers: [String], completion: @escaping (String?) -> Void) {
|
||||
let result = syncShell("powermetrics -n 1 -s \(samplers.joined(separator: ",")) --sample-rate 1000")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
166
SMC/smc.swift
166
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..<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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user