Files
onyx/Packages/OnyxKit/Sources/OnyxHelperProtocol/ChargeSafety.swift
T
ScarriffleandClaude Opus 5 f289d02c37 Der Akku bekommt ein Gedächtnis
Verlauf über 24 Stunden, sieben Tage und dreißig Tage, aufgezeichnet alle fünf
Minuten. Als Säulen und nicht als Linie: eine Linie verbindet auch dort, wo
nichts gemessen wurde, und ein zugeklappter Rechner hatte keinen Ladestand —
eine Linie darüber behauptet einen.

Dazu Segelmodus und Hitzeschutz als reine Regeln im Helfer. Beide haben zwei
Schwellen, eine zum Anhalten und eine zum Weitermachen. Ohne diesen Abstand
hinge der Akku am Limit und würde bei jedem verlorenen Prozent nachgeladen —
hunderte winzige Ladevorgänge am Tag, und genau die altern ihn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:06:06 +02:00

147 lines
6.1 KiB
Swift

import Foundation
/// Das Ladelimit — Kodierung und harte Grenzen.
///
/// Der SMC-Key heißt `CHLT`, drei Bytes. Gemessen am 11.08.2026 auf Mac17,9,
/// rein lesend, gegen macOS' eigene Ladebegrenzung:
///
/// | Einstellung | `CHLT` |
/// |---|---|
/// | 80 % | `50 01 00` |
/// | 85 % | `55 01 00` |
/// | 95 % | `5F 01 00` |
///
/// Erstes Byte ist die Prozentzahl, das zweite offenbar „Limit aktiv", das
/// dritte unbenutzt. Mehr ist nicht belegt, und mehr wird hier auch nicht
/// angenommen.
///
/// **Warum das strenger gehandhabt wird als die Lüfter:** dort liefert die
/// Firmware mit `F0Mn`/`F0Mx` ihre eigenen Grenzen mit, an denen sich das
/// Sicherheitsnetz festhalten kann. Hier gibt es nichts dergleichen — es gibt
/// nur die Werte, die wir gemessen haben. Ein falscher Wert im Lade-Subsystem
/// ist die eine Operation in diesem Projekt, die den Akku dauerhaft
/// beschädigen kann.
public enum ChargeSafety {
/// Der SMC-Key.
public static let key = "CHLT"
/// Untergrenze. macOS selbst bietet nichts darunter an; darunter ist
/// ungemessenes Gebiet.
public static let minimumPercent = 80
public static let maximumPercent = 100
/// „Kein Limit" heißt hundert Prozent.
///
/// Und nicht: das Aktiv-Byte auf null setzen. Diese Kodierung hat macOS nie
/// geschrieben, sie ist also ungemessen — hundert Prozent erreicht dasselbe
/// innerhalb dessen, was belegt ist.
public static let noLimit = 100
public static func clamp(_ percent: Int) -> Int {
// Anheben statt ablehnen: ein Regler, der bei einer Fehleingabe gar
// nichts tut, wirkt kaputt.
min(max(percent, minimumPercent), maximumPercent)
}
/// Die drei Bytes für einen Prozentwert.
///
/// Nur das erste Byte trägt den Wunsch. Die beiden anderen bekommen exakt
/// das, was macOS dort schreibt — ungemessene Bytes im Ladepfad werden
/// nicht angefasst.
public static func encode(_ percent: Int) -> [UInt8] {
[UInt8(clamp(percent)), 0x01, 0x00]
}
/// Der Prozentwert aus den gelesenen Bytes. `nil`, wenn kein Limit gilt
/// oder die Daten nicht plausibel sind.
public static func decode(_ bytes: [UInt8]) -> Int? {
guard bytes.count >= 2 else { return nil }
// Zweites Byte null: kein Limit, egal was im ersten steht.
guard bytes[1] != 0 else { return nil }
let percent = Int(bytes[0])
// Ein Wert außerhalb des Möglichen wird nicht angezeigt. „Unbekannt"
// ist eine ehrlichere Auskunft als eine Zahl, die es nicht geben kann.
guard (1...100).contains(percent) else { return nil }
return percent
}
}
/// Segelmodus und Hitzeschutz — die beiden Regeln, die *über* dem Limit liegen.
///
/// **Warum im Helfer und nicht in der App.** Beide greifen in das Laden ein,
/// und ein falscher Eingriff dort ist die eine Operation in diesem Projekt, die
/// den Akku dauerhaft beschädigen kann. Was der Helfer tut, ist von außen nicht
/// abschaltbar — dieselbe Begründung wie bei `FanSafety`.
public enum ChargePolicy {
/// Wie weit der Ladestand unter das Limit fallen muss, bevor wieder geladen
/// wird.
///
/// **Der eigentliche Zweck des Segelns.** Ohne Abstand hängt der Akku
/// dauerhaft am Limit und wird bei jedem verlorenen Prozent nachgeladen —
/// hunderte winzige Ladevorgänge am Tag, und genau die altern ihn. Mit
/// Abstand lädt er einmal und segelt dann von selbst hinunter.
public static let sailingHysteresis = 5
/// Ab dieser Zellentemperatur wird nicht geladen.
///
/// Hitze schadet einem Lithium-Akku mehr als jeder Ladestand. Der Wert ist
/// bewusst konservativ: macOS selbst drosselt später, aber macOS kennt auch
/// die Ladehistorie des Geräts, und wir kennen sie nicht.
public static let maximumChargingCelsius = 40.0
/// Unter dieser Temperatur wird wieder geladen.
///
/// Auch hier ein Abstand, aus demselben Grund wie beim Segeln: genau an der
/// Schwelle schaltete es sonst im Sekundentakt an und aus.
public static let resumeChargingCelsius = 36.0
/// Ob jetzt geladen werden soll.
///
/// - Parameters:
/// - charge: Ladestand, 0 bis 1.
/// - limit: Das eingestellte Limit in Prozent.
/// - celsius: Zellentemperatur, falls bekannt. Unbekannt heißt: kein
/// Hitzeschutz — eine Temperatur zu erfinden wäre schlimmer als keine.
/// - wasCharging: Ob zuletzt geladen wurde. Daran hängt der Abstand:
/// beide Regeln haben zwei Schwellen, eine zum Anhalten und eine zum
/// Weitermachen.
public static func shouldCharge(charge: Double, limit: Int, celsius: Double?,
wasCharging: Bool) -> Bool {
if let celsius {
if wasCharging, celsius >= maximumChargingCelsius { return false }
if !wasCharging, celsius >= resumeChargingCelsius { return false }
}
let percent = Int((min(max(charge, 0), 1) * 100).rounded())
// Ein Limit von hundert ist keines — dann gilt nur der Hitzeschutz.
guard limit < ChargeSafety.noLimit else { return true }
if wasCharging { return percent < limit }
return percent <= limit - sailingHysteresis
}
/// Warum gerade nicht geladen wird — für die Anzeige.
public enum Reason: Equatable, Sendable {
case charging
case atLimit(Int)
case sailing(Int)
case tooHot(Double)
}
public static func reason(charge: Double, limit: Int, celsius: Double?,
wasCharging: Bool) -> Reason {
if let celsius {
let threshold = wasCharging ? maximumChargingCelsius : resumeChargingCelsius
if celsius >= threshold { return .tooHot(celsius) }
}
guard shouldCharge(charge: charge, limit: limit, celsius: celsius,
wasCharging: wasCharging) else {
let percent = Int((min(max(charge, 0), 1) * 100).rounded())
return percent >= limit ? .atLimit(limit) : .sailing(limit)
}
return .charging
}
}