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) {
|
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) }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 })))
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user