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