Skip to content

Commit 1452aab

Browse files
committed
Fix Ftst key handling for Apple Silicon fan control
1 parent 9671269 commit 1452aab

File tree

6 files changed

+225
-13
lines changed

6 files changed

+225
-13
lines changed

Modules/Sensors/popup.swift

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -638,10 +638,14 @@ internal class FanView: NSStackView {
638638
height: view.frame.height - 8
639639
), mode: self.fan.mode)
640640
buttons.callback = { [weak self] (mode: FanMode) in
641-
if let fan = self?.fan, fan.mode != mode {
642-
self?.fan.mode = mode
643-
self?.fan.customMode = mode
644-
SMCHelper.shared.setFanMode(fan.id, mode: mode.rawValue)
641+
if let fan = self?.fan {
642+
// Always call setFanMode for automatic to ensure Ftst is reset
643+
// For manual, only call if mode changed to avoid redundant unlock
644+
if mode == .automatic || fan.mode != mode {
645+
self?.fan.mode = mode
646+
self?.fan.customMode = mode
647+
SMCHelper.shared.setFanMode(fan.id, mode: mode.rawValue)
648+
}
645649
}
646650
self?.toggleControlView(mode == .forced)
647651
}

Modules/Sensors/readers.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,14 @@ extension SensorsReader {
352352
}
353353

354354
private func getFanMode(_ id: Int) -> FanMode {
355+
#if arch(arm64)
356+
// Apple Silicon: Read F%dMd directly
357+
// Mode values: 0 = auto, 1 = manual, 3 = system (treated as auto for UI)
358+
let modeValue = Int(SMC.shared.getValue("F\(id)Md") ?? 0)
359+
return modeValue == 1 ? .forced : .automatic
360+
#else
361+
// Legacy Intel: Use FS! bitmask
362+
// Bitmask: 0 = all auto, 1 = fan 0 forced, 2 = fan 1 forced, 3 = both forced
355363
let fansMode: Int = Int(SMC.shared.getValue("FS! ") ?? 0)
356364
var mode: FanMode = .automatic
357365

@@ -366,6 +374,7 @@ extension SensorsReader {
366374
}
367375

368376
return mode
377+
#endif
369378
}
370379
}
371380

Modules/Sensors/values.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -255,10 +255,15 @@ public struct Fan: Sensor_p, Codable {
255255
public var mode: FanMode
256256

257257
public var percentage: Int {
258-
if self.value != 0 && self.maxSpeed != 0 && self.value != 1 && self.maxSpeed != 1 {
259-
return (100*Int(self.value)) / Int(self.maxSpeed)
260-
}
261-
return 0
258+
let range = self.maxSpeed - self.minSpeed
259+
// Avoid division by zero and handle edge cases
260+
guard range > 0 && self.maxSpeed > 1 else { return 0 }
261+
262+
// Calculate percentage based on min/max range
263+
// value at minSpeed = 0%, value at maxSpeed = 100%
264+
let normalized = self.value - self.minSpeed
265+
if normalized <= 0 { return 0 }
266+
return min(100, Int((100 * normalized) / range))
262267
}
263268

264269
public var group: SensorGroup = .sensor

SMC/Helper/Info.plist

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
<key>CFBundleName</key>
88
<string>eu.exelban.Stats.SMC.Helper</string>
99
<key>CFBundleShortVersionString</key>
10-
<string>1.0.1</string>
10+
<string>1.0.2</string>
1111
<key>CFBundleVersion</key>
12-
<string>2</string>
12+
<string>3</string>
1313
<key>CFBundleInfoDictionaryVersion</key>
1414
<string>6.0</string>
1515
<key>SMAuthorizedClients</key>

SMC/smc.swift

Lines changed: 171 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ internal enum SMCKeys: UInt8 {
4343
case readVers = 12
4444
}
4545

46+
4647
public enum FanMode: Int, Codable {
4748
case automatic = 0
4849
case forced = 1
@@ -352,6 +353,65 @@ public class SMC {
352353
// MARK: - fans
353354

354355
public func setFanMode(_ id: Int, mode: FanMode) {
356+
#if arch(arm64)
357+
// Apple Silicon: unlock before setting manual mode
358+
if mode == .forced {
359+
if !unlockFanControl(fanId: id) {
360+
print("Error: failed to unlock fan control for fan \(id)")
361+
return
362+
}
363+
// unlockFanControl already set the mode to 1, so we're done with F%dMd
364+
} else {
365+
// Setting to automatic - check if OTHER fans are still in manual mode
366+
let otherFansManual = countManualFans(excluding: id)
367+
368+
var result: kern_return_t = 0
369+
// Set mode and target to 0
370+
if self.getValue("F\(id)Md") != nil {
371+
var value = SMCVal_t("F\(id)Md")
372+
373+
result = read(&value)
374+
guard result == kIOReturnSuccess else {
375+
print("Error read fan mode: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
376+
return
377+
}
378+
379+
value.bytes[0] = 0
380+
result = write(value)
381+
guard result == kIOReturnSuccess else {
382+
print("Error write fan mode: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
383+
return
384+
}
385+
}
386+
387+
// Apple Silicon uses flt (IEEE 754 float) format
388+
var targetValue = SMCVal_t("F\(id)Tg")
389+
390+
result = read(&targetValue)
391+
guard result == kIOReturnSuccess else {
392+
print("Error read fan target: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
393+
return
394+
}
395+
396+
let bytes = Float(0).bytes
397+
targetValue.bytes[0] = bytes[0]
398+
targetValue.bytes[1] = bytes[1]
399+
targetValue.bytes[2] = bytes[2]
400+
targetValue.bytes[3] = bytes[3]
401+
402+
result = write(targetValue)
403+
guard result == kIOReturnSuccess else {
404+
print("Error write fan target: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
405+
return
406+
}
407+
408+
if otherFansManual == 0 {
409+
resetFanControl()
410+
print("Last manual fan - Ftst reset, thermalmonitord has control")
411+
}
412+
}
413+
#else
414+
// Intel
355415
if self.getValue("F\(id)Md") != nil {
356416
var result: kern_return_t = 0
357417
var value = SMCVal_t("F\(id)Md")
@@ -422,15 +482,31 @@ public class SMC {
422482
print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
423483
return
424484
}
485+
#endif
425486
}
426487

427488
public func setFanSpeed(_ id: Int, speed: Int) {
428-
let maxSpeed = Int(self.getValue("F\(id)Mx") ?? 4000)
489+
// Enforce recommended max as safety limit
490+
if let maxSpeed = self.getValue("F\(id)Mx"), speed > Int(maxSpeed) {
491+
print("Fan speed (\(speed)) exceeds maximum (\(Int(maxSpeed))), clamping")
492+
return setFanSpeed(id, speed: Int(maxSpeed))
493+
}
429494

430-
if speed > maxSpeed {
431-
print("new fan speed (\(speed)) is more than maximum speed (\(maxSpeed))")
495+
#if arch(arm64)
496+
// Apple Silicon: ensure fan is in manual mode before setting speed
497+
var modeVal = SMCVal_t("F\(id)Md")
498+
let modeResult = read(&modeVal)
499+
guard modeResult == kIOReturnSuccess else {
500+
print("Error read fan mode: " + (String(cString: mach_error_string(modeResult), encoding: String.Encoding.ascii) ?? "unknown error"))
432501
return
433502
}
503+
if modeVal.bytes[0] != 1 {
504+
if !unlockFanControl(fanId: id) {
505+
print("Error: Failed to unlock fan \(id) for speed change")
506+
return
507+
}
508+
}
509+
#endif
434510

435511
var result: kern_return_t = 0
436512
var value = SMCVal_t("F\(id)Tg")
@@ -471,6 +547,92 @@ public class SMC {
471547
}
472548
}
473549

550+
// MARK: - Apple Silicon Fan Control
551+
552+
#if arch(arm64)
553+
/// Unlock fan control for Apple Silicon by writing Ftst=1
554+
/// This prevents thermalmonitord from overriding manual fan settings
555+
private func unlockFanControl(fanId: Int) -> Bool {
556+
// Check if already unlocked (Ftst=1 from another fan)
557+
var ftstCheck = SMCVal_t("Ftst")
558+
var result = read(&ftstCheck)
559+
guard result == kIOReturnSuccess else {
560+
print("Error read Ftst: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
561+
return false
562+
}
563+
let alreadyUnlocked = ftstCheck.bytes[0] == 1
564+
565+
if !alreadyUnlocked {
566+
var ftstVal = SMCVal_t("Ftst")
567+
result = read(&ftstVal)
568+
guard result == kIOReturnSuccess else {
569+
print("Error read Ftst: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
570+
return false
571+
}
572+
ftstVal.bytes[0] = 1
573+
let ftstResult = write(ftstVal)
574+
guard ftstResult == kIOReturnSuccess else {
575+
print("Error unlocking fan control: " + (String(cString: mach_error_string(ftstResult), encoding: String.Encoding.ascii) ?? "unknown error"))
576+
return false
577+
}
578+
}
579+
580+
// Retry writing mode=1 until thermalmonitord releases control
581+
let modeKey = "F\(fanId)Md"
582+
let maxAttempts = alreadyUnlocked ? 10 : 100
583+
for attempt in 0..<maxAttempts {
584+
var modeVal = SMCVal_t(modeKey)
585+
guard read(&modeVal) == kIOReturnSuccess else {
586+
usleep(alreadyUnlocked ? 10_000 : 50_000)
587+
continue
588+
}
589+
modeVal.bytes[0] = 1
590+
let writeResult = write(modeVal)
591+
if writeResult == kIOReturnSuccess {
592+
print("Fan \(fanId) set to manual mode after \(attempt + 1) attempts")
593+
return true
594+
}
595+
usleep(alreadyUnlocked ? 10_000 : 50_000)
596+
}
597+
print("Timeout setting fan \(fanId) to manual mode")
598+
return false
599+
}
600+
601+
/// Reset Ftst to 0, returning control to thermalmonitord
602+
private func resetFanControl() {
603+
var value = SMCVal_t("Ftst")
604+
var result = read(&value)
605+
guard result == kIOReturnSuccess else {
606+
print("Error read Ftst: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
607+
return
608+
}
609+
value.bytes[0] = 0
610+
result = write(value)
611+
if result == kIOReturnSuccess {
612+
print("Ftst=0 written successfully")
613+
} else {
614+
print("Error writing Ftst=0: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
615+
}
616+
}
617+
618+
/// Check how many fans are currently in manual mode
619+
/// - Parameter excluding: Optional fan ID to exclude from count (for checking OTHER fans)
620+
private func countManualFans(excluding fanId: Int? = nil) -> Int {
621+
guard let fanCount = getValue("FNum") else { return 0 }
622+
var count = 0
623+
for i in 0..<Int(fanCount) {
624+
if let exclude = fanId, i == exclude { continue }
625+
var modeVal = SMCVal_t("F\(i)Md")
626+
if read(&modeVal) == kIOReturnSuccess {
627+
if modeVal.bytes[0] == 1 {
628+
count += 1
629+
}
630+
}
631+
}
632+
return count
633+
}
634+
#endif
635+
474636
// MARK: - internal functions
475637

476638
private func read(_ value: UnsafeMutablePointer<SMCVal_t>) -> kern_return_t {
@@ -520,6 +682,12 @@ public class SMC {
520682
return result
521683
}
522684

685+
// IOKit can return kIOReturnSuccess but SMC firmware may still reject the write.
686+
// Check SMC-level result code (0x00 = success, non-zero = error)
687+
if output.result != 0x00 {
688+
return kIOReturnError
689+
}
690+
523691
return kIOReturnSuccess
524692
}
525693

Stats.xcodeproj/project.pbxproj

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,20 @@
387387
remoteGlobalIDString = 9AF9EE0124648751005D2270;
388388
remoteInfo = Disk;
389389
};
390+
B2D05E752F253BFA0021E479 /* PBXContainerItemProxy */ = {
391+
isa = PBXContainerItemProxy;
392+
containerPortal = 9A1410ED229E721100D29793 /* Project object */;
393+
proxyType = 1;
394+
remoteGlobalIDString = 9ADE6FD7265D032100D2FBA8;
395+
remoteInfo = SMC;
396+
};
397+
B2D05E792F2548790021E479 /* PBXContainerItemProxy */ = {
398+
isa = PBXContainerItemProxy;
399+
containerPortal = 9A1410ED229E721100D29793 /* Project object */;
400+
proxyType = 1;
401+
remoteGlobalIDString = 5CFE492629264DF1000F2856;
402+
remoteInfo = Helper;
403+
};
390404
/* End PBXContainerItemProxy section */
391405

392406
/* Begin PBXCopyFilesBuildPhase section */
@@ -1481,6 +1495,8 @@
14811495
buildRules = (
14821496
);
14831497
dependencies = (
1498+
B2D05E7A2F2548790021E479 /* PBXTargetDependency */,
1499+
B2D05E762F253BFA0021E479 /* PBXTargetDependency */,
14841500
9A81C75C2449A41400825D92 /* PBXTargetDependency */,
14851501
9AF9EE0824648751005D2270 /* PBXTargetDependency */,
14861502
9A3E17D2247A94AF00449CD1 /* PBXTargetDependency */,
@@ -2374,6 +2390,16 @@
23742390
target = 9AF9EE0124648751005D2270 /* Disk */;
23752391
targetProxy = 9AF9EE0724648751005D2270 /* PBXContainerItemProxy */;
23762392
};
2393+
B2D05E762F253BFA0021E479 /* PBXTargetDependency */ = {
2394+
isa = PBXTargetDependency;
2395+
target = 9ADE6FD7265D032100D2FBA8 /* SMC */;
2396+
targetProxy = B2D05E752F253BFA0021E479 /* PBXContainerItemProxy */;
2397+
};
2398+
B2D05E7A2F2548790021E479 /* PBXTargetDependency */ = {
2399+
isa = PBXTargetDependency;
2400+
target = 5CFE492629264DF1000F2856 /* Helper */;
2401+
targetProxy = B2D05E792F2548790021E479 /* PBXContainerItemProxy */;
2402+
};
23772403
/* End PBXTargetDependency section */
23782404

23792405
/* Begin PBXVariantGroup section */

0 commit comments

Comments
 (0)