1 contributor
//
// 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 let meterStore = MeterNameStore.shared
private var chargeInsightsStore: ChargeInsightsStore?
private let chargerStandbyPowerStore = ChargerStandbyPowerStore()
private let chargeNotificationCoordinator = ChargeNotificationCoordinator()
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?.refreshMeterMetadata()
}
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]()
@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
chargeInsightsStore = ChargeInsightsStore(context: context)
chargeInsightsStoreObserver = NotificationCenter.default.publisher(
for: .NSManagedObjectContextObjectsDidChange,
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) {
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? {
if let cachedSummary = cachedActiveChargeSessionSummary(for: meterMACAddress) {
return cachedSummary
}
return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress)
}
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,
templateID: String?,
notes: String?,
meterMACAddress: String?
) -> Bool {
let didSave = chargeInsightsStore?.createCharger(
name: name,
templateID: templateID,
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,
templateID: String?,
notes: String?
) -> Bool {
let didSave = chargeInsightsStore?.updateCharger(
id: id,
name: name,
templateID: templateID,
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 {
reloadChargedDevices()
meter.resetChargeRecordGraph()
if let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description),
meter.supportsRecordingThreshold,
activeSession.stopThresholdAmps > 0 {
meter.recordingTreshold = activeSession.stopThresholdAmps
}
restoreChargeMonitoringStateIfNeeded(for: meter)
}
return didSave
}
@discardableResult
func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date()
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) -> Bool {
let didSave = chargeInsightsStore?.stopSession(
id: sessionID,
finalBatteryPercent: finalBatteryPercent
) ?? false
if didSave {
reloadChargedDevices()
}
return didSave
}
func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) {
guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
return
}
if chargeInsightsStore?.observe(snapshot: snapshot) == true {
reloadChargedDevices()
}
}
@discardableResult
func addBatteryCheckpoint(percent: Double, for meter: Meter) -> Bool {
observeChargeSnapshot(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 {
scheduleChargedDevicesReload(delay: 0)
}
return didDelete
}
@discardableResult
func flushChargeInsights() -> Bool {
let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
reloadChargedDevices()
return 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] {
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)
return 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
}
}
private func scheduleObjectWillChange() {
DispatchQueue.main.async { [weak self] in
self?.objectWillChange.send()
}
}
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 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 == .active && $0.meterMACAddress == normalizedMAC })
}
private func reloadChargedDevices() {
pendingChargedDevicesReloadWorkItem?.cancel()
pendingChargedDevicesReloadWorkItem = nil
let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
chargedDevices = (chargeInsightsStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
chargedDevice.withStandbyPowerMeasurements(
standbyMeasurementsByChargerID[chargedDevice.id] ?? []
)
}
chargeNotificationCoordinator.process(chargedDevices: deviceSummaries)
for meter in meters.values {
restoreChargeMonitoringStateIfNeeded(for: meter)
}
}
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.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.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)
}
}