Skip to content

Commit 4adab44

Browse files
committed
Add Apple Silicon fan control via direct SMC operations
1 parent 9671269 commit 4adab44

File tree

11 files changed

+501
-27
lines changed

11 files changed

+501
-27
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.1.0</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/Helper/main.swift

Lines changed: 94 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
//
1111

1212
import Foundation
13+
import IOKit
1314

1415
let helper = Helper()
1516
helper.run()
@@ -21,12 +22,27 @@ class Helper: NSObject, NSXPCListenerDelegate, HelperProtocol {
2122
private var shouldQuit = false
2223
private var shouldQuitCheckInterval = 1.0
2324

24-
private var smc: String? = nil
25+
private var smcConnection: io_connect_t = 0
2526

2627
override init() {
2728
self.listener = NSXPCListener(machServiceName: "eu.exelban.Stats.SMC.Helper")
2829
super.init()
2930
self.listener.delegate = self
31+
32+
// Open SMC connection on initialization
33+
let (conn, result) = smcOpenConnection()
34+
if result == kIOReturnSuccess {
35+
self.smcConnection = conn
36+
NSLog("SMC connection opened successfully")
37+
} else {
38+
NSLog("Failed to open SMC connection: \(result)")
39+
}
40+
}
41+
42+
deinit {
43+
if self.smcConnection != 0 {
44+
IOServiceClose(self.smcConnection)
45+
}
3046
}
3147

3248
public func run() {
@@ -111,40 +127,101 @@ extension Helper {
111127
completion(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0")
112128
}
113129
func setSMCPath(_ path: String) {
114-
self.smc = path
130+
// Legacy method - no longer needed with direct SMC access
131+
// Kept for protocol compatibility
115132
}
116133

117134
func setFanMode(id: Int, mode: Int, completion: (String?) -> Void) {
118-
guard let smc = self.smc else {
119-
completion("missing smc tool")
135+
guard self.smcConnection != 0 else {
136+
completion("SMC connection not available")
120137
return
121138
}
122-
let result = syncShell("\(smc) fan \(id) -m \(mode)")
123139

124-
if let error = result.error, !error.isEmpty {
125-
NSLog("error set fan mode: \(error)")
126-
completion(nil)
127-
return
140+
if mode == 1 {
141+
// Switching TO manual mode - unlock fan control for this specific fan
142+
// The unlock function also sets the fan to manual mode
143+
let unlockResult = smcUnlockFanControl(self.smcConnection, fanIndex: id)
144+
if unlockResult != kIOReturnSuccess {
145+
NSLog("Failed to unlock fan control: \(unlockResult)")
146+
completion("Failed to unlock fan control")
147+
return
148+
}
149+
} else if mode == 0 {
150+
// Switching TO automatic mode
151+
// Count how many OTHER fans are currently in manual mode (mode byte == 1)
152+
let (numResult, numBytes, _) = smcRead(self.smcConnection, key: SMC_KEY_FNUM)
153+
var otherFansManual = 0
154+
155+
if numResult == kIOReturnSuccess && !numBytes.isEmpty {
156+
let fanCount = Int(numBytes[0])
157+
for i in 0..<fanCount {
158+
if i == id { continue } // Skip the fan we're setting to auto
159+
let checkKey = String(format: SMC_KEY_FAN_MODE, i)
160+
let (_, checkBytes, _) = smcRead(self.smcConnection, key: checkKey)
161+
// Mode byte: 0=auto, 1=manual, 3=system
162+
if !checkBytes.isEmpty && checkBytes[0] == 1 {
163+
otherFansManual += 1
164+
}
165+
}
166+
}
167+
168+
if otherFansManual > 0 {
169+
// Other fans are still manual, keep Ftst=1 but set this fan to auto
170+
let modeKey = String(format: SMC_KEY_FAN_MODE, id)
171+
let modeResult = smcWrite(self.smcConnection, key: modeKey, value: [0], size: 1)
172+
if modeResult != kIOReturnSuccess {
173+
NSLog("Warning: Failed to set auto mode for fan \(id): \(modeResult)")
174+
}
175+
NSLog("Set fan \(id) to auto, other fans still manual")
176+
} else {
177+
// No other fans in manual mode, reset Ftst to restore automatic control
178+
// This allows fans to enter system mode (mode 3) and idle at 0 RPM
179+
let resetResult = smcWrite(
180+
self.smcConnection, key: SMC_KEY_FAN_TEST, value: [0], size: 1
181+
)
182+
if resetResult != kIOReturnSuccess {
183+
NSLog("Warning: Failed to reset Ftst: \(resetResult)")
184+
}
185+
NSLog("Reset Ftst, restoring automatic fan control")
186+
}
128187
}
129188

130-
completion(result.output)
189+
NSLog("Fan \(id) mode set to \(mode)")
190+
completion("Success")
131191
}
132192

133193
func setFanSpeed(id: Int, value: Int, completion: (String?) -> Void) {
134-
guard let smc = self.smc else {
135-
completion("missing smc tool")
194+
guard self.smcConnection != 0 else {
195+
completion("SMC connection not available")
136196
return
137197
}
138198

139-
let result = syncShell("\(smc) fan \(id) -v \(value)")
199+
// Read current target to determine data format (also verifies key exists)
200+
let targetKey = String(format: SMC_KEY_FAN_TARGET, id)
201+
let (readResult, _, size) = smcRead(self.smcConnection, key: targetKey)
140202

141-
if let error = result.error, !error.isEmpty {
142-
NSLog("error set fan speed: \(error)")
143-
completion(nil)
203+
if readResult != kIOReturnSuccess {
204+
NSLog("Error reading fan target: \(readResult)")
205+
completion("Failed to read fan target")
144206
return
145207
}
146208

147-
completion(result.output)
209+
// Convert speed to appropriate format based on data size
210+
let speedBytes = floatToBytes(Float(value), size: size)
211+
212+
// Write new target speed
213+
// Note: Fan should already be in manual mode from a prior setFanMode call.
214+
// If not, this write may not take effect until mode is set.
215+
let writeResult = smcWrite(self.smcConnection, key: targetKey, value: speedBytes, size: size)
216+
217+
if writeResult != kIOReturnSuccess {
218+
NSLog("Error setting fan speed: \(writeResult)")
219+
completion("Failed to set fan speed")
220+
return
221+
}
222+
223+
NSLog("Fan \(id) speed set to \(value)")
224+
completion("Success")
148225
}
149226

150227
func powermetrics(_ samplers: [String], completion: @escaping (String?) -> Void) {

SMC/SMCConnection.swift

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
//
2+
// SMCConnection.swift
3+
// Stats
4+
//
5+
// Created by Alex Goodkind <alex@goodkind.io> on 23/01/2026
6+
// Based on macos-smc-fan (github.com/agoodkind/macos-smc-fan)
7+
//
8+
9+
import Foundation
10+
import IOKit
11+
12+
// MARK: - SMC Connection Management
13+
14+
//
15+
// These functions handle IOKit service discovery and connection lifecycle.
16+
// They use IOKit directly (no C struct packing required).
17+
18+
/// Open connection to AppleSMC IOKit service
19+
func smcOpenConnection() -> (io_connect_t, kern_return_t) {
20+
var iterator: io_iterator_t = 0
21+
let matchingDict = IOServiceMatching("AppleSMC")
22+
23+
// Use kIOMainPortDefault on macOS 12.0+, kIOMasterPortDefault on older versions
24+
let mainPort: mach_port_t
25+
if #available(macOS 12.0, *) {
26+
mainPort = kIOMainPortDefault
27+
} else {
28+
mainPort = kIOMasterPortDefault
29+
}
30+
31+
let matchResult = IOServiceGetMatchingServices(
32+
mainPort,
33+
matchingDict,
34+
&iterator
35+
)
36+
guard matchResult == kIOReturnSuccess else {
37+
return (0, matchResult)
38+
}
39+
40+
let device = IOIteratorNext(iterator)
41+
IOObjectRelease(iterator)
42+
43+
guard device != 0 else {
44+
return (0, kIOReturnNotFound)
45+
}
46+
47+
var conn: io_connect_t = 0
48+
let openResult = IOServiceOpen(device, mach_task_self_, 0, &conn)
49+
IOObjectRelease(device)
50+
51+
return (conn, openResult)
52+
}
53+
54+
// MARK: - Swift Wrappers for C SMC Functions
55+
56+
//
57+
// Why these wrappers exist:
58+
// - C functions require raw pointers (const char*, unsigned char*)
59+
// - Swift arrays/strings don't auto-convert to C pointers
60+
// - withCString/withUnsafeBytes provides safe temporary pointers
61+
//
62+
// Why the underlying C functions exist (see smc.h):
63+
// - SMC requires IOConnectCallStructMethod with exact 80-byte struct layout
64+
// - Swift's automatic struct padding differs from C (offset 39 vs 42)
65+
66+
/// Read SMC key, returning (result, value, size)
67+
func smcRead(_ conn: io_connect_t, key: String) -> (kern_return_t, [UInt8], UInt32) {
68+
var value: [UInt8] = Array(repeating: 0, count: 32)
69+
var size: UInt32 = 0
70+
71+
let result = key.withCString { keyPtr in
72+
value.withUnsafeMutableBufferPointer { bufferPtr in
73+
smc_read_key(conn, keyPtr, bufferPtr.baseAddress!, &size)
74+
}
75+
}
76+
77+
return (result, value, size)
78+
}
79+
80+
/// Write SMC key with byte array
81+
func smcWrite(_ conn: io_connect_t, key: String, value: [UInt8], size: UInt32) -> kern_return_t {
82+
var val = value
83+
if val.count < 32 {
84+
val.append(contentsOf: [UInt8](repeating: 0, count: 32 - val.count))
85+
}
86+
87+
return key.withCString { keyPtr in
88+
val.withUnsafeBytes { bytes in
89+
smc_write_key(conn, keyPtr, bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), size)
90+
}
91+
}
92+
}
93+
94+
// MARK: - Fan Control Unlock
95+
96+
/// Unlock fan control by writing Ftst=1 and retrying mode write for the specified fan.
97+
/// This unlocks the SMC for manual fan control on Apple Silicon.
98+
/// Sets the specified fan to manual mode (1) as part of the unlock.
99+
func smcUnlockFanControl(
100+
_ conn: io_connect_t,
101+
fanIndex: Int = 0,
102+
maxRetries: Int = 100,
103+
timeout: TimeInterval = 10.0
104+
) -> kern_return_t {
105+
// Step 1: Write Ftst=1 to trigger unlock
106+
var result = smcWrite(conn, key: SMC_KEY_FAN_TEST, value: [1], size: 1)
107+
guard result == kIOReturnSuccess else { return result }
108+
109+
// Step 2: Retry writing mode=1 to the specified fan until it succeeds
110+
// This both verifies unlock and sets the fan to manual mode
111+
let modeKey = String(format: SMC_KEY_FAN_MODE, fanIndex)
112+
let startTime = Date()
113+
114+
for _ in 0 ..< maxRetries {
115+
result = smcWrite(conn, key: modeKey, value: [1], size: 1)
116+
117+
if result == kIOReturnSuccess {
118+
return kIOReturnSuccess
119+
}
120+
121+
if Date().timeIntervalSince(startTime) >= timeout {
122+
return kIOReturnTimeout
123+
}
124+
125+
Thread.sleep(forTimeInterval: 0.1)
126+
}
127+
128+
return kIOReturnTimeout
129+
}

0 commit comments

Comments
 (0)