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:
68
Onyx/ChargeLimitView.swift
Normal file
68
Onyx/ChargeLimitView.swift
Normal 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() }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user