// // ChargerStandbyPowerStore.swift // USB Meter // // Created by Codex on 13/04/2026. // import Foundation final class ChargerStandbyPowerStore { private struct Snapshot: Codable { var measurements: [ChargerStandbyPowerMeasurementSummary] } private let fileManager: FileManager private let fileURL: URL private let encoder: JSONEncoder private let decoder: JSONDecoder private var cachedMeasurements: [ChargerStandbyPowerMeasurementSummary]? init(fileManager: FileManager = .default) { self.fileManager = fileManager let applicationSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? fileManager.urls(for: .documentDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let directoryURL = applicationSupportURL.appendingPathComponent("ChargeInsights", isDirectory: true) fileURL = directoryURL.appendingPathComponent("charger-standby-power.json", isDirectory: false) encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] encoder.dateEncodingStrategy = .iso8601 decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 } func measurementsByChargerID() -> [UUID: [ChargerStandbyPowerMeasurementSummary]] { Dictionary(grouping: loadMeasurements()) { $0.chargerID } .mapValues { measurements in measurements.sorted { lhs, rhs in if lhs.endedAt != rhs.endedAt { return lhs.endedAt > rhs.endedAt } return lhs.id.uuidString > rhs.id.uuidString } } } @discardableResult func save(_ measurement: ChargerStandbyPowerMeasurementSummary) -> Bool { var measurements = loadMeasurements() measurements.append(measurement) measurements.sort { lhs, rhs in if lhs.endedAt != rhs.endedAt { return lhs.endedAt > rhs.endedAt } return lhs.id.uuidString > rhs.id.uuidString } return persist(measurements) } @discardableResult func removeMeasurements(for chargerID: UUID) -> Bool { let previousMeasurements = loadMeasurements() let filteredMeasurements = previousMeasurements.filter { $0.chargerID != chargerID } guard filteredMeasurements.count != previousMeasurements.count else { return true } return persist(filteredMeasurements) } private func loadMeasurements() -> [ChargerStandbyPowerMeasurementSummary] { if let cachedMeasurements { return cachedMeasurements } guard fileManager.fileExists(atPath: fileURL.path) else { cachedMeasurements = [] return [] } do { let data = try Data(contentsOf: fileURL) let snapshot = try decoder.decode(Snapshot.self, from: data) cachedMeasurements = snapshot.measurements return snapshot.measurements } catch { track("Failed to load charger standby power history: \(error.localizedDescription)") cachedMeasurements = [] return [] } } @discardableResult private func persist(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> Bool { do { try fileManager.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil ) let snapshot = Snapshot(measurements: measurements) let data = try encoder.encode(snapshot) try data.write(to: fileURL, options: .atomic) cachedMeasurements = measurements return true } catch { track("Failed to save charger standby power history: \(error.localizedDescription)") return false } } } final class ChargerStandbyPowerMonitorSession: ObservableObject, Identifiable { let id = UUID() let chargerID: UUID let meterMACAddress: String let meterName: String let meterModel: String let startedAt: Date @Published private(set) var samples: [ChargerStandbyPowerSample] = [] @Published private(set) var statistics: ChargerStandbyPowerMeasurementStatistics? @Published private(set) var stabilizedAt: Date? @Published private(set) var lastObservedAt: Date? @Published private(set) var isRunning = false var onChange: (() -> Void)? var onStabilized: (() -> Void)? private weak var meter: Meter? private var timer: Timer? private var hasTriggeredStabilityCallback = false private let sampleInterval: TimeInterval = 1 init(chargerID: UUID, meter: Meter, startedAt: Date = Date()) { self.chargerID = chargerID meterMACAddress = meter.btSerial.macAddress.description meterName = meter.name meterModel = meter.deviceModelSummary self.startedAt = startedAt self.meter = meter } deinit { stop() } var sampleCount: Int { statistics?.sampleCount ?? samples.count } var hasSamples: Bool { sampleCount > 0 } var readinessDescription: String { guard let statistics else { if let meter { switch meter.operationalState { case .peripheralConnectionPending, .peripheralConnected, .peripheralReady, .comunicating: return "Connecting to meter" case .peripheralNotConnected: return "Starting meter connection" case .notPresent, .dataIsAvailable: break } } return "Waiting for live samples" } if statistics.isStable { return "Stable average reached" } return "Collecting baseline" } func start() { guard isRunning == false else { return } isRunning = true captureSampleIfPossible(at: Date()) let timer = Timer(timeInterval: sampleInterval, repeats: true) { [weak self] _ in self?.captureSampleIfPossible(at: Date()) } self.timer = timer RunLoop.main.add(timer, forMode: .common) onChange?() } func stop() { timer?.invalidate() timer = nil guard isRunning else { return } isRunning = false onChange?() } func makeSummary(endedAt: Date = Date()) -> ChargerStandbyPowerMeasurementSummary? { ChargerStandbyPowerMeasurementAnalyzer.measurementSummary( chargerID: chargerID, meterMACAddress: meterMACAddress, meterName: meterName, meterModel: meterModel, startedAt: startedAt, endedAt: endedAt, samples: samples, stabilizedAt: stabilizedAt ) } private func captureSampleIfPossible(at timestamp: Date) { defer { statistics = ChargerStandbyPowerMeasurementAnalyzer.statistics( from: samples, startedAt: startedAt, referenceDate: timestamp ) onChange?() } guard let meter else { return } guard meter.operationalState == .dataIsAvailable else { return } let powerWatts = meter.power let currentAmps = meter.current let voltageVolts = meter.voltage guard powerWatts.isFinite, currentAmps.isFinite, voltageVolts.isFinite else { return } lastObservedAt = timestamp samples.append( ChargerStandbyPowerSample( timestamp: timestamp, powerWatts: powerWatts, currentAmps: currentAmps, voltageVolts: voltageVolts ) ) if stabilizedAt == nil, let refreshedStatistics = ChargerStandbyPowerMeasurementAnalyzer.statistics( from: samples, startedAt: startedAt, referenceDate: timestamp ), refreshedStatistics.isStable { stabilizedAt = timestamp if hasTriggeredStabilityCallback == false { hasTriggeredStabilityCallback = true onStabilized?() } } } }