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>
189 lines
6.8 KiB
Swift
189 lines
6.8 KiB
Swift
import SwiftUI
|
|
import OnyxHelperProtocol
|
|
|
|
/// Die Lüftersteuerung in den Einstellungen.
|
|
///
|
|
/// Bewusst hier und nicht im Notch-Panel: ein Regler, der die Kühlung des
|
|
/// Rechners verstellt, gehört nicht dorthin, wo man mit dem Mauszeiger
|
|
/// versehentlich hinkommt.
|
|
struct FanSettingsView: View {
|
|
@Bindable var control: FanControl
|
|
|
|
var body: some View {
|
|
Form {
|
|
switch control.installState {
|
|
case .notInstalled, .failed:
|
|
installSection
|
|
case .requiresApproval:
|
|
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)
|
|
.padding()
|
|
.onAppear {
|
|
control.refreshInstallState()
|
|
control.start()
|
|
}
|
|
.onDisappear { control.stop() }
|
|
}
|
|
|
|
// MARK: - Einrichtung
|
|
|
|
private var installSection: some View {
|
|
Section {
|
|
Text("fans.intro")
|
|
.font(.callout)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
// Das Risiko benennen, bevor jemand zustimmt — nicht danach.
|
|
Label("fans.warning", systemImage: "exclamationmark.triangle.fill")
|
|
.font(.callout)
|
|
.foregroundStyle(.orange)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Text("fans.safety")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
if case .failed(let message) = control.installState {
|
|
Text(message).font(.callout).foregroundStyle(.red)
|
|
}
|
|
|
|
Button("fans.install") { control.install() }
|
|
}
|
|
}
|
|
|
|
private var approvalSection: some View {
|
|
Section {
|
|
Label("fans.approval.needed", systemImage: "hand.raised")
|
|
.font(.callout)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
Button("fans.approval.open") { control.openApprovalSettings() }
|
|
Button("fans.approval.recheck") { control.refreshInstallState() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Steuerung
|
|
|
|
@ViewBuilder
|
|
private var controlSection: some View {
|
|
if let report = control.report {
|
|
Section {
|
|
if let hottest = report.hottestCelsius {
|
|
HStack {
|
|
Label("fans.hottest", systemImage: "thermometer")
|
|
Spacer()
|
|
Text("\(Int(hottest))°").monospacedDigit()
|
|
.foregroundStyle(hottest >= FanSafety.criticalCelsius
|
|
? .red : .secondary)
|
|
}
|
|
}
|
|
ForEach(report.fans, id: \.index) { fan in
|
|
FanRow(fan: fan, control: control)
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Text("fans.safety.active")
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
Button("fans.uninstall", role: .destructive) {
|
|
Task { await control.uninstall() }
|
|
}
|
|
}
|
|
} else {
|
|
Section {
|
|
HStack {
|
|
ProgressView().controlSize(.small)
|
|
Text("fans.connecting").foregroundStyle(.secondary)
|
|
}
|
|
if let error = control.lastError {
|
|
Text(error).font(.callout).foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct FanRow: View {
|
|
let fan: FanStatus
|
|
let control: FanControl
|
|
|
|
@State private var target: Double = 0
|
|
@State private var editing = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Label(name, systemImage: "fan")
|
|
Spacer()
|
|
Text("\(Int(fan.currentRPM)) U/min").monospacedDigit().foregroundStyle(.secondary)
|
|
}
|
|
|
|
Picker("", selection: Binding(
|
|
get: { fan.isManual },
|
|
set: { manual in
|
|
if manual {
|
|
control.setTarget(index: fan.index, rpm: max(target, fan.limits.minimum))
|
|
} else {
|
|
control.setAutomatic(index: fan.index)
|
|
}
|
|
})) {
|
|
Text("fans.mode.auto").tag(false)
|
|
Text("fans.mode.manual").tag(true)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
|
|
if fan.isManual, fan.limits.isUsable {
|
|
HStack {
|
|
Text("\(Int(fan.limits.minimum))").font(.caption2).foregroundStyle(.secondary)
|
|
Slider(value: $target,
|
|
in: fan.limits.minimum...fan.limits.maximum,
|
|
onEditingChanged: { active in
|
|
editing = active
|
|
// Erst beim Loslassen setzen: jede Bewegung des
|
|
// Reglers wäre sonst ein SMC-Schreibvorgang.
|
|
if !active { control.setTarget(index: fan.index, rpm: target) }
|
|
})
|
|
Text("\(Int(fan.limits.maximum))").font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
Text("\(Int(target)) U/min").font(.caption).monospacedDigit()
|
|
}
|
|
|
|
// Ein erzwungener Rückfall wird erklärt, nicht verschwiegen.
|
|
if let reason = fan.automaticReason, reason != "noRequest" {
|
|
Label(explanation(for: reason), systemImage: "shield.lefthalf.filled")
|
|
.font(.caption)
|
|
.foregroundStyle(.orange)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
.onAppear { if !editing { target = max(fan.targetRPM, fan.limits.minimum) } }
|
|
.onChange(of: fan.targetRPM) { _, new in if !editing { target = new } }
|
|
}
|
|
|
|
private var name: String {
|
|
fan.index == 0 ? String(localized: "fans.left") : String(localized: "fans.right")
|
|
}
|
|
|
|
private func explanation(for reason: String) -> LocalizedStringKey {
|
|
switch reason {
|
|
case "watchdog": "fans.reason.watchdog"
|
|
case "temperature": "fans.reason.temperature"
|
|
case "noTemperature": "fans.reason.noTemperature"
|
|
case "unknownLimits": "fans.reason.unknownLimits"
|
|
default: "fans.reason.other"
|
|
}
|
|
}
|
|
}
|