Phase 5: Hardware- und Netzwerkmonitor, Panel und Menüleiste
Sechs neue Widgets (CPU, GPU, Speicher, Akku, Sensoren, Netzwerk) und dieselben sechs als einzeln aktivierbare Menüleisten-Module mit fünf Darstellungsarten. Panel-Widget und Menüleisten-Modul derselben Größe teilen sich die Messschleife über Referenzzählung. Ohne das liefe für jede Anzeige ein eigener Timer mit denselben IOKit- und SMC-Abfragen — bei sechs Modulen und ebenso vielen Widgets ein Vielfaches der nötigen Arbeit. Meldet sich der letzte Konsument ab, hört die Schleife auf; im Ruhezustand misst Onyx nichts. Die Differenzrechnung ist testgetrieben, und die Tests haben zwei echte Fehler gefunden. Erstens: mein Überlaufschutz beim Durchsatz hielt einen zurückgesetzten Zähler (Interface-Wechsel) für einen Überlauf und zeigte 4,3 GB/s. Da if_data64 64-Bit-Zähler liefert, die bei 10 Gbit/s erst nach 470 Jahren umlaufen, ist ein kleinerer Wert immer ein Zurücksetzen — die Unterscheidung produzierte nur den Ausreißer, den sie verhindern sollte. Zweitens: ByteCountFormatter schrieb bei null „Zero KB/s" statt „0 KB/s". Bei den CPU-Zählern bleibt der Überlaufschutz nötig: die sind 32 Bit breit und laufen nach gut 400 Tagen wirklich um, deshalb `&-` statt `-`. Sensoren kommen über den SMC-Leser aus Spike A statt über die private IOHID-Schnittstelle — auf dieser Maschine nachweislich verifiziert, und die dort gefundene Little-Endian-Eigenheit ist berücksichtigt. Nur eine kurze Liste benannter Fühler statt aller 3486 Keys: eine Wand aus Kürzeln ist keine Information. Gegengemessen statt vermutet: 15 Kerne, 39,8 W, GPU 50 %, 16,6/24 GB mit kritischem Druck, Akku 68 Zyklen bei 100 % Gesundheit, fünf Sensoren, zwei Lüfter, en0 mit 661 KB/s. Menüleisten-Module zeichnen in labelColor statt in den Onyx-Farben — nur die Systemfarbe passt sich heller wie dunkler Leiste an. Feste Breite je Darstellungsart, sonst schiebt jeder Messwert die halbe Leiste hin und her. Standardmäßig ist kein Modul aktiv: sechs neue Symbole beim ersten Start wären eine Zumutung. 145 Tests grün.
This commit is contained in:
294
Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift
Normal file
294
Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift
Normal file
@@ -0,0 +1,294 @@
|
||||
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?
|
||||
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?
|
||||
}
|
||||
|
||||
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] = []
|
||||
|
||||
/// 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() {}
|
||||
|
||||
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")
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user