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>
264 lines
9.7 KiB
Swift
264 lines
9.7 KiB
Swift
import SwiftUI
|
||
import AppKit
|
||
import OnyxDesign
|
||
import OnyxWidgetKit
|
||
import OnyxMenuBar
|
||
import OnyxHelperProtocol
|
||
|
||
/// Die Lüfter — als Panel-Kachel und als Menüleistenmodul.
|
||
///
|
||
/// Beide zeigen dieselbe Ansicht auf derselben Datenquelle. Die Steuerung nur
|
||
/// in den Einstellungen zu verstecken war falsch: Lüfter regelt man, wenn es
|
||
/// gerade laut ist, und dann will man nicht erst ein Fenster suchen.
|
||
struct FanView: View {
|
||
let control: FanControl
|
||
/// Im Popover ist Platz für die Erklärung, in der Kachel nicht.
|
||
let roomy: Bool
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
switch control.installState {
|
||
case .installed:
|
||
if let report = control.report, !report.fans.isEmpty {
|
||
ForEach(Array(report.fans.enumerated()), id: \.offset) { index, fan in
|
||
FanRow(control: control, index: index, fan: fan, roomy: roomy)
|
||
}
|
||
Divider().overlay(Onyx.Color.hairline)
|
||
ChargeLimitView(control: control, roomy: roomy)
|
||
|
||
if let hottest = report.hottestCelsius {
|
||
Divider().overlay(Onyx.Color.hairline)
|
||
HStack(spacing: 5) {
|
||
Image(systemName: "thermometer.medium")
|
||
.font(.system(size: 10))
|
||
Text("\(Int(hottest.rounded())) °C")
|
||
.font(Onyx.Font.metricSmall).monospacedDigit()
|
||
Spacer(minLength: 0)
|
||
}
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
}
|
||
} else {
|
||
notice("fans.noData", symbol: "fan")
|
||
}
|
||
case .requiresApproval:
|
||
notice("fans.approval.needed", symbol: "hand.raised") {
|
||
control.openApprovalSettings()
|
||
}
|
||
case .notInstalled:
|
||
notice("fans.notInstalled", symbol: "fan") { control.install() }
|
||
case .failed(let reason):
|
||
Text(reason)
|
||
.font(Onyx.Font.caption)
|
||
.foregroundStyle(Onyx.Color.critical)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
.onAppear { control.refreshInstallState() }
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func notice(_ key: LocalizedStringKey, symbol: String,
|
||
action: (() -> Void)? = nil) -> some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Image(systemName: symbol)
|
||
.font(.system(size: 15, weight: .light))
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
Text(key)
|
||
.font(Onyx.Font.caption)
|
||
.foregroundStyle(Onyx.Color.textSecondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
if let action {
|
||
Button(action: action) {
|
||
Text("fans.install")
|
||
.font(Onyx.Font.caption)
|
||
.foregroundStyle(Onyx.Color.surface)
|
||
.padding(.horizontal, 10).padding(.vertical, 4)
|
||
.background(Onyx.Color.accent, in: .capsule)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct FanRow: View {
|
||
let control: FanControl
|
||
let index: Int
|
||
let fan: FanStatus
|
||
let roomy: Bool
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack(spacing: 6) {
|
||
Text("fans.fan \(fan.index + 1)")
|
||
.font(Onyx.Font.caption)
|
||
.foregroundStyle(Onyx.Color.textSecondary)
|
||
.lineLimit(1)
|
||
Spacer(minLength: 4)
|
||
Text("\(Int(fan.currentRPM)) U/min")
|
||
.font(Onyx.Font.metricSmall).monospacedDigit()
|
||
.foregroundStyle(Onyx.Color.textPrimary)
|
||
}
|
||
|
||
HStack(spacing: 8) {
|
||
// Automatik ist der Ruhezustand und bleibt einen Klick entfernt:
|
||
// wer manuell geregelt hat, muss ohne Nachdenken zurückkönnen.
|
||
// Als gefüllte Pille, wenn sie greift — ein blauer Text sieht
|
||
// aus wie ein Link und nicht wie ein Zustand.
|
||
Button { control.setAutomatic(index: index) } label: {
|
||
Text("fans.auto")
|
||
.font(Onyx.Font.metricSmall)
|
||
.foregroundStyle(fan.isManual ? Onyx.Color.textTertiary
|
||
: Onyx.Color.surface)
|
||
.padding(.horizontal, 7)
|
||
.padding(.vertical, 2)
|
||
.background {
|
||
Capsule()
|
||
.fill(fan.isManual ? Color.clear : Onyx.Color.accent)
|
||
.overlay {
|
||
Capsule().strokeBorder(
|
||
fan.isManual ? Onyx.Color.hairline : .clear,
|
||
lineWidth: 1)
|
||
}
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(!fan.isManual)
|
||
|
||
// Ein Regler über einen leeren Bereich stürzt ab. Meldet der
|
||
// Helfer keine brauchbaren Grenzen — etwa weil der Lüfter steht
|
||
// und der SMC nichts hergibt —, gibt es nichts zu ziehen.
|
||
if fan.limits.maximum > fan.limits.minimum {
|
||
Slider(value: Binding(
|
||
get: { min(max(fan.targetRPM, fan.limits.minimum), fan.limits.maximum) },
|
||
set: { control.setTarget(index: index, rpm: $0) }),
|
||
in: fan.limits.minimum...fan.limits.maximum)
|
||
.controlSize(.mini)
|
||
} else {
|
||
Text("fans.noRange")
|
||
.font(Onyx.Font.metricSmall)
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Panel-Kachel
|
||
|
||
struct FanWidget: OnyxWidget {
|
||
let id = "fans"
|
||
var displayName: String { String(localized: "widget.fans.name") }
|
||
let symbolName = "fan"
|
||
|
||
private let control: FanControl
|
||
|
||
init(control: FanControl) { self.control = control }
|
||
|
||
func makeView() -> AnyView {
|
||
AnyView(ScrollView { FanView(control: control, roomy: false) }
|
||
.scrollIndicators(.never))
|
||
}
|
||
}
|
||
|
||
// MARK: - Menüleistenmodul
|
||
|
||
@MainActor
|
||
final class FanMenuBarModule: MenuBarModule {
|
||
let id = "fans"
|
||
var displayName: String { String(localized: "widget.fans.name") }
|
||
|
||
private let control: FanControl
|
||
private var view: FanStatusView?
|
||
private var isActive = false
|
||
|
||
init(control: FanControl) { self.control = control }
|
||
|
||
func makeStatusView(presentation: MenuBarPresentation) -> NSView {
|
||
let view = FanStatusView(presentation: presentation)
|
||
view.update(rpm: control.report?.fans.map(\.currentRPM).max())
|
||
self.view = view
|
||
return view
|
||
}
|
||
|
||
func makePopoverView() -> AnyView {
|
||
AnyView(FanView(control: control, roomy: true)
|
||
.padding(14)
|
||
.frame(width: 260))
|
||
}
|
||
|
||
func activate() {
|
||
isActive = true
|
||
control.start()
|
||
startObserving()
|
||
}
|
||
|
||
func deactivate() {
|
||
isActive = false
|
||
view = nil
|
||
}
|
||
|
||
/// Keine eigene Messschleife: `FanControl` fragt den Helfer ohnehin ab, hier
|
||
/// wird nur zugehört. `withObservationTracking` meldet sich **einmal** und
|
||
/// muss danach neu eingerichtet werden.
|
||
private func startObserving() {
|
||
withObservationTracking {
|
||
_ = control.report
|
||
} onChange: {
|
||
Task { @MainActor [weak self] in
|
||
guard let self, isActive else { return }
|
||
view?.update(rpm: control.report?.fans.map(\.currentRPM).max())
|
||
startObserving()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Die Drehzahl in der Menüleiste.
|
||
private final class FanStatusView: NSView, WidthReporting {
|
||
private let presentation: MenuBarPresentation
|
||
private var rpm: Double?
|
||
|
||
init(presentation: MenuBarPresentation) {
|
||
self.presentation = presentation
|
||
super.init(frame: NSRect(x: 0, y: 0, width: 22, height: 22))
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) { fatalError() }
|
||
|
||
override var intrinsicContentSize: NSSize {
|
||
NSSize(width: MenuBarText.width(for: reference, includesSymbol: true), height: 22)
|
||
}
|
||
|
||
/// Die breiteste Zahl, die vorkommen kann — nicht die aktuelle. Sonst
|
||
/// änderte das Element bei jeder Messung seine Breite.
|
||
private var reference: String { presentation == .symbol ? "" : "9999" }
|
||
|
||
var onWidthChange: ((CGFloat) -> Void)?
|
||
|
||
func update(rpm: Double?) {
|
||
self.rpm = rpm
|
||
needsDisplay = true
|
||
}
|
||
|
||
override func draw(_ dirtyRect: NSRect) {
|
||
let color = NSColor.labelColor
|
||
|
||
if presentation == .symbol {
|
||
MenuBarText.drawSymbol("fan", color: color,
|
||
in: NSRect(x: bounds.midX - 7.5, y: bounds.midY - 7.5,
|
||
width: 15, height: 15))
|
||
return
|
||
}
|
||
|
||
MenuBarText.drawSymbol("fan", color: color,
|
||
in: NSRect(x: 0, y: bounds.midY - 6.5, width: 13, height: 13))
|
||
MenuBarText.draw(rpm.map { String(Int($0.rounded())) } ?? "–",
|
||
color: color,
|
||
in: NSRect(x: 15, y: 0, width: bounds.width - 15, height: bounds.height))
|
||
}
|
||
}
|