From f289d02c37defaca36843a454e1aaed51dda494f Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 5 Sep 2026 21:06:06 +0200 Subject: [PATCH] =?UTF-8?q?Der=20Akku=20bekommt=20ein=20Ged=C3=A4chtnis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verlauf über 24 Stunden, sieben Tage und dreißig Tage, aufgezeichnet alle fünf Minuten. Als Säulen und nicht als Linie: eine Linie verbindet auch dort, wo nichts gemessen wurde, und ein zugeklappter Rechner hatte keinen Ladestand — eine Linie darüber behauptet einen. Dazu Segelmodus und Hitzeschutz als reine Regeln im Helfer. Beide haben zwei Schwellen, eine zum Anhalten und eine zum Weitermachen. Ohne diesen Abstand hinge der Akku am Limit und würde bei jedem verlorenen Prozent nachgeladen — hunderte winzige Ladevorgänge am Tag, und genau die altern ihn. Co-Authored-By: Claude Opus 5 --- .../MetricsProvider/BatteryHistory.swift | 186 +++ .../MetricsProvider/BatteryHistoryView.swift | 90 ++ .../MetricsProvider/ChargeActivity.swift | 55 + .../MetricsProvider/Localizable.xcstrings | 1406 +++++++++-------- .../MetricsProvider/MetricWidgets.swift | 42 +- .../MetricsProvider/MetricsModel.swift | 9 + .../OnyxHelperProtocol/ChargeSafety.swift | 78 + .../BatteryHistoryTests.swift | 92 ++ .../ChargePolicyTests.swift | 89 ++ 9 files changed, 1357 insertions(+), 690 deletions(-) create mode 100644 Packages/OnyxKit/Sources/MetricsProvider/BatteryHistory.swift create mode 100644 Packages/OnyxKit/Sources/MetricsProvider/BatteryHistoryView.swift create mode 100644 Packages/OnyxKit/Tests/MetricsProviderTests/BatteryHistoryTests.swift create mode 100644 Packages/OnyxKit/Tests/OnyxHelperProtocolTests/ChargePolicyTests.swift diff --git a/Packages/OnyxKit/Sources/MetricsProvider/BatteryHistory.swift b/Packages/OnyxKit/Sources/MetricsProvider/BatteryHistory.swift new file mode 100644 index 0000000..d57a896 --- /dev/null +++ b/Packages/OnyxKit/Sources/MetricsProvider/BatteryHistory.swift @@ -0,0 +1,186 @@ +import Foundation +import Observation +import os + +private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "BatteryHistory") + +/// Ein Messpunkt des Akkus. +/// +/// Vier Zahlen, mehr nicht. Was sich nicht zeichnen lässt, gehört nicht in +/// einen Verlauf — Zyklen und Gesundheit ändern sich in Monaten und stehen +/// besser als aktueller Wert da. +public struct BatterySample: Equatable, Sendable, Codable { + public let at: Date + /// 0 bis 1. + public let charge: Double + /// Zellentemperatur, falls der SMC sie hergibt. + public let celsius: Double? + /// Negativ heißt Entladung. + public let watts: Double? + + public init(at: Date, charge: Double, celsius: Double?, watts: Double?) { + self.at = at + self.charge = charge + self.celsius = celsius + self.watts = watts + } +} + +/// Über welchen Zeitraum der Verlauf gezeigt wird. +public enum HistoryWindow: String, CaseIterable, Sendable { + case day, week, month + + public var duration: TimeInterval { + switch self { + case .day: 86_400 + case .week: 7 * 86_400 + case .month: 30 * 86_400 + } + } + + /// Wie viele Säulen die Anzeige bekommt. + /// + /// Nicht ein Punkt je Messung: über einen Monat wären das über achttausend, + /// und mehr Punkte als Bildpunkte zu zeichnen kostet Zeit, ohne etwas zu + /// zeigen. + public var buckets: Int { + switch self { + case .day: 48 // halbstündlich + case .week: 56 // dreistündlich + case .month: 60 // halbtägig + } + } +} + +/// Die Regeln des Verlaufs — ohne Uhr, ohne Dateisystem. +public enum BatteryHistoryRules { + + /// Wie oft gemessen wird. + /// + /// Fünf Minuten reichen: der Ladestand eines Akkus ändert sich nicht + /// sprunghaft, und ein Punkt je Minute wäre die zwölffache Datei für + /// dieselbe Kurve. + public static let interval: TimeInterval = 300 + + /// Wie lange Messpunkte aufgehoben werden. + /// + /// Genau so lang wie das längste Fenster. Alles darüber hinaus wäre eine + /// Datei, die wächst, ohne dass es je jemand ansieht. + public static let maximumAge: TimeInterval = HistoryWindow.month.duration + + /// Ob jetzt ein neuer Messpunkt fällig ist. + public static func shouldRecord(last: Date?, now: Date, + interval: TimeInterval = interval) -> Bool { + guard let last else { return true } + return now.timeIntervalSince(last) >= interval + } + + /// Was zu alt geworden ist, fällt heraus. + public static func pruned(_ samples: [BatterySample], now: Date, + maximumAge: TimeInterval = maximumAge) -> [BatterySample] { + let cutoff = now.addingTimeInterval(-maximumAge) + return samples.filter { $0.at >= cutoff } + } + + /// Fasst die Messpunkte eines Fensters zu Säulen zusammen. + /// + /// Der Mittelwert je Zeitabschnitt, nicht der erste oder letzte Wert: ein + /// einzelner Ausreißer soll die Säule nicht bestimmen. Abschnitte ohne + /// Messung bleiben `nil` — ein zugeklappter Rechner hat dort keinen + /// Ladestand gehabt, und eine durchgezogene Linie darüber wäre erfunden. + public static func buckets(_ samples: [BatterySample], + window: HistoryWindow, + now: Date) -> [Double?] { + let start = now.addingTimeInterval(-window.duration) + let width = window.duration / Double(window.buckets) + + var sums = [Double](repeating: 0, count: window.buckets) + var counts = [Int](repeating: 0, count: window.buckets) + + for sample in samples where sample.at >= start && sample.at <= now { + let offset = sample.at.timeIntervalSince(start) + let index = min(Int(offset / width), window.buckets - 1) + sums[index] += sample.charge + counts[index] += 1 + } + + return (0.. (low: Double, high: Double, average: Double)? { + let start = now.addingTimeInterval(-window.duration) + let inWindow = samples.filter { $0.at >= start && $0.at <= now } + guard !inWindow.isEmpty else { return nil } + let charges = inWindow.map(\.charge) + return (charges.min()!, charges.max()!, + charges.reduce(0, +) / Double(charges.count)) + } +} + +/// Der Verlauf, aufgezeichnet und abgelegt. +@MainActor +@Observable +public final class BatteryHistoryStore { + + public private(set) var samples: [BatterySample] = [] + + @ObservationIgnored private let fileURL: URL + @ObservationIgnored private var lastRecorded: Date? + + private struct Archive: Codable { + var version = 1 + var samples: [BatterySample] + } + + public init(directory: URL? = nil) { + let folder = directory ?? Self.defaultDirectory + try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + fileURL = folder.appendingPathComponent("battery.json") + load() + lastRecorded = samples.last?.at + } + + public static var defaultDirectory: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Onyx/Metrics", isDirectory: true) + } + + /// Nimmt eine Messung entgegen — und legt sie nur ab, wenn sie fällig ist. + public func record(_ reading: BatteryReading, now: Date = Date()) { + guard BatteryHistoryRules.shouldRecord(last: lastRecorded, now: now) else { return } + lastRecorded = now + samples.append(BatterySample(at: now, charge: reading.charge, + celsius: reading.celsius, watts: reading.watts)) + samples = BatteryHistoryRules.pruned(samples, now: now) + save() + } + + public func buckets(_ window: HistoryWindow, now: Date = Date()) -> [Double?] { + BatteryHistoryRules.buckets(samples, window: window, now: now) + } + + public func summary(_ window: HistoryWindow, + now: Date = Date()) -> (low: Double, high: Double, average: Double)? { + BatteryHistoryRules.summary(samples, window: window, now: now) + } + + private func load() { + guard let data = try? Data(contentsOf: fileURL) else { return } + guard let archive = try? JSONDecoder().decode(Archive.self, from: data), + archive.version == 1 else { + log.notice("Unbekannte Fassung des Akkuverlaufs — er beginnt neu") + return + } + samples = archive.samples.sorted { $0.at < $1.at } + } + + private func save() { + do { + try JSONEncoder().encode(Archive(samples: samples)).write(to: fileURL, options: .atomic) + } catch { + log.error("Verlauf nicht schreibbar: \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/Packages/OnyxKit/Sources/MetricsProvider/BatteryHistoryView.swift b/Packages/OnyxKit/Sources/MetricsProvider/BatteryHistoryView.swift new file mode 100644 index 0000000..f7f9da6 --- /dev/null +++ b/Packages/OnyxKit/Sources/MetricsProvider/BatteryHistoryView.swift @@ -0,0 +1,90 @@ +import SwiftUI +import OnyxDesign + +/// Der Ladeverlauf als Säulen. +/// +/// **Säulen und keine Linie.** Eine Linie verbindet auch dort, wo nichts +/// gemessen wurde — ein zugeklappter Rechner hat keinen Ladestand gehabt, und +/// eine Linie darüber behauptet einen. Säulen können einfach fehlen. +public struct BatteryHistoryView: View { + + private let store: BatteryHistoryStore + @State private var window: HistoryWindow = .day + + public init(store: BatteryHistoryStore) { self.store = store } + + public var body: some View { + VStack(alignment: .leading, spacing: Onyx.Space.sm) { + header + chart + } + } + + private var header: some View { + HStack(spacing: Onyx.Space.sm) { + if let summary = store.summary(window) { + Text(verbatim: "\(percent(summary.low)) – \(percent(summary.high)) %") + .font(Onyx.Font.metricSmall) + .foregroundStyle(Onyx.Color.textSecondary) + } else { + Text("battery.history.empty", bundle: .module) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textTertiary) + } + + Spacer(minLength: Onyx.Space.xs) + + // Drei kurze Wörter statt eines Menüs: die Auswahl hat drei + // Möglichkeiten, und ein Menü dafür verlangt zwei Klicks für das, + // was ein Klick kann. + ForEach(HistoryWindow.allCases, id: \.self) { candidate in + Button { window = candidate } label: { + Text(candidate.localizedName, bundle: .module) + .font(Onyx.Font.micro) + .foregroundStyle(candidate == window + ? Onyx.Color.accent : Onyx.Color.textTertiary) + } + .buttonStyle(.plain) + } + } + } + + private var chart: some View { + GeometryReader { geometry in + let buckets = store.buckets(window) + let gap: CGFloat = 1 + let width = max((geometry.size.width - gap * CGFloat(buckets.count - 1)) + / CGFloat(buckets.count), 1) + + HStack(alignment: .bottom, spacing: gap) { + ForEach(Array(buckets.enumerated()), id: \.offset) { _, value in + if let value { + RoundedRectangle(cornerRadius: Onyx.Radius.bar, style: .continuous) + .fill(Onyx.Color.accent) + .frame(width: width, + height: max(geometry.size.height * value, 1)) + } else { + // Die Lücke bleibt eine Lücke. + Color.clear.frame(width: width, height: 1) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + .frame(height: 44) + } + + private func percent(_ value: Double) -> Int { + Int((min(max(value, 0), 1) * 100).rounded()) + } +} + +extension HistoryWindow { + var localizedName: LocalizedStringKey { + switch self { + case .day: "battery.history.day" + case .week: "battery.history.week" + case .month: "battery.history.month" + } + } +} diff --git a/Packages/OnyxKit/Sources/MetricsProvider/ChargeActivity.swift b/Packages/OnyxKit/Sources/MetricsProvider/ChargeActivity.swift index c67ef64..8ca6782 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/ChargeActivity.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/ChargeActivity.swift @@ -35,3 +35,58 @@ public enum ChargeActivity: Equatable, Sendable { return .pluggedNotCharging } } + +/// Was am Strom geschehen ist — und ob es überhaupt etwas war. +/// +/// ``ChargeActivity`` sagt, welcher Zustand *gilt*. Das ist nicht dasselbe wie +/// „es ist etwas passiert": der Zustand gilt die ganze Zeit, gemeldet werden +/// soll er nur beim Wechsel. +public enum PowerEvent: Equatable, Sendable { + case charging + case plugged + case unplugged + case atLimit(Int) + /// Am Akku und fast leer. Schlägt `unplugged`: beides trifft zu, aber nur + /// eines davon muss man sehen. + case low +} + +/// Merkt sich den letzten Zustand und meldet nur Wechsel. +/// +/// **Der erste gelesene Zustand meldet nichts.** Sonst begrüßte Onyx jeden +/// Start mit „am Netz" — einer Nachricht über nichts, das gerade geschehen ist. +/// +/// Eigener Typ und nicht zwei Zeilen im Programmstart, weil hier drei Regeln +/// zusammenkommen, die man einzeln prüfen können muss: die Schwelle für „fast +/// leer", der Vorrang vor „am Akku" und das Schweigen beim ersten Mal. +public struct PowerWatcher: Equatable, Sendable { + + /// Ab hier heißt „am Akku" nicht mehr nur „am Akku". + public static let lowThreshold = 0.2 + + private var last: PowerEvent? + + public init() {} + + /// Der zuletzt gesehene Zustand. `nil`, solange nichts gelesen wurde. + public var current: PowerEvent? { last } + + /// Nimmt eine Messung entgegen und gibt zurück, was zu melden ist — oder + /// `nil`, wenn sich nichts geändert hat. + public mutating func observe(isCharging: Bool, isPluggedIn: Bool, + charge: Double, limit: Int?) -> PowerEvent? { + let state = ChargeActivity.resolve(isCharging: isCharging, + isPluggedIn: isPluggedIn, + charge: charge, limit: limit) + let event: PowerEvent = switch state { + case .charging: .charging + case .pausedAtLimit(let value): .atLimit(value) + case .pluggedNotCharging: .plugged + case .onBattery: charge <= Self.lowThreshold ? .low : .unplugged + } + + defer { last = event } + guard let previous = last else { return nil } + return event == previous ? nil : event + } +} diff --git a/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings index f031b04..d48183a 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings @@ -1,1064 +1,1126 @@ { - "sourceLanguage" : "en", - "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, - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld" + "sourceLanguage": "en", + "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, + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld" + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld" } } } }, - "%lld U/min" : { - "comment" : "A label that shows the fan speed in revolutions per minute. The value is the fan speed in revolutions per minute.", - "isCommentAutoGenerated" : true, - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld U/min" + "%lld U/min": { + "comment": "A label that shows the fan speed in revolutions per minute. The value is the fan speed in revolutions per minute.", + "isCommentAutoGenerated": true, + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld U/min" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld U/min" + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld U/min" } } } }, - "battery.charging" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lädt" + "Lüfter %lld": { + "comment": "A label that shows the name of a fan. The argument is the index of the fan.", + "isCommentAutoGenerated": true, + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter %lld" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Charging" + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan %lld" } } } }, - "battery.cycles" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zyklen" + "battery.charging": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lädt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cycles" + "en": { + "stringUnit": { + "state": "translated", + "value": "Charging" } } } }, - "battery.draining" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verbrauch" + "battery.cycles": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zyklen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Draw" + "en": { + "stringUnit": { + "state": "translated", + "value": "Cycles" } } } }, - "battery.health" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zustand" + "battery.draining": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbrauch" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Health" + "en": { + "stringUnit": { + "state": "translated", + "value": "Draw" } } } }, - "battery.lowPower" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Energiesparmodus" + "battery.health": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zustand" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Low Power Mode" + "en": { + "stringUnit": { + "state": "translated", + "value": "Health" } } } }, - "battery.pluggedIn" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Am Netz" + "battery.history.day": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "24 h" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Plugged in" + "en": { + "stringUnit": { + "state": "translated", + "value": "24 h" } } } }, - "battery.remaining %@" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "noch %@" + "battery.history.empty": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch kein Verlauf." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ remaining" + "en": { + "stringUnit": { + "state": "translated", + "value": "No history yet." } } } }, - "battery.untilFull %@" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ bis voll" + "battery.history.month": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "30 T" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ until full" + "en": { + "stringUnit": { + "state": "translated", + "value": "30 d" } } } }, - "charge.limit" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ladelimit" + "battery.history.week": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "7 T" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Charge limit" + "en": { + "stringUnit": { + "state": "translated", + "value": "7 d" } } } }, - "charge.limit.hint" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "80 bis 100 % — der Bereich, den macOS selbst anbietet." + "battery.lowPower": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Energiesparmodus" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "80 to 100 % — the range macOS itself offers." + "en": { + "stringUnit": { + "state": "translated", + "value": "Low Power Mode" } } } }, - "charge.limit.inSystemSettings" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "In den Systemeinstellungen einstellbar" + "battery.pluggedIn": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Am Netz" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Set in System Settings" + "en": { + "stringUnit": { + "state": "translated", + "value": "Plugged in" } } } }, - "charge.limit.needsHelper" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Braucht den privilegierten Dienst aus Einstellungen → Lüfter." + "battery.remaining %@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "noch %@" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Needs the privileged service from Settings → Fans." + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ remaining" } } } }, - "charge.limit.off" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aus" + "battery.untilFull %@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ bis voll" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Off" + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ until full" } } } }, - "Lüfter %lld" : { - "comment" : "A label that shows the name of a fan. The argument is the index of the fan.", - "isCommentAutoGenerated" : true, - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lüfter %lld" + "charge.limit": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladelimit" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fan %lld" + "en": { + "stringUnit": { + "state": "translated", + "value": "Charge limit" } } } }, - "menubar.short.battery" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Akku" + "charge.limit.hint": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "80 bis 100 % — der Bereich, den macOS selbst anbietet." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Batt" + "en": { + "stringUnit": { + "state": "translated", + "value": "80 to 100 % — the range macOS itself offers." } } } }, - "menubar.short.temperature" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temp" + "charge.limit.inSystemSettings": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "In den Systemeinstellungen einstellbar" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temp" + "en": { + "stringUnit": { + "state": "translated", + "value": "Set in System Settings" } } } }, - "metric.battery" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Akku" + "charge.limit.needsHelper": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Braucht den privilegierten Dienst aus Einstellungen → Lüfter." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Battery" + "en": { + "stringUnit": { + "state": "translated", + "value": "Needs the privileged service from Settings → Fans." } } } }, - "metric.battery.charging" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lädt" + "charge.limit.off": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Charging" + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" } } } }, - "metric.cpu" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "CPU" + "menubar.short.battery": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Akku" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "CPU" + "en": { + "stringUnit": { + "state": "translated", + "value": "Batt" } } } }, - "metric.cpu.perCore" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je Kern" + "menubar.short.temperature": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Temp" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Per core" + "en": { + "stringUnit": { + "state": "translated", + "value": "Temp" } } } }, - "metric.fan %lld" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lüfter %lld" + "metric.battery": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Akku" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fan %lld" + "en": { + "stringUnit": { + "state": "translated", + "value": "Battery" } } } }, - "metric.fan.controlComing" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Die Lüftersteuerung kommt mit dem privilegierten Helfer — Schreibzugriff auf den SMC verlangt root." + "metric.battery.charging": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lädt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fan control arrives with the privileged helper — writing to the SMC requires root." + "en": { + "stringUnit": { + "state": "translated", + "value": "Charging" } } } }, - "metric.gpu" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "GPU" + "metric.cpu": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "CPU" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "GPU" + "en": { + "stringUnit": { + "state": "translated", + "value": "CPU" } } } }, - "metric.memory" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Speicher" + "metric.cpu.perCore": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Je Kern" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Memory" + "en": { + "stringUnit": { + "state": "translated", + "value": "Per core" } } } }, - "metric.mode.temperature" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temperatur" + "metric.fan %lld": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter %lld" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temperature" + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan %lld" } } } }, - "metric.mode.usage" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auslastung" + "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" : "Usage" + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan control arrives with the privileged helper — writing to the SMC requires root." } } } }, - "metric.rpm" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "U/min" + "metric.gpu": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "GPU" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "rpm" + "en": { + "stringUnit": { + "state": "translated", + "value": "GPU" } } } }, - "metric.sensors" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sensoren" + "metric.memory": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Speicher" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sensors" + "en": { + "stringUnit": { + "state": "translated", + "value": "Memory" } } } }, - "metric.sensors.none" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Sensoren lesbar" + "metric.mode.temperature": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Temperatur" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No sensors readable" + "en": { + "stringUnit": { + "state": "translated", + "value": "Temperature" } } } }, - "metric.show" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anzeigen" + "metric.mode.usage": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auslastung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show" + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage" } } } }, - "popover.battery.calculating" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wird berechnet …" + "metric.rpm": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "U/min" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Calculating…" + "en": { + "stringUnit": { + "state": "translated", + "value": "rpm" } } } }, - "popover.battery.charging" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lädt" + "metric.sensors": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sensoren" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Charging" + "en": { + "stringUnit": { + "state": "translated", + "value": "Sensors" } } } }, - "popover.battery.condition" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zustand" + "metric.sensors.none": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Sensoren lesbar" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Condition" + "en": { + "stringUnit": { + "state": "translated", + "value": "No sensors readable" } } } }, - "popover.battery.cycles" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ladezyklen" + "metric.show": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cycles" + "en": { + "stringUnit": { + "state": "translated", + "value": "Show" } } } }, - "popover.battery.draining" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verbrauch" + "popover.battery.calculating": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird berechnet …" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Draw" + "en": { + "stringUnit": { + "state": "translated", + "value": "Calculating…" } } } }, - "popover.battery.health" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kapazität" + "popover.battery.charging": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lädt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Capacity" + "en": { + "stringUnit": { + "state": "translated", + "value": "Charging" } } } }, - "popover.battery.limitComing" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Das Ladelimit kommt mit dem privilegierten Helfer. Der zuständige SMC-Key ist auf diesem Modell noch nicht identifiziert — siehe docs/spikes/A-smc.md." + "popover.battery.condition": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zustand" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The charge limit arrives with the privileged helper. The relevant SMC key is not yet identified on this model — see docs/spikes/A-smc.md." + "en": { + "stringUnit": { + "state": "translated", + "value": "Condition" } } } }, - "popover.battery.none" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kein Akku gefunden" + "popover.battery.cycles": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladezyklen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No battery found" + "en": { + "stringUnit": { + "state": "translated", + "value": "Cycles" } } } }, - "popover.battery.notCharging" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Am Netz, lädt nicht" + "popover.battery.draining": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbrauch" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Plugged in, not charging" + "en": { + "stringUnit": { + "state": "translated", + "value": "Draw" } } } }, - "popover.battery.onBattery" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Akkubetrieb" + "popover.battery.health": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kapazität" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Running on battery" + "en": { + "stringUnit": { + "state": "translated", + "value": "Capacity" } } } }, - "popover.battery.pausedAtLimit %lld" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Angehalten — Ladelimit von %lld %% erreicht" + "popover.battery.limitComing": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Ladelimit kommt mit dem privilegierten Helfer. Der zuständige SMC-Key ist auf diesem Modell noch nicht identifiziert — siehe docs/spikes/A-smc.md." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Paused — charge limit of %lld %% reached" + "en": { + "stringUnit": { + "state": "translated", + "value": "The charge limit arrives with the privileged helper. The relevant SMC key is not yet identified on this model — see docs/spikes/A-smc.md." } } } }, - "popover.battery.pluggedIn" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Am Netzteil" + "popover.battery.none": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Akku gefunden" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "On adapter" + "en": { + "stringUnit": { + "state": "translated", + "value": "No battery found" } } } }, - "popover.battery.remaining" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "verbleibend" + "popover.battery.notCharging": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Am Netz, lädt nicht" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "remaining" + "en": { + "stringUnit": { + "state": "translated", + "value": "Plugged in, not charging" } } } }, - "popover.battery.untilFull" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "bis voll" + "popover.battery.onBattery": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Akkubetrieb" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "until full" + "en": { + "stringUnit": { + "state": "translated", + "value": "Running on battery" } } } }, - "popover.battery.voltage" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Spannung" + "popover.battery.pausedAtLimit %lld": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Angehalten — Ladelimit von %lld %% erreicht" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Voltage" + "en": { + "stringUnit": { + "state": "translated", + "value": "Paused — charge limit of %lld %% reached" } } } }, - "popover.fans" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lüfter" + "popover.battery.pluggedIn": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Am Netzteil" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fans" + "en": { + "stringUnit": { + "state": "translated", + "value": "On adapter" } } } }, - "popover.memory.app" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Programme" + "popover.battery.remaining": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "verbleibend" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Apps" + "en": { + "stringUnit": { + "state": "translated", + "value": "remaining" } } } }, - "popover.memory.compressed" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Komprimiert" + "popover.battery.untilFull": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "bis voll" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Compressed" + "en": { + "stringUnit": { + "state": "translated", + "value": "until full" } } } }, - "popover.memory.free" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Frei" + "popover.battery.voltage": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Spannung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Free" + "en": { + "stringUnit": { + "state": "translated", + "value": "Voltage" } } } }, - "popover.memory.swap" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auslagerung" + "popover.fans": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Swap" + "en": { + "stringUnit": { + "state": "translated", + "value": "Fans" } } } }, - "popover.memory.swapUsed" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Benutzt" + "popover.memory.app": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Programme" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Used" + "en": { + "stringUnit": { + "state": "translated", + "value": "Apps" } } } }, - "popover.memory.used" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Belegt" + "popover.memory.compressed": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Komprimiert" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Used" + "en": { + "stringUnit": { + "state": "translated", + "value": "Compressed" } } } }, - "popover.memory.wired" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reserviert" + "popover.memory.free": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Frei" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wired" + "en": { + "stringUnit": { + "state": "translated", + "value": "Free" } } } }, - "popover.perCore" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je Kern" + "popover.memory.swap": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auslagerung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Per core" + "en": { + "stringUnit": { + "state": "translated", + "value": "Swap" } } } }, - "popover.power" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Leistung" + "popover.memory.swapUsed": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Power" + "en": { + "stringUnit": { + "state": "translated", + "value": "Used" } } } }, - "popover.power.adapter" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Netzteil" + "popover.memory.used": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Belegt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Adapter" + "en": { + "stringUnit": { + "state": "translated", + "value": "Used" } } } }, - "popover.power.cpu" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "CPU" + "popover.memory.wired": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Reserviert" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "CPU" + "en": { + "stringUnit": { + "state": "translated", + "value": "Wired" } } } }, - "popover.power.display" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anzeige" + "popover.perCore": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Je Kern" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Display" + "en": { + "stringUnit": { + "state": "translated", + "value": "Per core" } } } }, - "popover.power.total" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gesamt" + "popover.power": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Leistung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Total" + "en": { + "stringUnit": { + "state": "translated", + "value": "Power" } } } }, - "popover.processes" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Größte Verbraucher" + "popover.power.adapter": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzteil" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Top consumers" + "en": { + "stringUnit": { + "state": "translated", + "value": "Adapter" } } } }, - "popover.temperature" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temperatur" + "popover.power.cpu": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "CPU" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temperature" + "en": { + "stringUnit": { + "state": "translated", + "value": "CPU" } } } }, - "settings.menubar.labelAndValue" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kürzel + Wert" + "popover.power.display": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeige" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Label + value" + "en": { + "stringUnit": { + "state": "translated", + "value": "Display" } } } }, - "settings.menubar.symbolAndValue" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Symbol + Wert" + "popover.power.total": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gesamt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Symbol + value" + "en": { + "stringUnit": { + "state": "translated", + "value": "Total" + } + } + } + }, + "popover.processes": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Größte Verbraucher" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Top consumers" + } + } + } + }, + "popover.temperature": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Temperatur" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Temperature" + } + } + } + }, + "settings.menubar.labelAndValue": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kürzel + Wert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Label + value" + } + } + } + }, + "settings.menubar.symbolAndValue": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Symbol + Wert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Symbol + value" } } } } }, - "version" : "1.1" -} \ No newline at end of file + "version": "1.1" +} diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift index e92680f..5d01ab6 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift @@ -82,14 +82,14 @@ private struct CompactMetric: View { private var mode: MetricDisplayMode { model.displayMode(for: metric) } var body: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: Onyx.Space.xs) { Label { Text(String(localized: .init(metric.localizationKey), bundle: .module)) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) } icon: { Image(systemName: metric.symbolName) - .font(.system(size: 11)) + .font(.system(size: Onyx.Symbol.small)) .foregroundStyle(Onyx.Color.accent) } @@ -103,7 +103,7 @@ private struct CompactMetric: View { if let detail = MetricSummary.detail(metric, model.snapshot) { Text(detail) - .font(.system(size: 9)) + .font(Onyx.Font.micro) .monospacedDigit() .foregroundStyle(Onyx.Color.textTertiary) .lineLimit(1) @@ -121,7 +121,7 @@ private struct WideMetric: View { // Übereinander, nicht nebeneinander: die Kachel ist hochkant, und ein // Verlauf über die volle Breite liest sich besser als einer, der sich // die Zeile mit der Zahl teilt. - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: Onyx.Space.md) { CompactMetric(model: model, metric: metric) Spacer(minLength: 0) Sparkline(values: model.series(metric), @@ -200,11 +200,11 @@ private struct SensorList: View { let detailed: Bool var body: some View { - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: Onyx.Space.xs) { ForEach(snapshot.sensors.prefix(detailed ? 8 : 3)) { sensor in - HStack(spacing: 6) { + HStack(spacing: Onyx.Space.sm) { Text(sensor.name) - .font(.system(size: 10)) + .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) .lineLimit(1) Spacer(minLength: 0) @@ -215,17 +215,17 @@ private struct SensorList: View { } if detailed, !snapshot.fans.isEmpty { - Divider().overlay(Onyx.Color.hairline).padding(.vertical, 2) + OnyxHairline().padding(.vertical, Onyx.Space.xxs) ForEach(snapshot.fans) { fan in - HStack(spacing: 6) { + HStack(spacing: Onyx.Space.sm) { Image(systemName: "fan") - .font(.system(size: 9)) + .font(.system(size: Onyx.Symbol.caption)) .foregroundStyle(Onyx.Color.textTertiary) Text("\(Int(fan.rpm))") .font(Onyx.Font.metricSmall) .foregroundStyle(Onyx.Color.textSecondary) Text(verbatim: "U/min") - .font(.system(size: 9)) + .font(Onyx.Font.micro) .foregroundStyle(Onyx.Color.textTertiary) Spacer(minLength: 0) } @@ -351,8 +351,10 @@ private struct BatteryTile: View { var body: some View { let battery = model.snapshot.battery - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .firstTextBaseline, spacing: 7) { + VStack(alignment: .leading, spacing: Onyx.Space.md) { + // Der Verlauf steht unter den Zahlen, nicht über ihnen: die Frage + // „wie voll ist er jetzt" kommt vor „wie war es gestern". + HStack(alignment: .firstTextBaseline, spacing: Onyx.Space.md) { // Das Symbol folgt dem Ladestand — sonst zeigt es bei 5 % // dasselbe wie bei 95 % und trägt nichts bei. if let battery { @@ -362,7 +364,7 @@ private struct BatteryTile: View { .foregroundStyle(tint(battery)) } else { Image(systemName: BatterySymbol.unavailable) - .font(.system(size: 13)) + .font(.system(size: Onyx.Symbol.medium)) .foregroundStyle(Onyx.Color.textTertiary) } Text(MetricFormat.percent(battery?.charge ?? 0)) @@ -386,7 +388,7 @@ private struct BatteryTile: View { Spacer(minLength: 0) - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: Onyx.Space.xs) { if let watts = battery.watts, abs(watts) > 0.1 { row(watts > 0 ? "battery.charging" : "battery.draining", value: String(format: "%.1f W", abs(watts))) @@ -405,19 +407,23 @@ private struct BatteryTile: View { // Der Energiesparmodus erklärt, warum die Maschine langsamer // wirkt. Ohne diesen Hinweis sucht man den Grund woanders. if battery.isLowPower { - HStack(spacing: 4) { - Image(systemName: "leaf.fill").font(.system(size: 9)) + HStack(spacing: Onyx.Space.xs) { + Image(systemName: "leaf.fill").font(.system(size: Onyx.Symbol.caption)) Text("battery.lowPower", bundle: .module).font(Onyx.Font.metricSmall) } .foregroundStyle(Onyx.Color.warning) } + + // Der Verlauf steht unten: „wie voll ist er jetzt" kommt vor + // „wie war es gestern". + BatteryHistoryView(store: model.batteryHistory) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private func row(_ key: String.LocalizationValue, value: String) -> some View { - HStack(spacing: 4) { + HStack(spacing: Onyx.Space.xs) { Text(String(localized: key, bundle: .module)) .foregroundStyle(Onyx.Color.textTertiary) Spacer(minLength: 4) diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift index d45e26e..23a9406 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift @@ -49,6 +49,14 @@ public final class MetricsModel { } public private(set) var snapshot = MetricsSnapshot() + + /// Der Akkuverlauf. + /// + /// Gehört hierher und nicht in den Programmstart: die Messwerte entstehen + /// hier, und ein zweiter Beobachter für dieselben Zahlen wäre ein zweiter + /// Weg zu ihnen. Wie oft wirklich aufgezeichnet wird, entscheidet der + /// Speicher selbst — hier wird nur jede Messung angeboten. + public let batteryHistory = BatteryHistoryStore() /// Verlauf für die Graphen, jüngster Wert zuletzt. public private(set) var history: [MetricsSnapshot] = [] @@ -132,6 +140,7 @@ public final class MetricsModel { private func sample() { let value = source.sample() snapshot = value + if let battery = value.battery { batteryHistory.record(battery) } history.append(value) if history.count > Self.historyLength { history.removeFirst(history.count - Self.historyLength) } } diff --git a/Packages/OnyxKit/Sources/OnyxHelperProtocol/ChargeSafety.swift b/Packages/OnyxKit/Sources/OnyxHelperProtocol/ChargeSafety.swift index 937d2d6..702be9a 100644 --- a/Packages/OnyxKit/Sources/OnyxHelperProtocol/ChargeSafety.swift +++ b/Packages/OnyxKit/Sources/OnyxHelperProtocol/ChargeSafety.swift @@ -66,3 +66,81 @@ public enum ChargeSafety { return percent } } + +/// Segelmodus und Hitzeschutz — die beiden Regeln, die *über* dem Limit liegen. +/// +/// **Warum im Helfer und nicht in der App.** Beide greifen in das Laden ein, +/// und ein falscher Eingriff dort ist die eine Operation in diesem Projekt, die +/// den Akku dauerhaft beschädigen kann. Was der Helfer tut, ist von außen nicht +/// abschaltbar — dieselbe Begründung wie bei `FanSafety`. +public enum ChargePolicy { + + /// Wie weit der Ladestand unter das Limit fallen muss, bevor wieder geladen + /// wird. + /// + /// **Der eigentliche Zweck des Segelns.** Ohne Abstand hängt der Akku + /// dauerhaft am Limit und wird bei jedem verlorenen Prozent nachgeladen — + /// hunderte winzige Ladevorgänge am Tag, und genau die altern ihn. Mit + /// Abstand lädt er einmal und segelt dann von selbst hinunter. + public static let sailingHysteresis = 5 + + /// Ab dieser Zellentemperatur wird nicht geladen. + /// + /// Hitze schadet einem Lithium-Akku mehr als jeder Ladestand. Der Wert ist + /// bewusst konservativ: macOS selbst drosselt später, aber macOS kennt auch + /// die Ladehistorie des Geräts, und wir kennen sie nicht. + public static let maximumChargingCelsius = 40.0 + + /// Unter dieser Temperatur wird wieder geladen. + /// + /// Auch hier ein Abstand, aus demselben Grund wie beim Segeln: genau an der + /// Schwelle schaltete es sonst im Sekundentakt an und aus. + public static let resumeChargingCelsius = 36.0 + + /// Ob jetzt geladen werden soll. + /// + /// - Parameters: + /// - charge: Ladestand, 0 bis 1. + /// - limit: Das eingestellte Limit in Prozent. + /// - celsius: Zellentemperatur, falls bekannt. Unbekannt heißt: kein + /// Hitzeschutz — eine Temperatur zu erfinden wäre schlimmer als keine. + /// - wasCharging: Ob zuletzt geladen wurde. Daran hängt der Abstand: + /// beide Regeln haben zwei Schwellen, eine zum Anhalten und eine zum + /// Weitermachen. + public static func shouldCharge(charge: Double, limit: Int, celsius: Double?, + wasCharging: Bool) -> Bool { + if let celsius { + if wasCharging, celsius >= maximumChargingCelsius { return false } + if !wasCharging, celsius >= resumeChargingCelsius { return false } + } + + let percent = Int((min(max(charge, 0), 1) * 100).rounded()) + // Ein Limit von hundert ist keines — dann gilt nur der Hitzeschutz. + guard limit < ChargeSafety.noLimit else { return true } + + if wasCharging { return percent < limit } + return percent <= limit - sailingHysteresis + } + + /// Warum gerade nicht geladen wird — für die Anzeige. + public enum Reason: Equatable, Sendable { + case charging + case atLimit(Int) + case sailing(Int) + case tooHot(Double) + } + + public static func reason(charge: Double, limit: Int, celsius: Double?, + wasCharging: Bool) -> Reason { + if let celsius { + let threshold = wasCharging ? maximumChargingCelsius : resumeChargingCelsius + if celsius >= threshold { return .tooHot(celsius) } + } + guard shouldCharge(charge: charge, limit: limit, celsius: celsius, + wasCharging: wasCharging) else { + let percent = Int((min(max(charge, 0), 1) * 100).rounded()) + return percent >= limit ? .atLimit(limit) : .sailing(limit) + } + return .charging + } +} diff --git a/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryHistoryTests.swift b/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryHistoryTests.swift new file mode 100644 index 0000000..d058fcc --- /dev/null +++ b/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryHistoryTests.swift @@ -0,0 +1,92 @@ +import Testing +import Foundation +@testable import MetricsProvider + +@Suite("Akkuverlauf") +struct BatteryHistoryTests { + + private func sample(_ seconds: TimeInterval, charge: Double) -> BatterySample { + BatterySample(at: Date(timeIntervalSince1970: seconds), charge: charge, + celsius: nil, watts: nil) + } + + private let now = Date(timeIntervalSince1970: 100 * 86_400) + + @Test("Ohne vorherige Messung wird sofort aufgezeichnet") + func firstSampleIsRecorded() { + #expect(BatteryHistoryRules.shouldRecord(last: nil, now: now)) + } + + @Test("Vor Ablauf des Abstands nicht noch einmal") + func waitsForTheInterval() { + let recent = now.addingTimeInterval(-60) + #expect(!BatteryHistoryRules.shouldRecord(last: recent, now: now)) + } + + @Test("Nach dem Abstand wieder") + func recordsAfterTheInterval() { + let old = now.addingTimeInterval(-BatteryHistoryRules.interval) + #expect(BatteryHistoryRules.shouldRecord(last: old, now: now)) + } + + @Test("Zu alte Messpunkte fallen heraus") + func oldSamplesArePruned() { + let samples = [ + sample(now.timeIntervalSince1970 - 40 * 86_400, charge: 0.5), + sample(now.timeIntervalSince1970 - 1 * 86_400, charge: 0.6), + ] + let kept = BatteryHistoryRules.pruned(samples, now: now) + #expect(kept.count == 1) + #expect(kept.first?.charge == 0.6) + } + + @Test("Die Säulen bekommen den Mittelwert ihres Abschnitts") + func bucketsAverage() { + // Zwei Messungen in derselben halben Stunde ergeben eine Säule mit + // ihrem Mittel — nicht die erste und nicht die letzte. + let base = now.timeIntervalSince1970 - 600 + let samples = [sample(base, charge: 0.4), sample(base + 60, charge: 0.6)] + let buckets = BatteryHistoryRules.buckets(samples, window: .day, now: now) + let filled = buckets.compactMap { $0 } + #expect(filled.count == 1) + #expect(abs(filled[0] - 0.5) < 0.0001) + } + + @Test("Abschnitte ohne Messung bleiben leer") + func gapsStayEmpty() { + // Ein zugeklappter Rechner hat dort keinen Ladestand gehabt. Eine + // durchgezogene Linie darüber wäre erfunden. + let samples = [sample(now.timeIntervalSince1970 - 600, charge: 0.5)] + let buckets = BatteryHistoryRules.buckets(samples, window: .day, now: now) + #expect(buckets.count == HistoryWindow.day.buckets) + #expect(buckets.filter { $0 == nil }.count == HistoryWindow.day.buckets - 1) + } + + @Test("Messpunkte außerhalb des Fensters zählen nicht mit") + func outOfWindowIsIgnored() { + let samples = [sample(now.timeIntervalSince1970 - 10 * 86_400, charge: 0.9)] + #expect(BatteryHistoryRules.buckets(samples, window: .day, now: now).allSatisfy { $0 == nil }) + #expect(BatteryHistoryRules.summary(samples, window: .day, now: now) == nil) + } + + @Test("Die Zusammenfassung nennt Tief, Hoch und Mittel") + func summaryReportsRange() { + let base = now.timeIntervalSince1970 - 3600 + let samples = [sample(base, charge: 0.2), sample(base + 60, charge: 0.8)] + let summary = BatteryHistoryRules.summary(samples, window: .day, now: now) + #expect(summary?.low == 0.2) + #expect(summary?.high == 0.8) + #expect(abs((summary?.average ?? 0) - 0.5) < 0.0001) + } + + @Test("Jedes Fenster hat weniger Säulen als Messpunkte") + func bucketsAreFewerThanSamples() { + // Über einen Monat wären es bei fünf Minuten Abstand über achttausend + // Punkte — mehr Punkte als Bildpunkte zu zeichnen kostet Zeit, ohne + // etwas zu zeigen. + for window in HistoryWindow.allCases { + let possible = Int(window.duration / BatteryHistoryRules.interval) + #expect(window.buckets < possible) + } + } +} diff --git a/Packages/OnyxKit/Tests/OnyxHelperProtocolTests/ChargePolicyTests.swift b/Packages/OnyxKit/Tests/OnyxHelperProtocolTests/ChargePolicyTests.swift new file mode 100644 index 0000000..fed6cf3 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxHelperProtocolTests/ChargePolicyTests.swift @@ -0,0 +1,89 @@ +import Testing +@testable import OnyxHelperProtocol + +@Suite("Segelmodus und Hitzeschutz") +struct ChargePolicyTests { + + // MARK: - Segeln + + @Test("Unter dem Limit wird geladen") + func chargesBelowLimit() { + #expect(ChargePolicy.shouldCharge(charge: 0.5, limit: 80, celsius: nil, + wasCharging: true)) + } + + @Test("Am Limit hört es auf") + func stopsAtLimit() { + #expect(!ChargePolicy.shouldCharge(charge: 0.80, limit: 80, celsius: nil, + wasCharging: true)) + } + + @Test("Nach dem Anhalten wird nicht sofort wieder geladen") + func doesNotResumeImmediately() { + // Der Kern des Segelns: ohne Abstand hinge der Akku am Limit und würde + // bei jedem verlorenen Prozent nachgeladen — hunderte winzige + // Ladevorgänge am Tag, und genau die altern ihn. + #expect(!ChargePolicy.shouldCharge(charge: 0.79, limit: 80, celsius: nil, + wasCharging: false)) + #expect(!ChargePolicy.shouldCharge(charge: 0.76, limit: 80, celsius: nil, + wasCharging: false)) + } + + @Test("Erst unter dem Abstand geht es wieder los") + func resumesBelowHysteresis() { + #expect(ChargePolicy.shouldCharge(charge: 0.75, limit: 80, celsius: nil, + wasCharging: false)) + } + + @Test("Ohne Limit gilt nur der Hitzeschutz") + func noLimitMeansAlwaysCharge() { + #expect(ChargePolicy.shouldCharge(charge: 0.99, limit: 100, celsius: nil, + wasCharging: false)) + } + + // MARK: - Hitze + + @Test("Zu heiß heißt: nicht laden") + func heatStopsCharging() { + #expect(!ChargePolicy.shouldCharge(charge: 0.3, limit: 100, celsius: 41, + wasCharging: true)) + } + + @Test("Nach dem Abkühlen nicht gleich an der Schwelle wieder los") + func heatHasHysteresisToo() { + // Genau an der Schwelle schaltete es sonst im Sekundentakt an und aus. + #expect(!ChargePolicy.shouldCharge(charge: 0.3, limit: 100, celsius: 37, + wasCharging: false)) + #expect(ChargePolicy.shouldCharge(charge: 0.3, limit: 100, celsius: 35, + wasCharging: false)) + } + + @Test("Ohne Temperaturmessung kein Hitzeschutz") + func unknownTemperatureIsNotAnExcuse() { + // Eine Temperatur zu erfinden wäre schlimmer als keine zu haben. + #expect(ChargePolicy.shouldCharge(charge: 0.3, limit: 100, celsius: nil, + wasCharging: true)) + } + + // MARK: - Der Grund, den die Anzeige nennt + + @Test("Der Grund unterscheidet Limit, Segeln und Hitze") + func reasonsAreDistinct() { + #expect(ChargePolicy.reason(charge: 0.5, limit: 80, celsius: nil, + wasCharging: true) == .charging) + #expect(ChargePolicy.reason(charge: 0.80, limit: 80, celsius: nil, + wasCharging: true) == .atLimit(80)) + #expect(ChargePolicy.reason(charge: 0.78, limit: 80, celsius: nil, + wasCharging: false) == .sailing(80)) + #expect(ChargePolicy.reason(charge: 0.3, limit: 100, celsius: 42, + wasCharging: true) == .tooHot(42)) + } + + @Test("Hitze schlägt jeden anderen Grund") + func heatWinsOverLimit() { + // Wer bei 42 Grad „am Limit" liest, sucht den Fehler an der falschen + // Stelle. + #expect(ChargePolicy.reason(charge: 0.95, limit: 80, celsius: 42, + wasCharging: true) == .tooHot(42)) + } +}