// // DataStore.swift // USB Meter // // Created by Bogdan Timofte on 03/03/2020. // Copyright © 2020 Bogdan Timofte. All rights reserved. // import SwiftUI import Combine import CoreBluetooth import CoreData import UserNotifications struct BatteryCheckpointPlausibilityWarning: Identifiable, Hashable { let title: String let message: String var id: String { "\(title)\n\(message)" } } final class AppData : ObservableObject { struct MeterSummary: Identifiable { let macAddress: String let displayName: String let modelSummary: String let advertisedName: String? let lastSeen: Date? let lastConnected: Date? let meter: Meter? var id: String { macAddress } } private var bluetoothManagerNotification: AnyCancellable? private var meterStoreObserver: AnyCancellable? private var meterStoreCloudObserver: AnyCancellable? private var chargeInsightsStoreObserver: AnyCancellable? private var chargeInsightsRemoteObserver: AnyCancellable? private var chargerStandbyPowerStoreObserver: AnyCancellable? private var pendingChargedDevicesReloadWorkItem: DispatchWorkItem? private var chargeInsightsReadStore: ChargeInsightsStore? private var pendingChargeObservationSnapshots: [String: ChargingMonitorSnapshot] = [:] private var pendingChargeObservationWorkItems: [String: DispatchWorkItem] = [:] private let chargedDevicesReloadQueue = DispatchQueue( label: "ro.xdev.usb-meter.charged-devices-reload", qos: .userInitiated ) private var chargedDevicesReloadInFlight = false private var chargedDevicesReloadPending = false private let chargeObservationPersistInterval: TimeInterval = 30 private let meterPresencePersistInterval: TimeInterval = 15 private let meterStore = MeterNameStore.shared private var chargeInsightsStore: ChargeInsightsStore? private let chargerStandbyPowerStore = ChargerStandbyPowerStore() private let chargeNotificationCoordinator = ChargeNotificationCoordinator() private var meterSummariesCache: (version: Int, summaries: [MeterSummary])? private var meterSummariesVersion: Int = 0 init() { bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in self?.scheduleObjectWillChange() } meterStoreObserver = NotificationCenter.default.publisher(for: .meterNameStoreDidChange) .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.invalidateMeterSummaries() self?.refreshMeterMetadata() self?.scheduleObjectWillChange() } meterStoreCloudObserver = NotificationCenter.default.publisher(for: .meterNameStoreCloudStatusDidChange) .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.scheduleObjectWillChange() } chargerStandbyPowerStoreObserver = NotificationCenter.default.publisher(for: .chargerStandbyPowerStoreDidChange) .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.reloadChargedDevices() } } let bluetoothManager = BluetoothManager() @Published var enableRecordFeature: Bool = true @Published var meters: [UUID:Meter] = [UUID:Meter]() { didSet { invalidateMeterSummaries() } } @Published private(set) var chargedDevices: [ChargedDeviceSummary] = [] @Published private(set) var activeChargerStandbySessions: [String: ChargerStandbyPowerMonitorSession] = [:] var deviceSummaries: [ChargedDeviceSummary] { chargedDevices.filter { !$0.isCharger } } var chargerSummaries: [ChargedDeviceSummary] { chargedDevices.filter { $0.isCharger } } var cloudAvailability: MeterNameStore.CloudAvailability { meterStore.currentCloudAvailability } func activateChargeInsights(context: NSManagedObjectContext) { guard chargeInsightsStore == nil else { return } context.automaticallyMergesChangesFromParent = true context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy if let coordinator = context.persistentStoreCoordinator { let writeContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) writeContext.persistentStoreCoordinator = coordinator writeContext.automaticallyMergesChangesFromParent = false writeContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy chargeInsightsStore = ChargeInsightsStore(context: writeContext) let readContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) readContext.persistentStoreCoordinator = coordinator readContext.automaticallyMergesChangesFromParent = true readContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy chargeInsightsReadStore = ChargeInsightsStore(context: readContext) chargeInsightsStoreObserver = NotificationCenter.default.publisher( for: .NSManagedObjectContextDidSave, object: writeContext ) .sink { [weak self, weak context] notification in guard let self, let context else { return } context.perform { context.mergeChanges(fromContextDidSave: notification) DispatchQueue.main.async { self.scheduleChargedDevicesReload() } } } } else { chargeInsightsStore = ChargeInsightsStore(context: context) chargeInsightsReadStore = ChargeInsightsStore(context: context) chargeInsightsStoreObserver = NotificationCenter.default.publisher( for: .NSManagedObjectContextDidSave, object: context ) .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.scheduleChargedDevicesReload() } } chargeInsightsRemoteObserver = NotificationCenter.default.publisher( for: .NSPersistentStoreRemoteChange, object: nil ) .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.scheduleChargedDevicesReload() } chargeNotificationCoordinator.ensureAuthorizationIfNeeded() reloadChargedDevices() } func meterName(for macAddress: String) -> String? { meterStore.name(for: macAddress) } func setMeterName(_ name: String, for macAddress: String) { meterStore.upsert(macAddress: macAddress, name: name, temperatureUnitRawValue: nil) } func temperatureUnitPreference(for macAddress: String) -> TemperatureUnitPreference { let rawValue = meterStore.temperatureUnitRawValue(for: macAddress) ?? TemperatureUnitPreference.celsius.rawValue return TemperatureUnitPreference(rawValue: rawValue) ?? .celsius } func setTemperatureUnitPreference(_ preference: TemperatureUnitPreference, for macAddress: String) { meterStore.upsert(macAddress: macAddress, name: nil, temperatureUnitRawValue: preference.rawValue) } func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) { meterStore.registerMeter(macAddress: macAddress, modelName: modelName, advertisedName: advertisedName) } func noteMeterSeen(at date: Date, macAddress: String) { if let persistedLastSeen = meterStore.lastSeen(for: macAddress), date.timeIntervalSince(persistedLastSeen) < meterPresencePersistInterval { return } meterStore.noteLastSeen(date, for: macAddress) } func noteMeterConnected(at date: Date, macAddress: String) { meterStore.noteLastConnected(date, for: macAddress) } func lastSeen(for macAddress: String) -> Date? { meterStore.lastSeen(for: macAddress) } func lastConnected(for macAddress: String) -> Date? { meterStore.lastConnected(for: macAddress) } func chargedDeviceSummary(id: UUID) -> ChargedDeviceSummary? { chargedDevices.first(where: { $0.id == id }) } func chargeSessionSummary(id: UUID) -> ChargeSessionSummary? { for chargedDevice in chargedDevices { if let session = chargedDevice.sessions.first(where: { $0.id == id }) { return session } } return nil } func chargedDevices(for meterMACAddress: String) -> [ChargedDeviceSummary] { let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() return chargedDevices.filter { chargedDevice in guard chargedDevice.isCharger == false else { return false } return chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC }) } } func chargers(for meterMACAddress: String) -> [ChargedDeviceSummary] { let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() return chargedDevices.filter { chargedDevice in guard chargedDevice.isCharger else { return false } return chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC }) } } func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? { let normalizedMAC = Self.normalizedMACAddress(meterMACAddress) if expireOverlongChargeSessionsIfNeeded() { reloadChargedDevices() return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC) } if let cachedSummary = cachedActiveChargeSessionSummary(for: normalizedMAC) { if let persistedSummary = chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC), persistedSummary.aggregatedSamples.count > cachedSummary.aggregatedSamples.count { return persistedSummary } return cachedSummary } return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC) } func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? { activeChargerStandbySessions[Self.normalizedMACAddress(meterMACAddress)] } @discardableResult func startChargerStandbyMeasurement(for chargerID: UUID, on meter: Meter) -> Bool { guard chargedDeviceSummary(id: chargerID)?.isCharger == true else { return false } let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description) if let existingSession = activeChargerStandbySessions[normalizedMAC] { return existingSession.chargerID == chargerID } let session = ChargerStandbyPowerMonitorSession(chargerID: chargerID, meter: meter) session.onChange = { [weak self] in self?.scheduleObjectWillChange() } session.onStabilized = { [weak self, weak session] in guard let self, let session else { return } self.notifyChargerStandbyMeasurementReady(for: session) } activeChargerStandbySessions[normalizedMAC] = session session.start() // Starting a standby run on an available meter should also initiate the BLE link. if meter.operationalState == .peripheralNotConnected { meter.connect() } scheduleObjectWillChange() return true } @discardableResult func finishChargerStandbyMeasurement(for meterMACAddress: String, save: Bool) -> Bool { let normalizedMAC = Self.normalizedMACAddress(meterMACAddress) guard let session = activeChargerStandbySessions[normalizedMAC] else { return false } session.stop() guard save else { activeChargerStandbySessions[normalizedMAC] = nil scheduleObjectWillChange() return true } guard let summary = session.makeSummary() else { scheduleObjectWillChange() return false } let didSave = chargerStandbyPowerStore.save(summary) if didSave { activeChargerStandbySessions[normalizedMAC] = nil reloadChargedDevices() } else { scheduleObjectWillChange() } return didSave } @discardableResult func deleteChargerStandbyMeasurement(id: UUID, chargerID: UUID) -> Bool { let didDelete = chargerStandbyPowerStore.removeMeasurement(id: id, chargerID: chargerID) if didDelete { reloadChargedDevices() } else { scheduleObjectWillChange() } return didDelete } @discardableResult func createDevice( name: String, deviceClass: ChargedDeviceClass, templateID: String?, chargingStateAvailability: ChargingStateAvailability, supportsWiredCharging: Bool, supportsWirelessCharging: Bool, wirelessChargingProfile: WirelessChargingProfile, configuredCompletionCurrents: [ChargeSessionKind: Double], notes: String? ) -> Bool { let didSave = chargeInsightsStore?.createDevice( name: name, deviceClass: deviceClass, templateID: templateID, chargingStateAvailability: chargingStateAvailability, supportsWiredCharging: supportsWiredCharging, supportsWirelessCharging: supportsWirelessCharging, wirelessChargingProfile: wirelessChargingProfile, configuredCompletionCurrents: configuredCompletionCurrents, notes: notes ) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func createCharger( name: String, chargerType: ChargerType, notes: String? ) -> Bool { let didSave = chargeInsightsStore?.createCharger( name: name, chargerType: chargerType, notes: notes ) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func updateDevice( id: UUID, name: String, deviceClass: ChargedDeviceClass, templateID: String?, chargingStateAvailability: ChargingStateAvailability, supportsWiredCharging: Bool, supportsWirelessCharging: Bool, wirelessChargingProfile: WirelessChargingProfile, configuredCompletionCurrents: [ChargeSessionKind: Double], notes: String? ) -> Bool { let didSave = chargeInsightsStore?.updateDevice( id: id, name: name, deviceClass: deviceClass, templateID: templateID, chargingStateAvailability: chargingStateAvailability, supportsWiredCharging: supportsWiredCharging, supportsWirelessCharging: supportsWirelessCharging, wirelessChargingProfile: wirelessChargingProfile, configuredCompletionCurrents: configuredCompletionCurrents, notes: notes ) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func updateCharger( id: UUID, name: String, chargerType: ChargerType, notes: String? ) -> Bool { let didSave = chargeInsightsStore?.updateCharger( id: id, name: name, chargerType: chargerType, notes: notes ) ?? false if didSave { reloadChargedDevices() } return didSave } func restoreChargeMonitoringStateIfNeeded(for meter: Meter) { guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else { return } guard activeSession.status.isOpen else { return } meter.restoreChargeMonitoringIfNeeded(from: activeSession) } @discardableResult func startChargeSession( for meter: Meter, chargedDeviceID: UUID, chargerID: UUID?, chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode, autoStopEnabled: Bool, initialBatteryPercent: Double?, startsFromFlatBattery: Bool ) -> Bool { meter.resetMeterCountersForNewSession() guard let snapshot = meter.chargingMonitorSnapshot else { return false } let didSave = chargeInsightsStore?.startSession( for: snapshot, chargedDeviceID: chargedDeviceID, chargerID: chargerID, chargingTransportMode: chargingTransportMode, chargingStateMode: chargingStateMode, autoStopEnabled: autoStopEnabled, initialBatteryPercent: initialBatteryPercent, startsFromFlatBattery: startsFromFlatBattery ) ?? false if didSave { meter.resetChargeRecordGraph() let activeSession = chargeInsightsStore?.activeChargeSessionSummary( forMeterMACAddress: meter.btSerial.macAddress.description ) if let activeSession, meter.supportsRecordingThreshold, activeSession.stopThresholdAmps > 0 { meter.recordingTreshold = activeSession.stopThresholdAmps } if let activeSession { meter.restoreChargeMonitoringIfNeeded(from: activeSession) } reloadChargedDevices() } return didSave } @discardableResult func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool { let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date() if let meter { _ = persistChargeSnapshot(from: meter, observedAt: observedAt) } else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress { _ = flushPendingChargeObservation(for: meterMACAddress) } let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func resumeChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool { let snapshot = meter?.chargingMonitorSnapshot let didSave = chargeInsightsStore?.resumeSession(id: sessionID, snapshot: snapshot) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func stopChargeSession(sessionID: UUID, finalBatteryPercent: Double? = nil, from meter: Meter? = nil) -> Bool { if let meter { _ = persistChargeSnapshot(from: meter) } else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress { _ = flushPendingChargeObservation(for: meterMACAddress) } let didSave = chargeInsightsStore?.stopSession( id: sessionID, finalBatteryPercent: finalBatteryPercent ) ?? false reloadChargedDevices() return didSave } func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) { guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else { return } stageChargeObservation(snapshot) } @discardableResult func addBatteryCheckpoint(percent: Double, for meter: Meter) -> Bool { _ = persistChargeSnapshot(from: meter) let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) } let didSave = chargeInsightsStore?.addBatteryCheckpoint( percent: percent, for: meter.btSerial.macAddress.description, measuredEnergyWh: checkpointEnergyWh ) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func addBatteryCheckpoint(percent: Double, for sessionID: UUID) -> Bool { guard canAddBatteryCheckpoint(to: sessionID) else { return false } let didSave = chargeInsightsStore?.addBatteryCheckpoint( percent: percent, for: sessionID ) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func addBatteryCheckpoint( percent: Double, for sessionID: UUID, measuredEnergyWh: Double? ) -> Bool { guard canAddBatteryCheckpoint(to: sessionID) else { return false } let didSave = chargeInsightsStore?.addBatteryCheckpoint( percent: percent, for: sessionID, measuredEnergyWh: measuredEnergyWh ) ?? false if didSave { reloadChargedDevices() } return didSave } func canAddBatteryCheckpoint(to sessionID: UUID) -> Bool { guard let session = chargeSessionSummary(id: sessionID), session.status.isOpen, let meterMACAddress = session.meterMACAddress else { return false } return meter(for: meterMACAddress) != nil } func batteryCheckpointCaptureRequirementMessage(for sessionID: UUID) -> String? { guard let session = chargeSessionSummary(id: sessionID) else { return "Battery checkpoints are available only while the charge session is still active." } guard session.status.isOpen else { return "Battery checkpoints are available only while the charge session is still active." } guard let meterMACAddress = session.meterMACAddress, meter(for: meterMACAddress) != nil else { return "Add battery checkpoints only on the device that is actively monitoring this charging session. Devices following the session through iCloud may not have data that is fresh or precise enough." } return nil } func batteryCheckpointPlausibilityWarning( percent: Double, for sessionID: UUID, effectiveEnergyWhOverride: Double? = nil ) -> BatteryCheckpointPlausibilityWarning? { guard let session = chargeSessionSummary(id: sessionID) else { return nil } return batteryCheckpointPlausibilityWarning( percent: percent, for: session, effectiveEnergyWhOverride: effectiveEnergyWhOverride ) } @discardableResult func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool { let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint( id: checkpointID, from: sessionID ) ?? false if didDelete { reloadChargedDevices() } return didDelete } @discardableResult func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool { let didSave = chargeInsightsStore?.setSessionTrim( sessionID: sessionID, start: start, end: end ) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func commitSessionTrim(sessionID: UUID) -> Bool { let didSave = chargeInsightsStore?.commitSessionTrim(sessionID: sessionID) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func flushChargeInsights() -> Bool { let didFlushObservations = flushAllPendingChargeObservations() let didSave = chargeInsightsStore?.flushPendingChanges() ?? false if didFlushObservations || didSave { reloadChargedDevices() } return didFlushObservations || didSave } @discardableResult func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool { guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else { return false } return setTargetBatteryPercent(percent, for: activeSession.id) } @discardableResult func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool { if percent != nil { chargeNotificationCoordinator.ensureAuthorizationIfNeeded() } let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func confirmChargeSessionCompletion(sessionID: UUID) -> Bool { let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func continueChargeSessionMonitoring(sessionID: UUID) -> Bool { let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func deleteChargeSession(sessionID: UUID) -> Bool { let deletedSession = chargedDevices .flatMap(\.sessions) .first(where: { $0.id == sessionID }) let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false guard didDelete else { return false } if deletedSession?.status.isOpen == true, let meterMACAddress = deletedSession?.meterMACAddress, let liveMeter = meter(for: meterMACAddress) { liveMeter.resetChargeRecord() } reloadChargedDevices() return true } @discardableResult func deleteChargedDevice(id: UUID) -> Bool { let deletedDevice = chargedDeviceSummary(id: id) let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false guard didDelete else { return false } if deletedDevice?.isCharger == true { _ = chargerStandbyPowerStore.removeMeasurements(for: id) for (meterMACAddress, session) in activeChargerStandbySessions where session.chargerID == id { session.stop() activeChargerStandbySessions[meterMACAddress] = nil } } if deletedDevice?.isCharger == false, deletedDevice?.activeSession?.status.isOpen == true, let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress, let liveMeter = meter(for: meterMACAddress) { liveMeter.resetChargeRecord() } reloadChargedDevices() return true } @discardableResult func createKnownMeter( macAddress: String, customName: String?, modelName: String, advertisedName: String? ) -> Bool { let normalizedMAC = Self.normalizedMACAddress(macAddress) guard Self.isValidMACAddress(normalizedMAC) else { return false } registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName) if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty { setMeterName(customName, for: normalizedMAC) } noteMeterSeen(at: Date(), macAddress: normalizedMAC) return true } @discardableResult func deleteMeter(macAddress: String) -> Bool { let normalizedMAC = Self.normalizedMACAddress(macAddress) guard Self.isValidMACAddress(normalizedMAC) else { return false } for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC { meter.disconnect() } meters = meters.filter { element in element.value.btSerial.macAddress.description != normalizedMAC } let didDelete = meterStore.remove(macAddress: normalizedMAC) if didDelete { scheduleObjectWillChange() } return didDelete } var meterSummaries: [MeterSummary] { if let meterSummariesCache, meterSummariesCache.version == meterSummariesVersion { return meterSummariesCache.summaries } let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) }) let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) }) let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys) let summaries = macAddresses.map { macAddress in let liveMeter = liveMetersByMAC[macAddress] let record = recordsByMAC[macAddress] return MeterSummary( macAddress: macAddress, displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record), modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter", advertisedName: liveMeter?.modelString ?? record?.advertisedName, lastSeen: liveMeter?.lastSeen ?? record?.lastSeen, lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected, meter: liveMeter ) } .sorted { lhs, rhs in if lhs.meter != nil && rhs.meter == nil { return true } if lhs.meter == nil && rhs.meter != nil { return false } let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) if byName != .orderedSame { return byName == .orderedAscending } return lhs.macAddress < rhs.macAddress } meterSummariesCache = (version: meterSummariesVersion, summaries: summaries) return summaries } private func scheduleObjectWillChange() { DispatchQueue.main.async { [weak self] in self?.objectWillChange.send() } } private func invalidateMeterSummaries() { meterSummariesVersion += 1 meterSummariesCache = nil } private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) { pendingChargedDevicesReloadWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in self?.reloadChargedDevices() } pendingChargedDevicesReloadWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } private func stageChargeObservation(_ snapshot: ChargingMonitorSnapshot, scheduleFlush: Bool = true) { let normalizedMAC = Self.normalizedMACAddress(snapshot.meterMACAddress) guard !normalizedMAC.isEmpty else { return } pendingChargeObservationSnapshots[normalizedMAC] = snapshot guard scheduleFlush else { return } guard pendingChargeObservationWorkItems[normalizedMAC] == nil else { return } let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.pendingChargeObservationWorkItems[normalizedMAC] = nil guard let snapshot = self.pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else { return } // CoreData write on background — DidSave observer handles the reload let store = self.chargeInsightsStore DispatchQueue.global(qos: .utility).async { store?.observe(snapshot: snapshot) } } pendingChargeObservationWorkItems[normalizedMAC] = workItem DispatchQueue.main.asyncAfter(deadline: .now() + chargeObservationPersistInterval, execute: workItem) } @discardableResult private func persistChargeSnapshot(from meter: Meter, observedAt: Date = Date()) -> Bool { guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else { return false } stageChargeObservation(snapshot, scheduleFlush: false) return flushPendingChargeObservation(for: snapshot.meterMACAddress) } @discardableResult private func flushPendingChargeObservation(for meterMACAddress: String) -> Bool { let normalizedMAC = Self.normalizedMACAddress(meterMACAddress) guard !normalizedMAC.isEmpty else { return false } pendingChargeObservationWorkItems[normalizedMAC]?.cancel() pendingChargeObservationWorkItems[normalizedMAC] = nil guard let snapshot = pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else { return false } let didSave = chargeInsightsStore?.observe(snapshot: snapshot) ?? false return didSave } @discardableResult private func flushAllPendingChargeObservations() -> Bool { let pendingMeterMACAddresses = Array(pendingChargeObservationSnapshots.keys) var didSave = false for meterMACAddress in pendingMeterMACAddresses { if flushPendingChargeObservation(for: meterMACAddress) { didSave = true } } return didSave } private func cachedActiveChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? { let normalizedMAC = Self.normalizedMACAddress(meterMACAddress) guard !normalizedMAC.isEmpty else { return nil } return chargedDevices .lazy .compactMap(\.activeSession) .first(where: { $0.status.isOpen && $0.meterMACAddress == normalizedMAC }) } @discardableResult private func healDuplicateOpenSessions() -> Bool { chargeInsightsStore?.healDuplicateOpenSessions() ?? false } @discardableResult private func expireOverlongChargeSessionsIfNeeded(referenceDate: Date = Date()) -> Bool { chargeInsightsStore?.completeExpiredOpenSessions(referenceDate: referenceDate) ?? false } private func reloadChargedDevices() { if Thread.isMainThread == false { DispatchQueue.main.async { [weak self] in self?.reloadChargedDevices() } return } pendingChargedDevicesReloadWorkItem?.cancel() pendingChargedDevicesReloadWorkItem = nil _ = healDuplicateOpenSessions() _ = expireOverlongChargeSessionsIfNeeded() guard chargedDevicesReloadInFlight == false else { chargedDevicesReloadPending = true return } let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID() let readStore = chargeInsightsReadStore ?? chargeInsightsStore chargedDevicesReloadInFlight = true chargedDevicesReloadPending = false chargedDevicesReloadQueue.async { [weak self] in guard let self else { return } readStore?.resetContext() let summaries = (readStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in chargedDevice.withStandbyPowerMeasurements( standbyMeasurementsByChargerID[chargedDevice.id] ?? [] ) } DispatchQueue.main.async { [weak self] in guard let self else { return } self.chargedDevices = summaries self.chargeNotificationCoordinator.process(chargedDevices: self.deviceSummaries) for meter in self.meters.values { self.restoreChargeMonitoringStateIfNeeded(for: meter) } self.chargedDevicesReloadInFlight = false if self.chargedDevicesReloadPending { self.reloadChargedDevices() } } } } private func meter(for meterMACAddress: String) -> Meter? { meters.values.first { meter in meter.btSerial.macAddress.description == meterMACAddress } } private func refreshMeterMetadata() { DispatchQueue.main.async { [weak self] in guard let self else { return } var didUpdateAnyMeter = false for meter in self.meters.values { let mac = meter.btSerial.macAddress.description let displayName = self.meterName(for: mac) ?? mac if meter.name != displayName { meter.updateNameFromStore(displayName) didUpdateAnyMeter = true } let previousTemperaturePreference = meter.tc66TemperatureUnitPreference meter.reloadTemperatureUnitPreference() if meter.tc66TemperatureUnitPreference != previousTemperaturePreference { didUpdateAnyMeter = true } } if didUpdateAnyMeter { self.scheduleObjectWillChange() } } } private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) { guard let charger = chargedDeviceSummary(id: session.chargerID), let statistics = session.statistics else { return } let content = UNMutableNotificationContent() content.title = "Standby baseline stabilised" content.body = "\(charger.name) is holding around \(statistics.averagePowerWatts.format(decimalDigits: 3)) W. You can save the measurement when ready." content.sound = .default content.threadIdentifier = "charger-standby-\(charger.id.uuidString)" let request = UNNotificationRequest( identifier: "charger-standby-\(session.id.uuidString)", content: content, trigger: nil ) UNUserNotificationCenter.current().add(request) scheduleObjectWillChange() } private func batteryCheckpointPlausibilityWarning( percent: Double, for session: ChargeSessionSummary, effectiveEnergyWhOverride: Double? = nil ) -> BatteryCheckpointPlausibilityWarning? { guard percent.isFinite, percent >= 0, percent <= 100 else { return nil } let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp } if lhs.measuredEnergyWh != rhs.measuredEnergyWh { return lhs.measuredEnergyWh < rhs.measuredEnergyWh } return lhs.id.uuidString < rhs.id.uuidString } if let lastCheckpoint = sortedCheckpoints.last, percent < lastCheckpoint.batteryPercent - 1.5 { return BatteryCheckpointPlausibilityWarning( title: "Checkpoint Goes Backwards", message: "The latest checkpoint is \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))% at \(lastCheckpoint.timestamp.format()). A new value of \(percent.format(decimalDigits: 0))% is unexpectedly lower while the session is still charging." ) } let effectiveEnergyWh = effectiveEnergyWhOverride ?? session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh if let lastCheckpoint = sortedCheckpoints.last, let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) { let estimatedCapacityWh = session.capacityEstimateWh ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode) ?? chargedDevice.estimatedBatteryCapacityWh if let estimatedCapacityWh, estimatedCapacityWh > 0 { let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0) let expectedPercent = min( 100, max( lastCheckpoint.batteryPercent, lastCheckpoint.batteryPercent + (energyDeltaWh / estimatedCapacityWh) * 100 ) ) let predictionGap = percent - expectedPercent guard abs(predictionGap) >= 4 else { return nil } let direction = predictionGap > 0 ? "above" : "below" let gapText = abs(predictionGap).format(decimalDigits: 0) let expectedText = expectedPercent.format(decimalDigits: 0) return BatteryCheckpointPlausibilityWarning( title: "Checkpoint Looks Implausible", message: "The last checkpoint stored \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))% at \(lastCheckpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh. The current counted energy is \(effectiveEnergyWh.format(decimalDigits: 2)) Wh, which supports about \(expectedText)% based on \(estimatedCapacityWh.format(decimalDigits: 2)) Wh capacity. The entered value is about \(gapText) percentage points \(direction) that." ) } } guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID), let prediction = chargedDevice.batteryLevelPrediction( for: session, effectiveEnergyWhOverride: effectiveEnergyWh ) else { return nil } let predictionGap = percent - prediction.predictedPercent guard abs(predictionGap) >= 4 else { return nil } let direction = predictionGap > 0 ? "above" : "below" let gapText = abs(predictionGap).format(decimalDigits: 0) let predictedText = prediction.predictedPercent.format(decimalDigits: 0) if let lastCheckpoint = sortedCheckpoints.last { let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0) return BatteryCheckpointPlausibilityWarning( title: "Checkpoint Looks Implausible", message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Since the last checkpoint at \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))%, only \(energyDeltaWh.format(decimalDigits: 2)) Wh were added." ) } return BatteryCheckpointPlausibilityWarning( title: "Checkpoint Looks Implausible", message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Save it only if the device gauge really jumped that much." ) } private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double { let storedEnergyWh = session.effectiveOrMeasuredEnergyWh guard session.isTrimmed == false else { return storedEnergyWh } guard session.status.isOpen else { return storedEnergyWh } guard session.meterMACAddress == meter.btSerial.macAddress.description else { return storedEnergyWh } if let baselineEnergyWh = session.meterEnergyBaselineWh { return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0)) } return storedEnergyWh } } extension AppData.MeterSummary { var tint: Color { switch modelSummary { case "UM25C": return .blue case "UM34C": return .yellow case "TC66C": return Model.TC66C.color default: return .secondary } } } extension AppData { static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String { if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty { return liveName } if let customName = record?.customName { return customName } if let advertisedName = record?.advertisedName { return advertisedName } if let recordModel = record?.modelName { return recordModel } if let liveModel = liveMeter?.deviceModelSummary { return liveModel } return "Meter" } static func normalizedMACAddress(_ macAddress: String) -> String { macAddress .trimmingCharacters(in: .whitespacesAndNewlines) .uppercased() } static func isValidMACAddress(_ macAddress: String) -> Bool { macAddress.range( of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#, options: .regularExpression ) != nil } } private final class ChargeNotificationCoordinator { private struct Payload { let id: String let title: String let body: String let threadIdentifier: String } private let notificationCenter = UNUserNotificationCenter.current() private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs" private let eventRecencyWindow: TimeInterval = 24 * 60 * 60 private var inFlightEventIDs: Set = [] func ensureAuthorizationIfNeeded() { notificationCenter.getNotificationSettings { [weak self] settings in guard settings.authorizationStatus == .notDetermined else { return } self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in if let error { track("Notification authorization request failed: \(error.localizedDescription)") } } } } func process(chargedDevices: [ChargedDeviceSummary]) { let now = Date() let pendingPayloads = chargedDevices.flatMap { chargedDevice in payloads(for: chargedDevice, now: now) } for payload in pendingPayloads { scheduleIfNeeded(payload) } } private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] { chargedDevice.sessions.compactMap { session in if let triggeredAt = session.targetBatteryAlertTriggeredAt, now.timeIntervalSince(triggeredAt) <= eventRecencyWindow, let targetBatteryPercent = session.targetBatteryPercent { let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent ?? session.endBatteryPercent ?? targetBatteryPercent return Payload( id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))", title: "Battery target reached", body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).", threadIdentifier: session.id.uuidString ) } if session.requiresCompletionConfirmation, let requestedAt = session.completionConfirmationRequestedAt, now.timeIntervalSince(requestedAt) <= eventRecencyWindow { let estimatedPercent = session.completionContradictionPercent ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")." let detail = estimatedPercent.map { " Estimated battery is only \($0.format(decimalDigits: 0))%." } ?? "" return Payload( id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))", title: "Confirm charge completion", body: bodyPrefix + detail, threadIdentifier: session.id.uuidString ) } return nil } } private func scheduleIfNeeded(_ payload: Payload) { guard deliveredEventIDs().contains(payload.id) == false else { return } guard inFlightEventIDs.contains(payload.id) == false else { return } inFlightEventIDs.insert(payload.id) notificationCenter.getNotificationSettings { [weak self] settings in guard let self else { return } guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else { DispatchQueue.main.async { self.inFlightEventIDs.remove(payload.id) } return } let content = UNMutableNotificationContent() content.title = payload.title content.body = payload.body content.sound = .default content.threadIdentifier = payload.threadIdentifier let request = UNNotificationRequest( identifier: payload.id, content: content, trigger: nil ) self.notificationCenter.add(request) { error in DispatchQueue.main.async { self.inFlightEventIDs.remove(payload.id) if let error { track("Failed scheduling local notification: \(error.localizedDescription)") return } self.storeDeliveredEventID(payload.id) } } } } private func deliveredEventIDs() -> Set { let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? [] return Set(values) } private func storeDeliveredEventID(_ id: String) { var values = deliveredEventIDs() values.insert(id) UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey) } }