Skip to content

Commit 15fcbcf

Browse files
committed
Add Apple Silicon fan control support with Ftst unlock mechanism
Signed-off-by: Alex Goodkind <alex@goodkind.io>
1 parent 9671269 commit 15fcbcf

File tree

6 files changed

+205
-22
lines changed

6 files changed

+205
-22
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: 151 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,56 @@ public class SMC {
352352
// MARK: - fans
353353

354354
public func setFanMode(_ id: Int, mode: FanMode) {
355+
#if arch(arm64)
356+
// Apple Silicon: unlock before setting manual mode
357+
if mode == .forced {
358+
if !unlockFanControl(fanId: id) {
359+
print("Error: failed to unlock fan control for fan \(id)")
360+
return
361+
}
362+
// unlockFanControl already set the mode to 1, so we're done with F%dMd
363+
} else {
364+
// Setting to automatic - count OTHER fans in manual mode (skip current fan)
365+
guard let fanCount = getValue("FNum") else {
366+
print("Error: Failed to read fan count")
367+
return
368+
}
369+
var otherFansManual = 0
370+
for i in 0 ..< Int(fanCount) {
371+
if i == id { continue }
372+
var modeVal = SMCVal_t("F\(i)Md")
373+
if read(&modeVal) == kIOReturnSuccess {
374+
if modeVal.bytes[0] == 1 {
375+
otherFansManual += 1
376+
}
377+
}
378+
}
379+
380+
// Set mode and target to 0
381+
if self.getValue("F\(id)Md") != nil {
382+
var value = SMCVal_t("F\(id)Md")
383+
_ = read(&value)
384+
value.bytes[0] = 0
385+
_ = write(value)
386+
}
387+
388+
// Apple Silicon uses flt (IEEE 754 float) format
389+
var targetValue = SMCVal_t("F\(id)Tg")
390+
_ = read(&targetValue)
391+
let bytes = Float(0).bytes
392+
targetValue.bytes[0] = bytes[0]
393+
targetValue.bytes[1] = bytes[1]
394+
targetValue.bytes[2] = bytes[2]
395+
targetValue.bytes[3] = bytes[3]
396+
_ = write(targetValue)
397+
398+
if otherFansManual == 0 {
399+
resetFanControl()
400+
print("Last manual fan - Ftst reset, thermalmonitord has control")
401+
}
402+
}
403+
#else
404+
// Intel
355405
if self.getValue("F\(id)Md") != nil {
356406
var result: kern_return_t = 0
357407
var value = SMCVal_t("F\(id)Md")
@@ -422,6 +472,7 @@ public class SMC {
422472
print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
423473
return
424474
}
475+
#endif
425476
}
426477

427478
public func setFanSpeed(_ id: Int, speed: Int) {
@@ -432,6 +483,18 @@ public class SMC {
432483
return
433484
}
434485

486+
#if arch(arm64)
487+
// Apple Silicon: ensure fan is in manual mode before setting speed
488+
var modeVal = SMCVal_t("F\(id)Md")
489+
_ = read(&modeVal)
490+
if modeVal.bytes[0] != 1 {
491+
if !unlockFanControl(fanId: id) {
492+
print("Error: Failed to unlock fan \(id) for speed change")
493+
return
494+
}
495+
}
496+
#endif
497+
435498
var result: kern_return_t = 0
436499
var value = SMCVal_t("F\(id)Tg")
437500

@@ -441,18 +504,20 @@ public class SMC {
441504
return
442505
}
443506

444-
if value.dataType == "flt " {
445-
let bytes = Float(speed).bytes
446-
value.bytes[0] = bytes[0]
447-
value.bytes[1] = bytes[1]
448-
value.bytes[2] = bytes[2]
449-
value.bytes[3] = bytes[3]
450-
} else if value.dataType == "fpe2" {
451-
value.bytes[0] = UInt8(speed >> 6)
452-
value.bytes[1] = UInt8((speed << 2) ^ ((speed >> 6) << 8))
453-
value.bytes[2] = UInt8(0)
454-
value.bytes[3] = UInt8(0)
455-
}
507+
#if arch(arm64)
508+
// Apple Silicon: flt (IEEE 754 float) format
509+
let bytes = Float(speed).bytes
510+
value.bytes[0] = bytes[0]
511+
value.bytes[1] = bytes[1]
512+
value.bytes[2] = bytes[2]
513+
value.bytes[3] = bytes[3]
514+
#else
515+
// Intel: fpe2 format
516+
value.bytes[0] = UInt8(speed >> 6)
517+
value.bytes[1] = UInt8((speed << 2) ^ ((speed >> 6) << 8))
518+
value.bytes[2] = UInt8(0)
519+
value.bytes[3] = UInt8(0)
520+
#endif
456521

457522
result = write(value)
458523
if result != kIOReturnSuccess {
@@ -469,7 +534,81 @@ public class SMC {
469534
if result != kIOReturnSuccess {
470535
print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
471536
}
537+
538+
#if arch(arm64)
539+
// Also reset Ftst on Apple Silicon
540+
resetFanControl()
541+
#endif
542+
}
543+
544+
// MARK: - Apple Silicon Fan Control
545+
546+
#if arch(arm64)
547+
/// Unlock fan control for Apple Silicon by writing Ftst=1
548+
/// This prevents thermalmonitord from overriding manual fan settings
549+
private func unlockFanControl(fanId: Int) -> Bool {
550+
// Check if already unlocked (Ftst=1 from another fan)
551+
var ftstCheck = SMCVal_t("Ftst")
552+
_ = read(&ftstCheck)
553+
let alreadyUnlocked = ftstCheck.bytes[0] == 1
554+
555+
if !alreadyUnlocked {
556+
var ftstVal = SMCVal_t("Ftst")
557+
_ = read(&ftstVal)
558+
ftstVal.bytes[0] = 1
559+
let ftstResult = write(ftstVal)
560+
if ftstResult != kIOReturnSuccess {
561+
print("Error unlocking fan control: " + (String(cString: mach_error_string(ftstResult), encoding: String.Encoding.ascii) ?? "unknown error"))
562+
return false
563+
}
564+
}
565+
566+
// Retry writing mode=1 until thermalmonitord releases control
567+
let modeKey = "F\(fanId)Md"
568+
let maxAttempts = alreadyUnlocked ? 10 : 100
569+
for attempt in 0..<maxAttempts {
570+
var modeVal = SMCVal_t(modeKey)
571+
_ = read(&modeVal)
572+
modeVal.bytes[0] = 1
573+
let result = write(modeVal)
574+
if result == kIOReturnSuccess {
575+
print("Fan \(fanId) set to manual mode after \(attempt + 1) attempts")
576+
return true
577+
}
578+
usleep(alreadyUnlocked ? 10_000 : 50_000)
579+
}
580+
print("Timeout setting fan \(fanId) to manual mode")
581+
return false
582+
}
583+
584+
/// Reset Ftst to 0, returning control to thermalmonitord
585+
private func resetFanControl() {
586+
var value = SMCVal_t("Ftst")
587+
_ = read(&value)
588+
value.bytes[0] = 0
589+
let result = write(value)
590+
if result == kIOReturnSuccess {
591+
print("Ftst=0 written successfully")
592+
} else {
593+
print("Error writing Ftst=0: \(result)")
594+
}
595+
}
596+
597+
/// Check how many fans are currently in manual mode
598+
private func countManualFans() -> Int {
599+
guard let fanCount = getValue("FNum") else { return 0 }
600+
var count = 0
601+
for i in 0..<Int(fanCount) {
602+
var modeVal = SMCVal_t("F\(i)Md")
603+
if read(&modeVal) == kIOReturnSuccess {
604+
if modeVal.bytes[0] == 1 {
605+
count += 1
606+
}
607+
}
608+
}
609+
return count
472610
}
611+
#endif
473612

474613
// MARK: - internal functions
475614

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)