USB-Meter / USB Meter / Model / ConsumptionMonitorStore.swift
1 contributor
356 lines | 12.716kb
//
//  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?()
    }
}