1 contributor
//
// ChargeInsightsStore.swift
// USB Meter
//
// Created by Codex on 10/04/2026.
//
import CoreData
import Foundation
final class ChargeInsightsStore {
private enum EntityName {
static let chargedDevice = "ChargedDevice"
static let chargeSession = "ChargeSession"
static let chargeCheckpoint = "ChargeCheckpoint"
static let chargeSessionSample = "ChargeSessionSample"
}
private enum MeterAssignmentKind {
case chargedDevice
case charger
var expectsChargerClass: Bool {
switch self {
case .chargedDevice:
return false
case .charger:
return true
}
}
}
private static let persistedSamplesPerHour = 300
private static let aggregatedSampleBucketDuration = 3600.0 / Double(persistedSamplesPerHour)
private let context: NSManagedObjectContext
private let stopDetectionHoldDuration: TimeInterval = 20
private let maximumLiveIntegrationGap: TimeInterval = 20
private let activeSessionSaveInterval: TimeInterval = 15
private let counterDecreaseTolerance = 0.002
private let completionConfirmationCooldown: TimeInterval = 15 * 60
private let defaultCompletionPercentThreshold = 95.0
private let completionContradictionTolerancePercent = 2.0
private let minimumWirelessEfficiencyFactor = 0.35
private let maximumWirelessEfficiencyFactor = 0.95
private let lowWirelessEfficiencyThreshold = 0.72
init(context: NSManagedObjectContext) {
self.context = context
}
func refreshContext() {
context.performAndWait {
context.processPendingChanges()
}
}
@discardableResult
func flushPendingChanges() -> Bool {
var didSave = false
context.performAndWait {
context.processPendingChanges()
didSave = saveContext()
}
return didSave
}
@discardableResult
func createChargedDevice(
name: String,
deviceClass: ChargedDeviceClass,
supportsChargingWhileOff: Bool,
supportsWiredCharging: Bool,
supportsWirelessCharging: Bool,
preferredChargingTransportMode: ChargingTransportMode,
wirelessChargingProfile: WirelessChargingProfile,
wiredChargeCompletionCurrentAmps: Double?,
wirelessChargeCompletionCurrentAmps: Double?,
notes: String?,
assignTo meterMACAddress: String?
) -> Bool {
let normalizedName = normalizedText(name)
guard !normalizedName.isEmpty else { return false }
guard supportsWiredCharging || supportsWirelessCharging else { return false }
var didSave = false
context.performAndWait {
guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
return
}
let object = NSManagedObject(entity: entity, insertInto: context)
let now = Date()
object.setValue(UUID().uuidString, forKey: "id")
object.setValue(normalizedName, forKey: "name")
object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
object.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
object.setValue(supportsWiredCharging, forKey: "supportsWiredCharging")
object.setValue(supportsWirelessCharging, forKey: "supportsWirelessCharging")
object.setValue(
resolvedPreferredChargingTransportMode(
preferredChargingTransportMode,
supportsWiredCharging: supportsWiredCharging,
supportsWirelessCharging: supportsWirelessCharging
).rawValue,
forKey: "preferredChargingTransportRawValue"
)
object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
object.setValue(wiredChargeCompletionCurrentAmps, forKey: "wiredChargeCompletionCurrentAmps")
object.setValue(wirelessChargeCompletionCurrentAmps, forKey: "wirelessChargeCompletionCurrentAmps")
object.setValue(normalizedOptionalText(notes), forKey: "notes")
object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC")
object.setValue(now, forKey: "createdAt")
object.setValue(now, forKey: "updatedAt")
didSave = saveContext()
}
return didSave
}
@discardableResult
func updateChargedDevice(
id: UUID,
name: String,
deviceClass: ChargedDeviceClass,
supportsChargingWhileOff: Bool,
supportsWiredCharging: Bool,
supportsWirelessCharging: Bool,
preferredChargingTransportMode: ChargingTransportMode,
wirelessChargingProfile: WirelessChargingProfile,
wiredChargeCompletionCurrentAmps: Double?,
wirelessChargeCompletionCurrentAmps: Double?,
notes: String?
) -> Bool {
let normalizedName = normalizedText(name)
guard !normalizedName.isEmpty else { return false }
guard supportsWiredCharging || supportsWirelessCharging else { return false }
var didSave = false
context.performAndWait {
guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
return
}
let previousSupportsChargingWhileOff = boolValue(object, key: "supportsChargingWhileOff")
let previousSupportsWiredCharging = self.supportsWiredCharging(for: object)
let previousSupportsWirelessCharging = self.supportsWirelessCharging(for: object)
let previousPreferredChargingTransportMode = self.preferredChargingTransportMode(for: object)
let resolvedPreferredTransportMode = resolvedPreferredChargingTransportMode(
preferredChargingTransportMode,
supportsWiredCharging: supportsWiredCharging,
supportsWirelessCharging: supportsWirelessCharging
)
let now = Date()
object.setValue(normalizedName, forKey: "name")
object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
object.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
object.setValue(supportsWiredCharging, forKey: "supportsWiredCharging")
object.setValue(supportsWirelessCharging, forKey: "supportsWirelessCharging")
object.setValue(resolvedPreferredTransportMode.rawValue, forKey: "preferredChargingTransportRawValue")
object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
object.setValue(wiredChargeCompletionCurrentAmps, forKey: "wiredChargeCompletionCurrentAmps")
object.setValue(wirelessChargeCompletionCurrentAmps, forKey: "wirelessChargeCompletionCurrentAmps")
object.setValue(normalizedOptionalText(notes), forKey: "notes")
object.setValue(now, forKey: "updatedAt")
let shouldRecalculateSessionCapacity = previousSupportsChargingWhileOff != supportsChargingWhileOff
let shouldRefreshActiveSessions = shouldRecalculateSessionCapacity
|| previousSupportsWiredCharging != supportsWiredCharging
|| previousSupportsWirelessCharging != supportsWirelessCharging
|| previousPreferredChargingTransportMode != resolvedPreferredTransportMode
if shouldRecalculateSessionCapacity || shouldRefreshActiveSessions {
let sessions = fetchSessions(forChargedDeviceID: id.uuidString)
for session in sessions {
let isActive = statusValue(session, key: "statusRawValue") == .active
if shouldRecalculateSessionCapacity {
session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
updateCapacityEstimate(for: session)
session.setValue(now, forKey: "updatedAt")
}
guard isActive, shouldRefreshActiveSessions else {
continue
}
let resolvedSessionChargingTransportMode = resolvedPreferredChargingTransportMode(
chargingTransportMode(for: session),
supportsWiredCharging: supportsWiredCharging,
supportsWirelessCharging: supportsWirelessCharging
)
let fallbackStopThreshold = max(optionalDoubleValue(session, key: "stopThresholdAmps") ?? 0.01, 0.01)
session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
session.setValue(resolvedSessionChargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
session.setValue(
resolvedStopThreshold(
for: object,
chargingTransportMode: resolvedSessionChargingTransportMode,
fallback: fallbackStopThreshold
),
forKey: "stopThresholdAmps"
)
session.setValue(now, forKey: "updatedAt")
updateCapacityEstimate(for: session)
}
}
refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
didSave = saveContext()
}
return didSave
}
@discardableResult
func assignChargedDevice(id: UUID, to meterMACAddress: String) -> Bool {
assign(itemWithID: id, to: meterMACAddress, kind: .chargedDevice)
}
@discardableResult
func assignCharger(id: UUID, to meterMACAddress: String) -> Bool {
assign(itemWithID: id, to: meterMACAddress, kind: .charger)
}
@discardableResult
private func assign(
itemWithID id: UUID,
to meterMACAddress: String,
kind: MeterAssignmentKind
) -> Bool {
let normalizedMAC = normalizedMACAddress(meterMACAddress)
guard !normalizedMAC.isEmpty else { return false }
var didSave = false
context.performAndWait {
guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
return
}
let isCharger = ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
guard isCharger == kind.expectsChargerClass else {
return
}
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
request.predicate = NSPredicate(
format: "lastAssociatedMeterMAC == %@ AND id != %@",
normalizedMAC,
id.uuidString
)
let previouslyAssignedDevices = (try? context.fetch(request)) ?? []
for previousDevice in previouslyAssignedDevices {
let previousIsCharger = ChargedDeviceClass(rawValue: stringValue(previousDevice, key: "deviceClassRawValue") ?? "") == .charger
guard previousIsCharger == kind.expectsChargerClass else {
continue
}
previousDevice.setValue(nil, forKey: "lastAssociatedMeterMAC")
previousDevice.setValue(Date(), forKey: "updatedAt")
}
object.setValue(normalizedMAC, forKey: "lastAssociatedMeterMAC")
object.setValue(Date(), forKey: "updatedAt")
if kind == .charger,
let activeSession = fetchActiveSessionObject(forMeterMACAddress: normalizedMAC),
chargingTransportMode(for: activeSession) == .wireless {
activeSession.setValue(id.uuidString, forKey: "chargerID")
activeSession.setValue(Date(), forKey: "updatedAt")
}
didSave = saveContext()
}
return didSave
}
@discardableResult
func setChargingTransportMode(_ chargingTransportMode: ChargingTransportMode, for meterMACAddress: String) -> Bool {
let normalizedMAC = normalizedMACAddress(meterMACAddress)
guard !normalizedMAC.isEmpty else { return false }
var didSave = false
context.performAndWait {
let activeSession = fetchActiveSessionObject(forMeterMACAddress: normalizedMAC)
let device = (activeSession.flatMap { stringValue($0, key: "chargedDeviceID") }.flatMap(fetchChargedDeviceObject(id:)))
?? resolvedDeviceObject(for: normalizedMAC)
guard let device else {
return
}
let resolvedMode = resolvedPreferredChargingTransportMode(
chargingTransportMode,
supportsWiredCharging: supportsWiredCharging(for: device),
supportsWirelessCharging: supportsWirelessCharging(for: device)
)
let charger = resolvedMode == .wireless ? resolvedChargerObject(for: normalizedMAC) : nil
guard resolvedMode == .wired || charger != nil else {
return
}
device.setValue(resolvedMode.rawValue, forKey: "preferredChargingTransportRawValue")
device.setValue(Date(), forKey: "updatedAt")
if let activeSession {
activeSession.setValue(resolvedMode.rawValue, forKey: "chargingTransportRawValue")
activeSession.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
activeSession.setValue(Date(), forKey: "updatedAt")
}
didSave = saveContext()
}
return didSave
}
@discardableResult
func ensureSession(for snapshot: ChargingMonitorSnapshot, forceStart: Bool) -> Bool {
var didSave = false
context.performAndWait {
guard let resolved = resolvedDeviceObject(for: snapshot.meterMACAddress) else {
return
}
if fetchActiveSessionObject(forMeterMACAddress: snapshot.meterMACAddress) != nil {
didSave = false
return
}
let chargingTransportMode = preferredChargingTransportMode(for: resolved)
let charger = chargingTransportMode == .wireless
? resolvedChargerObject(for: snapshot.meterMACAddress)
: nil
guard chargingTransportMode == .wired || charger != nil else {
return
}
let stopThreshold = resolvedStopThreshold(
for: resolved,
chargingTransportMode: chargingTransportMode,
fallback: snapshot.fallbackStopThresholdAmps
)
guard forceStart || snapshot.currentAmps > stopThreshold else {
return
}
_ = createSessionObject(
for: resolved,
charger: charger,
snapshot: snapshot,
stopThreshold: stopThreshold,
chargingTransportMode: chargingTransportMode
)
didSave = saveContext()
}
return didSave
}
@discardableResult
func addBatteryCheckpoint(
percent: Double,
label: String?,
for meterMACAddress: String
) -> Bool {
guard percent.isFinite, percent >= 0, percent <= 100 else {
return false
}
var didSave = false
context.performAndWait {
guard
let session = fetchActiveSessionObject(forMeterMACAddress: meterMACAddress)
?? fetchLatestSessionObject(forMeterMACAddress: meterMACAddress)
else {
return
}
didSave = addBatteryCheckpoint(percent: percent, label: label, to: session)
}
return didSave
}
@discardableResult
func addBatteryCheckpoint(
percent: Double,
label: String?,
for sessionID: UUID
) -> Bool {
guard percent.isFinite, percent >= 0, percent <= 100 else {
return false
}
var didSave = false
context.performAndWait {
guard let session = fetchSessionObject(id: sessionID.uuidString) else {
return
}
didSave = addBatteryCheckpoint(percent: percent, label: label, to: session)
}
return didSave
}
@discardableResult
func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
if let percent, (!percent.isFinite || percent <= 0 || percent > 100) {
return false
}
var didSave = false
context.performAndWait {
guard let session = fetchSessionObject(id: sessionID.uuidString) else {
return
}
session.setValue(percent, forKey: "targetBatteryPercent")
session.setValue(nil, forKey: "targetBatteryAlertTriggeredAt")
session.setValue(Date(), forKey: "updatedAt")
didSave = saveContext()
}
return didSave
}
@discardableResult
func confirmCompletion(for sessionID: UUID) -> Bool {
var didSave = false
context.performAndWait {
guard let session = fetchSessionObject(id: sessionID.uuidString) else {
return
}
guard statusValue(session, key: "statusRawValue") == .active else {
return
}
let endedAt = dateValue(session, key: "lastObservedAt") ?? Date()
session.setValue(ChargeSessionStatus.completed.rawValue, forKey: "statusRawValue")
session.setValue(endedAt, forKey: "endedAt")
session.setValue(optionalDoubleValue(session, key: "lastObservedCurrentAmps"), forKey: "completionCurrentAmps")
clearCompletionConfirmationState(for: session)
updateCapacityEstimate(for: session)
session.setValue(Date(), forKey: "updatedAt")
if saveContext() {
if let deviceID = stringValue(session, key: "chargedDeviceID") {
refreshDerivedMetrics(forChargedDeviceID: deviceID)
didSave = saveContext()
} else {
didSave = true
}
}
}
return didSave
}
@discardableResult
func continueMonitoringDespiteCompletionContradiction(for sessionID: UUID) -> Bool {
var didSave = false
context.performAndWait {
guard let session = fetchSessionObject(id: sessionID.uuidString) else {
return
}
guard statusValue(session, key: "statusRawValue") == .active else {
return
}
clearCompletionConfirmationState(for: session)
session.setValue(Date().addingTimeInterval(completionConfirmationCooldown), forKey: "completionConfirmationCooldownUntil")
session.setValue(Date(), forKey: "updatedAt")
didSave = saveContext()
}
return didSave
}
@discardableResult
func deleteChargeSession(id sessionID: UUID) -> Bool {
var didSave = false
context.performAndWait {
guard let session = fetchSessionObject(id: sessionID.uuidString) else {
return
}
let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
context.delete(session)
guard saveContext() else {
return
}
if let chargedDeviceID {
refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
didSave = saveContext()
} else {
didSave = true
}
}
return didSave
}
@discardableResult
func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
var didSave = false
context.performAndWait {
guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
return
}
let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
var impactedChargedDeviceIDs = Set<String>()
for session in deviceSessions {
if let impactedID = stringValue(session, key: "chargedDeviceID") {
impactedChargedDeviceIDs.insert(impactedID)
}
if let impactedChargerID = stringValue(session, key: "chargerID") {
impactedChargedDeviceIDs.insert(impactedChargerID)
}
if let sessionID = stringValue(session, key: "id") {
fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete)
fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete)
}
context.delete(session)
}
if deviceClass == .charger {
for session in linkedWirelessSessions {
guard stringValue(session, key: "chargedDeviceID") != chargedDeviceID.uuidString else {
continue
}
if let impactedID = stringValue(session, key: "chargedDeviceID") {
impactedChargedDeviceIDs.insert(impactedID)
}
session.setValue(nil, forKey: "chargerID")
session.setValue(Date(), forKey: "updatedAt")
}
}
context.delete(chargedDevice)
guard saveContext() else {
return
}
impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
for impactedID in impactedChargedDeviceIDs {
refreshDerivedMetrics(forChargedDeviceID: impactedID)
}
didSave = saveContext()
}
return didSave
}
@discardableResult
func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
var didSave = false
context.performAndWait {
let activeSession = fetchActiveSessionObject(forMeterMACAddress: snapshot.meterMACAddress)
let resolvedDevice = activeSession.flatMap {
stringValue($0, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:))
} ?? resolvedDeviceObject(for: snapshot.meterMACAddress)
guard let resolvedDevice else {
return
}
let chargingTransportMode = activeSession.map { self.chargingTransportMode(for: $0) }
?? preferredChargingTransportMode(for: resolvedDevice)
let charger = chargingTransportMode == .wireless
? (activeSession.flatMap { stringValue($0, key: "chargerID") }.flatMap(fetchChargedDeviceObject(id:))
?? resolvedChargerObject(for: snapshot.meterMACAddress))
: nil
guard chargingTransportMode == .wired || charger != nil else {
return
}
let stopThreshold = resolvedStopThreshold(
for: resolvedDevice,
chargingTransportMode: chargingTransportMode,
fallback: snapshot.fallbackStopThresholdAmps
)
let session = activeSession ?? {
guard snapshot.currentAmps > stopThreshold else {
return nil
}
return createSessionObject(
for: resolvedDevice,
charger: charger,
snapshot: snapshot,
stopThreshold: stopThreshold,
chargingTransportMode: chargingTransportMode
)
}()
guard let session else {
return
}
update(session: session, with: snapshot, stopThreshold: stopThreshold)
updateAggregatedSample(session: session, with: snapshot)
let saveReason = saveReason(for: session, observedAt: snapshot.observedAt)
guard saveReason != .none else {
return
}
session.setValue(snapshot.observedAt, forKey: "updatedAt")
if saveContext() {
if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
refreshDerivedMetrics(forChargedDeviceID: deviceID)
didSave = saveContext()
} else {
didSave = true
}
}
}
return didSave
}
func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
var summaries: [ChargedDeviceSummary] = []
context.performAndWait {
let devices = fetchObjects(entityName: EntityName.chargedDevice)
let sessions = fetchObjects(entityName: EntityName.chargeSession)
let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
let sessionSamples = fetchObjects(entityName: EntityName.chargeSessionSample)
let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
let samplesBySessionID = Dictionary(grouping: sessionSamples) { stringValue($0, key: "sessionID") ?? "" }
let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
summaries = devices.compactMap { device in
guard
let id = uuidValue(device, key: "id"),
let name = stringValue(device, key: "name"),
let qrIdentifier = stringValue(device, key: "qrIdentifier"),
let rawClass = stringValue(device, key: "deviceClassRawValue"),
let deviceClass = ChargedDeviceClass(rawValue: rawClass)
else {
return nil
}
let sessionObjects = relevantSessionObjects(
for: id.uuidString,
deviceClass: deviceClass,
sessionsByDeviceID: sessionsByDeviceID,
sessionsByChargerID: sessionsByChargerID
)
let sessionSummaries = sessionObjects
.compactMap { session in
makeSessionSummary(
from: session,
checkpoints: checkpointsBySessionID[stringValue(session, key: "id") ?? ""] ?? [],
samples: samplesBySessionID[stringValue(session, key: "id") ?? ""] ?? []
)
}
.sorted { lhs, rhs in
if lhs.status == .active && rhs.status != .active {
return true
}
if lhs.status != .active && rhs.status == .active {
return false
}
return lhs.startedAt > rhs.startedAt
}
return ChargedDeviceSummary(
id: id,
qrIdentifier: qrIdentifier,
name: name,
deviceClass: deviceClass,
supportsChargingWhileOff: boolValue(device, key: "supportsChargingWhileOff"),
supportsWiredCharging: supportsWiredCharging(for: device),
supportsWirelessCharging: supportsWirelessCharging(for: device),
preferredChargingTransportMode: preferredChargingTransportMode(for: device),
wirelessChargingProfile: wirelessChargingProfile(for: device),
wirelessChargerEfficiencyFactor: optionalDoubleValue(device, key: "wirelessChargerEfficiencyFactor"),
wiredChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wiredChargeCompletionCurrentAmps"),
wirelessChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wirelessChargeCompletionCurrentAmps"),
chargerObservedVoltageSelections: decodedObservedVoltageSelections(from: device),
chargerIdleCurrentAmps: optionalDoubleValue(device, key: "chargerIdleCurrentAmps"),
chargerEfficiencyFactor: optionalDoubleValue(device, key: "chargerEfficiencyFactor"),
chargerMaximumPowerWatts: optionalDoubleValue(device, key: "chargerMaximumPowerWatts"),
notes: stringValue(device, key: "notes"),
minimumCurrentAmps: optionalDoubleValue(device, key: "minimumCurrentAmps"),
estimatedBatteryCapacityWh: optionalDoubleValue(device, key: "estimatedBatteryCapacityWh"),
wiredMinimumCurrentAmps: optionalDoubleValue(device, key: "wiredMinimumCurrentAmps"),
wirelessMinimumCurrentAmps: optionalDoubleValue(device, key: "wirelessMinimumCurrentAmps"),
wiredEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wiredEstimatedBatteryCapacityWh"),
wirelessEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wirelessEstimatedBatteryCapacityWh"),
lastAssociatedMeterMAC: stringValue(device, key: "lastAssociatedMeterMAC"),
createdAt: dateValue(device, key: "createdAt") ?? .distantPast,
updatedAt: dateValue(device, key: "updatedAt") ?? .distantPast,
sessions: sessionSummaries,
capacityHistory: buildCapacityHistory(from: sessionSummaries),
typicalCurve: buildTypicalCurve(from: sessionSummaries)
)
}
.sorted { lhs, rhs in
if lhs.activeSession != nil && rhs.activeSession == nil {
return true
}
if lhs.activeSession == nil && rhs.activeSession != nil {
return false
}
if lhs.updatedAt != rhs.updatedAt {
return lhs.updatedAt > rhs.updatedAt
}
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
}
return summaries
}
func resolvedChargedDeviceSummary(forMeterMACAddress meterMACAddress: String) -> ChargedDeviceSummary? {
let normalizedMAC = normalizedMACAddress(meterMACAddress)
guard !normalizedMAC.isEmpty else { return nil }
let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
if let activeMatch = summaries.first(where: { summary in
summary.activeSession?.meterMACAddress == normalizedMAC
}) {
return activeMatch
}
return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
}
func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
let normalizedMAC = normalizedMACAddress(meterMACAddress)
guard !normalizedMAC.isEmpty else { return nil }
return fetchChargedDeviceSummaries()
.flatMap(\.sessions)
.first(where: {
$0.status == .active && $0.meterMACAddress == normalizedMAC
})
}
private func createSessionObject(
for chargedDevice: NSManagedObject,
charger: NSManagedObject?,
snapshot: ChargingMonitorSnapshot,
stopThreshold: Double,
chargingTransportMode: ChargingTransportMode
) -> NSManagedObject? {
guard
let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
let chargedDeviceID = stringValue(chargedDevice, key: "id")
else {
return nil
}
let session = NSManagedObject(entity: entity, insertInto: context)
let now = snapshot.observedAt
session.setValue(UUID().uuidString, forKey: "id")
session.setValue(chargedDeviceID, forKey: "chargedDeviceID")
session.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress")
session.setValue(snapshot.meterName, forKey: "meterName")
session.setValue(snapshot.meterModel, forKey: "meterModel")
session.setValue(now, forKey: "startedAt")
session.setValue(now, forKey: "lastObservedAt")
session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
session.setValue(ChargeSessionSourceMode.live.rawValue, forKey: "sourceModeRawValue")
session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
session.setValue(stopThreshold, forKey: "stopThresholdAmps")
session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
session.setValue(
chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
forKey: "lastObservedVoltageVolts"
)
session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
session.setValue(
chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
forKey: "maximumObservedVoltageVolts"
)
session.setValue(boolValue(chargedDevice, key: "supportsChargingWhileOff"), forKey: "supportsChargingWhileOff")
if let selectedDataGroup = snapshot.selectedDataGroup {
session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup")
}
if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
}
if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh")
session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
}
session.setValue(now, forKey: "createdAt")
session.setValue(now, forKey: "updatedAt")
chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
chargedDevice.setValue(chargingTransportMode.rawValue, forKey: "preferredChargingTransportRawValue")
chargedDevice.setValue(now, forKey: "updatedAt")
return session
}
private func update(
session: NSManagedObject,
with snapshot: ChargingMonitorSnapshot,
stopThreshold: Double
) {
let sessionChargingTransportMode = chargingTransportMode(for: session)
let lastObservedAt = dateValue(session, key: "lastObservedAt")
let previousPower = doubleValue(session, key: "lastObservedPowerWatts")
let previousCurrent = doubleValue(session, key: "lastObservedCurrentAmps")
var measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
var measuredChargeAh = doubleValue(session, key: "measuredChargeAh")
var sourceMode = ChargeSessionSourceMode(rawValue: stringValue(session, key: "sourceModeRawValue") ?? "") ?? .live
var usedOfflineMeterCounters = boolValue(session, key: "usedOfflineMeterCounters")
if let lastObservedAt {
let deltaSeconds = max(snapshot.observedAt.timeIntervalSince(lastObservedAt), 0)
if deltaSeconds > 0, deltaSeconds <= maximumLiveIntegrationGap {
measuredEnergyWh += max(previousPower, 0) * deltaSeconds / 3600
measuredChargeAh += max(previousCurrent, 0) * deltaSeconds / 3600
if sourceMode == .offline {
sourceMode = .blended
}
}
}
if let counterGroup = snapshot.selectedDataGroup,
let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
UInt8(storedGroup) != counterGroup {
session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
}
if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
}
if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
let offlineEnergy = meterEnergyCounterWh - baselineEnergy
if offlineEnergy > measuredEnergyWh {
measuredEnergyWh = offlineEnergy
}
usedOfflineMeterCounters = true
sourceMode = sourceMode == .live && measuredEnergyWh > 0 ? .blended : .offline
} else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
let delta = meterEnergyCounterWh - lastEnergy
if delta > 0 {
measuredEnergyWh += delta
usedOfflineMeterCounters = true
sourceMode = .blended
}
}
session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
}
if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
}
if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
let offlineCharge = meterChargeCounterAh - baselineCharge
if offlineCharge > measuredChargeAh {
measuredChargeAh = offlineCharge
}
usedOfflineMeterCounters = true
} else if let lastCharge, meterChargeCounterAh > lastCharge {
let delta = meterChargeCounterAh - lastCharge
if delta > 0 {
measuredChargeAh += delta
usedOfflineMeterCounters = true
}
}
session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
}
let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps")
let updatedMinimum: Double
if snapshot.currentAmps > 0 {
updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps)
} else {
updatedMinimum = existingMinimum ?? 0
}
session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh")
session.setValue(measuredChargeAh, forKey: "measuredChargeAh")
session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps")
session.setValue(stopThreshold, forKey: "stopThresholdAmps")
session.setValue(snapshot.observedAt, forKey: "lastObservedAt")
session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
session.setValue(
sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil,
forKey: "lastObservedVoltageVolts"
)
session.setValue(
max(optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? snapshot.currentAmps, snapshot.currentAmps),
forKey: "maximumObservedCurrentAmps"
)
session.setValue(
max(optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? snapshot.powerWatts, snapshot.powerWatts),
forKey: "maximumObservedPowerWatts"
)
session.setValue(
sessionChargingTransportMode == .wired
? max(optionalDoubleValue(session, key: "maximumObservedVoltageVolts") ?? snapshot.voltageVolts, snapshot.voltageVolts)
: nil,
forKey: "maximumObservedVoltageVolts"
)
session.setValue(usedOfflineMeterCounters, forKey: "usedOfflineMeterCounters")
session.setValue(sourceMode.rawValue, forKey: "sourceModeRawValue")
maybeTriggerTargetBatteryAlert(for: session, observedAt: snapshot.observedAt)
if snapshot.currentAmps <= stopThreshold {
let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt
session.setValue(belowThresholdSince, forKey: "belowThresholdSince")
if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
if boolValue(session, key: "requiresCompletionConfirmation") {
// Leave the session active until the user explicitly confirms or charging resumes.
return
}
if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt)
} else {
session.setValue(ChargeSessionStatus.completed.rawValue, forKey: "statusRawValue")
session.setValue(snapshot.observedAt, forKey: "endedAt")
session.setValue(snapshot.currentAmps, forKey: "completionCurrentAmps")
updateCapacityEstimate(for: session)
}
}
} else {
session.setValue(nil, forKey: "belowThresholdSince")
clearCompletionConfirmationState(for: session)
session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
}
}
private func updateAggregatedSample(
session: NSManagedObject,
with snapshot: ChargingMonitorSnapshot
) {
guard
let sessionID = stringValue(session, key: "id"),
let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
let startedAt = dateValue(session, key: "startedAt"),
let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context)
else {
return
}
let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0)
let bucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
let bucketStart = startedAt.addingTimeInterval(Double(bucketIndex) * Self.aggregatedSampleBucketDuration)
let bucketIdentifier = "\(sessionID)-\(bucketIndex)"
let sample = fetchSessionSampleObject(sessionID: sessionID, bucketIndex: bucketIndex)
?? NSManagedObject(entity: entity, insertInto: context)
let sessionChargingTransportMode = chargingTransportMode(for: session)
let sampleVoltage = sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil
let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
let updatedCount = existingCount + 1
sample.setValue(bucketIdentifier, forKey: "id")
sample.setValue(sessionID, forKey: "sessionID")
sample.setValue(chargedDeviceID, forKey: "chargedDeviceID")
sample.setValue(bucketIndex, forKey: "bucketIndex")
sample.setValue(max(snapshot.observedAt, bucketStart), forKey: "timestamp")
sample.setValue(
runningAverage(
currentAverage: optionalDoubleValue(sample, key: "averageCurrentAmps") ?? snapshot.currentAmps,
currentCount: Int(existingCount),
newValue: snapshot.currentAmps
),
forKey: "averageCurrentAmps"
)
sample.setValue(
sampleVoltage.flatMap { voltage in
runningAverage(
currentAverage: optionalDoubleValue(sample, key: "averageVoltageVolts") ?? voltage,
currentCount: Int(existingCount),
newValue: voltage
)
},
forKey: "averageVoltageVolts"
)
sample.setValue(
runningAverage(
currentAverage: optionalDoubleValue(sample, key: "averagePowerWatts") ?? snapshot.powerWatts,
currentCount: Int(existingCount),
newValue: snapshot.powerWatts
),
forKey: "averagePowerWatts"
)
sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh")
sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
sample.setValue(Int16(updatedCount), forKey: "sampleCount")
sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
sample.setValue(snapshot.observedAt, forKey: "updatedAt")
}
private func maybeTriggerTargetBatteryAlert(for session: NSManagedObject, observedAt: Date) {
guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
return
}
guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
return
}
let predictedBatteryPercent = predictedBatteryPercent(for: session)
?? optionalDoubleValue(session, key: "endBatteryPercent")
guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
return
}
session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
}
private func shouldRequireCompletionConfirmation(
for session: NSManagedObject,
observedAt: Date
) -> Bool {
if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"),
cooldownUntil > observedAt {
return false
}
guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
return false
}
let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
?? defaultCompletionPercentThreshold
return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
}
private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
guard !boolValue(session, key: "requiresCompletionConfirmation") else {
return
}
session.setValue(true, forKey: "requiresCompletionConfirmation")
session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
}
private func clearCompletionConfirmationState(for session: NSManagedObject) {
session.setValue(false, forKey: "requiresCompletionConfirmation")
session.setValue(nil, forKey: "completionConfirmationRequestedAt")
session.setValue(nil, forKey: "completionContradictionPercent")
}
private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
guard
let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice),
estimatedCapacityWh > 0
else {
return nil
}
let measuredEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
?? doubleValue(session, key: "measuredEnergyWh")
let sessionID = stringValue(session, key: "id") ?? ""
struct Anchor {
let percent: Double
let energyWh: Double
}
var anchors: [Anchor] = []
if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent") {
anchors.append(Anchor(percent: startBatteryPercent, energyWh: 0))
}
let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
.compactMap(makeCheckpointSummary(from:))
.sorted { lhs, rhs in
if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
return lhs.measuredEnergyWh < rhs.measuredEnergyWh
}
return lhs.timestamp < rhs.timestamp
}
.map { Anchor(percent: $0.batteryPercent, energyWh: $0.measuredEnergyWh) }
anchors.append(contentsOf: checkpointAnchors)
guard !anchors.isEmpty else {
return optionalDoubleValue(session, key: "endBatteryPercent")
}
let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
return min(
100,
max(
0,
anchor.percent + (((measuredEnergyWh - anchor.energyWh) / estimatedCapacityWh) * 100)
)
)
}
private func resolvedEstimatedBatteryCapacityWh(
for session: NSManagedObject,
chargedDevice: NSManagedObject
) -> Double? {
if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
sessionCapacityEstimate > 0 {
return sessionCapacityEstimate
}
switch chargingTransportMode(for: session) {
case .wired:
return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
case .wireless:
return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
}
}
private func updateCapacityEstimate(for session: NSManagedObject) {
guard
let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
else {
session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
session.setValue(nil, forKey: "capacityEstimateWh")
return
}
let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
let chargingMode = chargingTransportMode(for: session)
let wirelessResolution = chargingMode == .wireless
? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
: nil
let effectiveBatteryEnergyWh = chargingMode == .wired
? measuredEnergyWh
: wirelessResolution.map { measuredEnergyWh * $0.factor }
session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
guard
let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
else {
session.setValue(nil, forKey: "capacityEstimateWh")
return
}
let percentDelta = endBatteryPercent - startBatteryPercent
let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
guard percentDelta >= 20, let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
session.setValue(nil, forKey: "capacityEstimateWh")
return
}
if !supportsChargingWhileOff && endBatteryPercent >= 99.5 {
session.setValue(nil, forKey: "capacityEstimateWh")
return
}
let capacityEstimateWh = effectiveBatteryEnergyWh / (percentDelta / 100)
session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
}
@discardableResult
private func addBatteryCheckpoint(
percent: Double,
label: String?,
to session: NSManagedObject
) -> Bool {
guard
let sessionID = stringValue(session, key: "id"),
let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
else {
return false
}
let checkpoint = NSManagedObject(entity: entity, insertInto: context)
let checkpointEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
?? doubleValue(session, key: "measuredEnergyWh")
checkpoint.setValue(UUID().uuidString, forKey: "id")
checkpoint.setValue(sessionID, forKey: "sessionID")
checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
checkpoint.setValue(Date(), forKey: "timestamp")
checkpoint.setValue(percent, forKey: "batteryPercent")
checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
checkpoint.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
checkpoint.setValue(
chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
forKey: "voltageVolts"
)
checkpoint.setValue(normalizedOptionalText(label), forKey: "label")
checkpoint.setValue(Date(), forKey: "createdAt")
if session.value(forKey: "startBatteryPercent") == nil {
session.setValue(percent, forKey: "startBatteryPercent")
}
session.setValue(percent, forKey: "endBatteryPercent")
session.setValue(Date(), forKey: "updatedAt")
updateCapacityEstimate(for: session)
guard saveContext() else {
return false
}
refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
return saveContext()
}
private func resolvedWirelessEfficiency(
for session: NSManagedObject,
chargedDevice: NSManagedObject
) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
storedFactor > 0 {
return (
factor: storedFactor,
usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
)
}
let chargingProfile = wirelessChargingProfile(for: chargedDevice)
let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
guard measuredEnergyWh > 0 else {
return nil
}
if chargingProfile == .magsafe,
let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
calibratedFactor > 0 {
return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
}
guard
let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
else {
return nil
}
let percentDelta = endBatteryPercent - startBatteryPercent
guard percentDelta >= 20 else {
return nil
}
guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
?? ((preferredChargingTransportMode(for: chargedDevice) == .wired)
? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
: nil),
wiredCapacityWh > 0
else {
return nil
}
let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
let usesEstimated = chargingProfile != .magsafe
let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
}
private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
return
}
let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
let sessions = relevantSessionObjects(
for: chargedDeviceID,
deviceClass: deviceClass,
sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
)
let wiredMinimumCurrent = derivedMinimumCurrent(
from: sessions,
chargingTransportMode: .wired
)
let wirelessMinimumCurrent = derivedMinimumCurrent(
from: sessions,
chargingTransportMode: .wireless
)
let wiredCapacity = derivedCapacity(
from: sessions,
chargingTransportMode: .wired,
supportsChargingWhileOff: supportsChargingWhileOff
)
let wirelessCapacity = derivedCapacity(
from: sessions,
chargingTransportMode: .wireless,
supportsChargingWhileOff: supportsChargingWhileOff
)
let wirelessEfficiency = derivedWirelessEfficiency(
from: sessions,
chargingProfile: wirelessProfile
)
let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
let preferredChargingTransportMode = preferredChargingTransportMode(for: chargedDevice)
let preferredMinimumCurrent: Double?
let preferredCapacity: Double?
switch preferredChargingTransportMode {
case .wired:
preferredMinimumCurrent = configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
preferredCapacity = wiredCapacity ?? wirelessCapacity
case .wireless:
preferredMinimumCurrent = configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
preferredCapacity = wirelessCapacity ?? wiredCapacity
}
chargedDevice.setValue(wiredMinimumCurrent, forKey: "wiredMinimumCurrentAmps")
chargedDevice.setValue(wirelessMinimumCurrent, forKey: "wirelessMinimumCurrentAmps")
chargedDevice.setValue(wiredCapacity, forKey: "wiredEstimatedBatteryCapacityWh")
chargedDevice.setValue(wirelessCapacity, forKey: "wirelessEstimatedBatteryCapacityWh")
chargedDevice.setValue(wirelessEfficiency, forKey: "wirelessChargerEfficiencyFactor")
chargedDevice.setValue(encodedObservedVoltageSelections(chargerObservedVoltages), forKey: "chargerObservedVoltageSelectionsRawValue")
chargedDevice.setValue(chargerIdleCurrent, forKey: "chargerIdleCurrentAmps")
chargedDevice.setValue(chargerEfficiency, forKey: "chargerEfficiencyFactor")
chargedDevice.setValue(chargerMaximumPower, forKey: "chargerMaximumPowerWatts")
chargedDevice.setValue(preferredMinimumCurrent, forKey: "minimumCurrentAmps")
chargedDevice.setValue(preferredCapacity, forKey: "estimatedBatteryCapacityWh")
chargedDevice.setValue(Date(), forKey: "updatedAt")
}
private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
sessions
.filter { $0.status == .completed }
.compactMap { session in
guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
let timestamp = session.endedAt ?? session.lastObservedAt
return CapacityTrendPoint(
sessionID: session.id,
timestamp: timestamp,
capacityWh: capacityEstimateWh,
chargingTransportMode: session.chargingTransportMode
)
}
.sorted { $0.timestamp < $1.timestamp }
}
private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
var groupedEnergyByBin: [Int: [Double]] = [:]
var groupedChargeByBin: [Int: [Double]] = [:]
for session in sessions where session.status == .completed {
var points = session.checkpoints
if let startBatteryPercent = session.startBatteryPercent {
points.append(
ChargeCheckpointSummary(
id: UUID(),
sessionID: session.id,
chargedDeviceID: session.chargedDeviceID,
timestamp: session.startedAt,
batteryPercent: startBatteryPercent,
measuredEnergyWh: 0,
measuredChargeAh: 0,
currentAmps: 0,
voltageVolts: nil,
label: "Start"
)
)
}
if let endBatteryPercent = session.endBatteryPercent {
points.append(
ChargeCheckpointSummary(
id: UUID(),
sessionID: session.id,
chargedDeviceID: session.chargedDeviceID,
timestamp: session.endedAt ?? session.lastObservedAt,
batteryPercent: endBatteryPercent,
measuredEnergyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
measuredChargeAh: session.measuredChargeAh,
currentAmps: 0,
voltageVolts: nil,
label: "End"
)
)
}
for point in points {
let percentBin = Int((point.batteryPercent / 10).rounded(.toNearestOrEven)) * 10
groupedEnergyByBin[percentBin, default: []].append(point.measuredEnergyWh)
groupedChargeByBin[percentBin, default: []].append(point.measuredChargeAh)
}
}
return groupedEnergyByBin.keys.sorted().compactMap { percentBin in
guard
let energies = groupedEnergyByBin[percentBin],
let charges = groupedChargeByBin[percentBin],
!energies.isEmpty,
!charges.isEmpty
else {
return nil
}
return TypicalChargeCurvePoint(
percentBin: percentBin,
averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
averageChargeAh: charges.reduce(0, +) / Double(charges.count),
sampleCount: min(energies.count, charges.count)
)
}
}
private func makeSessionSummary(
from object: NSManagedObject,
checkpoints: [NSManagedObject],
samples: [NSManagedObject]
) -> ChargeSessionSummary? {
let chargingTransportMode = chargingTransportMode(for: object)
guard
let id = uuidValue(object, key: "id"),
let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
let startedAt = dateValue(object, key: "startedAt"),
let lastObservedAt = dateValue(object, key: "lastObservedAt"),
let status = statusValue(object, key: "statusRawValue"),
let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
else {
return nil
}
let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
.sorted { $0.timestamp < $1.timestamp }
let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
.sorted { lhs, rhs in
if lhs.bucketIndex != rhs.bucketIndex {
return lhs.bucketIndex < rhs.bucketIndex
}
return lhs.timestamp < rhs.timestamp
}
return ChargeSessionSummary(
id: id,
chargedDeviceID: chargedDeviceID,
chargerID: uuidValue(object, key: "chargerID"),
meterMACAddress: stringValue(object, key: "meterMACAddress"),
meterName: stringValue(object, key: "meterName"),
meterModel: stringValue(object, key: "meterModel"),
startedAt: startedAt,
endedAt: dateValue(object, key: "endedAt"),
lastObservedAt: lastObservedAt,
status: status,
sourceMode: sourceMode,
chargingTransportMode: chargingTransportMode,
measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
maximumObservedVoltageVolts: chargingTransportMode == .wired
? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
: nil,
selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
checkpoints: checkpointSummaries,
aggregatedSamples: sampleSummaries
)
}
private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
guard
let id = uuidValue(object, key: "id"),
let sessionID = uuidValue(object, key: "sessionID"),
let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
let timestamp = dateValue(object, key: "timestamp")
else {
return nil
}
return ChargeCheckpointSummary(
id: id,
sessionID: sessionID,
chargedDeviceID: chargedDeviceID,
timestamp: timestamp,
batteryPercent: doubleValue(object, key: "batteryPercent"),
measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
currentAmps: doubleValue(object, key: "currentAmps"),
voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
label: stringValue(object, key: "label")
)
}
private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
guard
let sessionID = uuidValue(object, key: "sessionID"),
let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
let timestamp = dateValue(object, key: "timestamp")
else {
return nil
}
return ChargeSessionSampleSummary(
sessionID: sessionID,
chargedDeviceID: chargedDeviceID,
bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
timestamp: timestamp,
averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
)
}
private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
fetchSessionObject(
predicate: NSPredicate(
format: "meterMACAddress == %@ AND statusRawValue == %@",
normalizedMACAddress(meterMACAddress),
ChargeSessionStatus.active.rawValue
)
)
}
private func fetchLatestSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
fetchSessionObject(
predicate: NSPredicate(
format: "meterMACAddress == %@",
normalizedMACAddress(meterMACAddress)
)
)
}
private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
request.predicate = predicate
request.fetchLimit = 1
request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
return (try? context.fetch(request))?.first
}
private func fetchSessionObject(id: String) -> NSManagedObject? {
fetchSessionObject(
predicate: NSPredicate(format: "id == %@", id)
)
}
private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
request.predicate = NSPredicate(
format: "sessionID == %@ AND bucketIndex == %d",
sessionID,
bucketIndex
)
request.fetchLimit = 1
return (try? context.fetch(request))?.first
}
private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
return (try? context.fetch(request)) ?? []
}
private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
return (try? context.fetch(request)) ?? []
}
private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
return (try? context.fetch(request)) ?? []
}
private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
return (try? context.fetch(request)) ?? []
}
private func relevantSessionObjects(
for chargedDeviceID: String,
deviceClass: ChargedDeviceClass,
sessionsByDeviceID: [String: [NSManagedObject]],
sessionsByChargerID: [String: [NSManagedObject]]
) -> [NSManagedObject] {
let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
guard deviceClass == .charger else {
return directSessions
}
var seenSessionIDs = Set<String>()
return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
.filter { session in
let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
return seenSessionIDs.insert(sessionID).inserted
}
.sorted {
let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
return lhsDate < rhsDate
}
}
private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false)
}
private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true)
}
private func resolvedAssignedObject(
for meterMACAddress: String,
expectsChargerClass: Bool
) -> NSManagedObject? {
let normalizedMAC = normalizedMACAddress(meterMACAddress)
guard !normalizedMAC.isEmpty else { return nil }
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
request.predicate = NSPredicate(format: "lastAssociatedMeterMAC == %@", normalizedMAC)
request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
let matches = (try? context.fetch(request)) ?? []
return matches.first { object in
let isCharger = ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
return isCharger == expectsChargerClass
}
}
private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
request.predicate = NSPredicate(format: "id == %@", id)
request.fetchLimit = 1
return (try? context.fetch(request))?.first
}
private func fetchObjects(entityName: String) -> [NSManagedObject] {
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
return (try? context.fetch(request)) ?? []
}
private func resolvedStopThreshold(
for chargedDevice: NSManagedObject,
chargingTransportMode: ChargingTransportMode,
fallback: Double
) -> Double {
let persistedMinimum: Double?
switch chargingTransportMode {
case .wired:
persistedMinimum = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
case .wireless:
persistedMinimum = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
}
return max(persistedMinimum ?? fallback, 0.01)
}
private func preferredChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
let persistedMode = chargingTransportModeValue(chargedDevice, key: "preferredChargingTransportRawValue") ?? .wired
return resolvedPreferredChargingTransportMode(
persistedMode,
supportsWiredCharging: supportsWiredCharging,
supportsWirelessCharging: supportsWirelessCharging
)
}
private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
if chargedDevice.value(forKey: "supportsWiredCharging") == nil {
return true
}
return boolValue(chargedDevice, key: "supportsWiredCharging")
}
private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
if chargedDevice.value(forKey: "supportsWirelessCharging") == nil {
return false
}
return boolValue(chargedDevice, key: "supportsWirelessCharging")
}
private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
let profile = WirelessChargingProfile(rawValue: rawValue) else {
return .genericQi
}
return profile
}
private func resolvedPreferredChargingTransportMode(
_ preferredChargingTransportMode: ChargingTransportMode,
supportsWiredCharging: Bool,
supportsWirelessCharging: Bool
) -> ChargingTransportMode {
switch preferredChargingTransportMode {
case .wired where supportsWiredCharging:
return .wired
case .wireless where supportsWirelessCharging:
return .wireless
default:
if supportsWiredCharging {
return .wired
}
if supportsWirelessCharging {
return .wireless
}
return .wired
}
}
private func derivedMinimumCurrent(
from sessions: [NSManagedObject],
chargingTransportMode: ChargingTransportMode
) -> Double? {
let completionCurrents = sessions.compactMap { session -> Double? in
guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
guard self.chargingTransportMode(for: session) == chargingTransportMode else {
return nil
}
guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
return nil
}
return completionCurrent
}
let recentCompletionCurrents = Array(completionCurrents.suffix(5))
guard !recentCompletionCurrents.isEmpty else { return nil }
return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
}
private func derivedCapacity(
from sessions: [NSManagedObject],
chargingTransportMode: ChargingTransportMode,
supportsChargingWhileOff: Bool
) -> Double? {
let capacityCandidates = sessions.compactMap { session -> Double? in
guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
guard self.chargingTransportMode(for: session) == chargingTransportMode else {
return nil
}
guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
return nil
}
if supportsChargingWhileOff {
return capacityEstimate
}
guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
return nil
}
return capacityEstimate
}
let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
guard !recentCapacityCandidates.isEmpty else { return nil }
return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
}
private func derivedWirelessEfficiency(
from sessions: [NSManagedObject],
chargingProfile: WirelessChargingProfile
) -> Double? {
guard chargingProfile == .magsafe else {
return nil
}
let candidates = sessions.compactMap { session -> Double? in
guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
guard chargingTransportMode(for: session) == .wireless else { return nil }
guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
return nil
}
return factor
}
let recentCandidates = Array(candidates.suffix(6))
guard !recentCandidates.isEmpty else { return nil }
return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
}
private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
let candidates = sessions.compactMap { session -> Double? in
guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
return nil
}
return (sourceVoltage * 10).rounded() / 10
}
let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
return counts.keys.sorted()
}
private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
let candidates = sessions.compactMap { session -> Double? in
guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
return nil
}
return minimumObservedCurrent
}
let recentCandidates = Array(candidates.suffix(6))
guard !recentCandidates.isEmpty else { return nil }
return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
}
private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
let candidates = sessions.compactMap { session -> Double? in
guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
return nil
}
return factor
}
let recentCandidates = Array(candidates.suffix(6))
guard !recentCandidates.isEmpty else { return nil }
return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
}
private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
sessions.compactMap { session -> Double? in
guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
return nil
}
return maximumObservedPower
}
.max()
}
private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
return persistedChargingTransportMode
}
if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
return preferredChargingTransportMode(for: chargedDevice)
}
return .wired
}
private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
if session.isInserted {
return .created
}
let committedValues = session.committedValues(
forKeys: [
"statusRawValue",
"updatedAt",
"targetBatteryAlertTriggeredAt",
"requiresCompletionConfirmation"
]
)
let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
let currentStatus = statusValue(session, key: "statusRawValue")
let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
?? (committedValues["requiresCompletionConfirmation"] as? Bool)
?? false
let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
if currentStatus == .completed, committedStatus != .completed {
return .completed
}
if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
|| committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
return .event
}
let lastPersistedAt = (committedValues["updatedAt"] as? Date)
?? dateValue(session, key: "createdAt")
?? observedAt
if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
return .periodic
}
return .none
}
private func generateQRIdentifier() -> String {
"device:\(UUID().uuidString)"
}
@discardableResult
private func saveContext() -> Bool {
guard context.hasChanges else { return true }
do {
try context.save()
return true
} catch {
track("Failed saving charge insights context: \(error)")
context.rollback()
return false
}
}
private func normalizedText(_ text: String) -> String {
text.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func normalizedOptionalText(_ text: String?) -> String? {
guard let text else { return nil }
let normalized = normalizedText(text)
return normalized.isEmpty ? nil : normalized
}
private func normalizedMACAddress(_ macAddress: String) -> String {
normalizedText(macAddress).uppercased()
}
private func stringValue(_ object: NSManagedObject, key: String) -> String? {
guard let value = object.value(forKey: key) as? String else { return nil }
let normalized = normalizedOptionalText(value)
return normalized
}
private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
object.value(forKey: key) as? Date
}
private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
if let value = object.value(forKey: key) as? Double {
return value
}
if let value = object.value(forKey: key) as? NSNumber {
return value.doubleValue
}
return 0
}
private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
let rawValue = object.value(forKey: key)
if rawValue == nil {
return nil
}
return doubleValue(object, key: key)
}
private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
if let value = object.value(forKey: key) as? Int16 {
return value
}
if let value = object.value(forKey: key) as? NSNumber {
return value.int16Value
}
return nil
}
private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
if let value = object.value(forKey: key) as? Int32 {
return value
}
if let value = object.value(forKey: key) as? NSNumber {
return value.int32Value
}
return nil
}
private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
if let value = object.value(forKey: key) as? Bool {
return value
}
if let value = object.value(forKey: key) as? NSNumber {
return value.boolValue
}
return false
}
private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
guard let value = stringValue(object, key: key) else { return nil }
return UUID(uuidString: value)
}
private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
guard let value = stringValue(object, key: key) else { return nil }
return ChargeSessionStatus(rawValue: value)
}
private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
guard let value = stringValue(object, key: key) else { return nil }
return ChargingTransportMode(rawValue: value)
}
private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
return []
}
return rawValue
.split(separator: ",")
.compactMap { Double($0) }
.sorted()
}
private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
let uniqueVoltages = Array(Set(voltages)).sorted()
guard !uniqueVoltages.isEmpty else {
return nil
}
return uniqueVoltages
.map { String(format: "%.1f", $0) }
.joined(separator: ",")
}
private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
guard currentCount > 0 else {
return newValue
}
let total = (currentAverage * Double(currentCount)) + newValue
return total / Double(currentCount + 1)
}
}
private enum ObservationSaveReason {
case none
case created
case periodic
case completed
case event
}