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:
Scarriffle
2026-09-05 21:06:06 +02:00
co-authored by Claude Opus 5
parent edb327cabe
commit f289d02c37
9 changed files with 1357 additions and 690 deletions
@@ -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
}
}
File diff suppressed because it is too large Load Diff
@@ -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) }
}