1 contributor
//
// 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?()
}
}