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 let meterStore = MeterNameStore.shared
private var chargeInsightsStore: ChargeInsightsStore?
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()
}
}
let bluetoothManager = BluetoothManager()
@Published var enableRecordFeature: Bool = true
@Published var meters: [UUID:Meter] = [UUID:Meter]()
@Published private(set) var chargedDevices: [ChargedDeviceSummary] = []
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?.reloadChargedDevices()
}
chargeInsightsRemoteObserver = NotificationCenter.default.publisher(
for: .NSPersistentStoreRemoteChange,
object: nil
)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.reloadChargedDevices()
}
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 = activeChargeSessionSummary(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 = activeChargeSessionSummary(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? {
chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress)
}
@discardableResult
func createDevice(
name: String,
deviceClass: ChargedDeviceClass,
chargingStateAvailability: ChargingStateAvailability,
supportsWiredCharging: Bool,
supportsWirelessCharging: Bool,
wirelessChargingProfile: WirelessChargingProfile,
configuredCompletionCurrents: [ChargeSessionKind: Double],
notes: String?,
meterMACAddress: String?
) -> Bool {
let didSave = chargeInsightsStore?.createDevice(
name: name,
deviceClass: deviceClass,
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,
notes: String?,
meterMACAddress: String?
) -> Bool {
let didSave = chargeInsightsStore?.createCharger(
name: name,
notes: notes,
assignTo: meterMACAddress
) ?? false
if didSave {
reloadChargedDevices()
}
return didSave
}
@discardableResult
func updateDevice(
id: UUID,
name: String,
deviceClass: ChargedDeviceClass,
chargingStateAvailability: ChargingStateAvailability,
supportsWiredCharging: Bool,
supportsWirelessCharging: Bool,
wirelessChargingProfile: WirelessChargingProfile,
configuredCompletionCurrents: [ChargeSessionKind: Double],
notes: String?
) -> Bool {
let didSave = chargeInsightsStore?.updateDevice(
id: id,
name: name,
deviceClass: deviceClass,
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,
notes: String?
) -> Bool {
let didSave = chargeInsightsStore?.updateCharger(
id: id,
name: name,
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,
label: String? = "Final"
) -> Bool {
let didSave = chargeInsightsStore?.stopSession(
id: sessionID,
finalBatteryPercent: finalBatteryPercent,
label: label
) ?? 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, label: String?, 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,
label: label,
for: meter.btSerial.macAddress.description,
measuredEnergyWh: checkpointEnergyWh,
measuredChargeAh: checkpointChargeAh
) ?? false
if didSave {
reloadChargedDevices()
}
return didSave
}
@discardableResult
func addBatteryCheckpoint(percent: Double, label: String?, for sessionID: UUID) -> Bool {
let didSave = chargeInsightsStore?.addBatteryCheckpoint(
percent: percent,
label: label,
for: sessionID
) ?? false
if didSave {
reloadChargedDevices()
}
return didSave
}
func batteryCheckpointPlausibilityWarning(
percent: Double,
for sessionID: UUID
) -> BatteryCheckpointPlausibilityWarning? {
guard let session = chargeSessionSummary(id: sessionID) else {
return nil
}
return batteryCheckpointPlausibilityWarning(percent: percent, for: session)
}
@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 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 == 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 reloadChargedDevices() {
chargedDevices = chargeInsightsStore?.fetchChargedDeviceSummaries() ?? []
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 batteryCheckpointPlausibilityWarning(
percent: Double,
for session: ChargeSessionSummary
) -> 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."
)
}
guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
let prediction = chargedDevice.batteryLevelPrediction(for: session)
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 effectiveEnergyWh = session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh
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)
}
}