// // ConsumptionMonitorStore.swift // USB Meter // import Foundation import Combine // MARK: - Store final class ConsumptionMonitorStore { private struct Snapshot: Codable { var sessions: [ConsumptionMonitorSessionSummary] } private enum Keys { static let cloudSessions = "ConsumptionMonitorStore.sessions" } 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: "ConsumptionMonitorStore.Queue") private var ubiquitousObserver: NSObjectProtocol? private var ubiquityIdentityObserver: NSObjectProtocol? private var cachedSessions: [ConsumptionMonitorSessionSummary]? 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("consumption-monitor.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 sessionsByDeviceID() -> [UUID: [ConsumptionMonitorSessionSummary]] { Dictionary(grouping: loadSessions()) { $0.chargedDeviceID } .mapValues { sessions in sessions.sorted { lhs, rhs in (lhs.endedAt ?? .distantFuture) > (rhs.endedAt ?? .distantFuture) } } } @discardableResult func save(_ session: ConsumptionMonitorSessionSummary) -> Bool { var sessions = loadSessions() if let index = sessions.firstIndex(where: { $0.id == session.id }) { sessions[index] = session } else { sessions.append(session) } sessions.sort { lhs, rhs in (lhs.endedAt ?? .distantFuture) > (rhs.endedAt ?? .distantFuture) } return persist(sessions) } @discardableResult func appendSample(_ sample: ConsumptionMonitorSample, to sessionID: UUID) -> Bool { var sessions = loadSessions() guard let index = sessions.firstIndex(where: { $0.id == sessionID }) else { return false } sessions[index].samples.append(sample) return persist(sessions) } @discardableResult func completeSession(id sessionID: UUID, endedAt: Date) -> Bool { var sessions = loadSessions() guard let index = sessions.firstIndex(where: { $0.id == sessionID }) else { return false } sessions[index].endedAt = endedAt sessions.sort { lhs, rhs in (lhs.endedAt ?? .distantFuture) > (rhs.endedAt ?? .distantFuture) } return persist(sessions) } @discardableResult func removeSession(id: UUID, deviceID: UUID) -> Bool { let previous = loadSessions() let filtered = previous.filter { !($0.id == id && $0.chargedDeviceID == deviceID) } guard filtered.count != previous.count else { return true } return persist(filtered) } @discardableResult func removeSessions(for deviceID: UUID) -> Bool { let previous = loadSessions() let filtered = previous.filter { $0.chargedDeviceID != deviceID } guard filtered.count != previous.count else { return true } return persist(filtered) } func openSession(for meterMACAddress: String) -> ConsumptionMonitorSessionSummary? { loadSessions().first { $0.isOpen && $0.meterMACAddress == meterMACAddress } } // MARK: - Private private func loadSessions() -> [ConsumptionMonitorSessionSummary] { if let cachedSessions { return cachedSessions } let local = loadLocalSessions() let cloud = loadCloudSessions() let merged = merge(localSessions: local, cloudSessions: cloud) cachedSessions = merged return merged } private func loadLocalSessions() -> [ConsumptionMonitorSessionSummary] { guard fileManager.fileExists(atPath: fileURL.path) else { return [] } do { let data = try Data(contentsOf: fileURL) return try decoder.decode(Snapshot.self, from: data).sessions } catch { track("ConsumptionMonitorStore: failed to load local sessions: \(error.localizedDescription)") return [] } } private func loadCloudSessions() -> [ConsumptionMonitorSessionSummary] { guard isICloudDriveAvailable, let data = ubiquitousStore.data(forKey: Keys.cloudSessions) else { return [] } do { return try decoder.decode(Snapshot.self, from: data).sessions } catch { track("ConsumptionMonitorStore: failed to decode cloud sessions: \(error.localizedDescription)") return [] } } @discardableResult private func persist(_ sessions: [ConsumptionMonitorSessionSummary]) -> Bool { let didLocal = persistLocally(sessions) let didCloud = persistToCloudIfPossible(sessions) if didLocal || didCloud { cachedSessions = sessions } return didLocal || didCloud } @discardableResult private func persistLocally(_ sessions: [ConsumptionMonitorSessionSummary]) -> Bool { do { try fileManager.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil ) let data = try encoder.encode(Snapshot(sessions: sessions)) try data.write(to: fileURL, options: .atomic) return true } catch { track("ConsumptionMonitorStore: failed to save locally: \(error.localizedDescription)") return false } } @discardableResult private func persistToCloudIfPossible(_ sessions: [ConsumptionMonitorSessionSummary]) -> Bool { guard isICloudDriveAvailable else { return false } do { let data = try encoder.encode(Snapshot(sessions: sessions)) ubiquitousStore.set(data, forKey: Keys.cloudSessions) return ubiquitousStore.synchronize() } catch { track("ConsumptionMonitorStore: failed to sync to cloud: \(error.localizedDescription)") return false } } private func merge( localSessions: [ConsumptionMonitorSessionSummary], cloudSessions: [ConsumptionMonitorSessionSummary] ) -> [ConsumptionMonitorSessionSummary] { var byID: [UUID: ConsumptionMonitorSessionSummary] = [:] for session in localSessions { byID[session.id] = session } for session in cloudSessions { if let existing = byID[session.id] { // Keep the one with more samples or a definitive end time if session.samples.count > existing.samples.count || (session.endedAt != nil && existing.endedAt == nil) { byID[session.id] = session } } else { byID[session.id] = session } } return byID.values.sorted { lhs, rhs in (lhs.endedAt ?? .distantFuture) > (rhs.endedAt ?? .distantFuture) } } private func syncLocalValuesToCloudIfPossible(reason: String) { let sessions = loadLocalSessions() guard !sessions.isEmpty else { return } persistToCloudIfPossible(sessions) } private func handleUbiquitousStoreChange(_ notification: Notification) { cachedSessions = nil NotificationCenter.default.post(name: .consumptionMonitorStoreDidChange, object: nil) } private var isICloudDriveAvailable: Bool { FileManager.default.ubiquityIdentityToken != nil } } extension Notification.Name { static let consumptionMonitorStoreDidChange = Notification.Name("ConsumptionMonitorStore.DidChange") } // MARK: - Live Session final class ConsumptionMonitorLiveSession: ObservableObject { static let bucketDurationSeconds: TimeInterval = 60 let sessionID: UUID let chargedDeviceID: UUID let meterMACAddress: String let startedAt: Date @Published private(set) var currentPowerWatts: Double = 0 @Published private(set) var currentCurrentAmps: Double = 0 @Published private(set) var currentVoltageVolts: Double = 0 @Published private(set) var committedSampleCount: Int = 0 @Published private(set) var committedSamples: [ConsumptionMonitorSample] = [] @Published private(set) var cumulativeEnergyWh: Double = 0 @Published private(set) var isRunning: Bool = false var meterName: String? var meterModel: String? var onSample: ((ConsumptionMonitorSample) -> Void)? var onChange: (() -> Void)? private var flushTimer: Timer? private var powerReadings: [(power: Double, current: Double, voltage: Double)] = [] private var lastObservationTime: Date? private var nextBucketIndex: Int = 0 init(sessionID: UUID, chargedDeviceID: UUID, meterMACAddress: String, startedAt: Date) { self.sessionID = sessionID self.chargedDeviceID = chargedDeviceID self.meterMACAddress = meterMACAddress self.startedAt = startedAt } func start() { guard !isRunning else { return } isRunning = true scheduleNextFlush() } func stop() { isRunning = false flushTimer?.invalidate() flushTimer = nil flushBucket() } func observe(powerWatts: Double, currentAmps: Double, voltageVolts: Double, observedAt: Date) { currentPowerWatts = powerWatts currentCurrentAmps = currentAmps currentVoltageVolts = voltageVolts if let last = lastObservationTime { let dtHours = observedAt.timeIntervalSince(last) / 3600 cumulativeEnergyWh += powerWatts * dtHours } lastObservationTime = observedAt powerReadings.append((powerWatts, currentAmps, voltageVolts)) onChange?() } var elapsedDuration: TimeInterval { Date().timeIntervalSince(startedAt) } // MARK: - Private private func scheduleNextFlush() { flushTimer?.invalidate() flushTimer = Timer.scheduledTimer( withTimeInterval: Self.bucketDurationSeconds, repeats: false ) { [weak self] _ in self?.flushBucket() if self?.isRunning == true { self?.scheduleNextFlush() } } } private func flushBucket() { guard !powerReadings.isEmpty else { return } let n = Double(powerReadings.count) let avgPower = powerReadings.map(\.power).reduce(0, +) / n let avgCurrent = powerReadings.map(\.current).reduce(0, +) / n let avgVoltage = powerReadings.map(\.voltage).reduce(0, +) / n let bucketIndex = nextBucketIndex nextBucketIndex += 1 let sample = ConsumptionMonitorSample( bucketIndex: bucketIndex, timestamp: Date(), averagePowerWatts: avgPower, averageCurrentAmps: avgCurrent, averageVoltageVolts: avgVoltage, sampleCount: powerReadings.count, cumulativeEnergyWh: cumulativeEnergyWh ) powerReadings = [] committedSamples.append(sample) committedSampleCount = nextBucketIndex onSample?(sample) onChange?() } }