Files
onyx/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift
Guido Schmit 594d7c422a 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.
2026-08-10 21:48:47 +02:00

109 lines
3.5 KiB
Swift

import Foundation
import SwiftUI
/// Ein Messwertstrom mit Referenzzählung.
///
/// Panel-Widget und Menüleisten-Modul greifen auf **dieselbe** Messschleife zu.
/// Ohne das liefe für jede Anzeige ein eigener Timer, der dieselben IOKit- und
/// SMC-Abfragen doppelt macht bei sechs Modulen und ebenso vielen Widgets
/// wäre das ein Vielfaches der nötigen Arbeit.
///
/// Meldet sich der letzte Konsument ab, hört die Schleife auf. Im Ruhezustand
/// misst Onyx nichts.
@MainActor
@Observable
public final class MetricsModel {
public private(set) var snapshot = MetricsSnapshot()
/// Verlauf für die Graphen, jüngster Wert zuletzt.
public private(set) var history: [MetricsSnapshot] = []
/// So viele Punkte zeigt ein Menüleisten-Graph. Mehr aufzuheben kostet
/// Speicher für etwas, das niemand sieht.
public static let historyLength = 60
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() {}
/// Meldet Bedarf an. Der Rückgabewert wird zum Abmelden gebraucht.
@discardableResult
public func addConsumer(interval: TimeInterval = 2) -> UUID {
let token = UUID()
demands[token] = min(max(interval, 1), 10)
restartTimer()
return token
}
public func removeConsumer(_ token: UUID) {
demands[token] = nil
restartTimer()
}
/// Ändert das Intervall eines bestehenden Konsumenten, ohne die Schleife
/// anzuhalten etwa wenn das Panel aufgeht und schneller messen will.
public func updateConsumer(_ token: UUID, interval: TimeInterval) {
guard demands[token] != nil else { return }
demands[token] = min(max(interval, 1), 10)
restartTimer()
}
private func restartTimer() {
timer?.invalidate()
timer = nil
guard let interval = demands.values.min() else {
history.removeAll()
return
}
sample()
let timer = Timer(timeInterval: interval, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.sample() }
}
// `.common`, sonst steht die Messung, während ein Menü offen ist.
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
}
private func sample() {
let value = source.sample()
snapshot = value
history.append(value)
if history.count > Self.historyLength { history.removeFirst(history.count - Self.historyLength) }
}
}
public extension MetricsModel {
/// Verlauf einer einzelnen Größe, für die Mini-Graphen.
func series(_ metric: MetricKind) -> [Double] {
history.compactMap { snapshot in
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 }
}
}
}
}
public enum MetricKind: String, CaseIterable, Sendable, Identifiable {
case cpu, gpu, memory, battery, temperature
public var id: String { rawValue }
public var symbolName: String {
switch self {
case .cpu: "cpu"
case .gpu: "cpu.fill"
case .memory: "memorychip"
case .battery: "battery.100"
case .temperature: "thermometer"
}
}
}