Files
onyx/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift
Guido Schmit a4cc75ab6e CPU und GPU zwischen Auslastung und Temperatur umschaltbar
Die M-Serie-Konvention gilt auch auf dem M5: Tp* sind die CPU-Kerne, Tg* die
GPU-Cluster. Gezeigt wird der wärmste Punkt, nicht der Durchschnitt —
gedrosselt wird nach dem heißesten Kern. Gemessen: CPU 76 °C, GPU 70 °C.

Die Fühlerliste wird zur Laufzeit gesucht statt fest verdrahtet: welche es
gibt, hängt am Modell. Auf diesem Mac sind es 285 Temperaturfühler.

Zwei Leistungsprobleme, gefunden bevor sie jemandem auffallen konnten:

Die Suche geht alle 3486 Keys durch und braucht knapp eine Sekunde. Auf dem
Hauptthread wäre das eine spürbare Startverzögerung — sie läuft jetzt im
Hintergrund, bis dahin zeigt die Oberfläche "—" statt einer erfundenen Zahl.

Fünfzig Fühler zu lesen kostete 34 ms je Abtastung, bei 1 Hz also 3,4 % CPU.
Zwei Maßnahmen: Typ und Größe je Key werden zwischengespeichert (sie stehen in
der Firmware fest, sie bei jedem Lesen zu erfragen verdoppelte die
IOKit-Aufrufe), und Temperaturen werden höchstens alle zwei Sekunden neu
gelesen — sie bewegen sich in einer Sekunde ohnehin kaum. Ergebnis: 6 ms
Grundlast, 18 ms alle zwei Sekunden, im Mittel gut 1 %.

Dabei noch eine Eigenheit gefunden: die Byte-Reihenfolge im SMC ist nicht
durchgängig. Messwerte wie B0AV kommen little-endian aus dem Akku-Baustein,
die Metadaten des SMC selbst dagegen big-endian. #KEY little-endian gelesen
ergibt 2 651 652 096 statt 3486 — die Fühlersuche brach damit still ab und
alle Temperaturen blieben leer.

Lüfterdrehzahlen stehen jetzt im Sensor-Popover, mit Balken zwischen Minimum
und Maximum der Firmware: "2321 U/min" allein sagt niemandem, ob das viel ist.
Die Steuerung selbst kommt mit Phase 6 — Schreibzugriff auf den SMC verlangt
root — und der Popover sagt das auch.

145 Tests grün.
2026-08-10 22:05:38 +02:00

327 lines
12 KiB
Swift

import AppKit
import SwiftUI
import OnyxDesign
import OnyxMenuBar
/// Ein Menüleisten-Modul je Hardwaregröße.
///
/// Teilt sich das Modell und damit die Messschleife mit dem Panel-Widget
/// derselben Größe. Wer CPU im Panel **und** in der Menüleiste zeigt, bekommt
/// trotzdem nur eine Messung.
@MainActor
public final class MetricMenuBarModule: MenuBarModule {
public let id: String
public var displayName: String {
String(localized: .init(metric.localizationKey), bundle: .module)
}
private let metric: MetricKind
private let model: MetricsModel
private var token: UUID?
private var view: MetricStatusView?
private var observation: (any NSObjectProtocol)?
public init(metric: MetricKind, model: MetricsModel) {
self.metric = metric
self.model = model
self.id = "metric.\(metric.rawValue)"
}
public func makeStatusView(presentation: MenuBarPresentation) -> NSView {
let view = MetricStatusView(metric: metric, presentation: presentation)
view.update(snapshot: model.snapshot, history: model.series(metric),
mode: model.displayMode(for: metric))
self.view = view
return view
}
public func makePopoverView() -> AnyView {
AnyView(MetricPopover(model: model, metric: metric))
}
public func activate() {
guard token == nil else { return }
token = model.addConsumer(interval: 2)
startObserving()
}
public func deactivate() {
if let token { model.removeConsumer(token) }
token = nil
observation = nil
view = nil
}
/// `withObservationTracking` meldet sich **einmal** und muss danach neu
/// eingerichtet werden sonst friert die Anzeige nach der ersten Messung ein.
private func startObserving() {
withObservationTracking {
_ = model.snapshot
_ = model.displayModes // auch auf den Moduswechsel reagieren
} onChange: {
Task { @MainActor [weak self] in
guard let self, token != nil else { return }
view?.update(snapshot: model.snapshot, history: model.series(metric),
mode: model.displayMode(for: metric))
startObserving()
}
}
}
}
/// Zeichnet den Messwert in die Menüleiste.
///
/// Eigene `NSView` statt SwiftUI: die Menüleiste verlangt eine feste Breite je
/// Darstellungsart, sonst springt bei jeder Messung die ganze Leiste. Mit
/// SwiftUI müsste man diese Breite doppelt führen einmal für das Layout und
/// einmal für `NSStatusItem.length`.
final class MetricStatusView: NSView {
private let metric: MetricKind
private let presentation: MenuBarPresentation
private var snapshot = MetricsSnapshot()
private var history: [Double] = []
private var mode: MetricDisplayMode = .usage
init(metric: MetricKind, presentation: MenuBarPresentation) {
self.metric = metric
self.presentation = presentation
super.init(frame: NSRect(x: 0, y: 0, width: presentation.width, height: 22))
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override var intrinsicContentSize: NSSize {
NSSize(width: presentation.width, height: 22)
}
func update(snapshot: MetricsSnapshot, history: [Double], mode: MetricDisplayMode) {
self.snapshot = snapshot
self.history = history
self.mode = mode
needsDisplay = true
}
override func draw(_ dirtyRect: NSRect) {
// `labelColor` statt der Onyx-Farben: die Menüleiste ist mal hell, mal
// dunkel, und nur die Systemfarbe passt sich beidem an.
let color = NSColor.labelColor
switch presentation {
case .value:
drawText(MetricSummary.value(metric, snapshot, mode: mode), color: color)
case .symbol:
drawSymbol(color: color)
case .graph:
drawGraph(color: color, in: bounds.insetBy(dx: 2, dy: 5))
case .bars:
drawBars(color: color)
case .valueAndGraph:
let split = bounds.width * 0.55
drawText(MetricSummary.value(metric, snapshot, mode: mode), color: color,
in: NSRect(x: 0, y: 0, width: split, height: bounds.height))
drawGraph(color: color,
in: NSRect(x: split + 2, y: 5,
width: bounds.width - split - 4, height: bounds.height - 10))
}
}
private func drawText(_ text: String, color: NSColor, in rect: NSRect? = nil) {
let attributes: [NSAttributedString.Key: Any] = [
// Feste Ziffernbreite: ohne sie wackelt der Text bei jeder Messung.
.font: NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular),
.foregroundColor: color,
]
let string = NSAttributedString(string: text, attributes: attributes)
let area = rect ?? bounds
let size = string.size()
string.draw(at: NSPoint(x: area.midX - size.width / 2,
y: area.midY - size.height / 2))
}
private func drawSymbol(color: NSColor) {
guard let image = NSImage(systemSymbolName: metric.symbolName,
accessibilityDescription: nil) else { return }
image.isTemplate = true
let side: CGFloat = 15
let rect = NSRect(x: bounds.midX - side / 2, y: bounds.midY - side / 2,
width: side, height: side)
color.set()
image.draw(in: rect)
}
private func drawGraph(color: NSColor, in rect: NSRect) {
guard history.count > 1, let context = NSGraphicsContext.current?.cgContext else { return }
let step = rect.width / CGFloat(history.count - 1)
context.setStrokeColor(color.withAlphaComponent(0.85).cgColor)
context.setLineWidth(1)
context.setLineJoin(.round)
for (index, value) in history.enumerated() {
let point = CGPoint(x: rect.minX + CGFloat(index) * step,
y: rect.minY + rect.height * CGFloat(min(max(value, 0), 1)))
index == 0 ? context.move(to: point) : context.addLine(to: point)
}
context.strokePath()
}
/// Ein Balken je Kern. Bei fünfzehn Kernen ist das kein Diagramm mehr,
/// sondern ein Muster aber genau daran erkennt man auf einen Blick, ob
/// eine einzelne Last läuft oder alles ausgelastet ist.
private func drawBars(color: NSColor) {
// Bei der CPU-Auslastung ein Balken je Kern; sonst auch bei der
// CPU-Temperatur ein einzelner Balken.
let values = (metric == .cpu && mode == .usage)
? snapshot.cpu.perCore
: [MetricSummary.fraction(metric, snapshot, mode: mode)]
guard !values.isEmpty, let context = NSGraphicsContext.current?.cgContext else { return }
let area = bounds.insetBy(dx: 2, dy: 5)
let gap: CGFloat = values.count > 4 ? 1 : 2
let width = (area.width - gap * CGFloat(values.count - 1)) / CGFloat(values.count)
for (index, value) in values.enumerated() {
let height = max(area.height * CGFloat(min(max(value, 0), 1)), 1)
let rect = NSRect(x: area.minX + CGFloat(index) * (width + gap),
y: area.minY, width: width, height: height)
context.setFillColor(color.withAlphaComponent(0.85).cgColor)
context.fill(rect)
}
}
}
extension MenuBarPresentation {
/// Feste Breite je Darstellungsart.
///
/// Ohne sie ändert das Element bei jedem Messwert seine Größe und schiebt
/// alle Symbole rechts davon hin und her das fällt in der Menüleiste
/// sofort unangenehm auf.
var width: CGFloat {
switch self {
case .value: 42
case .symbol: 22
case .graph: 34
case .bars: 30
case .valueAndGraph: 74
}
}
}
public extension MetricSummary {
/// Der Messwert als Anteil von 0 bis 1 für Balken und Graphen.
static func fraction(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> Double {
switch metric {
case .cpu: snapshot.cpu.total
case .gpu: snapshot.gpu ?? 0
case .memory: snapshot.memory.usedFraction
case .battery: snapshot.battery?.charge ?? 0
case .temperature: (snapshot.sensors.map(\.celsius).max() ?? 0) / 100
}
}
}
// MARK: - Popover
private struct MetricPopover: View {
let model: MetricsModel
let metric: MetricKind
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: .init(metric.localizationKey), bundle: .module))
.font(.headline)
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(MetricSummary.value(metric, model.snapshot))
.font(.system(size: 26, weight: .medium).monospacedDigit())
if let detail = MetricSummary.detail(metric, model.snapshot) {
Text(detail).font(.callout).foregroundStyle(.secondary)
}
}
Sparkline(values: model.series(metric), tint: .accentColor)
.frame(width: 220, height: 44)
if metric == .cpu, !model.snapshot.cpu.perCore.isEmpty {
Text("metric.cpu.perCore", bundle: .module)
.font(.caption).foregroundStyle(.secondary)
CoreBars(values: model.snapshot.cpu.perCore)
.frame(height: 26)
}
if metric == .temperature {
ForEach(model.snapshot.sensors) { sensor in
HStack {
Text(sensor.name).font(.callout)
Spacer()
Text(MetricFormat.temperature(sensor.celsius))
.font(.callout.monospacedDigit())
.foregroundStyle(.secondary)
}
}
if !model.snapshot.fans.isEmpty {
Divider()
ForEach(model.snapshot.fans) { fan in
VStack(alignment: .leading, spacing: 2) {
HStack {
Label("Lüfter \(fan.index + 1)", systemImage: "fan")
.font(.callout)
Spacer()
Text("\(Int(fan.rpm)) U/min")
.font(.callout.monospacedDigit())
.foregroundStyle(.secondary)
}
// Der Balken zeigt die Drehzahl zwischen Minimum und
// Maximum der Firmware 2321 U/min" allein sagt
// niemandem, ob das viel ist.
ProgressView(value: fan.fraction).controlSize(.small)
}
}
Text("metric.fan.controlComing", bundle: .module)
.font(.caption)
.foregroundStyle(.secondary)
}
}
if metric.supportsDisplayModes {
Divider()
Picker("", selection: Binding(
get: { model.displayMode(for: metric) },
set: { model.setDisplayMode($0, for: metric) })) {
Text("metric.mode.usage", bundle: .module).tag(MetricDisplayMode.usage)
Text("metric.mode.temperature", bundle: .module)
.tag(MetricDisplayMode.temperature)
}
.pickerStyle(.segmented)
.labelsHidden()
}
}
.padding(14)
.frame(width: 250)
}
}
private struct CoreBars: View {
let values: [Double]
var body: some View {
GeometryReader { geometry in
let gap: CGFloat = 2
let width = (geometry.size.width - gap * CGFloat(values.count - 1))
/ CGFloat(values.count)
HStack(alignment: .bottom, spacing: gap) {
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
RoundedRectangle(cornerRadius: 1, style: .continuous)
.fill(Color.accentColor)
.frame(width: width,
height: max(geometry.size.height * value, 1))
}
}
.frame(height: geometry.size.height, alignment: .bottom)
}
}
}