Der Akku bekommt ein Gedächtnis
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
edb327cabe
commit
f289d02c37
@@ -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..<window.buckets).map { counts[$0] == 0 ? nil : sums[$0] / Double(counts[$0]) }
|
||||
}
|
||||
|
||||
/// Höchst-, Tiefst- und Mittelwert eines Fensters. `nil`, wenn nichts vorliegt.
|
||||
public static func summary(_ samples: [BatterySample], window: HistoryWindow,
|
||||
now: Date) -> (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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"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,
|
||||
@@ -40,6 +38,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "Fan %lld"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"battery.charging": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -104,6 +120,70 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"battery.history.day": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "24 h"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "24 h"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"battery.history.empty": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Noch kein Verlauf."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "No history yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"battery.history.month": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "30 T"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "30 d"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"battery.history.week": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "7 T"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "7 d"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"battery.lowPower": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -250,24 +330,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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" : "Fan %lld"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"menubar.short.battery": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user