Skip to content

Commit fa90434

Browse files
committed
Fix charging continuing above the max limit
Charge inhibition wrote only CH0C=0x01, which left the battery trickle charging above the limit instead of bypassing it. Match the batt reference: write CH0B and CH0C together (0x02 = inhibit) on pre-Tahoe firmware, or CHTE on Tahoe firmware. SMCComm probes key capability once and picks the right path, so both firmware families work. All control paths (heat protection, charge-to-full, manual overrides, auto limit) now route through SMCComm.disableCharging()/enableCharging(). With charging properly inhibited the adapter still powers the system, so the battery is held at the limit rather than charged or discharged. Update the README SMC-key table to match.
1 parent 973a4d5 commit fa90434

3 files changed

Lines changed: 64 additions & 16 deletions

File tree

Daemon/PowerMonitor.swift

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -326,8 +326,7 @@ class PowerMonitor {
326326
if currentTemp >= heatThreshold {
327327
DaemonLogger.shared.log("Heat protection active (\(currentTemp)°C). Disabling charging.")
328328
isHeatProtectionTriggered = true
329-
_ = SMCComm.writeKey("CH0C", value: [0x01])
330-
_ = SMCComm.writeKey("CHTE", value: [0x01, 0x00, 0x00, 0x00])
329+
_ = SMCComm.disableCharging()
331330
updateMagSafeLED(chargingDisabled: true, isManualDischarge: false)
332331
updateSleepAssertion(isCharging: false, isDischarging: false)
333332
return
@@ -338,8 +337,7 @@ class PowerMonitor {
338337
// 2. Charge to Full
339338
if chargingToFull {
340339
DaemonLogger.shared.log("Charge to full active.")
341-
_ = SMCComm.writeKey("CH0C", value: [0x00])
342-
_ = SMCComm.writeKey("CHTE", value: [0x00, 0x00, 0x00, 0x00])
340+
_ = SMCComm.enableCharging()
343341
_ = SMCComm.writeKey("CHIE", value: [0x00])
344342
_ = SMCComm.writeKey("CH0J", value: [0x00])
345343
updateMagSafeLED(chargingDisabled: false, isManualDischarge: false)
@@ -350,8 +348,7 @@ class PowerMonitor {
350348
// 3. Manual Overrides
351349
if adapterDisabledManual {
352350
DaemonLogger.shared.log("Manual adapter disable active.")
353-
_ = SMCComm.writeKey("CH0C", value: [0x01])
354-
_ = SMCComm.writeKey("CHTE", value: [0x01, 0x00, 0x00, 0x00])
351+
_ = SMCComm.disableCharging()
355352
_ = SMCComm.writeKey("CHIE", value: [0x08])
356353
_ = SMCComm.writeKey("CH0J", value: [0x20])
357354
updateMagSafeLED(chargingDisabled: true, isManualDischarge: true)
@@ -361,8 +358,7 @@ class PowerMonitor {
361358

362359
if chargingDisabledManual {
363360
DaemonLogger.shared.log("Manual charging disable active.")
364-
_ = SMCComm.writeKey("CH0C", value: [0x01])
365-
_ = SMCComm.writeKey("CHTE", value: [0x01, 0x00, 0x00, 0x00])
361+
_ = SMCComm.disableCharging()
366362
_ = SMCComm.writeKey("CHIE", value: [0x00])
367363
_ = SMCComm.writeKey("CH0J", value: [0x00])
368364
updateMagSafeLED(chargingDisabled: true, isManualDischarge: false)
@@ -389,13 +385,12 @@ class PowerMonitor {
389385
}
390386

391387
if !isChargingEnabledState {
392-
// Check if macOS overrode our setting
388+
// Check if macOS overrode our setting (CH0C 0x00 == charging enabled)
393389
if let currentCH0C = SMCComm.readKey("CH0C"), currentCH0C.first == 0x00 {
394-
DaemonLogger.shared.log("macOS override detected! CH0C was 00 (enabled), forcing back to 01 (inhibited).")
390+
DaemonLogger.shared.log("macOS override detected! CH0C was 00 (enabled), forcing back to inhibited.")
395391
}
396392

397-
_ = SMCComm.writeKey("CH0C", value: [0x01])
398-
_ = SMCComm.writeKey("CHTE", value: [0x01, 0x00, 0x00, 0x00])
393+
_ = SMCComm.disableCharging()
399394

400395
var isDischarging = false
401396
if floatingModeEnabled || (autoDischargeEnabled && cap > maxLimit) {
@@ -418,8 +413,7 @@ class PowerMonitor {
418413
updateSleepAssertion(isCharging: false, isDischarging: isDischarging)
419414
} else {
420415
DaemonLogger.shared.log("Control: Allowing charge (\(cap)% < \(startLimit)% start target).")
421-
_ = SMCComm.writeKey("CH0C", value: [0x00])
422-
_ = SMCComm.writeKey("CHTE", value: [0x00, 0x00, 0x00, 0x00])
416+
_ = SMCComm.enableCharging()
423417
_ = SMCComm.writeKey("CHIE", value: [0x00])
424418
_ = SMCComm.writeKey("CH0J", value: [0x00])
425419

Daemon/SMCComm.swift

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ let smcLogger = Logger(subsystem: "com.chargecontrol.daemon", category: "SMC")
66

77
public struct SMCComm {
88
private static var connection: io_connect_t = 0
9-
9+
10+
/// Cache of which keys the SMC exposes, so firmware detection probes each
11+
/// key only once.
12+
private static var keyCapabilities: [String: Bool] = [:]
13+
1014
public static func open() -> Bool {
1115
guard connection == 0 else { return true }
1216

@@ -190,6 +194,55 @@ public struct SMCComm {
190194
return bytes.withUnsafeBytes { $0.load(as: UInt32.self) }
191195
}
192196

197+
/// Whether the SMC exposes a given key on this machine. The result is
198+
/// cached, so the key-info lookup only runs once per key.
199+
private static func keyExists(_ key: String) -> Bool {
200+
if let cached = keyCapabilities[key] { return cached }
201+
guard open() else { return false }
202+
203+
var input = SMCParamStruct()
204+
var output = SMCParamStruct()
205+
input.key = key.fourCharCode
206+
input.data8 = UInt8(kSMCGetKeyInfo)
207+
208+
let result = callSMCFunctionYPC(input: &input, output: &output)
209+
let exists = result == kIOReturnSuccess && output.result == UInt8(kSMCSuccess)
210+
keyCapabilities[key] = exists
211+
return exists
212+
}
213+
214+
/// Pre-Tahoe firmware exposes the CH0B/CH0C charging keys; Tahoe and later
215+
/// use CHTE instead. We prefer the legacy pair when both are present.
216+
private static var usesLegacyChargingKeys: Bool {
217+
return keyExists("CH0B") && keyExists("CH0C")
218+
}
219+
220+
/// Inhibit or allow battery charging. While inhibited the system runs from
221+
/// the adapter and the battery is bypassed (neither charged nor discharged).
222+
///
223+
/// Values match the `batt` reference: pre-Tahoe firmware writes 0x02/0x00 to
224+
/// both CH0B and CH0C; Tahoe firmware writes 0x01/0x00 to CHTE.
225+
private static func setChargingInhibited(_ inhibited: Bool) -> Bool {
226+
if usesLegacyChargingKeys {
227+
let value: UInt8 = inhibited ? 0x02 : 0x00
228+
let b = writeKey("CH0B", value: [value])
229+
let c = writeKey("CH0C", value: [value])
230+
return b && c
231+
}
232+
let value: UInt8 = inhibited ? 0x01 : 0x00
233+
return writeKey("CHTE", value: [value, 0x00, 0x00, 0x00])
234+
}
235+
236+
/// Inhibit charging (bypass the battery, run from the adapter).
237+
public static func disableCharging() -> Bool {
238+
return setChargingInhibited(true)
239+
}
240+
241+
/// Allow charging.
242+
public static func enableCharging() -> Bool {
243+
return setChargingInhibited(false)
244+
}
245+
193246
public enum MagSafeColor: UInt8 {
194247
case system = 0x00
195248
case off = 0x01

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,8 @@ graph TD
187187

188188
### SMC Keys (Apple Silicon)
189189
ChargeControl interacts with the AppleSMC using the following keys:
190-
- `CH0C` / `CHTE`: Charging control (0 = Enable, 1 = Disable).
190+
- `CH0B` / `CH0C`: Charging control on pre-Tahoe firmware — both are written together (`0x00` = Enable, `0x02` = Disable/bypass).
191+
- `CHTE`: Charging control on Tahoe firmware (`00 00 00 00` = Enable, `01 00 00 00` = Disable).
191192
- `CHIE` / `CH0J`: Power adapter isolation (0 = Connected, 8/32 = Isolated).
192193
- `ACLC`: MagSafe LED control (0 = System, 1 = Off, 3 = Green, 4 = Orange).
193194
- `B0Te`, `TC0P`, `TG0P`, `Ts0P`: Thermal sensors (Battery, CPU, GPU, Palm Rest).

0 commit comments

Comments
 (0)