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.
370 lines
15 KiB
Swift
370 lines
15 KiB
Swift
import Foundation
|
|
import IOKit
|
|
import IOKit.ps
|
|
import Darwin
|
|
|
|
/// Ein vollständiger Messwertsatz.
|
|
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] = []
|
|
public var fans: [FanReading] = []
|
|
public var asOf = Date()
|
|
}
|
|
|
|
public struct CPUReading: Equatable, Sendable {
|
|
/// Gesamtauslastung, 0 bis 1.
|
|
public var total: Double = 0
|
|
/// Auslastung je Kern, in der Reihenfolge des Systems.
|
|
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 {
|
|
public var used: UInt64 = 0
|
|
public var total: UInt64 = 0
|
|
public var compressed: UInt64 = 0
|
|
public var swapUsed: UInt64 = 0
|
|
public var pressure: MemoryPressure = .normal
|
|
|
|
public var usedFraction: Double {
|
|
total > 0 ? Double(used) / Double(total) : 0
|
|
}
|
|
}
|
|
|
|
public struct BatteryReading: Equatable, Sendable {
|
|
public var charge: Double = 0 // 0 bis 1
|
|
public var isCharging = false
|
|
public var isPluggedIn = false
|
|
public var cycleCount: Int?
|
|
public var health: Double? // 0 bis 1
|
|
public var watts: Double? // negativ = Entladung
|
|
public var timeRemaining: TimeInterval?
|
|
}
|
|
|
|
public struct SensorReading: Equatable, Sendable, Identifiable {
|
|
public let key: String
|
|
public let name: String
|
|
public let celsius: Double
|
|
public var id: String { key }
|
|
}
|
|
|
|
public struct FanReading: Equatable, Sendable, Identifiable {
|
|
public let index: Int
|
|
public let rpm: Double
|
|
public let minimum: Double
|
|
public let maximum: Double
|
|
public var id: Int { index }
|
|
|
|
/// Anteil zwischen Minimum und Maximum, 0 bis 1.
|
|
public var fraction: Double {
|
|
guard maximum > minimum else { return 0 }
|
|
return min(max((rpm - minimum) / (maximum - minimum), 0), 1)
|
|
}
|
|
}
|
|
|
|
/// Liest die Hardwarewerte des Systems.
|
|
///
|
|
/// Bewusst kein Singleton mit Timer: das Abtasten steuert `MetricsModel`, weil
|
|
/// nur dort bekannt ist, wie viele Konsumenten es gerade gibt und wie schnell
|
|
/// sie es brauchen.
|
|
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
|
|
var length = MemoryLayout<UInt64>.size
|
|
guard sysctlbyname("hw.pagesize", &size, &length, nil, 0) == 0, size > 0 else {
|
|
return 16384 // Apple Silicon
|
|
}
|
|
return size
|
|
}()
|
|
|
|
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()
|
|
snapshot.cpu = readCPU()
|
|
snapshot.gpu = readGPU()
|
|
snapshot.memory = readMemory()
|
|
snapshot.battery = readBattery()
|
|
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
|
|
}
|
|
|
|
// MARK: - CPU
|
|
|
|
private func readCPU() -> CPUReading {
|
|
var count: natural_t = 0
|
|
var info: processor_info_array_t?
|
|
var infoCount: mach_msg_type_number_t = 0
|
|
|
|
guard host_processor_info(mach_host_self(), PROCESSOR_CPU_LOAD_INFO,
|
|
&count, &info, &infoCount) == KERN_SUCCESS,
|
|
let info else { return CPUReading() }
|
|
|
|
defer {
|
|
vm_deallocate(mach_task_self_, vm_address_t(bitPattern: info),
|
|
vm_size_t(infoCount) * vm_size_t(MemoryLayout<integer_t>.stride))
|
|
}
|
|
|
|
var current: [CPUTicks] = []
|
|
current.reserveCapacity(Int(count))
|
|
for core in 0..<Int(count) {
|
|
let base = core * Int(CPU_STATE_MAX)
|
|
current.append(CPUTicks(
|
|
user: UInt32(bitPattern: info[base + Int(CPU_STATE_USER)]),
|
|
system: UInt32(bitPattern: info[base + Int(CPU_STATE_SYSTEM)]),
|
|
idle: UInt32(bitPattern: info[base + Int(CPU_STATE_IDLE)]),
|
|
nice: UInt32(bitPattern: info[base + Int(CPU_STATE_NICE)])))
|
|
}
|
|
|
|
// Die erste Abtastung hat keinen Vorgänger und kann deshalb keine
|
|
// Auslastung ergeben. Null zu zeigen ist ehrlicher als der Anteil seit
|
|
// dem Systemstart, den man sonst bekäme — der liegt immer bei ein paar
|
|
// Prozent und sieht aus wie ein Messwert.
|
|
defer { previousCPUTicks = current }
|
|
guard previousCPUTicks.count == current.count else { return CPUReading() }
|
|
|
|
let perCore = zip(previousCPUTicks, current).map(CPUTicks.usage(from:to:))
|
|
return CPUReading(total: perCore.isEmpty ? 0 : perCore.reduce(0, +) / Double(perCore.count),
|
|
perCore: perCore,
|
|
watts: nil)
|
|
}
|
|
|
|
// MARK: - GPU
|
|
|
|
private func readGPU() -> Double? {
|
|
var iterator: io_iterator_t = 0
|
|
guard IOServiceGetMatchingServices(kIOMainPortDefault,
|
|
IOServiceMatching("IOAccelerator"),
|
|
&iterator) == kIOReturnSuccess else { return nil }
|
|
defer { IOObjectRelease(iterator) }
|
|
|
|
while case let entry = IOIteratorNext(iterator), entry != 0 {
|
|
defer { IOObjectRelease(entry) }
|
|
|
|
var unmanaged: Unmanaged<CFMutableDictionary>?
|
|
guard IORegistryEntryCreateCFProperties(entry, &unmanaged, kCFAllocatorDefault, 0)
|
|
== kIOReturnSuccess,
|
|
let properties = unmanaged?.takeRetainedValue() as? [String: Any],
|
|
let statistics = properties["PerformanceStatistics"] as? [String: Any]
|
|
else { continue }
|
|
|
|
// Der Schlüssel heißt je nach Gerät unterschiedlich; auf Apple
|
|
// Silicon ist es „Device Utilization %".
|
|
for name in ["Device Utilization %", "GPU Activity(%)", "Renderer Utilization %"] {
|
|
if let value = statistics[name] as? Int { return Double(value) / 100 }
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Arbeitsspeicher
|
|
|
|
private func readMemory() -> MemoryReading {
|
|
var statistics = vm_statistics64_data_t()
|
|
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64_data_t>.size
|
|
/ MemoryLayout<integer_t>.size)
|
|
let result = withUnsafeMutablePointer(to: &statistics) {
|
|
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
|
|
host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
|
|
}
|
|
}
|
|
guard result == KERN_SUCCESS else { return MemoryReading() }
|
|
|
|
// `vm_kernel_page_size` ist eine globale Variable und damit unter
|
|
// strenger Nebenläufigkeitsprüfung tabu. Der Wert steht seit dem Start
|
|
// fest, also einmal über sysctl holen.
|
|
let pageSize = Self.pageSize
|
|
let total = ProcessInfo.processInfo.physicalMemory
|
|
let compressed = UInt64(statistics.compressor_page_count) * pageSize
|
|
|
|
// „Benutzt" wie im Aktivitätsmonitor: aktiv, verdrahtet und komprimiert.
|
|
// Der Dateicache (`external_page_count`) zählt nicht mit — er wird bei
|
|
// Bedarf freigegeben, und ihn mitzuzählen ließe jeden Mac dauernd voll
|
|
// aussehen.
|
|
let used = UInt64(statistics.active_count) * pageSize
|
|
+ UInt64(statistics.wire_count) * pageSize
|
|
+ compressed
|
|
|
|
var swap = xsw_usage()
|
|
var swapSize = MemoryLayout<xsw_usage>.size
|
|
sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0)
|
|
|
|
let free = total > 0 ? 1 - Double(used) / Double(total) : 0
|
|
return MemoryReading(used: used, total: total, compressed: compressed,
|
|
swapUsed: swap.xsu_used,
|
|
pressure: MemoryPressure.classify(
|
|
free: free,
|
|
compressed: total > 0 ? Double(compressed) / Double(total) : 0,
|
|
swapUsed: swap.xsu_used))
|
|
}
|
|
|
|
// MARK: - Akku
|
|
|
|
private func readBattery() -> BatteryReading? {
|
|
guard let blob = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
|
|
let sources = IOPSCopyPowerSourcesList(blob)?.takeRetainedValue() as? [CFTypeRef],
|
|
let source = sources.first,
|
|
let description = IOPSGetPowerSourceDescription(blob, source)?
|
|
.takeUnretainedValue() as? [String: Any] else { return nil }
|
|
|
|
var reading = BatteryReading()
|
|
if let current = description[kIOPSCurrentCapacityKey] as? Int,
|
|
let maximum = description[kIOPSMaxCapacityKey] as? Int, maximum > 0 {
|
|
reading.charge = Double(current) / Double(maximum)
|
|
}
|
|
reading.isCharging = description[kIOPSIsChargingKey] as? Bool ?? false
|
|
reading.isPluggedIn = (description[kIOPSPowerSourceStateKey] as? String)
|
|
== kIOPSACPowerValue
|
|
|
|
if let seconds = description[kIOPSTimeToEmptyKey] as? Int, seconds > 0 {
|
|
reading.timeRemaining = TimeInterval(seconds * 60)
|
|
}
|
|
if let seconds = description[kIOPSTimeToFullChargeKey] as? Int, seconds > 0 {
|
|
reading.timeRemaining = TimeInterval(seconds * 60)
|
|
}
|
|
|
|
// Zyklen und Gesundheit stehen nicht in der Power-Source-Beschreibung,
|
|
// sondern in der Batterie selbst.
|
|
let service = IOServiceGetMatchingService(kIOMainPortDefault,
|
|
IOServiceMatching("AppleSmartBattery"))
|
|
if service != 0 {
|
|
defer { IOObjectRelease(service) }
|
|
var unmanaged: Unmanaged<CFMutableDictionary>?
|
|
if IORegistryEntryCreateCFProperties(service, &unmanaged, kCFAllocatorDefault, 0)
|
|
== kIOReturnSuccess,
|
|
let properties = unmanaged?.takeRetainedValue() as? [String: Any] {
|
|
reading.cycleCount = properties["CycleCount"] as? Int
|
|
if let maximum = properties["MaxCapacity"] as? Int, maximum > 0 {
|
|
reading.health = Double(maximum) / 100
|
|
}
|
|
}
|
|
}
|
|
|
|
// Leistung aus dem SMC: positiv beim Laden, negativ beim Entladen.
|
|
if let watts = smc?.float("PPBR") {
|
|
reading.watts = reading.isCharging ? watts : -watts
|
|
}
|
|
return reading
|
|
}
|
|
|
|
// MARK: - Sensoren und Lüfter
|
|
|
|
/// Die Temperaturfühler, die auf `Mac17,9` tatsächlich existieren.
|
|
///
|
|
/// Bewusst eine kurze Liste statt aller 3486 SMC-Keys: die meisten sind
|
|
/// unbenannt, und eine Wand aus Kürzeln ist keine Information. Was fehlt,
|
|
/// fällt beim Lesen einfach weg.
|
|
private static let temperatureKeys: [(String, String)] = [
|
|
("Ts0P", "Gehäuse vorn"),
|
|
("Ts1P", "Gehäuse hinten"),
|
|
("TB0T", "Akku"),
|
|
("TW0P", "WLAN"),
|
|
("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
|
|
guard let celsius = smc.float(key), celsius > 0, celsius < 150 else { return nil }
|
|
return SensorReading(key: key, name: name, celsius: celsius)
|
|
}
|
|
}
|
|
|
|
private func readFans() -> [FanReading] {
|
|
guard let smc, let count = smc.integer("FNum"), count > 0 else { return [] }
|
|
return (0..<Int(count)).compactMap { index in
|
|
guard let rpm = smc.float("F\(index)Ac") else { return nil }
|
|
return FanReading(index: index,
|
|
rpm: rpm,
|
|
minimum: smc.float("F\(index)Mn") ?? 0,
|
|
maximum: smc.float("F\(index)Mx") ?? 0)
|
|
}
|
|
}
|
|
}
|