Ladelimit: Key gemessen, Schreibpfad gebaut

Der Key heißt CHLT, drei Bytes. Identifiziert per Differenzmessung gegen
macOS' eigene Ladebegrenzung, ohne einen einzigen Schreibzugriff:

  80 %  →  50 01 00
  95 %  →  5F 01 00
  85 %  →  55 01 00

Erstes Byte ist die Prozentzahl, zweites offenbar „Limit aktiv", drittes
unbenutzt. Drei Messpunkte, weil einer Zufall sein kann: BACC hatte beim
ersten Vergleich ebenfalls gepasst — ein Zähler, in dem gerade 50 stand.

ChargerConfiguration aus ioreg sah zunächst wie eine zweite Quelle aus
(niedriges Byte 0x50 = 80), blieb aber unverändert, als CHLT längst auf
95 stand. Also Zufall, und gut, dass darauf nichts gebaut wurde.

Geschrieben wird nur das erste Byte, und nur zwischen 80 und 100 — dem
Bereich, den macOS selbst anbietet und in dem gemessen wurde. Darunter ist
ungemessenes Gebiet. Bei den Lüftern liefert die Firmware mit F0Mn/F0Mx
eigene Grenzen mit, an denen sich ein Sicherheitsnetz festhalten kann; hier
gibt es das nicht, hier gibt es nur die Messung. Ein falscher Wert im
Lade-Subsystem ist die eine Operation in diesem Projekt, die den Akku
dauerhaft beschädigen kann.

Nach jedem Schreiben wird zurückgelesen und gemeldet, was tatsächlich
steht — nicht, was gewünscht war. „Kein Limit" heißt 100 Prozent und nicht
das Aktiv-Byte auf null: diese Kodierung hat macOS nie geschrieben, sie
wäre also ungemessen.

Bedienung im Lüfter-Bereich der Einstellungen und in der Lüfterkachel,
also auch im Menüleisten-Popover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-11 12:01:50 +02:00
parent c9366302fc
commit b2f69f34ae
11 changed files with 1577 additions and 1175 deletions

View File

@@ -0,0 +1,68 @@
import SwiftUI
import OnyxDesign
import OnyxHelperProtocol
/// Das Ladelimit.
///
/// Ein Akku, der dauerhaft bei 100 % am Netz hängt, altert schneller. Deshalb
/// gibt es die Einstellung und deshalb ist sie so schmal: 80 bis 100 % ist
/// der Bereich, den macOS selbst anbietet und in dem gemessen wurde, wie der
/// zuständige SMC-Key aussieht. Darunter wäre geraten.
struct ChargeLimitView: View {
let control: FanControl
/// In der Kachel ist es eng, im Einstellungsfenster nicht.
let roomy: Bool
@State private var pending: Double?
private var current: Double {
pending ?? Double(control.chargeLimit ?? ChargeSafety.noLimit)
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: "battery.75percent")
.font(.system(size: 11))
.foregroundStyle(Onyx.Color.textTertiary)
Text("charge.limit")
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
Spacer(minLength: 4)
// Hundert Prozent ist kein Limit und soll auch nicht so
// heißen, sonst sucht man den Ausschalter.
Group {
if current >= Double(ChargeSafety.noLimit) {
Text("charge.limit.off")
} else {
Text(verbatim: "\(Int(current)) %")
}
}
.font(Onyx.Font.metricSmall).monospacedDigit()
.foregroundStyle(Onyx.Color.textPrimary)
}
Slider(value: Binding(get: { current }, set: { pending = $0.rounded() }),
in: Double(ChargeSafety.minimumPercent)...Double(ChargeSafety.maximumPercent),
step: 5,
onEditingChanged: { editing in
// Erst beim Loslassen schreiben. Bei jedem Zwischenwert
// in den Ladepfad zu schreiben wäre ein Dutzend
// Schreibvorgänge für eine einzige Entscheidung.
guard !editing, let pending else { return }
control.setChargeLimit(Int(pending))
self.pending = nil
})
.controlSize(.mini)
.disabled(control.installState != .installed)
if roomy {
Text("charge.limit.hint")
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
.onAppear { control.refreshChargeLimit() }
}
}

View File

@@ -119,6 +119,7 @@ public final class FanControl {
/// abstürzt, beendet wird oder hängt fallen die Lüfter im Helfer nach
/// fünf Sekunden auf Automatik zurück.
public func start() {
refreshChargeLimit()
guard installState == .installed, heartbeatTimer == nil else { return }
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
@@ -160,4 +161,25 @@ public final class FanControl {
Task { @MainActor in self?.apply(data) }
}
}
// MARK: - Ladelimit
/// Das eingestellte Ladelimit in Prozent. `nil` heißt: keines, oder noch
/// nicht gelesen.
public private(set) var chargeLimit: Int?
public func refreshChargeLimit() {
proxy()?.chargeLimit { [weak self] percent in
Task { @MainActor in self?.chargeLimit = percent > 0 ? percent : nil }
}
}
/// Setzt das Limit und übernimmt, was der Helfer **zurückgelesen** hat
/// nicht das, was gewünscht war. Ein Schreibvorgang, den der SMC verwirft,
/// darf sich in der Oberfläche nicht als Erfolg zeigen.
public func setChargeLimit(_ percent: Int) {
proxy()?.setChargeLimit(percent: percent) { [weak self] result in
Task { @MainActor in self?.chargeLimit = result > 0 ? result : nil }
}
}
}

View File

@@ -18,6 +18,12 @@ struct FanSettingsView: View {
approvalSection
case .installed:
controlSection
// Das Ladelimit hängt am selben Helfer wie die Lüfter beides
// ist Hardwaresteuerung über den SMC, und beides ist erst
// erreichbar, wenn der Helfer läuft.
Section("charge.section") {
ChargeLimitView(control: control, roomy: true)
}
}
}
.formStyle(.grouped)

View File

@@ -23,6 +23,9 @@ struct FanView: View {
ForEach(Array(report.fans.enumerated()), id: \.offset) { index, fan in
FanRow(control: control, index: index, fan: fan, roomy: roomy)
}
Divider().overlay(Onyx.Color.hairline)
ChargeLimitView(control: control, roomy: roomy)
if let hottest = report.hottestCelsius {
Divider().overlay(Onyx.Color.hairline)
HStack(spacing: 5) {

File diff suppressed because it is too large Load Diff