USB-Meter / USB Meter / Model / ChargerStandbyPowerStore.swift
1 contributor
460 lines | 15.534kb
//
//  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")
}