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:
Guido Schmit
2026-08-10 21:48:47 +02:00
parent a198d88104
commit 594d7c422a
21 changed files with 2897 additions and 350 deletions

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