// // ChargerStandbyPowerStore.swift // USB Meter // // Created by Codex on 13/04/2026. // import Foundation final class ChargerStandbyPowerStore { private struct Snapshot: Codable { var measurements: [ChargerStandbyPowerMeasurementSummary] } private enum Keys { static let cloudMeasurements = "ChargerStandbyPowerStore.measurements" } private let fileManager: FileManager private let fileURL: URL private let encoder: JSONEncoder private let decoder: JSONDecoder private let ubiquitousStore = NSUbiquitousKeyValueStore.default private let workQueue = DispatchQueue(label: "ChargerStandbyPowerStore.Queue") private var ubiquitousObserver: NSObjectProtocol? private var ubiquityIdentityObserver: NSObjectProtocol? 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 ubiquitousObserver = NotificationCenter.default.addObserver( forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: ubiquitousStore, queue: nil ) { [weak self] notification in self?.handleUbiquitousStoreChange(notification) } ubiquityIdentityObserver = NotificationCenter.default.addObserver( forName: NSNotification.Name.NSUbiquityIdentityDidChange, object: nil, queue: nil ) { [weak self] _ in self?.syncLocalValuesToCloudIfPossible(reason: "identity-changed") } ubiquitousStore.synchronize() syncLocalValuesToCloudIfPossible(reason: "startup") } 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) } @discardableResult func removeMeasurement(id: UUID, chargerID: UUID? = nil) -> Bool { let previousMeasurements = loadMeasurements() let filteredMeasurements = previousMeasurements.filter { measurement in guard measurement.id == id else { return true } if let chargerID { return measurement.chargerID != chargerID } return false } guard filteredMeasurements.count != previousMeasurements.count else { return true } return persist(filteredMeasurements) } private func loadMeasurements() -> [ChargerStandbyPowerMeasurementSummary] { if let cachedMeasurements { return cachedMeasurements } let localMeasurements = loadLocalMeasurements() let cloudMeasurements = loadCloudMeasurements() let mergedMeasurements = merge(localMeasurements: localMeasurements, cloudMeasurements: cloudMeasurements) cachedMeasurements = mergedMeasurements return mergedMeasurements } private func loadLocalMeasurements() -> [ChargerStandbyPowerMeasurementSummary] { guard fileManager.fileExists(atPath: fileURL.path) else { return [] } do { let data = try Data(contentsOf: fileURL) let snapshot = try decoder.decode(Snapshot.self, from: data) return snapshot.measurements } catch { track("Failed to load charger standby power history: \(error.localizedDescription)") return [] } } private func loadCloudMeasurements() -> [ChargerStandbyPowerMeasurementSummary] { guard isICloudDriveAvailable, let data = ubiquitousStore.data(forKey: Keys.cloudMeasurements) else { return [] } do { let snapshot = try decoder.decode(Snapshot.self, from: data) return snapshot.measurements } catch { track("Failed to decode charger standby power history from iCloud KVS: \(error.localizedDescription)") return [] } } @discardableResult private func persist(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> Bool { let sortedMeasurements = sortMeasurements(measurements) let didPersistLocal = persistLocally(sortedMeasurements) let didPersistCloud = persistToCloudIfPossible(sortedMeasurements) if didPersistLocal || didPersistCloud { cachedMeasurements = sortedMeasurements } return didPersistLocal || didPersistCloud } @discardableResult private func persistLocally(_ 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) return true } catch { track("Failed to save charger standby power history: \(error.localizedDescription)") return false } } @discardableResult private func persistToCloudIfPossible(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> Bool { guard isICloudDriveAvailable else { return false } do { let snapshot = Snapshot(measurements: measurements) let data = try encoder.encode(snapshot) ubiquitousStore.set(data, forKey: Keys.cloudMeasurements) ubiquitousStore.synchronize() return true } catch { track("Failed to encode charger standby power history for iCloud KVS: \(error.localizedDescription)") return false } } private func merge( localMeasurements: [ChargerStandbyPowerMeasurementSummary], cloudMeasurements: [ChargerStandbyPowerMeasurementSummary] ) -> [ChargerStandbyPowerMeasurementSummary] { var mergedByID: [UUID: ChargerStandbyPowerMeasurementSummary] = [:] for measurement in localMeasurements { mergedByID[measurement.id] = measurement } for measurement in cloudMeasurements { mergedByID[measurement.id] = measurement } return sortMeasurements(Array(mergedByID.values)) } private func sortMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> [ChargerStandbyPowerMeasurementSummary] { measurements.sorted { lhs, rhs in if lhs.endedAt != rhs.endedAt { return lhs.endedAt > rhs.endedAt } return lhs.id.uuidString > rhs.id.uuidString } } private var isICloudDriveAvailable: Bool { FileManager.default.ubiquityIdentityToken != nil } private func handleUbiquitousStoreChange(_ notification: Notification) { if let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String], changedKeys.contains(Keys.cloudMeasurements) == false { return } workQueue.async { [weak self] in guard let self else { return } let mergedMeasurements = self.merge( localMeasurements: self.loadLocalMeasurements(), cloudMeasurements: self.loadCloudMeasurements() ) self.cachedMeasurements = mergedMeasurements _ = self.persistLocally(mergedMeasurements) DispatchQueue.main.async { NotificationCenter.default.post(name: .chargerStandbyPowerStoreDidChange, object: nil) } } } private func syncLocalValuesToCloudIfPossible(reason: String) { guard isICloudDriveAvailable else { return } workQueue.async { [weak self] in guard let self else { return } let mergedMeasurements = self.merge( localMeasurements: self.loadLocalMeasurements(), cloudMeasurements: self.loadCloudMeasurements() ) let didPersistLocal = self.persistLocally(mergedMeasurements) let didPersistCloud = self.persistToCloudIfPossible(mergedMeasurements) self.cachedMeasurements = mergedMeasurements if didPersistLocal || didPersistCloud { track("ChargerStandbyPowerStore synchronized standby measurements with iCloud KVS (\(reason)).") DispatchQueue.main.async { NotificationCenter.default.post(name: .chargerStandbyPowerStoreDidChange, object: nil) } } } } deinit { if let observer = ubiquitousObserver { NotificationCenter.default.removeObserver(observer) } if let observer = ubiquityIdentityObserver { NotificationCenter.default.removeObserver(observer) } } } 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?() } } } } extension Notification.Name { static let chargerStandbyPowerStoreDidChange = Notification.Name("ChargerStandbyPowerStoreDidChange") }