Skip to content

Commit fa32095

Browse files
committed
feat(battery): close the gap with battery-cli — manual toggles, force-discharge, time remaining, daemon logs
Feature parity pass against actuallymentor/battery v1.3.2, all verified live on M2 MacBook Air: - `battery charging on|off` and `battery adapter on|off`: expose the existing setChargingEnabled/setAdapterEnabled XPC calls in the CLI. When a maintain policy is active the CLI warns that the toggle may be overridden on the next evaluation (verified: it is, within seconds). - `battery maintain N --force-discharge`: the policy actively discharges down to the band by cutting adapter power while above the upper bound, and restores it once inside. Persisted as battery.force_discharge in config.toml. The evaluation loop re-asserts the cut every period, which makes it self-healing against the firmware quirk below. Switching back to a plain maintain hands the adapter back (daemon-side transition, verified both directions). - `battery status` shows time-to-empty/full from IOKit power sources (nil while the gauge is settling — mirrors pmset's "(no estimate)"). - `daemon logs [--last 1h]`: wraps `log show` with the smctl subsystem predicate. Field finding that shaped the design: writing the charging-enable key (CHTE) while an adapter cut (CHIE) is active made the SMC silently re-enable adapter power — observed live, it stalled a one-shot `battery discharge` forever at 79%. The one-shot discharge loop now re-asserts the cut every poll, and force-discharge inherits immunity from the policy loop (verified: triggering the same reset under force-discharge is corrected within one XPC call). PolicyEngine: BatteryObservation gains isAdapterEnabled (nil when adapter control is unsupported — the policy then never touches the adapter, which also keeps it out of the way of manual discharges). New transition-matrix tests for the force-discharge branches.
1 parent 214d2c1 commit fa32095

9 files changed

Lines changed: 291 additions & 28 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,16 @@ $ smctl sensors --watch # live temperatures, fan RPM, package power
3131
| Command | What it does |
3232
|---|---|
3333
| `smctl sensors [--watch] [--json]` | Temperatures by sensor group, fan RPM/mode, battery, package power |
34-
| `smctl battery status` | Charge level, charging state, configured limit |
35-
| `smctl battery maintain 80` / `70-80` / `stop` | Charge limit with dead-band (no charge/discharge flapping) |
34+
| `smctl battery status` | Charge level, charging state, configured limit, time estimate |
35+
| `smctl battery maintain 80` / `70-80` / `stop` | Charge limit with dead-band; add `--force-discharge` to drain down to the band |
36+
| `smctl battery charging on\|off` / `adapter on\|off` | Manual charging and adapter-power toggles |
3637
| `smctl battery charge 90` / `discharge 40` | One-shot top-up or supervised discharge |
3738
| `smctl fan status` | Per-fan actual/target/min/max RPM and control mode |
3839
| `smctl fan set 2500 [--fan N]` | Manual fan target |
3940
| `smctl fan profile quiet\|full\|auto\|<custom>` | Declarative fan curves (TOML), hysteresis + slew-rate limited |
4041
| `smctl power status [--watch] [--json]` | Thermal pressure, CPU throttling (% speed limit), system/package + input power |
4142
| `smctl alert list\|status\|test <name>` | Temperature/event alerts → webhook, command, or log (configured in TOML) |
42-
| `smctl daemon install\|uninstall\|status\|ping` | Manage the privileged helper |
43+
| `smctl daemon install\|uninstall\|status\|ping\|logs` | Manage the privileged helper and inspect recent daemon logs |
4344

4445
Policies live in `/etc/smctl/config.toml` — declarative, diffable, dotfiles-friendly.
4546

Sources/PolicyEngine/PolicyEngine.swift

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,15 @@ public struct BatteryObservation: Codable, Equatable, Sendable {
100100
public var isChargingAllowed: Bool
101101
/// Whether wall power is present (AC-W).
102102
public var isPluggedIn: Bool
103+
/// Whether the SMC currently allows adapter power input (CHIE / CH0I status).
104+
/// nil when adapter control is unsupported or unreadable.
105+
public var isAdapterEnabled: Bool?
103106

104-
public init(chargePercent: Int, isChargingAllowed: Bool, isPluggedIn: Bool = true) {
107+
public init(chargePercent: Int, isChargingAllowed: Bool, isPluggedIn: Bool = true, isAdapterEnabled: Bool? = nil) {
105108
self.chargePercent = min(100, max(0, chargePercent))
106109
self.isChargingAllowed = isChargingAllowed
107110
self.isPluggedIn = isPluggedIn
111+
self.isAdapterEnabled = isAdapterEnabled
108112
}
109113

110114
/// Best available approximation of "actively charging": power present and charging allowed.
@@ -146,7 +150,8 @@ public struct ChargeStateMachine: Sendable {
146150
public mutating func evaluate(
147151
limit: ChargeLimit,
148152
observation: BatteryObservation,
149-
now: Date
153+
now: Date,
154+
forceDischarge: Bool = false
150155
) -> ChargeEvaluation {
151156
let slept = lastEvaluation.map { now.timeIntervalSince($0) > period * missedBeatMultiplier } ?? false
152157
lastEvaluation = now
@@ -182,6 +187,20 @@ public struct ChargeStateMachine: Sendable {
182187
actions.append(.setChargingEnabled(false))
183188
}
184189

190+
// Force-discharge: when enabled, the policy owns the adapter switch — cut
191+
// adapter power while above the band so the battery drains down to it, and
192+
// restore once inside. Acts only when the adapter state is readable, and
193+
// never below the upper bound, so it cannot fight the charge dead-band.
194+
// When disabled the policy never touches the adapter: a manual one-shot
195+
// `discharge` owns it then.
196+
if forceDischarge {
197+
if effectiveObservation.chargePercent > limit.upperBound, effectiveObservation.isAdapterEnabled == true {
198+
actions.append(.setAdapterEnabled(false))
199+
} else if effectiveObservation.chargePercent <= limit.upperBound, effectiveObservation.isAdapterEnabled == false {
200+
actions.append(.setAdapterEnabled(true))
201+
}
202+
}
203+
185204
return ChargeEvaluation(
186205
actions: actions,
187206
sleptSinceLastEvaluation: slept,
@@ -193,9 +212,10 @@ public struct ChargeStateMachine: Sendable {
193212
public mutating func evaluate<C: PolicyClock>(
194213
limit: ChargeLimit,
195214
observation: BatteryObservation,
196-
clock: C
215+
clock: C,
216+
forceDischarge: Bool = false
197217
) -> ChargeEvaluation {
198-
evaluate(limit: limit, observation: observation, now: clock.now)
218+
evaluate(limit: limit, observation: observation, now: clock.now, forceDischarge: forceDischarge)
199219
}
200220
}
201221

Sources/SMCtlDaemonCore/Daemon.swift

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,15 @@ struct BatteryConfig: Codable, Equatable, Sendable {
7272
var limit: String
7373
var sleep_policy: String
7474
var magsafe_led: Bool
75+
/// When true, maintain actively discharges down to the band by cutting
76+
/// adapter power while above the upper bound (unreliable in clamshell mode).
77+
var force_discharge: Bool
7578

76-
init(limit: String = "100", sleep_policy: String = "strict", magsafe_led: Bool = true) {
79+
init(limit: String = "100", sleep_policy: String = "strict", magsafe_led: Bool = true, force_discharge: Bool = false) {
7780
self.limit = limit
7881
self.sleep_policy = sleep_policy
7982
self.magsafe_led = magsafe_led
83+
self.force_discharge = force_discharge
8084
}
8185

8286
// Defensive decoding: missing keys fall back to defaults. Synthesized decoding
@@ -87,6 +91,7 @@ struct BatteryConfig: Codable, Equatable, Sendable {
8791
limit = try container.decodeIfPresent(String.self, forKey: .limit) ?? "100"
8892
sleep_policy = try container.decodeIfPresent(String.self, forKey: .sleep_policy) ?? "strict"
8993
magsafe_led = try container.decodeIfPresent(Bool.self, forKey: .magsafe_led) ?? true
94+
force_discharge = try container.decodeIfPresent(Bool.self, forKey: .force_discharge) ?? false
9095
}
9196
}
9297

@@ -376,18 +381,30 @@ public final class SmctlDaemon: @unchecked Sendable {
376381

377382
func reloadConfig() {
378383
queue.sync {
384+
let wasForcing = config.battery.force_discharge
379385
config = Self.loadConfig(path: configPath)
380386
let policy = (try? SleepPolicy.parse(config.battery.sleep_policy)) ?? .strict
381387
sleepMachine.setPolicy(policy)
388+
if wasForcing, !config.battery.force_discharge, readAdapterEnabledLocked() == false {
389+
try? applyAdapterLocked(true)
390+
}
382391
evaluateLocked()
383392
}
384393
}
385394

386-
func setChargeLimit(_ limit: String) throws {
387-
_ = try ChargeLimit.parse(limit)
395+
func setChargeLimit(_ limit: String, forceDischarge: Bool = false) throws {
396+
let parsedLimit = try ChargeLimit.parse(limit)
397+
let effectiveForceDischarge = parsedLimit.isLimiting && forceDischarge
388398
try queue.sync {
399+
let wasForcing = config.battery.force_discharge
389400
config.battery.limit = limit
401+
config.battery.force_discharge = effectiveForceDischarge
390402
try Self.writeConfig(config, path: configPath)
403+
// Leaving force-discharge with the adapter cut would strand the Mac
404+
// on battery; hand the adapter back before the policy stops owning it.
405+
if wasForcing, !effectiveForceDischarge, readAdapterEnabledLocked() == false {
406+
try? applyAdapterLocked(true)
407+
}
391408
evaluateLocked()
392409
}
393410
}
@@ -521,7 +538,12 @@ public final class SmctlDaemon: @unchecked Sendable {
521538
}
522539
let now = Date()
523540
let limit = currentChargeLimitLocked()
524-
let evaluation = chargeMachine.evaluate(limit: limit, observation: observation, now: now)
541+
let evaluation = chargeMachine.evaluate(
542+
limit: limit,
543+
observation: observation,
544+
now: now,
545+
forceDischarge: config.battery.force_discharge
546+
)
525547
do {
526548
try applyActionsLocked(evaluation.actions)
527549
lastEvaluation = now
@@ -547,7 +569,8 @@ public final class SmctlDaemon: @unchecked Sendable {
547569
return BatteryObservation(
548570
chargePercent: Int(charge.rounded()),
549571
isChargingAllowed: chargingAllowed,
550-
isPluggedIn: pluggedIn
572+
isPluggedIn: pluggedIn,
573+
isAdapterEnabled: readAdapterEnabledLocked()
551574
)
552575
}
553576

@@ -558,6 +581,13 @@ public final class SmctlDaemon: @unchecked Sendable {
558581
return value.bytes.allSatisfy { $0 == 0 }
559582
}
560583

584+
private func readAdapterEnabledLocked() -> Bool? {
585+
guard let group = capabilities.adapterControl, let value = try? backend?.readValue(group.statusKey) else {
586+
return nil
587+
}
588+
return value.bytes.allSatisfy { $0 == 0 }
589+
}
590+
561591
private func readPluggedInLocked() -> Bool? {
562592
guard let value = readNumericLocked("AC-W") else {
563593
return nil
@@ -980,6 +1010,7 @@ public final class SmctlDaemon: @unchecked Sendable {
9801010
statusMessage = nil
9811011
}
9821012

1013+
let timeEstimate = PowerSourceTimeEstimate.read()
9831014
return BatteryStatusDTO(
9841015
timestamp: Date(),
9851016
chargePercent: observation?.chargePercent,
@@ -993,7 +1024,10 @@ public final class SmctlDaemon: @unchecked Sendable {
9931024
lowerBound: limit.lowerBound,
9941025
upperBound: limit.upperBound,
9951026
sleepPolicy: sleepMachine.policy.rawValue,
996-
message: statusMessage
1027+
message: statusMessage,
1028+
timeToEmptyMinutes: timeEstimate.toEmpty,
1029+
timeToFullMinutes: timeEstimate.toFull,
1030+
forceDischarge: config.battery.force_discharge
9971031
)
9981032
}
9991033

@@ -1064,6 +1098,7 @@ public final class SmctlDaemon: @unchecked Sendable {
10641098
limit = "\(config.battery.limit)"
10651099
sleep_policy = "\(config.battery.sleep_policy)"
10661100
magsafe_led = \(config.battery.magsafe_led ? "true" : "false")
1101+
force_discharge = \(config.battery.force_discharge ? "true" : "false")
10671102
10681103
[fan]
10691104
profile = "\(config.fan.profile)"
@@ -1214,7 +1249,7 @@ final class XPCService: NSObject, SMCtlDaemonXPCProtocol {
12141249
func setChargeLimit(_ requestData: Data, withReply reply: @escaping (Data?, String?) -> Void) {
12151250
doWrite(reply) {
12161251
let request = try SMCtlProtocolCoding.decode(SetChargeLimitRequestDTO.self, from: requestData)
1217-
try daemon.setChargeLimit(request.limit)
1252+
try daemon.setChargeLimit(request.limit, forceDischarge: request.forceDischarge ?? false)
12181253
}
12191254
}
12201255

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import Foundation
2+
import IOKit.ps
3+
4+
/// Time-remaining estimates from IOKit power sources. These come from the OS
5+
/// gauge (not the SMC), so they live here as presentation data for battery
6+
/// status rather than in SMCCore.
7+
enum PowerSourceTimeEstimate {
8+
/// Minutes to empty (discharging) and to full (charging) for the internal
9+
/// battery. IOKit reports -1 while still calculating; that and missing
10+
/// keys both map to nil.
11+
static func read() -> (toEmpty: Int?, toFull: Int?) {
12+
guard
13+
let blob = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
14+
let list = IOPSCopyPowerSourcesList(blob)?.takeRetainedValue() as? [CFTypeRef]
15+
else {
16+
return (nil, nil)
17+
}
18+
19+
for source in list {
20+
guard
21+
let description = IOPSGetPowerSourceDescription(blob, source)?.takeUnretainedValue() as? [String: Any],
22+
description[kIOPSTypeKey] as? String == kIOPSInternalBatteryType
23+
else {
24+
continue
25+
}
26+
let toEmpty = (description[kIOPSTimeToEmptyKey] as? Int).flatMap { $0 > 0 ? $0 : nil }
27+
let toFull = (description[kIOPSTimeToFullChargeKey] as? Int).flatMap { $0 > 0 ? $0 : nil }
28+
return (toEmpty, toFull)
29+
}
30+
return (nil, nil)
31+
}
32+
}

Sources/SMCtlProtocol/SMCtlProtocol.swift

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ public struct BatteryStatusDTO: Codable, Equatable, Sendable {
9191
public var upperBound: Int
9292
public var sleepPolicy: String
9393
public var message: String?
94+
/// IOKit power-source estimates in minutes; nil when unknown, still
95+
/// calculating, or talking to an older daemon (missing keys decode to nil).
96+
public var timeToEmptyMinutes: Int?
97+
public var timeToFullMinutes: Int?
98+
/// Whether maintain actively discharges down to the band (nil from older daemons).
99+
public var forceDischarge: Bool?
94100

95101
public init(
96102
timestamp: Date,
@@ -105,7 +111,10 @@ public struct BatteryStatusDTO: Codable, Equatable, Sendable {
105111
lowerBound: Int,
106112
upperBound: Int,
107113
sleepPolicy: String,
108-
message: String?
114+
message: String?,
115+
timeToEmptyMinutes: Int? = nil,
116+
timeToFullMinutes: Int? = nil,
117+
forceDischarge: Bool? = nil
109118
) {
110119
self.timestamp = timestamp
111120
self.chargePercent = chargePercent
@@ -120,6 +129,9 @@ public struct BatteryStatusDTO: Codable, Equatable, Sendable {
120129
self.upperBound = upperBound
121130
self.sleepPolicy = sleepPolicy
122131
self.message = message
132+
self.timeToEmptyMinutes = timeToEmptyMinutes
133+
self.timeToFullMinutes = timeToFullMinutes
134+
self.forceDischarge = forceDischarge
123135
}
124136
}
125137

@@ -178,9 +190,13 @@ public struct DaemonStatusDTO: Codable, Equatable, Sendable {
178190

179191
public struct SetChargeLimitRequestDTO: Codable, Equatable, Sendable {
180192
public var limit: String
193+
/// Actively discharge down to the band by cutting adapter power while above
194+
/// the upper bound. Optional so requests from older CLIs decode to nil (off).
195+
public var forceDischarge: Bool?
181196

182-
public init(limit: String) {
197+
public init(limit: String, forceDischarge: Bool? = nil) {
183198
self.limit = limit
199+
self.forceDischarge = forceDischarge
184200
}
185201
}
186202

0 commit comments

Comments
 (0)