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

File diff suppressed because it is too large Load Diff

View File

@@ -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) }

View File

@@ -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

View File

@@ -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 })))

View File

@@ -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"
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 }
}

View File

@@ -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)))
}

View File

@@ -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