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:
File diff suppressed because it is too large
Load Diff
@@ -258,7 +258,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private func showSettings(tab: SettingsTab) {
|
||||
guard let model, let calendarModel, let weatherModel else { return }
|
||||
settingsWindow.show(model: model, calendarModel: calendarModel,
|
||||
weatherModel: weatherModel, tab: tab)
|
||||
weatherModel: weatherModel, metricsModel: metricsModel, tab: tab)
|
||||
}
|
||||
|
||||
@objc private func quit() { NSApp.terminate(nil) }
|
||||
|
||||
@@ -4,6 +4,7 @@ import OnyxNotch
|
||||
import OnyxWidgetKit
|
||||
import CalendarProvider
|
||||
import OnyxMenuBar
|
||||
import MetricsProvider
|
||||
import WeatherProvider
|
||||
|
||||
enum SettingsTab: Hashable {
|
||||
@@ -14,6 +15,7 @@ struct SettingsView: View {
|
||||
@Bindable var model: AppModel
|
||||
let calendarModel: CalendarModel
|
||||
let weatherModel: WeatherModel
|
||||
let metricsModel: MetricsModel?
|
||||
@Binding var selectedTab: SettingsTab
|
||||
|
||||
var body: some View {
|
||||
@@ -24,7 +26,7 @@ struct SettingsView: View {
|
||||
DisplaySettings(model: model)
|
||||
.tabItem { Label("settings.tab.display", systemImage: "macbook") }
|
||||
.tag(SettingsTab.display)
|
||||
MenuBarSettings(model: model)
|
||||
MenuBarSettings(model: model, metricsModel: metricsModel)
|
||||
.tabItem { Label("settings.tab.menubar", systemImage: "menubar.rectangle") }
|
||||
.tag(SettingsTab.menubar)
|
||||
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel)
|
||||
@@ -160,6 +162,7 @@ private struct PermissionRow: View {
|
||||
/// blockiert die Kamera zusätzlich die Mitte.
|
||||
private struct MenuBarSettings: View {
|
||||
@Bindable var model: AppModel
|
||||
let metricsModel: MetricsModel?
|
||||
|
||||
private static let modules: [(id: String, label: LocalizedStringKey, symbol: String)] = [
|
||||
("metric.cpu", "metric.cpu", "cpu"),
|
||||
@@ -180,7 +183,8 @@ private struct MenuBarSettings: View {
|
||||
List {
|
||||
ForEach(Self.modules, id: \.id) { module in
|
||||
ModuleRow(model: model, id: module.id,
|
||||
label: module.label, symbol: module.symbol)
|
||||
label: module.label, symbol: module.symbol,
|
||||
metricsModel: metricsModel)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
@@ -197,6 +201,13 @@ private struct ModuleRow: View {
|
||||
let id: String
|
||||
let label: LocalizedStringKey
|
||||
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 {
|
||||
let settings = model.menuBarSettings(for: id)
|
||||
@@ -214,6 +225,21 @@ private struct ModuleRow: View {
|
||||
|
||||
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(
|
||||
get: { settings.presentation },
|
||||
set: { presentation in
|
||||
|
||||
@@ -2,6 +2,7 @@ import AppKit
|
||||
import SwiftUI
|
||||
import CalendarProvider
|
||||
import WeatherProvider
|
||||
import MetricsProvider
|
||||
|
||||
/// Führt das Einstellungsfenster selbst.
|
||||
///
|
||||
@@ -25,7 +26,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
||||
var keepsDockIcon = false
|
||||
|
||||
func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel,
|
||||
tab: SettingsTab = .widgets) {
|
||||
metricsModel: MetricsModel?, tab: SettingsTab = .widgets) {
|
||||
selectedTab = tab
|
||||
keepsDockIcon = model.showsDockIcon
|
||||
|
||||
@@ -42,6 +43,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
||||
rootView: SettingsView(model: model,
|
||||
calendarModel: calendarModel,
|
||||
weatherModel: weatherModel,
|
||||
metricsModel: metricsModel,
|
||||
selectedTab: Binding(
|
||||
get: { [weak self] in self?.selectedTab ?? .widgets },
|
||||
set: { [weak self] in self?.selectedTab = $0 })))
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"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": {
|
||||
"localizations": {
|
||||
"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": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -49,22 +101,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"metric.battery": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Akku"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Battery"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"metric.sensors": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -97,38 +133,102 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"metric.battery.charging": {
|
||||
"metric.show": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Lädt"
|
||||
"value": "Anzeigen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Charging"
|
||||
"value": "Show"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"metric.cpu.perCore": {
|
||||
"metric.mode.usage": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Je Kern"
|
||||
"value": "Auslastung"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"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"
|
||||
}
|
||||
@@ -30,7 +30,8 @@ public final class MetricMenuBarModule: MenuBarModule {
|
||||
|
||||
public func makeStatusView(presentation: MenuBarPresentation) -> NSView {
|
||||
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
|
||||
return view
|
||||
}
|
||||
@@ -57,10 +58,12 @@ public final class MetricMenuBarModule: MenuBarModule {
|
||||
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))
|
||||
view?.update(snapshot: model.snapshot, history: model.series(metric),
|
||||
mode: model.displayMode(for: metric))
|
||||
startObserving()
|
||||
}
|
||||
}
|
||||
@@ -79,6 +82,7 @@ final class MetricStatusView: NSView {
|
||||
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
|
||||
@@ -93,9 +97,10 @@ final class MetricStatusView: NSView {
|
||||
NSSize(width: presentation.width, height: 22)
|
||||
}
|
||||
|
||||
func update(snapshot: MetricsSnapshot, history: [Double]) {
|
||||
func update(snapshot: MetricsSnapshot, history: [Double], mode: MetricDisplayMode) {
|
||||
self.snapshot = snapshot
|
||||
self.history = history
|
||||
self.mode = mode
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
@@ -106,7 +111,7 @@ final class MetricStatusView: NSView {
|
||||
|
||||
switch presentation {
|
||||
case .value:
|
||||
drawText(MetricSummary.value(metric, snapshot), color: color)
|
||||
drawText(MetricSummary.value(metric, snapshot, mode: mode), color: color)
|
||||
case .symbol:
|
||||
drawSymbol(color: color)
|
||||
case .graph:
|
||||
@@ -115,7 +120,7 @@ final class MetricStatusView: NSView {
|
||||
drawBars(color: color)
|
||||
case .valueAndGraph:
|
||||
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))
|
||||
drawGraph(color: color,
|
||||
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
|
||||
/// eine einzelne Last läuft oder alles ausgelastet ist.
|
||||
private func drawBars(color: NSColor) {
|
||||
let values = metric == .cpu ? snapshot.cpu.perCore
|
||||
: [MetricSummary.fraction(metric, snapshot)]
|
||||
// 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)
|
||||
@@ -253,6 +261,42 @@ private struct MetricPopover: View {
|
||||
.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)
|
||||
|
||||
@@ -80,6 +80,8 @@ private struct CompactMetric: View {
|
||||
let model: MetricsModel
|
||||
let metric: MetricKind
|
||||
|
||||
private var mode: MetricDisplayMode { model.displayMode(for: metric) }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label {
|
||||
@@ -94,10 +96,11 @@ private struct CompactMetric: View {
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Text(MetricSummary.value(metric, model.snapshot))
|
||||
Text(MetricSummary.value(metric, model.snapshot, mode: mode))
|
||||
.font(Onyx.Font.metric)
|
||||
.foregroundStyle(MetricSummary.color(metric, model.snapshot))
|
||||
.animation(Onyx.Motion.value, value: MetricSummary.value(metric, model.snapshot))
|
||||
.foregroundStyle(MetricSummary.color(metric, model.snapshot, mode: mode))
|
||||
.animation(Onyx.Motion.value,
|
||||
value: MetricSummary.value(metric, model.snapshot, mode: mode))
|
||||
|
||||
if let detail = MetricSummary.detail(metric, model.snapshot) {
|
||||
Text(detail)
|
||||
@@ -120,7 +123,8 @@ private struct WideMetric: View {
|
||||
CompactMetric(model: model, metric: metric)
|
||||
.frame(width: 84)
|
||||
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.
|
||||
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 {
|
||||
switch metric {
|
||||
case .cpu: MetricFormat.percent(snapshot.cpu.total)
|
||||
|
||||
@@ -22,12 +22,37 @@ public final class MetricsModel {
|
||||
/// Speicher für etwas, das niemand sieht.
|
||||
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 var timer: Timer?
|
||||
/// Je Konsument das gewünschte Intervall. Gemessen wird mit dem kürzesten.
|
||||
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.
|
||||
@discardableResult
|
||||
@@ -80,18 +105,33 @@ public final class MetricsModel {
|
||||
public extension MetricsModel {
|
||||
/// Verlauf einer einzelnen Größe, für die Mini-Graphen.
|
||||
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 {
|
||||
case .cpu: snapshot.cpu.total
|
||||
case .gpu: snapshot.gpu
|
||||
case .memory: snapshot.memory.usedFraction
|
||||
case .battery: snapshot.battery?.charge
|
||||
case .temperature: snapshot.sensors.map(\.celsius).max().map { $0 / 100 }
|
||||
case .cpu: return snapshot.cpu.total
|
||||
case .gpu: return snapshot.gpu
|
||||
case .memory: return snapshot.memory.usedFraction
|
||||
case .battery: return snapshot.battery?.charge
|
||||
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 {
|
||||
case cpu, gpu, memory, battery, temperature
|
||||
public var id: String { rawValue }
|
||||
@@ -105,4 +145,8 @@ public enum MetricKind: String, CaseIterable, Sendable, Identifiable {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -48,11 +48,18 @@ public final class SMCReader: @unchecked Sendable {
|
||||
|
||||
private static let handleYPCEvent: UInt32 = 2
|
||||
private static let readKey: UInt8 = 5
|
||||
private static let getKeyFromIndex: UInt8 = 8
|
||||
private static let getKeyInfo: UInt8 = 9
|
||||
|
||||
private var connection: io_connect_t = 0
|
||||
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?() {
|
||||
let service = IOServiceGetMatchingService(kIOMainPortDefault,
|
||||
IOServiceMatching("AppleSMC"))
|
||||
@@ -92,26 +99,75 @@ public final class SMCReader: @unchecked Sendable {
|
||||
|
||||
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 func read(_ key: String) -> Value? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
let described: KeyInfo
|
||||
if let cached = keyInfoCache[key] {
|
||||
described = cached
|
||||
} else {
|
||||
var info = Param()
|
||||
info.key = Self.fourCC(key)
|
||||
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()
|
||||
command.key = Self.fourCC(key)
|
||||
command.keyInfo = described.keyInfo
|
||||
command.keyInfo = described
|
||||
command.data8 = Self.readKey
|
||||
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) }
|
||||
return Value(type: Self.fourCCString(described.keyInfo.dataType),
|
||||
return Value(type: Self.fourCCString(described.dataType),
|
||||
bytes: Array(all.prefix(size)))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import Darwin
|
||||
public struct MetricsSnapshot: Equatable, Sendable {
|
||||
public var cpu = CPUReading()
|
||||
public var gpu: Double?
|
||||
/// Wärmster Punkt der GPU in Grad Celsius.
|
||||
public var gpuTemperature: Double?
|
||||
public var memory = MemoryReading()
|
||||
public var battery: BatteryReading?
|
||||
public var sensors: [SensorReading] = []
|
||||
@@ -21,6 +23,12 @@ public struct CPUReading: Equatable, Sendable {
|
||||
public var perCore: [Double] = []
|
||||
/// Leistungsaufnahme in Watt, falls messbar.
|
||||
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 {
|
||||
@@ -76,6 +84,26 @@ public final class SystemMetrics: @unchecked Sendable {
|
||||
private let smc = SMCReader()
|
||||
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.
|
||||
private static let pageSize: UInt64 = {
|
||||
var size: UInt64 = 0
|
||||
@@ -86,7 +114,21 @@ public final class SystemMetrics: @unchecked Sendable {
|
||||
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 {
|
||||
var snapshot = MetricsSnapshot()
|
||||
@@ -97,6 +139,9 @@ public final class SystemMetrics: @unchecked Sendable {
|
||||
snapshot.sensors = readSensors()
|
||||
snapshot.fans = readFans()
|
||||
snapshot.cpu.watts = smc?.float("PSTR")
|
||||
let temperatures = readTemperatures()
|
||||
snapshot.cpu.temperature = temperatures.cpu
|
||||
snapshot.gpuTemperature = temperatures.gpu
|
||||
return snapshot
|
||||
}
|
||||
|
||||
@@ -273,6 +318,36 @@ public final class SystemMetrics: @unchecked Sendable {
|
||||
("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] {
|
||||
guard let smc else { return [] }
|
||||
return Self.temperatureKeys.compactMap { key, name in
|
||||
|
||||
Reference in New Issue
Block a user