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.
This commit is contained in:
Guido Schmit
2026-08-10 22:05:38 +02:00
parent 594d7c422a
commit a4cc75ab6e
10 changed files with 914 additions and 520 deletions

View File

@@ -52,6 +52,108 @@
} }
} }
}, },
"metric.battery" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Akku"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Battery"
}
}
}
},
"metric.cpu" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "CPU"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "CPU"
}
}
}
},
"metric.gpu" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "GPU"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "GPU"
}
}
}
},
"metric.memory" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Speicher"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Memory"
}
}
}
},
"metric.sensors" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sensoren"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sensors"
}
}
}
},
"network.name" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Netzwerk"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Network"
}
}
}
},
"settings.display.all" : { "settings.display.all" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -183,6 +285,107 @@
} }
} }
}, },
"settings.menubar.bars" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Balken"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bars"
}
}
}
},
"settings.menubar.graph" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Verlauf"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Graph"
}
}
}
},
"settings.menubar.hint" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Jedes Modul erscheint als eigenes Symbol in der Menüleiste und teilt sich die Messung mit dem Widget im Panel. Standardmäßig ist keines an — die Menüleiste ist knapp, auf einem Notch-Display zusätzlich in der Mitte blockiert."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Each module appears as its own menu bar icon and shares its measurement with the panel widget. None are on by default — the menu bar is tight, and on a notched display the middle is blocked as well."
}
}
}
},
"settings.menubar.symbol" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nur Symbol"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Symbol only"
}
}
}
},
"settings.menubar.value" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Wert"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Value"
}
}
}
},
"settings.menubar.valueAndGraph" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Wert und Verlauf"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Value and graph"
}
}
}
},
"settings.permissions.calendar" : { "settings.permissions.calendar" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -387,6 +590,23 @@
} }
} }
}, },
"settings.tab.menubar" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Menüleiste"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Menu bar"
}
}
}
},
"settings.tab.permissions" : { "settings.tab.permissions" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -520,214 +740,6 @@
} }
} }
} }
},
"settings.tab.menubar": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Menüleiste"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Menu bar"
}
}
}
},
"settings.menubar.hint": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Jedes Modul erscheint als eigenes Symbol in der Menüleiste und teilt sich die Messung mit dem Widget im Panel. Standardmäßig ist keines an — die Menüleiste ist knapp, auf einem Notch-Display zusätzlich in der Mitte blockiert."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Each module appears as its own menu bar icon and shares its measurement with the panel widget. None are on by default — the menu bar is tight, and on a notched display the middle is blocked as well."
}
}
}
},
"settings.menubar.value": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wert"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Value"
}
}
}
},
"settings.menubar.graph": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Verlauf"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Graph"
}
}
}
},
"settings.menubar.bars": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Balken"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Bars"
}
}
}
},
"settings.menubar.valueAndGraph": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wert und Verlauf"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Value and graph"
}
}
}
},
"settings.menubar.symbol": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nur Symbol"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Symbol only"
}
}
}
},
"metric.cpu": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "CPU"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "CPU"
}
}
}
},
"metric.gpu": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "GPU"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "GPU"
}
}
}
},
"metric.memory": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Speicher"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Memory"
}
}
}
},
"metric.battery": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Akku"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Battery"
}
}
}
},
"metric.sensors": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Sensoren"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Sensors"
}
}
}
},
"network.name": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Netzwerk"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Network"
}
}
}
} }
}, },
"version" : "1.0" "version" : "1.0"

View File

@@ -258,7 +258,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private func showSettings(tab: SettingsTab) { private func showSettings(tab: SettingsTab) {
guard let model, let calendarModel, let weatherModel else { return } guard let model, let calendarModel, let weatherModel else { return }
settingsWindow.show(model: model, calendarModel: calendarModel, settingsWindow.show(model: model, calendarModel: calendarModel,
weatherModel: weatherModel, tab: tab) weatherModel: weatherModel, metricsModel: metricsModel, tab: tab)
} }
@objc private func quit() { NSApp.terminate(nil) } @objc private func quit() { NSApp.terminate(nil) }

View File

@@ -4,6 +4,7 @@ import OnyxNotch
import OnyxWidgetKit import OnyxWidgetKit
import CalendarProvider import CalendarProvider
import OnyxMenuBar import OnyxMenuBar
import MetricsProvider
import WeatherProvider import WeatherProvider
enum SettingsTab: Hashable { enum SettingsTab: Hashable {
@@ -14,6 +15,7 @@ struct SettingsView: View {
@Bindable var model: AppModel @Bindable var model: AppModel
let calendarModel: CalendarModel let calendarModel: CalendarModel
let weatherModel: WeatherModel let weatherModel: WeatherModel
let metricsModel: MetricsModel?
@Binding var selectedTab: SettingsTab @Binding var selectedTab: SettingsTab
var body: some View { var body: some View {
@@ -24,7 +26,7 @@ struct SettingsView: View {
DisplaySettings(model: model) DisplaySettings(model: model)
.tabItem { Label("settings.tab.display", systemImage: "macbook") } .tabItem { Label("settings.tab.display", systemImage: "macbook") }
.tag(SettingsTab.display) .tag(SettingsTab.display)
MenuBarSettings(model: model) MenuBarSettings(model: model, metricsModel: metricsModel)
.tabItem { Label("settings.tab.menubar", systemImage: "menubar.rectangle") } .tabItem { Label("settings.tab.menubar", systemImage: "menubar.rectangle") }
.tag(SettingsTab.menubar) .tag(SettingsTab.menubar)
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel) PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel)
@@ -160,6 +162,7 @@ private struct PermissionRow: View {
/// blockiert die Kamera zusätzlich die Mitte. /// blockiert die Kamera zusätzlich die Mitte.
private struct MenuBarSettings: View { private struct MenuBarSettings: View {
@Bindable var model: AppModel @Bindable var model: AppModel
let metricsModel: MetricsModel?
private static let modules: [(id: String, label: LocalizedStringKey, symbol: String)] = [ private static let modules: [(id: String, label: LocalizedStringKey, symbol: String)] = [
("metric.cpu", "metric.cpu", "cpu"), ("metric.cpu", "metric.cpu", "cpu"),
@@ -180,7 +183,8 @@ private struct MenuBarSettings: View {
List { List {
ForEach(Self.modules, id: \.id) { module in ForEach(Self.modules, id: \.id) { module in
ModuleRow(model: model, id: module.id, ModuleRow(model: model, id: module.id,
label: module.label, symbol: module.symbol) label: module.label, symbol: module.symbol,
metricsModel: metricsModel)
} }
} }
.listStyle(.inset) .listStyle(.inset)
@@ -197,6 +201,13 @@ private struct ModuleRow: View {
let id: String let id: String
let label: LocalizedStringKey let label: LocalizedStringKey
let symbol: String let symbol: String
let metricsModel: MetricsModel?
/// Zu welcher Hardwaregröße dieses Modul gehört `nil` beim Netzwerk.
private var metricKind: MetricKind? {
guard id.hasPrefix("metric.") else { return nil }
return MetricKind(rawValue: String(id.dropFirst("metric.".count)))
}
var body: some View { var body: some View {
let settings = model.menuBarSettings(for: id) let settings = model.menuBarSettings(for: id)
@@ -214,6 +225,21 @@ private struct ModuleRow: View {
Spacer() Spacer()
// Bei CPU und GPU zusätzlich wählbar, was gezeigt wird. Beide Werte
// stammen aus derselben Messung ein zweites Element in der
// ohnehin knappen Menüleiste wäre Verschwendung.
if let metric = metricKind, metric.supportsDisplayModes, let metricsModel {
Picker("", selection: Binding(
get: { metricsModel.displayMode(for: metric) },
set: { metricsModel.setDisplayMode($0, for: metric) })) {
Text("metric.mode.usage").tag(MetricDisplayMode.usage)
Text("metric.mode.temperature").tag(MetricDisplayMode.temperature)
}
.labelsHidden()
.frame(width: 110)
.disabled(!settings.isEnabled)
}
Picker("", selection: Binding( Picker("", selection: Binding(
get: { settings.presentation }, get: { settings.presentation },
set: { presentation in set: { presentation in

View File

@@ -2,6 +2,7 @@ import AppKit
import SwiftUI import SwiftUI
import CalendarProvider import CalendarProvider
import WeatherProvider import WeatherProvider
import MetricsProvider
/// Führt das Einstellungsfenster selbst. /// Führt das Einstellungsfenster selbst.
/// ///
@@ -25,7 +26,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
var keepsDockIcon = false var keepsDockIcon = false
func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel, func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel,
tab: SettingsTab = .widgets) { metricsModel: MetricsModel?, tab: SettingsTab = .widgets) {
selectedTab = tab selectedTab = tab
keepsDockIcon = model.showsDockIcon keepsDockIcon = model.showsDockIcon
@@ -42,6 +43,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
rootView: SettingsView(model: model, rootView: SettingsView(model: model,
calendarModel: calendarModel, calendarModel: calendarModel,
weatherModel: weatherModel, weatherModel: weatherModel,
metricsModel: metricsModel,
selectedTab: Binding( selectedTab: Binding(
get: { [weak self] in self?.selectedTab ?? .widgets }, get: { [weak self] in self?.selectedTab ?? .widgets },
set: { [weak self] in self?.selectedTab = $0 }))) set: { [weak self] in self?.selectedTab = $0 })))

View File

@@ -1,6 +1,42 @@
{ {
"sourceLanguage": "en", "sourceLanguage": "en",
"strings": { "strings": {
"%lld": {
"comment": "A label displaying the speed of a fan. The argument is the speed of the fan, in revolutions per minute.",
"isCommentAutoGenerated": true
},
"metric.battery": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Akku"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Battery"
}
}
}
},
"metric.battery.charging": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Lädt"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Charging"
}
}
}
},
"metric.cpu": { "metric.cpu": {
"localizations": { "localizations": {
"de": { "de": {
@@ -17,6 +53,22 @@
} }
} }
}, },
"metric.cpu.perCore": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Je Kern"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Per core"
}
}
}
},
"metric.gpu": { "metric.gpu": {
"localizations": { "localizations": {
"de": { "de": {
@@ -49,22 +101,6 @@
} }
} }
}, },
"metric.battery": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Akku"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Battery"
}
}
}
},
"metric.sensors": { "metric.sensors": {
"localizations": { "localizations": {
"de": { "de": {
@@ -97,38 +133,102 @@
} }
} }
}, },
"metric.battery.charging": { "metric.show": {
"localizations": { "localizations": {
"de": { "de": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "Lädt" "value": "Anzeigen"
} }
}, },
"en": { "en": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "Charging" "value": "Show"
} }
} }
} }
}, },
"metric.cpu.perCore": { "metric.mode.usage": {
"localizations": { "localizations": {
"de": { "de": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "Je Kern" "value": "Auslastung"
} }
}, },
"en": { "en": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "Per core" "value": "Usage"
}
}
}
},
"metric.mode.temperature": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Temperatur"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Temperature"
}
}
}
},
"metric.rpm": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "U/min"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "rpm"
}
}
}
},
"metric.fan.controlComing": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Die Lüftersteuerung kommt mit dem privilegierten Helfer — Schreibzugriff auf den SMC verlangt root."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Fan control arrives with the privileged helper — writing to the SMC requires root."
}
}
}
},
"metric.fan %lld": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Lüfter %lld"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Fan %lld"
} }
} }
} }
} }
}, },
"version": "1.0" "version": "1.1"
} }

View File

@@ -30,7 +30,8 @@ public final class MetricMenuBarModule: MenuBarModule {
public func makeStatusView(presentation: MenuBarPresentation) -> NSView { public func makeStatusView(presentation: MenuBarPresentation) -> NSView {
let view = MetricStatusView(metric: metric, presentation: presentation) let view = MetricStatusView(metric: metric, presentation: presentation)
view.update(snapshot: model.snapshot, history: model.series(metric)) view.update(snapshot: model.snapshot, history: model.series(metric),
mode: model.displayMode(for: metric))
self.view = view self.view = view
return view return view
} }
@@ -57,10 +58,12 @@ public final class MetricMenuBarModule: MenuBarModule {
private func startObserving() { private func startObserving() {
withObservationTracking { withObservationTracking {
_ = model.snapshot _ = model.snapshot
_ = model.displayModes // auch auf den Moduswechsel reagieren
} onChange: { } onChange: {
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self, token != nil else { return } guard let self, token != nil else { return }
view?.update(snapshot: model.snapshot, history: model.series(metric)) view?.update(snapshot: model.snapshot, history: model.series(metric),
mode: model.displayMode(for: metric))
startObserving() startObserving()
} }
} }
@@ -79,6 +82,7 @@ final class MetricStatusView: NSView {
private let presentation: MenuBarPresentation private let presentation: MenuBarPresentation
private var snapshot = MetricsSnapshot() private var snapshot = MetricsSnapshot()
private var history: [Double] = [] private var history: [Double] = []
private var mode: MetricDisplayMode = .usage
init(metric: MetricKind, presentation: MenuBarPresentation) { init(metric: MetricKind, presentation: MenuBarPresentation) {
self.metric = metric self.metric = metric
@@ -93,9 +97,10 @@ final class MetricStatusView: NSView {
NSSize(width: presentation.width, height: 22) NSSize(width: presentation.width, height: 22)
} }
func update(snapshot: MetricsSnapshot, history: [Double]) { func update(snapshot: MetricsSnapshot, history: [Double], mode: MetricDisplayMode) {
self.snapshot = snapshot self.snapshot = snapshot
self.history = history self.history = history
self.mode = mode
needsDisplay = true needsDisplay = true
} }
@@ -106,7 +111,7 @@ final class MetricStatusView: NSView {
switch presentation { switch presentation {
case .value: case .value:
drawText(MetricSummary.value(metric, snapshot), color: color) drawText(MetricSummary.value(metric, snapshot, mode: mode), color: color)
case .symbol: case .symbol:
drawSymbol(color: color) drawSymbol(color: color)
case .graph: case .graph:
@@ -115,7 +120,7 @@ final class MetricStatusView: NSView {
drawBars(color: color) drawBars(color: color)
case .valueAndGraph: case .valueAndGraph:
let split = bounds.width * 0.55 let split = bounds.width * 0.55
drawText(MetricSummary.value(metric, snapshot), color: color, drawText(MetricSummary.value(metric, snapshot, mode: mode), color: color,
in: NSRect(x: 0, y: 0, width: split, height: bounds.height)) in: NSRect(x: 0, y: 0, width: split, height: bounds.height))
drawGraph(color: color, drawGraph(color: color,
in: NSRect(x: split + 2, y: 5, in: NSRect(x: split + 2, y: 5,
@@ -166,8 +171,11 @@ final class MetricStatusView: NSView {
/// sondern ein Muster aber genau daran erkennt man auf einen Blick, ob /// sondern ein Muster aber genau daran erkennt man auf einen Blick, ob
/// eine einzelne Last läuft oder alles ausgelastet ist. /// eine einzelne Last läuft oder alles ausgelastet ist.
private func drawBars(color: NSColor) { private func drawBars(color: NSColor) {
let values = metric == .cpu ? snapshot.cpu.perCore // Bei der CPU-Auslastung ein Balken je Kern; sonst auch bei der
: [MetricSummary.fraction(metric, snapshot)] // 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 } guard !values.isEmpty, let context = NSGraphicsContext.current?.cgContext else { return }
let area = bounds.insetBy(dx: 2, dy: 5) let area = bounds.insetBy(dx: 2, dy: 5)
@@ -253,6 +261,42 @@ private struct MetricPopover: View {
.foregroundStyle(.secondary) .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) .padding(14)

View File

@@ -80,6 +80,8 @@ private struct CompactMetric: View {
let model: MetricsModel let model: MetricsModel
let metric: MetricKind let metric: MetricKind
private var mode: MetricDisplayMode { model.displayMode(for: metric) }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Label { Label {
@@ -94,10 +96,11 @@ private struct CompactMetric: View {
Spacer(minLength: 0) Spacer(minLength: 0)
Text(MetricSummary.value(metric, model.snapshot)) Text(MetricSummary.value(metric, model.snapshot, mode: mode))
.font(Onyx.Font.metric) .font(Onyx.Font.metric)
.foregroundStyle(MetricSummary.color(metric, model.snapshot)) .foregroundStyle(MetricSummary.color(metric, model.snapshot, mode: mode))
.animation(Onyx.Motion.value, value: MetricSummary.value(metric, model.snapshot)) .animation(Onyx.Motion.value,
value: MetricSummary.value(metric, model.snapshot, mode: mode))
if let detail = MetricSummary.detail(metric, model.snapshot) { if let detail = MetricSummary.detail(metric, model.snapshot) {
Text(detail) Text(detail)
@@ -120,7 +123,8 @@ private struct WideMetric: View {
CompactMetric(model: model, metric: metric) CompactMetric(model: model, metric: metric)
.frame(width: 84) .frame(width: 84)
Sparkline(values: model.series(metric), Sparkline(values: model.series(metric),
tint: MetricSummary.color(metric, model.snapshot)) tint: MetricSummary.color(metric, model.snapshot,
mode: model.displayMode(for: metric)))
} }
} }
} }
@@ -224,6 +228,37 @@ private struct SensorList: View {
/// Menüleiste nicht auseinanderlaufen. /// Menüleiste nicht auseinanderlaufen.
public enum MetricSummary { public enum MetricSummary {
/// Der anzuzeigende Wert, abhängig vom gewählten Modus.
public static func value(_ metric: MetricKind, _ snapshot: MetricsSnapshot,
mode: MetricDisplayMode) -> String {
guard mode == .temperature, metric.supportsDisplayModes else {
return value(metric, snapshot)
}
let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature
// Solange die Fühlersuche im Hintergrund läuft, gibt es keinen Wert.
// " ist dann die richtige Auskunft, nicht eine 0.
return celsius.map(MetricFormat.temperature) ?? ""
}
public static func color(_ metric: MetricKind, _ snapshot: MetricsSnapshot,
mode: MetricDisplayMode) -> Color {
guard mode == .temperature, metric.supportsDisplayModes else {
return color(metric, snapshot)
}
let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature
return temperatureColor(celsius ?? 0)
}
public static func fraction(_ metric: MetricKind, _ snapshot: MetricsSnapshot,
mode: MetricDisplayMode) -> Double {
guard mode == .temperature, metric.supportsDisplayModes else {
return fraction(metric, snapshot)
}
let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature
// Auf 100 °C normiert: darüber wird ohnehin gedrosselt.
return min(max((celsius ?? 0) / 100, 0), 1)
}
public static func value(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> String { public static func value(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> String {
switch metric { switch metric {
case .cpu: MetricFormat.percent(snapshot.cpu.total) case .cpu: MetricFormat.percent(snapshot.cpu.total)

View File

@@ -22,12 +22,37 @@ public final class MetricsModel {
/// Speicher für etwas, das niemand sieht. /// Speicher für etwas, das niemand sieht.
public static let historyLength = 60 public static let historyLength = 60
/// Was CPU und GPU zeigen. Beobachtbar, damit Panel und Menüleiste sofort
/// umschalten.
public private(set) var displayModes: [MetricKind: MetricDisplayMode] = [:]
private let defaults: UserDefaults
private let source = SystemMetrics() private let source = SystemMetrics()
private var timer: Timer? private var timer: Timer?
/// Je Konsument das gewünschte Intervall. Gemessen wird mit dem kürzesten. /// Je Konsument das gewünschte Intervall. Gemessen wird mit dem kürzesten.
private var demands: [UUID: TimeInterval] = [:] private var demands: [UUID: TimeInterval] = [:]
public init() {} public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
for metric in MetricKind.allCases where metric.supportsDisplayModes {
let stored = defaults.string(forKey: Self.key(metric))
displayModes[metric] = stored.flatMap(MetricDisplayMode.init(rawValue:)) ?? .usage
}
}
public func displayMode(for metric: MetricKind) -> MetricDisplayMode {
displayModes[metric] ?? .usage
}
public func setDisplayMode(_ mode: MetricDisplayMode, for metric: MetricKind) {
guard metric.supportsDisplayModes else { return }
displayModes[metric] = mode
defaults.set(mode.rawValue, forKey: Self.key(metric))
}
private static func key(_ metric: MetricKind) -> String {
"onyx.metric.\(metric.rawValue).displayMode"
}
/// Meldet Bedarf an. Der Rückgabewert wird zum Abmelden gebraucht. /// Meldet Bedarf an. Der Rückgabewert wird zum Abmelden gebraucht.
@discardableResult @discardableResult
@@ -80,18 +105,33 @@ public final class MetricsModel {
public extension MetricsModel { public extension MetricsModel {
/// Verlauf einer einzelnen Größe, für die Mini-Graphen. /// Verlauf einer einzelnen Größe, für die Mini-Graphen.
func series(_ metric: MetricKind) -> [Double] { func series(_ metric: MetricKind) -> [Double] {
history.compactMap { snapshot in let mode = displayMode(for: metric)
return history.compactMap { snapshot in
if mode == .temperature, metric.supportsDisplayModes {
let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature
return celsius.map { $0 / 100 }
}
switch metric { switch metric {
case .cpu: snapshot.cpu.total case .cpu: return snapshot.cpu.total
case .gpu: snapshot.gpu case .gpu: return snapshot.gpu
case .memory: snapshot.memory.usedFraction case .memory: return snapshot.memory.usedFraction
case .battery: snapshot.battery?.charge case .battery: return snapshot.battery?.charge
case .temperature: snapshot.sensors.map(\.celsius).max().map { $0 / 100 } case .temperature: return snapshot.sensors.map(\.celsius).max().map { $0 / 100 }
} }
} }
} }
} }
/// Was ein CPU- oder GPU-Element zeigt.
///
/// Beide Werte kommen aus derselben Messung die Umschaltung kostet nichts und
/// erspart ein zweites Element in einer ohnehin knappen Menüleiste.
public enum MetricDisplayMode: String, Codable, Sendable, CaseIterable, Identifiable {
case usage
case temperature
public var id: String { rawValue }
}
public enum MetricKind: String, CaseIterable, Sendable, Identifiable { public enum MetricKind: String, CaseIterable, Sendable, Identifiable {
case cpu, gpu, memory, battery, temperature case cpu, gpu, memory, battery, temperature
public var id: String { rawValue } public var id: String { rawValue }
@@ -105,4 +145,8 @@ public enum MetricKind: String, CaseIterable, Sendable, Identifiable {
case .temperature: "thermometer" case .temperature: "thermometer"
} }
} }
/// Nur CPU und GPU haben beides. Speicher und Akku haben keine eigene
/// Temperatur, die Sensorkarte zeigt ohnehin nur Temperaturen.
public var supportsDisplayModes: Bool { self == .cpu || self == .gpu }
} }

View File

@@ -48,11 +48,18 @@ public final class SMCReader: @unchecked Sendable {
private static let handleYPCEvent: UInt32 = 2 private static let handleYPCEvent: UInt32 = 2
private static let readKey: UInt8 = 5 private static let readKey: UInt8 = 5
private static let getKeyFromIndex: UInt8 = 8
private static let getKeyInfo: UInt8 = 9 private static let getKeyInfo: UInt8 = 9
private var connection: io_connect_t = 0 private var connection: io_connect_t = 0
private let lock = NSLock() private let lock = NSLock()
/// Typ und Größe je Key. Beides steht in der Firmware fest und ändert sich
/// zur Laufzeit nicht es bei jedem Lesen erneut zu erfragen verdoppelt
/// die Zahl der IOKit-Aufrufe. Bei fünfzig Temperaturfühlern im Sekundentakt
/// ist das der Unterschied zwischen spürbar und unmerklich.
private var keyInfoCache: [String: KeyInfo] = [:]
public init?() { public init?() {
let service = IOServiceGetMatchingService(kIOMainPortDefault, let service = IOServiceGetMatchingService(kIOMainPortDefault,
IOServiceMatching("AppleSMC")) IOServiceMatching("AppleSMC"))
@@ -92,26 +99,75 @@ public final class SMCReader: @unchecked Sendable {
public func exists(_ key: String) -> Bool { read(key) != nil } public func exists(_ key: String) -> Bool { read(key) != nil }
/// Alle Keys, deren Name mit einem der Präfixe beginnt.
///
/// Wird einmal beim Start aufgerufen. Fest verdrahtete Listen wären hier
/// falsch: welche Fühler es gibt, hängt am Modell auf dem M5 sind es
/// 285 Temperaturfühler, auf einem anderen Mac ganz andere.
public func keys(matching prefixes: [String]) -> [String] {
guard let total = keyCount() else { return [] }
var found: [String] = []
for index in 0..<UInt32(total) {
guard let name = key(atIndex: index) else { continue }
if prefixes.contains(where: name.hasPrefix) { found.append(name) }
}
return found
}
/// Die Anzahl der Keys **big-endian**.
///
/// Die Byte-Reihenfolge ist im SMC nicht durchgängig, und das ist keine
/// Nachlässigkeit dieser Klasse, sondern der Hardware: 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 Suche brach damit still ab und alle Temperaturen
/// blieben leer.
private func keyCount() -> UInt32? {
guard let value = read("#KEY"), value.bytes.count >= 4 else { return nil }
let count = UInt32(value.bytes[0]) << 24 | UInt32(value.bytes[1]) << 16
| UInt32(value.bytes[2]) << 8 | UInt32(value.bytes[3])
return (0..<100_000).contains(count) ? count : nil
}
private func key(atIndex index: UInt32) -> String? {
lock.lock()
defer { lock.unlock() }
var command = Param()
command.data8 = Self.getKeyFromIndex
command.data32 = index
guard let output = call(command) else { return nil }
return Self.fourCCString(output.key)
}
private struct Value { let type: String; let bytes: [UInt8] } private struct Value { let type: String; let bytes: [UInt8] }
private func read(_ key: String) -> Value? { private func read(_ key: String) -> Value? {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
let described: KeyInfo
if let cached = keyInfoCache[key] {
described = cached
} else {
var info = Param() var info = Param()
info.key = Self.fourCC(key) info.key = Self.fourCC(key)
info.data8 = Self.getKeyInfo info.data8 = Self.getKeyInfo
guard let described = call(info) else { return nil } guard let result = call(info) else { return nil }
described = result.keyInfo
keyInfoCache[key] = described
}
var command = Param() var command = Param()
command.key = Self.fourCC(key) command.key = Self.fourCC(key)
command.keyInfo = described.keyInfo command.keyInfo = described
command.data8 = Self.readKey command.data8 = Self.readKey
guard let output = call(command) else { return nil } guard let output = call(command) else { return nil }
let size = Int(min(described.keyInfo.dataSize, 32)) let size = Int(min(described.dataSize, 32))
let all = withUnsafeBytes(of: output.bytes) { Array($0) } let all = withUnsafeBytes(of: output.bytes) { Array($0) }
return Value(type: Self.fourCCString(described.keyInfo.dataType), return Value(type: Self.fourCCString(described.dataType),
bytes: Array(all.prefix(size))) bytes: Array(all.prefix(size)))
} }

View File

@@ -7,6 +7,8 @@ import Darwin
public struct MetricsSnapshot: Equatable, Sendable { public struct MetricsSnapshot: Equatable, Sendable {
public var cpu = CPUReading() public var cpu = CPUReading()
public var gpu: Double? public var gpu: Double?
/// Wärmster Punkt der GPU in Grad Celsius.
public var gpuTemperature: Double?
public var memory = MemoryReading() public var memory = MemoryReading()
public var battery: BatteryReading? public var battery: BatteryReading?
public var sensors: [SensorReading] = [] public var sensors: [SensorReading] = []
@@ -21,6 +23,12 @@ public struct CPUReading: Equatable, Sendable {
public var perCore: [Double] = [] public var perCore: [Double] = []
/// Leistungsaufnahme in Watt, falls messbar. /// Leistungsaufnahme in Watt, falls messbar.
public var watts: Double? public var watts: Double?
/// Der wärmste Kern in Grad Celsius.
///
/// Apple Silicon hat pro Kern einen Fühler auf dem M5 Pro sind es
/// über zwanzig. Der Höchstwert ist der aussagekräftige: gedrosselt wird
/// nach dem heißesten Punkt, nicht nach dem Durchschnitt.
public var temperature: Double?
} }
public struct MemoryReading: Equatable, Sendable { public struct MemoryReading: Equatable, Sendable {
@@ -76,6 +84,26 @@ public final class SystemMetrics: @unchecked Sendable {
private let smc = SMCReader() private let smc = SMCReader()
private var previousCPUTicks: [CPUTicks] = [] private var previousCPUTicks: [CPUTicks] = []
/// Welche Kern- und GPU-Fühler dieses Modell hat. `Tp*` sind die
/// CPU-Kerne, `Tg*` die GPU-Cluster die Konvention gilt für die ganze
/// M-Serie.
///
/// Die Suche läuft im Hintergrund: sie geht alle 3486 Keys durch und
/// braucht dafür knapp eine Sekunde. Auf dem Hauptthread wäre das eine
/// spürbare Startverzögerung für etwas, das nach dem ersten Messwert
/// niemandem auffällt.
private var temperatureKeys: (cpu: [String], gpu: [String]) = ([], [])
private let keysLock = NSLock()
/// Zwischenspeicher für die Temperaturen.
///
/// Fünfzig Fühler zu lesen kostet gut 15 ms. Bei 1 Hz wären das anderthalb
/// Prozent CPU für Werte, die sich in einer Sekunde kaum bewegen deshalb
/// höchstens alle zwei Sekunden, unabhängig von der Abtastrate ringsum.
private var cachedTemperatures: (cpu: Double?, gpu: Double?) = (nil, nil)
private var temperaturesReadAt = Date.distantPast
private static let temperatureInterval: TimeInterval = 2
/// Seit dem Start unveränderlich einmal ermitteln reicht. /// Seit dem Start unveränderlich einmal ermitteln reicht.
private static let pageSize: UInt64 = { private static let pageSize: UInt64 = {
var size: UInt64 = 0 var size: UInt64 = 0
@@ -86,7 +114,21 @@ public final class SystemMetrics: @unchecked Sendable {
return size return size
}() }()
public init() {} public init() {
// Nicht im Initialisierer blockieren: der läuft beim App-Start.
// `DispatchQueue` statt `Task.detached`: die Suche ist blockierende
// IOKit-Arbeit ohne Suspendierungspunkt, sie gehört nicht in den
// Nebenläufigkeitspool von Swift und `NSLock` ist aus einem
// asynchronen Kontext ohnehin nicht erlaubt.
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self, let smc else { return }
let cpu = smc.keys(matching: ["Tp"])
let gpu = smc.keys(matching: ["Tg"])
keysLock.lock()
temperatureKeys = (cpu, gpu)
keysLock.unlock()
}
}
public func sample() -> MetricsSnapshot { public func sample() -> MetricsSnapshot {
var snapshot = MetricsSnapshot() var snapshot = MetricsSnapshot()
@@ -97,6 +139,9 @@ public final class SystemMetrics: @unchecked Sendable {
snapshot.sensors = readSensors() snapshot.sensors = readSensors()
snapshot.fans = readFans() snapshot.fans = readFans()
snapshot.cpu.watts = smc?.float("PSTR") snapshot.cpu.watts = smc?.float("PSTR")
let temperatures = readTemperatures()
snapshot.cpu.temperature = temperatures.cpu
snapshot.gpuTemperature = temperatures.gpu
return snapshot return snapshot
} }
@@ -273,6 +318,36 @@ public final class SystemMetrics: @unchecked Sendable {
("TH0x", "SSD"), ("TH0x", "SSD"),
] ]
private func readTemperatures() -> (cpu: Double?, gpu: Double?) {
guard Date().timeIntervalSince(temperaturesReadAt) >= Self.temperatureInterval else {
return cachedTemperatures
}
keysLock.lock()
let keys = temperatureKeys
keysLock.unlock()
// Solange die Suche im Hintergrund läuft, bleiben die Werte leer die
// Oberfläche zeigt dann " statt einer erfundenen Zahl.
guard !keys.cpu.isEmpty || !keys.gpu.isEmpty else { return (nil, nil) }
cachedTemperatures = (hottest(of: keys.cpu), hottest(of: keys.gpu))
temperaturesReadAt = Date()
return cachedTemperatures
}
/// Der höchste plausible Messwert einer Fühlergruppe.
///
/// Die Plausibilitätsgrenze ist nötig: einzelne Fühler liefern gelegentlich
/// 0 oder Werte jenseits von 200 °C, und ein einziger Ausreißer würde die
/// Anzeige übernehmen, weil hier das Maximum gebildet wird.
private func hottest(of keys: [String]) -> Double? {
guard let smc, !keys.isEmpty else { return nil }
return keys.compactMap { key -> Double? in
guard let value = smc.float(key), value > 5, value < 150 else { return nil }
return value
}.max()
}
private func readSensors() -> [SensorReading] { private func readSensors() -> [SensorReading] {
guard let smc else { return [] } guard let smc else { return [] }
return Self.temperatureKeys.compactMap { key, name in return Self.temperatureKeys.compactMap { key, name in