1 contributor
1443 lines | 53.149kb
//
//  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.lastAssociatedMeterMAC == normalizedMAC
                || 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.lastAssociatedMeterMAC == normalizedMAC
                || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
        }
    }

    func currentChargedDeviceSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()

        if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC),
           let liveDevice = chargedDevices.first(where: {
               $0.id == activeSession.chargedDeviceID && $0.isCharger == false
           }) {
            return liveDevice
        }

        return chargedDevices.first(where: {
            $0.isCharger == false && $0.lastAssociatedMeterMAC == normalizedMAC
        })
    }

    func currentChargerSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()

        if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC),
           let chargerID = activeSession.chargerID,
           let liveCharger = chargedDevices.first(where: {
               $0.id == chargerID && $0.isCharger
           }) {
            return liveCharger
        }

        return chargedDevices.first(where: {
            $0.isCharger && $0.lastAssociatedMeterMAC == 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) {
            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?,
        meterMACAddress: String?
    ) -> Bool {
        let didSave = chargeInsightsStore?.createDevice(
            name: name,
            deviceClass: deviceClass,
            templateID: templateID,
            chargingStateAvailability: chargingStateAvailability,
            supportsWiredCharging: supportsWiredCharging,
            supportsWirelessCharging: supportsWirelessCharging,
            wirelessChargingProfile: wirelessChargingProfile,
            configuredCompletionCurrents: configuredCompletionCurrents,
            notes: notes,
            assignTo: meterMACAddress
        ) ?? false

        if didSave {
            reloadChargedDevices()
        }

        return didSave
    }

    @discardableResult
    func createCharger(
        name: String,
        chargerType: ChargerType,
        notes: String?,
        meterMACAddress: String?
    ) -> Bool {
        let didSave = chargeInsightsStore?.createCharger(
            name: name,
            chargerType: chargerType,
            notes: notes,
            assignTo: meterMACAddress
        ) ?? 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
    }

    @discardableResult
    func assignChargedDevice(_ chargedDeviceID: UUID, to meterMACAddress: String) -> Bool {
        let didSave = chargeInsightsStore?.assignChargedDevice(id: chargedDeviceID, to: meterMACAddress) ?? false
        if didSave {
            reloadChargedDevices()
        }
        return didSave
    }

    @discardableResult
    func assignCharger(_ chargerID: UUID, to meterMACAddress: String) -> Bool {
        let didSave = chargeInsightsStore?.assignCharger(id: chargerID, to: meterMACAddress) ?? 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 == .active 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) -> Bool {
        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 checkpointChargeAh = activeSession.map { displayedSessionChargeAh(for: $0, on: meter) }

        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
            percent: percent,
            for: meter.btSerial.macAddress.description,
            measuredEnergyWh: checkpointEnergyWh,
            measuredChargeAh: checkpointChargeAh
        ) ?? 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?,
        measuredChargeAh: Double?
    ) -> Bool {
        guard canAddBatteryCheckpoint(to: sessionID) else {
            return false
        }

        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
            percent: percent,
            for: sessionID,
            measuredEnergyWh: measuredEnergyWh,
            measuredChargeAh: measuredChargeAh
        ) ?? 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 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 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

        _ = 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
    }

    private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
        let storedChargeAh = session.measuredChargeAh
        guard session.isTrimmed == false else {
            return storedChargeAh
        }
        guard session.status.isOpen else {
            return storedChargeAh
        }

        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
            return storedChargeAh
        }

        if let baselineChargeAh = session.meterChargeBaselineAh {
            return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
        }

        return storedChargeAh
    }
}

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<String> = []

    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<String> {
        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)
    }
}