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