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

View File

@@ -28,6 +28,15 @@ import Foundation
/// Ich lebe noch." Bleibt er aus, fallen alle Lüfter auf Automatik zurück.
func heartbeat(reply: @escaping (Bool) -> Void)
/// Das aktuelle Ladelimit in Prozent, `-1` wenn keines gilt.
///
/// `Int` statt `Int?`: Optionals sind über `@objc` nicht darstellbar.
func chargeLimit(reply: @escaping (Int) -> Void)
/// Setzt das Ladelimit. Der Helfer begrenzt auf den gemessenen Bereich und
/// liest nach dem Schreiben zurück gemeldet wird, was tatsächlich steht.
func setChargeLimit(percent: Int, reply: @escaping (Int) -> Void)
}
/// Was der Helfer über einen Lüfter berichtet.

View File

@@ -0,0 +1,117 @@
import Testing
@testable import OnyxHelperProtocol
// Gemessen am 11.08.2026 auf Mac17,9, rein lesend, gegen macOS' eigene
// Ladebegrenzung:
//
// Limit 80 % CHLT = 50 01 00
// Limit 95 % CHLT = 5F 01 00
// Limit 85 % CHLT = 55 01 00
//
// Erstes Byte ist die Prozentzahl, zweites offenbar Limit aktiv", drittes
// unbenutzt. Was hier steht, ist gemessen nicht abgeleitet.
@Suite("Ladelimit — Kodierung")
struct ChargeEncodingTests {
@Test("Die drei gemessenen Werte werden richtig kodiert")
func encodesMeasuredValues() {
#expect(ChargeSafety.encode(80) == [0x50, 0x01, 0x00])
#expect(ChargeSafety.encode(95) == [0x5F, 0x01, 0x00])
#expect(ChargeSafety.encode(85) == [0x55, 0x01, 0x00])
}
@Test("Und wieder gelesen ergeben sie dasselbe")
func decodesMeasuredValues() {
#expect(ChargeSafety.decode([0x50, 0x01, 0x00]) == 80)
#expect(ChargeSafety.decode([0x5F, 0x01, 0x00]) == 95)
#expect(ChargeSafety.decode([0x55, 0x01, 0x00]) == 85)
}
@Test("Ein abgeschaltetes Limit liest sich als kein Limit")
func disabledReadsAsNoLimit() {
// Zweites Byte auf null: macOS schreibt das zwar nicht selbst, aber
// fremde Werkzeuge könnten es. Dann gilt kein Limit, egal was im
// ersten Byte steht.
#expect(ChargeSafety.decode([0x50, 0x00, 0x00]) == nil)
}
@Test("Zu kurze Daten ergeben kein Ergebnis statt eines falschen")
func shortDataIsRefused() {
#expect(ChargeSafety.decode([0x50]) == nil)
#expect(ChargeSafety.decode([]) == nil)
}
@Test("Ein unsinniger Wert im Speicher wird nicht als Limit ausgegeben")
func nonsenseIsRefused() {
// 200 % ist kein Ladestand. Lieber unbekannt" melden, als eine Zahl
// anzuzeigen, die es nicht geben kann.
#expect(ChargeSafety.decode([200, 0x01, 0x00]) == nil)
}
}
@Suite("Ladelimit — Sicherheitsnetz")
struct ChargeSafetyTests {
@Test("Der gemessene Bereich wird eingehalten")
func staysInMeasuredRange() {
// macOS selbst lässt nur 80 bis 100 zu. Darunter ist ungemessenes
// Gebiet, und im Lade-Subsystem wird nicht geraten: das ist die eine
// Stelle in diesem Projekt, an der ein falscher Wert die Hardware
// dauerhaft beschädigen kann.
#expect(ChargeSafety.minimumPercent == 80)
#expect(ChargeSafety.maximumPercent == 100)
}
@Test("Zu kleine Werte werden angehoben, nicht abgelehnt")
func lowValuesAreClamped() {
// Anheben statt ablehnen: ein Regler, der bei einer Fehleingabe gar
// nichts tut, wirkt kaputt.
#expect(ChargeSafety.clamp(50) == 80)
#expect(ChargeSafety.clamp(0) == 80)
#expect(ChargeSafety.clamp(-10) == 80)
}
@Test("Zu große Werte werden gedeckelt")
func highValuesAreClamped() {
#expect(ChargeSafety.clamp(120) == 100)
#expect(ChargeSafety.clamp(255) == 100)
}
@Test("Werte im Bereich bleiben unverändert")
func validValuesPass() {
for percent in 80...100 {
#expect(ChargeSafety.clamp(percent) == percent)
}
}
@Test("Kein Limit heißt hundert Prozent")
func noLimitIsHundred() {
// Und nicht: das Aktiv-Byte auf null setzen. Diese Kodierung hat macOS
// nie geschrieben, also ist sie ungemessen hundert Prozent erreicht
// dasselbe innerhalb dessen, was wir belegt haben.
#expect(ChargeSafety.encode(ChargeSafety.noLimit) == [0x64, 0x01, 0x00])
#expect(ChargeSafety.noLimit == 100)
}
@Test("Geschriebene Bytes behalten immer die Form, die macOS benutzt")
func writtenShapeIsAlwaysTheSame() {
// Nur das erste Byte wird verändert. Alles andere ist ungemessen, und
// ungemessene Bytes im Ladepfad werden nicht angefasst.
for percent in [80, 85, 90, 95, 100] {
let bytes = ChargeSafety.encode(percent)
#expect(bytes.count == 3)
#expect(bytes[1] == 0x01)
#expect(bytes[2] == 0x00)
}
}
@Test("Was geschrieben wurde, muss auch zurückgelesen werden können")
func writeIsVerifiable() {
// Der Helfer prüft nach dem Schreiben nach. Bleibt der Wert aus, hat
// der SMC ihn abgelehnt und das muss auffallen, statt als Erfolg
// gemeldet zu werden.
let bytes = ChargeSafety.encode(85)
#expect(ChargeSafety.decode(bytes) == 85)
}
}