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:
@@ -9,6 +9,7 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; };
|
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; };
|
||||||
0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12041E330231599C6540C21 /* FanSettingsView.swift */; };
|
0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12041E330231599C6540C21 /* FanSettingsView.swift */; };
|
||||||
|
1017511BBC848C7E57CECE83 /* ChargeLimitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6054F5AFB96492E51CAFCE1 /* ChargeLimitView.swift */; };
|
||||||
1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */; };
|
1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */; };
|
||||||
1E2D654EFDDF55AEA75DBB7C /* WeatherProvider in Frameworks */ = {isa = PBXBuildFile; productRef = D4578EB64F7A3372B49BC50C /* WeatherProvider */; };
|
1E2D654EFDDF55AEA75DBB7C /* WeatherProvider in Frameworks */ = {isa = PBXBuildFile; productRef = D4578EB64F7A3372B49BC50C /* WeatherProvider */; };
|
||||||
2BB6AFD10DFB13FC4467BE6B /* SMCAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */; };
|
2BB6AFD10DFB13FC4467BE6B /* SMCAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */; };
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
B442E8A39C37C8FD92B4CA83 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
B442E8A39C37C8FD92B4CA83 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
||||||
B83C4E3CB821F903F3977E88 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
B83C4E3CB821F903F3977E88 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
||||||
F12041E330231599C6540C21 /* FanSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanSettingsView.swift; sourceTree = "<group>"; };
|
F12041E330231599C6540C21 /* FanSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanSettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
F6054F5AFB96492E51CAFCE1 /* ChargeLimitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeLimitView.swift; sourceTree = "<group>"; };
|
||||||
F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnyxApp.swift; sourceTree = "<group>"; };
|
F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnyxApp.swift; sourceTree = "<group>"; };
|
||||||
FC3FDFD387188D36AF8287DE /* FanControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanControl.swift; sourceTree = "<group>"; };
|
FC3FDFD387188D36AF8287DE /* FanControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanControl.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@@ -154,6 +156,7 @@
|
|||||||
0FBA13B97C53F1D0D740A633 /* Resources */,
|
0FBA13B97C53F1D0D740A633 /* Resources */,
|
||||||
055263008CB3306A8D09E6D4 /* AppIcon.icon */,
|
055263008CB3306A8D09E6D4 /* AppIcon.icon */,
|
||||||
B83C4E3CB821F903F3977E88 /* AppModel.swift */,
|
B83C4E3CB821F903F3977E88 /* AppModel.swift */,
|
||||||
|
F6054F5AFB96492E51CAFCE1 /* ChargeLimitView.swift */,
|
||||||
FC3FDFD387188D36AF8287DE /* FanControl.swift */,
|
FC3FDFD387188D36AF8287DE /* FanControl.swift */,
|
||||||
F12041E330231599C6540C21 /* FanSettingsView.swift */,
|
F12041E330231599C6540C21 /* FanSettingsView.swift */,
|
||||||
89309D9F5722FA1B91CF2905 /* FanWidget.swift */,
|
89309D9F5722FA1B91CF2905 /* FanWidget.swift */,
|
||||||
@@ -332,6 +335,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
C041F171FB69F4381309D978 /* AppModel.swift in Sources */,
|
C041F171FB69F4381309D978 /* AppModel.swift in Sources */,
|
||||||
|
1017511BBC848C7E57CECE83 /* ChargeLimitView.swift in Sources */,
|
||||||
CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */,
|
CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */,
|
||||||
0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */,
|
0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */,
|
||||||
6CD3F5666C39F54506B8430A /* FanWidget.swift in Sources */,
|
6CD3F5666C39F54506B8430A /* FanWidget.swift in Sources */,
|
||||||
|
|||||||
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
|
/// abstürzt, beendet wird oder hängt — fallen die Lüfter im Helfer nach
|
||||||
/// fünf Sekunden auf Automatik zurück.
|
/// fünf Sekunden auf Automatik zurück.
|
||||||
public func start() {
|
public func start() {
|
||||||
|
refreshChargeLimit()
|
||||||
guard installState == .installed, heartbeatTimer == nil else { return }
|
guard installState == .installed, heartbeatTimer == nil else { return }
|
||||||
|
|
||||||
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
|
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
|
||||||
@@ -160,4 +161,25 @@ public final class FanControl {
|
|||||||
Task { @MainActor in self?.apply(data) }
|
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
|
approvalSection
|
||||||
case .installed:
|
case .installed:
|
||||||
controlSection
|
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)
|
.formStyle(.grouped)
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ struct FanView: View {
|
|||||||
ForEach(Array(report.fans.enumerated()), id: \.offset) { index, fan in
|
ForEach(Array(report.fans.enumerated()), id: \.offset) { index, fan in
|
||||||
FanRow(control: control, index: index, fan: fan, roomy: roomy)
|
FanRow(control: control, index: index, fan: fan, roomy: roomy)
|
||||||
}
|
}
|
||||||
|
Divider().overlay(Onyx.Color.hairline)
|
||||||
|
ChargeLimitView(control: control, roomy: roomy)
|
||||||
|
|
||||||
if let hottest = report.hottestCelsius {
|
if let hottest = report.hottestCelsius {
|
||||||
Divider().overlay(Onyx.Color.hairline)
|
Divider().overlay(Onyx.Color.hairline)
|
||||||
HStack(spacing: 5) {
|
HStack(spacing: 5) {
|
||||||
|
|||||||
@@ -19,24 +19,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"%@ °C" : {
|
|
||||||
"comment" : "A label showing the temperature of the hottest fan in the report, in degrees Celsius. The argument is the temperature in degrees Celsius.",
|
|
||||||
"isCommentAutoGenerated" : true,
|
|
||||||
"localizations" : {
|
|
||||||
"de" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "%@ °C"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "%@ °C"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"%@ U/min": {
|
"%@ U/min": {
|
||||||
"comment": "A label that shows the current speed of a fan. The argument is the current speed of the fan.",
|
"comment": "A label that shows the current speed of a fan. The argument is the current speed of the fan.",
|
||||||
"isCommentAutoGenerated": true,
|
"isCommentAutoGenerated": true,
|
||||||
@@ -55,6 +37,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"%@ °C": {
|
||||||
|
"comment": "A label showing the temperature of the hottest fan in the report, in degrees Celsius. The argument is the temperature in degrees Celsius.",
|
||||||
|
"isCommentAutoGenerated": true,
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%@ °C"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%@ °C"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"%@°": {
|
"%@°": {
|
||||||
"comment": "A temperature in °C.",
|
"comment": "A temperature in °C.",
|
||||||
"isCommentAutoGenerated": true,
|
"isCommentAutoGenerated": true,
|
||||||
@@ -73,6 +73,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"charge.limit": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Ladelimit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Charge limit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"charge.limit.hint": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Ein Akku, der dauerhaft bei 100 % am Netz hängt, altert schneller. 80 bis 100 % ist der Bereich, den macOS selbst anbietet — darunter ist für dieses Gerät ungemessen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "A battery kept at 100 % on power ages faster. 80 to 100 % is the range macOS itself offers — below that is untested for this machine."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"charge.limit.off": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Aus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"charge.section": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Akku"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Battery"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"fans.approval.needed": {
|
"fans.approval.needed": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ final class SMCAccess {
|
|||||||
write(key, bytes: [value])
|
write(key, bytes: [value])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Schreibt beliebige Bytes — für `CHLT`, das drei davon hat.
|
||||||
|
@discardableResult
|
||||||
|
func writeBytes(_ key: String, _ bytes: [UInt8]) -> Bool {
|
||||||
|
write(key, bytes: bytes)
|
||||||
|
}
|
||||||
|
|
||||||
private func write(_ key: String, bytes: [UInt8]) -> Bool {
|
private func write(_ key: String, bytes: [UInt8]) -> Bool {
|
||||||
guard let info = keyInfo(for: key) else { return false }
|
guard let info = keyInfo(for: key) else { return false }
|
||||||
// Nur schreiben, wenn die Größe zur Firmware passt. Ein zu kurzer oder
|
// Nur schreiben, wenn die Größe zur Firmware passt. Ein zu kurzer oder
|
||||||
@@ -134,6 +140,12 @@ final class SMCAccess {
|
|||||||
return output.keyInfo
|
return output.keyInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Liest die Rohbytes eines Keys — für `CHLT`, dessen Typ `hex_` ist und
|
||||||
|
/// das sich damit keiner der Zahlenfassungen zuordnen lässt.
|
||||||
|
func readBytes(_ key: String) -> [UInt8]? {
|
||||||
|
read(key)?.bytes
|
||||||
|
}
|
||||||
|
|
||||||
private func read(_ key: String) -> Value? {
|
private func read(_ key: String) -> Value? {
|
||||||
guard let info = keyInfo(for: key) else { return nil }
|
guard let info = keyInfo(for: key) else { return nil }
|
||||||
var command = Param()
|
var command = Param()
|
||||||
|
|||||||
@@ -124,6 +124,35 @@ final class FanService: NSObject, OnyxHelperProtocol {
|
|||||||
reply(report())
|
reply(report())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Ladelimit
|
||||||
|
|
||||||
|
/// Der Key trägt drei Bytes; nur das erste wird verändert. Siehe
|
||||||
|
/// `ChargeSafety` für die Messung, auf der das beruht.
|
||||||
|
func chargeLimit(reply: @escaping (Int) -> Void) {
|
||||||
|
lastHeartbeat = Date()
|
||||||
|
guard let smc, let bytes = smc.readBytes(ChargeSafety.key),
|
||||||
|
let percent = ChargeSafety.decode(bytes) else {
|
||||||
|
reply(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply(percent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setChargeLimit(percent: Int, reply: @escaping (Int) -> Void) {
|
||||||
|
lastHeartbeat = Date()
|
||||||
|
guard let smc else { reply(-1); return }
|
||||||
|
|
||||||
|
let wanted = ChargeSafety.clamp(percent)
|
||||||
|
let written = smc.writeBytes(ChargeSafety.key, ChargeSafety.encode(wanted))
|
||||||
|
|
||||||
|
// Immer zurücklesen. Ein Schreibvorgang, den der SMC stillschweigend
|
||||||
|
// verwirft, darf nicht als Erfolg gemeldet werden — sonst zeigt die
|
||||||
|
// Oberfläche eine Grenze an, die es nicht gibt.
|
||||||
|
let readBack = smc.readBytes(ChargeSafety.key).flatMap(ChargeSafety.decode) ?? -1
|
||||||
|
log.notice("Ladelimit: \(percent, privacy: .public) % gewünscht, \(wanted, privacy: .public) % geschrieben (\(written, privacy: .public)), zurückgelesen \(readBack, privacy: .public) %")
|
||||||
|
reply(readBack)
|
||||||
|
}
|
||||||
|
|
||||||
func setFanAutomatic(index: Int, reply: @escaping (Data?) -> Void) {
|
func setFanAutomatic(index: Int, reply: @escaping (Data?) -> Void) {
|
||||||
lastHeartbeat = Date()
|
lastHeartbeat = Date()
|
||||||
requests[index] = nil
|
requests[index] = nil
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,15 @@ import Foundation
|
|||||||
|
|
||||||
/// „Ich lebe noch." Bleibt er aus, fallen alle Lüfter auf Automatik zurück.
|
/// „Ich lebe noch." Bleibt er aus, fallen alle Lüfter auf Automatik zurück.
|
||||||
func heartbeat(reply: @escaping (Bool) -> Void)
|
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.
|
/// Was der Helfer über einen Lüfter berichtet.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user