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 } }