// // 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 maximumChargeSessionDuration: TimeInterval = 12 * 60 * 60 private static let persistedSamplesPerHour = 360 private static let aggregatedSampleBucketDuration = 3600.0 / Double(persistedSamplesPerHour) private let context: NSManagedObjectContext private let stopDetectionHoldDuration: TimeInterval = 20 private let maximumLiveIntegrationGap: TimeInterval = 90 private let activeSessionSaveInterval: TimeInterval = 60 private let aggregatedSampleSaveInterval: TimeInterval = 30 private let counterDecreaseTolerance = 0.002 private let completionConfirmationCooldown: TimeInterval = 15 * 60 private let pausedSessionTimeout: TimeInterval = 10 * 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 private let unresolvedFlatBatteryPercent = -1.0 init(context: NSManagedObjectContext) { self.context = context } func refreshContext() { context.performAndWait { context.processPendingChanges() } } func resetContext() { context.performAndWait { context.reset() } } @discardableResult func flushPendingChanges() -> Bool { var didSave = false context.performAndWait { context.processPendingChanges() didSave = saveContext() } return didSave } @discardableResult func completeExpiredOpenSessions(referenceDate: Date = Date()) -> Bool { var didSave = false context.performAndWait { let expiredSessions = fetchOpenSessionObjects().compactMap { session -> NSManagedObject? in guard automaticCompletionDate(for: session, referenceDate: referenceDate) != nil else { return nil } return session } guard expiredSessions.isEmpty == false else { return } var chargedDeviceIDsToRefresh = Set() for session in expiredSessions { guard let completionDate = automaticCompletionDate(for: session, referenceDate: referenceDate) else { continue } finishSession( session, observedAt: completionDate, finalBatteryPercent: nil, status: .completed ) if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") { chargedDeviceIDsToRefresh.insert(chargedDeviceID) } } guard saveContext() else { return } for chargedDeviceID in chargedDeviceIDsToRefresh { refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) } didSave = saveContext() } return didSave } // Heals the invariant "at most one open session per meter MAC". // Called after every remote CloudKit sync import to resolve sessions that were started // independently on different devices while offline. // // Scenario: session A started on Device 1 and forgotten; user starts session B on Device 2 // while offline. After sync both appear open for the same meter. // // Winner = session with the latest startedAt (represents the user's intentional new session). // Loser endedAt is set to winner's startedAt so there is no time overlap. @discardableResult func healDuplicateOpenSessions() -> Bool { var didSave = false context.performAndWait { let openSessions = fetchOpenSessionObjects() var sessionsByMAC: [String: [NSManagedObject]] = [:] for session in openSessions { guard let mac = stringValue(session, key: "meterMACAddress") else { continue } sessionsByMAC[mac, default: []].append(session) } let duplicatedMACs = sessionsByMAC.filter { $0.value.count > 1 } guard !duplicatedMACs.isEmpty else { return } var chargedDeviceIDsToRefresh = Set() for (_, sessions) in duplicatedMACs { // Winner = most recently started (explicit user intent); tie-break by measuredEnergyWh let winner = sessions.max { a, b in let aDate = (a.value(forKey: "startedAt") as? Date) ?? .distantPast let bDate = (b.value(forKey: "startedAt") as? Date) ?? .distantPast if aDate != bDate { return aDate < bDate } let aEnergy = (a.value(forKey: "measuredEnergyWh") as? Double) ?? 0 let bEnergy = (b.value(forKey: "measuredEnergyWh") as? Double) ?? 0 return aEnergy < bEnergy } let winnerStartedAt = (winner?.value(forKey: "startedAt") as? Date) ?? Date() for loser in sessions where loser !== winner { // End the loser exactly when the winner began — no overlap. finishSession(loser, observedAt: winnerStartedAt, finalBatteryPercent: nil, status: .abandoned) loser.setValue(true, forKey: "wasConflictHealed") if let chargedDeviceID = stringValue(loser, key: "chargedDeviceID") { chargedDeviceIDsToRefresh.insert(chargedDeviceID) } track("ChargeInsightsStore: healed duplicate open session \(stringValue(loser, key: "id") ?? "?") for meter \(stringValue(loser, key: "meterMACAddress") ?? "?")") } } guard saveContext() else { return } for chargedDeviceID in chargedDeviceIDsToRefresh { refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) } didSave = saveContext() } return didSave } @discardableResult func createDevice( name: String, deviceClass: ChargedDeviceClass, templateID: String?, chargingStateAvailability: ChargingStateAvailability, supportsWiredCharging: Bool, supportsWirelessCharging: Bool, wirelessChargingProfile: WirelessChargingProfile, configuredCompletionCurrents: [ChargeSessionKind: Double], notes: String?, assignTo meterMACAddress: String? ) -> Bool { guard deviceClass.kind == .device else { return false } let normalizedName = normalizedText(name) let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability) let normalizedChargingSupport = deviceClass.normalizedChargingSupport( supportsWiredCharging: supportsWiredCharging, supportsWirelessCharging: supportsWirelessCharging ) let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device) guard !normalizedName.isEmpty else { return false } guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless 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(normalizedTemplateID, forKey: "deviceTemplateID") object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue") object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff") object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging") object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging") object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue") object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue") object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps") object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), 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 createCharger( name: String, chargerType: ChargerType, notes: String?, assignTo meterMACAddress: String? ) -> Bool { let normalizedName = normalizedText(name) guard !normalizedName.isEmpty 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(ChargedDeviceClass.charger.rawValue, forKey: "deviceClassRawValue") object.setValue(nil, forKey: "deviceTemplateID") object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue") object.setValue(false, forKey: "supportsChargingWhileOff") object.setValue(false, forKey: "supportsWiredCharging") object.setValue(true, forKey: "supportsWirelessCharging") if object.entity.attributesByName["chargerTypeRawValue"] != nil { object.setValue(chargerType.rawValue, forKey: "chargerTypeRawValue") } object.setValue(chargerType.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue") object.setValue(encodedCompletionCurrents([:]), forKey: "configuredCompletionCurrentsRawValue") object.setValue(nil, forKey: "wiredChargeCompletionCurrentAmps") object.setValue(nil, 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 updateDevice( id: UUID, name: String, deviceClass: ChargedDeviceClass, templateID: String?, chargingStateAvailability: ChargingStateAvailability, supportsWiredCharging: Bool, supportsWirelessCharging: Bool, wirelessChargingProfile: WirelessChargingProfile, configuredCompletionCurrents: [ChargeSessionKind: Double], notes: String? ) -> Bool { guard deviceClass.kind == .device else { return false } let normalizedName = normalizedText(name) let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability) let normalizedChargingSupport = deviceClass.normalizedChargingSupport( supportsWiredCharging: supportsWiredCharging, supportsWirelessCharging: supportsWirelessCharging ) let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device) guard !normalizedName.isEmpty else { return false } guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false } var didSave = false context.performAndWait { guard let object = fetchChargedDeviceObject(id: id.uuidString) else { return } guard isChargerObject(object) == false else { return } let previousSupportsChargingWhileOff = boolValue(object, key: "supportsChargingWhileOff") let previousChargingStateAvailability = self.chargingStateAvailability(for: object) let previousSupportsWiredCharging = self.supportsWiredCharging(for: object) let previousSupportsWirelessCharging = self.supportsWirelessCharging(for: object) let now = Date() object.setValue(normalizedName, forKey: "name") object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue") object.setValue(normalizedTemplateID, forKey: "deviceTemplateID") object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue") object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff") object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging") object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging") object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue") object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue") object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps") object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps") object.setValue(normalizedOptionalText(notes), forKey: "notes") object.setValue(now, forKey: "updatedAt") let supportsChargingWhileOff = normalizedChargingStateAvailability.supportsChargingWhileOff let shouldRecalculateSessionCapacity = previousSupportsChargingWhileOff != supportsChargingWhileOff let shouldRefreshActiveSessions = shouldRecalculateSessionCapacity || previousChargingStateAvailability != normalizedChargingStateAvailability || previousSupportsWiredCharging != normalizedChargingSupport.wired || previousSupportsWirelessCharging != normalizedChargingSupport.wireless if shouldRecalculateSessionCapacity || shouldRefreshActiveSessions { let sessions = fetchSessions(forChargedDeviceID: id.uuidString) for session in sessions { let isOpen = statusValue(session, key: "statusRawValue")?.isOpen == true if shouldRecalculateSessionCapacity { session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff") updateCapacityEstimate(for: session) session.setValue(now, forKey: "updatedAt") } guard isOpen, shouldRefreshActiveSessions else { continue } let resolvedSessionChargingTransportMode = resolvedPreferredChargingTransportMode( chargingTransportMode(for: session), supportsWiredCharging: normalizedChargingSupport.wired, supportsWirelessCharging: normalizedChargingSupport.wireless ) let resolvedSessionChargingStateMode = resolvedChargingStateMode( chargingStateMode(for: session), availability: normalizedChargingStateAvailability ) let charger = stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:)) session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff") session.setValue(resolvedSessionChargingTransportMode.rawValue, forKey: "chargingTransportRawValue") session.setValue(resolvedSessionChargingStateMode.rawValue, forKey: "chargingStateRawValue") session.setValue( resolvedStopThreshold( for: object, chargingTransportMode: resolvedSessionChargingTransportMode, chargingStateMode: resolvedSessionChargingStateMode, charger: charger, fallback: optionalDoubleValue(session, key: "stopThresholdAmps") ) ?? 0, forKey: "stopThresholdAmps" ) session.setValue(now, forKey: "updatedAt") updateCapacityEstimate(for: session) } } refreshDerivedMetrics(forChargedDeviceID: id.uuidString) didSave = saveContext() } return didSave } @discardableResult func updateCharger( id: UUID, name: String, chargerType: ChargerType, notes: String? ) -> Bool { let normalizedName = normalizedText(name) guard !normalizedName.isEmpty else { return false } var didSave = false context.performAndWait { guard let object = fetchChargedDeviceObject(id: id.uuidString) else { return } guard isChargerObject(object) else { return } object.setValue(normalizedName, forKey: "name") object.setValue(nil, forKey: "deviceTemplateID") object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue") object.setValue(false, forKey: "supportsChargingWhileOff") object.setValue(false, forKey: "supportsWiredCharging") object.setValue(true, forKey: "supportsWirelessCharging") if object.entity.attributesByName["chargerTypeRawValue"] != nil { object.setValue(chargerType.rawValue, forKey: "chargerTypeRawValue") } object.setValue(chargerType.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue") object.setValue(normalizedOptionalText(notes), forKey: "notes") object.setValue(Date(), forKey: "updatedAt") 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(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 openSession = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC), chargingTransportMode(for: openSession) == .wireless { openSession.setValue(id.uuidString, forKey: "chargerID") openSession.setValue(Date(), forKey: "updatedAt") } didSave = saveContext() } return didSave } @discardableResult func startSession( for snapshot: ChargingMonitorSnapshot, chargedDeviceID: UUID, chargerID: UUID?, chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode, autoStopEnabled: Bool, initialBatteryPercent: Double?, startsFromFlatBattery: Bool ) -> Bool { if let initialBatteryPercent, (initialBatteryPercent.isFinite == false || initialBatteryPercent < 0 || initialBatteryPercent > 100) { return false } var didSave = false context.performAndWait { guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else { return } guard isChargerObject(chargedDevice) == false else { return } guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else { return } let resolvedChargingTransportMode = resolvedPreferredChargingTransportMode( chargingTransportMode, supportsWiredCharging: supportsWiredCharging(for: chargedDevice), supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice) ) let resolvedChargingStateMode = resolvedChargingStateMode( chargingStateMode, availability: chargingStateAvailability(for: chargedDevice) ) let charger = resolvedChargingTransportMode == .wireless ? chargerID.flatMap { fetchChargedDeviceObject(id: $0.uuidString) } : nil if let charger, isChargerObject(charger) == false { return } guard resolvedChargingTransportMode == .wired || charger != nil else { return } let stopThreshold = resolvedStopThreshold( for: chargedDevice, chargingTransportMode: resolvedChargingTransportMode, chargingStateMode: resolvedChargingStateMode, charger: charger, fallback: snapshot.fallbackStopThresholdAmps > 0 ? snapshot.fallbackStopThresholdAmps : nil ) guard let session = createSessionObject( for: chargedDevice, charger: charger, snapshot: snapshot, stopThreshold: stopThreshold, chargingTransportMode: resolvedChargingTransportMode, chargingStateMode: resolvedChargingStateMode, autoStopEnabled: autoStopEnabled ) else { return } if startsFromFlatBattery { session.setValue(unresolvedFlatBatteryPercent, forKey: "startBatteryPercent") session.setValue(nil, forKey: "endBatteryPercent") } else if let initialBatteryPercent { guard insertBatteryCheckpoint( percent: initialBatteryPercent, flag: .initial, timestamp: snapshot.observedAt, to: session ) != nil else { return } } didSave = saveContext() } return didSave } @discardableResult func pauseSession(id sessionID: UUID, observedAt: Date) -> Bool { var didSave = false context.performAndWait { guard let session = fetchSessionObject(id: sessionID.uuidString) else { return } guard statusValue(session, key: "statusRawValue") == .active else { return } session.setValue(ChargeSessionStatus.paused.rawValue, forKey: "statusRawValue") session.setValue(observedAt, forKey: "pausedAt") session.setValue(nil, forKey: "belowThresholdSince") clearCompletionConfirmationState(for: session) session.setValue(observedAt, forKey: "updatedAt") didSave = saveContext() } return didSave } @discardableResult func resumeSession(id sessionID: UUID, snapshot: ChargingMonitorSnapshot?) -> Bool { var didSave = false context.performAndWait { guard let session = fetchSessionObject(id: sessionID.uuidString) else { return } guard statusValue(session, key: "statusRawValue") == .paused else { return } let resumedAt = snapshot?.observedAt ?? Date() if let completionDate = automaticCompletionDate(for: session, referenceDate: resumedAt) { finishSession( session, observedAt: completionDate, finalBatteryPercent: nil, status: .completed ) guard saveContext() else { return } if let deviceID = stringValue(session, key: "chargedDeviceID") { refreshDerivedMetrics(forChargedDeviceID: deviceID) didSave = saveContext() } else { didSave = true } return } session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue") session.setValue(nil, forKey: "pausedAt") session.setValue(nil, forKey: "belowThresholdSince") clearCompletionConfirmationState(for: session) session.setValue(resumedAt, forKey: "lastObservedAt") if let snapshot { session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps") session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts") session.setValue( chargingTransportMode(for: session) == .wired ? snapshot.voltageVolts : nil, forKey: "lastObservedVoltageVolts" ) } else { session.setValue(0, forKey: "lastObservedCurrentAmps") session.setValue(0, forKey: "lastObservedPowerWatts") session.setValue(nil, forKey: "lastObservedVoltageVolts") } session.setValue(resumedAt, forKey: "updatedAt") didSave = saveContext() } return didSave } @discardableResult func stopSession( id sessionID: UUID, finalBatteryPercent: Double? = nil ) -> Bool { if let finalBatteryPercent { guard finalBatteryPercent.isFinite, finalBatteryPercent >= 0, finalBatteryPercent <= 100 else { return false } } var didSave = false var deviceIDToRefresh: String? context.performAndWait { guard let session = fetchSessionObject(id: sessionID.uuidString) else { return } guard statusValue(session, key: "statusRawValue")?.isOpen == true else { return } restoreMeasuredTotalsFromLatestSampleIfNeeded(session) guard hasSavableChargeData(session) else { return } let observedAt = snapshotDateForManualStop(session) finishSession( session, observedAt: observedAt, finalBatteryPercent: finalBatteryPercent, status: .completed ) guard saveContext() else { return } didSave = true deviceIDToRefresh = stringValue(session, key: "chargedDeviceID") } if let deviceID = deviceIDToRefresh { context.perform { [weak self] in guard let self else { return } self.refreshDerivedMetrics(forChargedDeviceID: deviceID) self.saveContext() } } return didSave } @discardableResult func addBatteryCheckpoint( percent: Double, for meterMACAddress: String, measuredEnergyWh: Double? = nil ) -> Bool { guard percent.isFinite, percent >= 0, percent <= 100 else { return false } var didSave = false context.performAndWait { guard let session = fetchOpenSessionObject(forMeterMACAddress: meterMACAddress) else { return } didSave = addBatteryCheckpoint( percent: percent, measuredEnergyWh: measuredEnergyWh, flag: .intermediate, to: session ) } return didSave } @discardableResult func addBatteryCheckpoint( percent: Double, for sessionID: UUID, measuredEnergyWh: Double? = nil ) -> 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, measuredEnergyWh: measuredEnergyWh, flag: .intermediate, to: session ) } return didSave } @discardableResult func deleteBatteryCheckpoint( id checkpointID: UUID, from sessionID: UUID ) -> Bool { var didSave = false context.performAndWait { guard let session = fetchSessionObject(id: sessionID.uuidString), let checkpoint = fetchCheckpointObject( id: checkpointID.uuidString, sessionID: sessionID.uuidString ) else { return } let chargedDeviceID = stringValue(session, key: "chargedDeviceID") context.delete(checkpoint) refreshCheckpointDerivedValues(for: session) if let chargedDeviceID { refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) } didSave = saveContext() } 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 } finishSession( session, observedAt: dateValue(session, key: "lastObservedAt") ?? Date(), finalBatteryPercent: nil, status: .completed ) 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(nil, forKey: "belowThresholdSince") session.setValue(Date().addingTimeInterval(completionConfirmationCooldown), forKey: "completionConfirmationCooldownUntil") session.setValue(Date(), forKey: "updatedAt") didSave = saveContext() } return didSave } @discardableResult func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool { var didSave = false context.performAndWait { guard let session = fetchSessionObject(id: sessionID.uuidString) else { return } let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast let sessionEnd = dateValue(session, key: "endedAt") ?? dateValue(session, key: "lastObservedAt") ?? Date.distantFuture let effectiveStart = min(max(start ?? sessionStart, sessionStart), sessionEnd) let effectiveEnd = max(min(end ?? sessionEnd, sessionEnd), effectiveStart) let persistedStart = effectiveStart == sessionStart ? nil : effectiveStart let persistedEnd = effectiveEnd == sessionEnd ? nil : effectiveEnd let allSamples = fetchSessionSampleObjects(forSessionID: sessionID.uuidString) .compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in guard let ts = dateValue(obj, key: "timestamp") else { return nil } return ( timestamp: ts, energy: doubleValue(obj, key: "measuredEnergyWh"), charge: doubleValue(obj, key: "measuredChargeAh") ) } .sorted { $0.timestamp < $1.timestamp } // Each sample stores cumulative energy since session start. // Trimmed energy = value at trimEnd - value just before trimStart. let baselineSample = allSamples.last { $0.timestamp <= effectiveStart } let endSample = allSamples.last { $0.timestamp <= effectiveEnd } let baselineEnergy = baselineSample?.energy ?? 0 let baselineCharge = baselineSample?.charge ?? 0 if let endSample { let trimmedEnergy = max(endSample.energy - baselineEnergy, 0) let trimmedCharge = max(endSample.charge - baselineCharge, 0) session.setValue(trimmedEnergy, forKey: "measuredEnergyWh") session.setValue(trimmedCharge, forKey: "measuredChargeAh") } else { session.setValue(0, forKey: "measuredEnergyWh") session.setValue(0, forKey: "measuredChargeAh") } session.setValue(persistedStart, forKey: "trimStart") session.setValue(persistedEnd, forKey: "trimEnd") session.setValue(Date(), forKey: "updatedAt") let checkpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString) for checkpoint in checkpoints { guard let timestamp = dateValue(checkpoint, key: "timestamp") else { continue } if timestamp < effectiveStart || timestamp > effectiveEnd { context.delete(checkpoint) continue } let checkpointSample = allSamples.last { $0.timestamp <= timestamp } let rebasedEnergy = max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0) let rebasedCharge = max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0) checkpoint.setValue(rebasedEnergy, forKey: "measuredEnergyWh") checkpoint.setValue(rebasedCharge, forKey: "measuredChargeAh") } let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString) .sorted { (dateValue($0, key: "timestamp") ?? .distantPast) < (dateValue($1, key: "timestamp") ?? .distantPast) } let restoredInitialCheckpoint = remainingCheckpoints.first { checkpoint in let label = stringValue(checkpoint, key: "label") let timestamp = dateValue(checkpoint, key: "timestamp") ?? .distantFuture return ChargeCheckpointFlag.fromStoredLabel(label) == .initial || timestamp <= effectiveStart } if persistedStart == nil { if let restoredInitialCheckpoint, let percent = optionalDoubleValue(restoredInitialCheckpoint, key: "batteryPercent"), percent >= 0 { session.setValue(percent, forKey: "startBatteryPercent") } } else { session.setValue(nil, forKey: "startBatteryPercent") } refreshCheckpointDerivedValues(for: session) if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") { refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) } didSave = saveContext() } return didSave } @discardableResult func commitSessionTrim(sessionID: UUID) -> Bool { var didSave = false context.performAndWait { guard let session = fetchSessionObject(id: sessionID.uuidString), statusValue(session, key: "statusRawValue")?.isOpen == false else { return } guard dateValue(session, key: "trimStart") != nil || dateValue(session, key: "trimEnd") != nil else { return } let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast let sessionEnd = dateValue(session, key: "endedAt") ?? dateValue(session, key: "lastObservedAt") ?? sessionStart let effectiveStart = min(max(dateValue(session, key: "trimStart") ?? sessionStart, sessionStart), sessionEnd) let effectiveEnd = max( min(dateValue(session, key: "trimEnd") ?? sessionEnd, sessionEnd), effectiveStart ) let sampleObjects = fetchSessionSampleObjects(forSessionID: sessionID.uuidString) let allSamples = sampleObjects .compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in guard let timestamp = dateValue(obj, key: "timestamp") else { return nil } return ( timestamp: timestamp, energy: doubleValue(obj, key: "measuredEnergyWh"), charge: doubleValue(obj, key: "measuredChargeAh") ) } .sorted { $0.timestamp < $1.timestamp } let baselineSample = allSamples.last { $0.timestamp <= effectiveStart } let endSample = allSamples.last { $0.timestamp <= effectiveEnd } let baselineEnergy = baselineSample?.energy ?? 0 let baselineCharge = baselineSample?.charge ?? 0 let committedEnergy = endSample.map { max($0.energy - baselineEnergy, 0) } ?? doubleValue(session, key: "measuredEnergyWh") let committedCharge = endSample.map { max($0.charge - baselineCharge, 0) } ?? doubleValue(session, key: "measuredChargeAh") var retainedSamples: [(current: Double, power: Double, voltage: Double?)] = [] for sample in sampleObjects { guard let timestamp = dateValue(sample, key: "timestamp") else { context.delete(sample) continue } guard timestamp >= effectiveStart && timestamp <= effectiveEnd else { context.delete(sample) continue } let rebasedEnergy = max(doubleValue(sample, key: "measuredEnergyWh") - baselineEnergy, 0) let rebasedCharge = max(doubleValue(sample, key: "measuredChargeAh") - baselineCharge, 0) let elapsed = max(timestamp.timeIntervalSince(effectiveStart), 0) let rebasedBucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration)) sample.setValue("\(sessionID.uuidString)-\(rebasedBucketIndex)", forKey: "id") sample.setValue(rebasedBucketIndex, forKey: "bucketIndex") sample.setValue(rebasedEnergy, forKey: "measuredEnergyWh") sample.setValue(rebasedCharge, forKey: "measuredChargeAh") sample.setValue(Date(), forKey: "updatedAt") retainedSamples.append( ( current: doubleValue(sample, key: "averageCurrentAmps"), power: doubleValue(sample, key: "averagePowerWatts"), voltage: optionalDoubleValue(sample, key: "averageVoltageVolts") ) ) } for checkpoint in fetchCheckpointObjects(forSessionID: sessionID.uuidString) { guard let timestamp = dateValue(checkpoint, key: "timestamp") else { context.delete(checkpoint) continue } guard timestamp >= effectiveStart && timestamp <= effectiveEnd else { context.delete(checkpoint) continue } let checkpointSample = allSamples.last { $0.timestamp <= timestamp } checkpoint.setValue( max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0), forKey: "measuredEnergyWh" ) checkpoint.setValue( max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0), forKey: "measuredChargeAh" ) } if !retainedSamples.isEmpty { let positiveCurrents = retainedSamples.map { $0.current }.filter { $0 > 0 } session.setValue(positiveCurrents.min(), forKey: "minimumObservedCurrentAmps") session.setValue(retainedSamples.map { $0.current }.max(), forKey: "maximumObservedCurrentAmps") session.setValue(retainedSamples.map { $0.power }.max(), forKey: "maximumObservedPowerWatts") session.setValue(retainedSamples.compactMap { $0.voltage }.max(), forKey: "maximumObservedVoltageVolts") session.setValue( retainedSamples.contains { $0.power > 0.05 || $0.current > 0.01 }, forKey: "hasObservedChargeFlow" ) } else { session.setValue(nil, forKey: "minimumObservedCurrentAmps") session.setValue(nil, forKey: "maximumObservedCurrentAmps") session.setValue(nil, forKey: "maximumObservedPowerWatts") session.setValue(nil, forKey: "maximumObservedVoltageVolts") session.setValue(committedEnergy > 0 || committedCharge > 0, forKey: "hasObservedChargeFlow") } session.setValue(effectiveStart, forKey: "startedAt") session.setValue(effectiveEnd, forKey: "lastObservedAt") if dateValue(session, key: "endedAt") != nil { session.setValue(effectiveEnd, forKey: "endedAt") } session.setValue(committedEnergy, forKey: "measuredEnergyWh") session.setValue(committedCharge, forKey: "measuredChargeAh") session.setValue(nil, forKey: "trimStart") session.setValue(nil, forKey: "trimEnd") session.setValue(Date(), forKey: "updatedAt") refreshCheckpointDerivedValues(for: session) if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") { refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) } 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() 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 { guard let session = fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress), let resolvedDevice = stringValue(session, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:)) else { return } if statusValue(session, key: "statusRawValue") == .paused { if maybeCompleteOpenSession(session, observedAt: snapshot.observedAt) { didSave = true } return } let chargingTransportMode = self.chargingTransportMode(for: session) let chargingStateMode = self.chargingStateMode(for: session) let charger = chargingTransportMode == .wireless ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:)) : nil guard chargingTransportMode == .wired || charger != nil else { return } let stopThreshold = resolvedStopThreshold( for: resolvedDevice, chargingTransportMode: chargingTransportMode, chargingStateMode: chargingStateMode, charger: charger, fallback: optionalDoubleValue(session, key: "stopThresholdAmps") ) let sessionSnapshot = snapshotClampedToMaximumDuration(snapshot, for: session) update(session: session, with: sessionSnapshot, stopThreshold: stopThreshold, charger: charger) let aggregatedSample = updateAggregatedSample(session: session, with: sessionSnapshot) if let completionDate = automaticCompletionDate(for: session, referenceDate: snapshot.observedAt), statusValue(session, key: "statusRawValue")?.isOpen == true { finishSession( session, observedAt: completionDate, finalBatteryPercent: nil, status: .completed ) } let saveReason = saveReason(for: session, observedAt: sessionSnapshot.observedAt) let shouldPersistAggregatedCurve = aggregatedSample.map { shouldPersistAggregatedSample($0, observedAt: sessionSnapshot.observedAt) } ?? false guard saveReason != .none || shouldPersistAggregatedCurve else { return } session.setValue(sessionSnapshot.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 checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" } let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" } let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" } let sampleBackedSessionIDs = sampleBackedSessionIDs( devices: devices, sessionsByDeviceID: sessionsByDeviceID, sessionsByChargerID: sessionsByChargerID ) let samplesBySessionID = Dictionary( grouping: fetchSessionSampleObjects(forSessionIDs: Array(sampleBackedSessionIDs)) ) { stringValue($0, key: "sessionID") ?? "" } 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 chargingStateAvailability = chargingStateAvailability(for: device) let supportsWiredCharging = supportsWiredCharging(for: device) let supportsWirelessCharging = supportsWirelessCharging(for: device) let templateDefinition = templateDefinition(for: device) 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.isOpen && !rhs.status.isOpen { return true } if !lhs.status.isOpen && rhs.status.isOpen { return false } if lhs.status == .active && rhs.status == .paused { return true } if lhs.status == .paused && rhs.status == .active { return false } return lhs.startedAt > rhs.startedAt } return ChargedDeviceSummary( id: id, qrIdentifier: qrIdentifier, name: name, deviceClass: deviceClass, deviceTemplateID: stringValue(device, key: "deviceTemplateID"), templateDefinition: templateDefinition, supportsChargingWhileOff: chargingStateAvailability.supportsChargingWhileOff, chargingStateAvailability: chargingStateAvailability, supportsWiredCharging: supportsWiredCharging, supportsWirelessCharging: supportsWirelessCharging, chargerType: chargerType(for: device), wirelessChargingProfile: wirelessChargingProfile(for: device), configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"), learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"), 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), standbyPowerMeasurements: [] ) } .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 } var summary: ChargeSessionSummary? context.performAndWait { guard let session = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC), let sessionID = stringValue(session, key: "id") else { return } summary = makeSessionSummary( from: session, checkpoints: fetchCheckpointObjects(forSessionID: sessionID), samples: fetchSessionSampleObjects(forSessionID: sessionID) ) } return summary } private func createSessionObject( for chargedDevice: NSManagedObject, charger: NSManagedObject?, snapshot: ChargingMonitorSnapshot, stopThreshold: Double?, chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode, autoStopEnabled: Bool ) -> 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") let usesMeterCounters = snapshot.meterEnergyCounterWh != nil || snapshot.meterChargeCounterAh != nil session.setValue( (usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue, forKey: "sourceModeRawValue" ) session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue") session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue") session.setValue(autoStopEnabled, forKey: "autoStopEnabled") session.setValue(stopThreshold ?? 0, 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( hasObservedChargeFlow( currentAmps: snapshot.currentAmps, chargingTransportMode: chargingTransportMode, charger: charger, stopThreshold: stopThreshold ), forKey: "hasObservedChargeFlow" ) 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") } if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds { setValue(meterRecordingDurationSeconds, on: session, key: "meterDurationBaselineSeconds") setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds") } session.setValue(now, forKey: "createdAt") session.setValue(now, forKey: "updatedAt") chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC") chargedDevice.setValue(now, forKey: "updatedAt") return session } private func update( session: NSManagedObject, with snapshot: ChargingMonitorSnapshot, stopThreshold: Double?, charger: NSManagedObject? ) { 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 measuredEnergyWh = max(offlineEnergy, 0) usedOfflineMeterCounters = true sourceMode = .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 measuredChargeAh = max(offlineCharge, 0) usedOfflineMeterCounters = true } else if let lastCharge, meterChargeCounterAh > lastCharge { let delta = meterChargeCounterAh - lastCharge if delta > 0 { measuredChargeAh += delta usedOfflineMeterCounters = true } } session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh") } if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds { let baselineDuration = optionalDoubleValue(session, key: "meterDurationBaselineSeconds") ?? meterRecordingDurationSeconds if optionalDoubleValue(session, key: "meterDurationBaselineSeconds") == nil { setValue(baselineDuration, on: session, key: "meterDurationBaselineSeconds") } setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds") } let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps") let updatedMinimum: Double if snapshot.currentAmps > 0 { updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps) } else { updatedMinimum = existingMinimum ?? 0 } let effectiveCurrent = effectiveCurrentAmps( fromMeasuredCurrent: snapshot.currentAmps, chargingTransportMode: sessionChargingTransportMode, charger: charger ) let observedChargeFlow = boolValue(session, key: "hasObservedChargeFlow") || hasObservedChargeFlow( currentAmps: snapshot.currentAmps, chargingTransportMode: sessionChargingTransportMode, charger: charger, stopThreshold: stopThreshold ) session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh") session.setValue(measuredChargeAh, forKey: "measuredChargeAh") session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps") session.setValue(stopThreshold ?? 0, 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(observedChargeFlow, forKey: "hasObservedChargeFlow") 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) guard boolValue(session, key: "autoStopEnabled"), let stopThreshold, stopThreshold > 0, observedChargeFlow else { session.setValue(nil, forKey: "belowThresholdSince") clearCompletionConfirmationState(for: session) session.setValue(nil, forKey: "completionConfirmationCooldownUntil") return } if effectiveCurrent <= 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 { finishSession( session, observedAt: snapshot.observedAt, finalBatteryPercent: nil, status: .completed ) } } } else { session.setValue(nil, forKey: "belowThresholdSince") clearCompletionConfirmationState(for: session) session.setValue(nil, forKey: "completionConfirmationCooldownUntil") } } private func updateAggregatedSample( session: NSManagedObject, with snapshot: ChargingMonitorSnapshot ) -> NSManagedObject? { 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 nil } 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") return sample } private func maybeTriggerTargetBatteryAlert( for session: NSManagedObject, observedAt: Date, completionFallbackPercent: Double? = nil ) { 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") ?? completionFallbackPercent 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 snapshotDateForManualStop(_ session: NSManagedObject) -> Date { if statusValue(session, key: "statusRawValue") == .paused { return dateValue(session, key: "pausedAt") ?? dateValue(session, key: "lastObservedAt") ?? Date() } return dateValue(session, key: "lastObservedAt") ?? Date() } private func snapshotClampedToMaximumDuration( _ snapshot: ChargingMonitorSnapshot, for session: NSManagedObject ) -> ChargingMonitorSnapshot { guard let maximumEndDate = maximumEndDate(for: session), snapshot.observedAt > maximumEndDate else { return snapshot } return ChargingMonitorSnapshot( meterMACAddress: snapshot.meterMACAddress, meterName: snapshot.meterName, meterModel: snapshot.meterModel, observedAt: maximumEndDate, voltageVolts: snapshot.voltageVolts, currentAmps: snapshot.currentAmps, powerWatts: snapshot.powerWatts, selectedDataGroup: snapshot.selectedDataGroup, meterChargeCounterAh: snapshot.meterChargeCounterAh, meterEnergyCounterWh: snapshot.meterEnergyCounterWh, meterRecordingDurationSeconds: snapshot.meterRecordingDurationSeconds, fallbackStopThresholdAmps: snapshot.fallbackStopThresholdAmps ) } private func automaticCompletionDate( for session: NSManagedObject, referenceDate: Date ) -> Date? { guard statusValue(session, key: "statusRawValue")?.isOpen == true else { return nil } var completionDates: [Date] = [] if let maximumEndDate = maximumEndDate(for: session) { completionDates.append(maximumEndDate) } if statusValue(session, key: "statusRawValue") == .paused, let pausedAt = dateValue(session, key: "pausedAt") { completionDates.append(pausedAt.addingTimeInterval(pausedSessionTimeout)) } guard let completionDate = completionDates.min(), referenceDate >= completionDate else { return nil } return completionDate } private func maximumEndDate(for session: NSManagedObject) -> Date? { dateValue(session, key: "startedAt")?.addingTimeInterval(Self.maximumChargeSessionDuration) } @discardableResult private func maybeCompleteOpenSession(_ session: NSManagedObject, observedAt: Date) -> Bool { guard statusValue(session, key: "statusRawValue")?.isOpen == true, let completionDate = automaticCompletionDate(for: session, referenceDate: observedAt) else { return false } finishSession( session, observedAt: completionDate, finalBatteryPercent: nil, status: .completed ) guard saveContext() else { return false } if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") { refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) return saveContext() } return true } private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? { let chargingTransportMode = chargingTransportMode(for: session) let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps") ?? doubleValue(session, key: "lastObservedCurrentAmps") guard measuredCurrent > 0 else { return nil } let charger = chargingTransportMode == .wireless ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:)) : nil if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil { return nil } let effectiveCurrent = effectiveCurrentAmps( fromMeasuredCurrent: measuredCurrent, chargingTransportMode: chargingTransportMode, charger: charger ) guard effectiveCurrent > 0 else { return nil } return effectiveCurrent } private func finishSession( _ session: NSManagedObject, observedAt: Date, finalBatteryPercent: Double?, status: ChargeSessionStatus ) { if let finalBatteryPercent { _ = insertBatteryCheckpoint( percent: finalBatteryPercent, flag: .final, timestamp: observedAt, to: session ) } session.setValue(status.rawValue, forKey: "statusRawValue") session.setValue(nil, forKey: "pausedAt") session.setValue(nil, forKey: "belowThresholdSince") session.setValue(observedAt, forKey: "endedAt") session.setValue(observedAt, forKey: "lastObservedAt") session.setValue(completionCurrentForSessionEnd(session), forKey: "completionCurrentAmps") clearCompletionConfirmationState(for: session) session.setValue(nil, forKey: "completionConfirmationCooldownUntil") updateCapacityEstimate(for: session) session.setValue(observedAt, forKey: "updatedAt") if status == .completed { maybeTriggerTargetBatteryAlert( for: session, observedAt: observedAt, completionFallbackPercent: defaultCompletionPercentThreshold ) } } 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 } // Compute effective battery energy dynamically so the prediction uses the // most current measuredEnergyWh rather than the stale effectiveBatteryEnergyWh // (which is only refreshed at session start, checkpoint insertion, and finish). let rawMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh") let measuredEnergyWh: Double switch chargingTransportMode(for: session) { case .wired: measuredEnergyWh = rawMeasuredEnergyWh case .wireless: if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 { measuredEnergyWh = rawMeasuredEnergyWh * factor } else { measuredEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh") ?? rawMeasuredEnergyWh } } let sessionID = stringValue(session, key: "id") ?? "" struct Anchor { let percent: Double let energyWh: Double let timestamp: Date let isCheckpoint: Bool } var anchors: [Anchor] = [] if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"), startBatteryPercent >= 0 { anchors.append( Anchor( percent: startBatteryPercent, energyWh: 0, timestamp: dateValue(session, key: "trimStart") ?? dateValue(session, key: "startedAt") ?? Date.distantPast, isCheckpoint: false ) ) } 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 } .filter { $0.batteryPercent >= 0 } .map { Anchor( percent: $0.batteryPercent, energyWh: $0.measuredEnergyWh, timestamp: $0.timestamp, isCheckpoint: true ) } 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 BatteryLevelPredictionTuning.predictedPercent( anchorPercent: anchor.percent, anchorEnergyWh: anchor.energyWh, anchorTimestamp: anchor.timestamp, anchorIsCheckpoint: anchor.isCheckpoint, effectiveEnergyWh: measuredEnergyWh, referenceTimestamp: dateValue(session, key: "lastObservedAt") ?? anchor.timestamp, estimatedCapacityWh: estimatedCapacityWh ) } 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") let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff") guard let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else { session.setValue(nil, forKey: "capacityEstimateWh") return } struct CapacityAnchor { let percent: Double let energyWh: Double let timestamp: Date } var anchors: [CapacityAnchor] = [] if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"), startBatteryPercent >= 0 { anchors.append( CapacityAnchor( percent: startBatteryPercent, energyWh: 0, timestamp: dateValue(session, key: "trimStart") ?? dateValue(session, key: "startedAt") ?? Date.distantPast ) ) } if let sessionID = stringValue(session, key: "id") { anchors.append( contentsOf: fetchCheckpointObjects(forSessionID: sessionID).compactMap { checkpoint in guard let percent = optionalDoubleValue(checkpoint, key: "batteryPercent"), percent >= 0, let timestamp = dateValue(checkpoint, key: "timestamp") else { return nil } return CapacityAnchor( percent: percent, energyWh: doubleValue(checkpoint, key: "measuredEnergyWh"), timestamp: timestamp ) } ) } if let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent >= 0 { anchors.append( CapacityAnchor( percent: endBatteryPercent, energyWh: effectiveBatteryEnergyWh, timestamp: dateValue(session, key: "endedAt") ?? dateValue(session, key: "lastObservedAt") ?? Date.distantPast ) ) } let sortedAnchors = anchors.sorted { lhs, rhs in if lhs.energyWh != rhs.energyWh { return lhs.energyWh < rhs.energyWh } return lhs.timestamp < rhs.timestamp } guard let firstAnchor = sortedAnchors.first, let lastAnchor = sortedAnchors.last else { session.setValue(nil, forKey: "capacityEstimateWh") return } let percentDelta = lastAnchor.percent - firstAnchor.percent let energyDelta = lastAnchor.energyWh - firstAnchor.energyWh guard percentDelta >= 20, energyDelta > 0 else { session.setValue(nil, forKey: "capacityEstimateWh") return } if !supportsChargingWhileOff && lastAnchor.percent >= 99.5 { session.setValue(nil, forKey: "capacityEstimateWh") return } let capacityEstimateWh = energyDelta / (percentDelta / 100) session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh") } @discardableResult private func insertBatteryCheckpoint( percent: Double, flag: ChargeCheckpointFlag, timestamp: Date = Date(), measuredEnergyWhOverride: Double? = nil, to session: NSManagedObject ) -> String? { guard let sessionID = stringValue(session, key: "id"), let chargedDeviceID = stringValue(session, key: "chargedDeviceID"), let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context) else { return nil } let checkpoint = NSManagedObject(entity: entity, insertInto: context) let checkpointEnergyWh = measuredEnergyWhOverride ?? 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(timestamp, forKey: "timestamp") checkpoint.setValue(percent, forKey: "batteryPercent") checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh") checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps") checkpoint.setValue( chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil, forKey: "voltageVolts" ) checkpoint.setValue(flag.rawValue, forKey: "label") checkpoint.setValue(timestamp, forKey: "createdAt") let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent") if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) { session.setValue(percent, forKey: "startBatteryPercent") } if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 { session.setValue(percent, forKey: "endBatteryPercent") } session.setValue(timestamp, forKey: "updatedAt") updateCapacityEstimate(for: session) return chargedDeviceID } private func refreshCheckpointDerivedValues(for session: NSManagedObject) { guard let sessionID = stringValue(session, key: "id") else { return } let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID) if let latestCheckpoint = remainingCheckpoints.last { session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent") } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"), startBatteryPercent >= 0 { session.setValue(startBatteryPercent, forKey: "endBatteryPercent") } else { session.setValue(nil, forKey: "endBatteryPercent") } session.setValue(Date(), forKey: "updatedAt") updateCapacityEstimate(for: session) } @discardableResult private func addBatteryCheckpoint( percent: Double, measuredEnergyWh: Double? = nil, flag: ChargeCheckpointFlag, to session: NSManagedObject, timestamp: Date = Date() ) -> Bool { if let measuredEnergyWh, measuredEnergyWh.isFinite { session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh") } guard let chargedDeviceID = insertBatteryCheckpoint( percent: percent, flag: flag, timestamp: timestamp, measuredEnergyWhOverride: measuredEnergyWh, to: session ) else { return false } 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") ?? ((fallbackChargingTransportMode(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 chargingStateAvailability = chargingStateAvailability(for: chargedDevice) let supportsChargingWhileOff = chargingStateAvailability.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 learnedCompletionCurrents = derivedCompletionCurrents(from: sessions) 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 configuredCompletionCurrents = decodedCompletionCurrents( from: chargedDevice, key: "configuredCompletionCurrentsRawValue" ) 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 = fallbackChargingTransportMode(for: chargedDevice) let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on let preferredMinimumCurrent: Double? let preferredCapacity: Double? switch preferredChargingTransportMode { case .wired: preferredMinimumCurrent = configuredCompletionCurrents[ ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode) ] ?? learnedCompletionCurrents[ ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode) ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent preferredCapacity = wiredCapacity ?? wirelessCapacity case .wireless: preferredMinimumCurrent = configuredCompletionCurrents[ ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode) ] ?? learnedCompletionCurrents[ ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode) ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent preferredCapacity = wirelessCapacity ?? wiredCapacity } setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps") setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps") setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue") setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh") setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh") setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor") setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue") setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps") setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor") setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts") setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps") setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh") setValue(Date(), on: chargedDevice, key: "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]] = [:] for session in sessions where session.status == .completed { let anchors = normalizedTypicalCurveAnchors(for: session) guard anchors.count >= 2 else { continue } for percentBin in stride(from: 0, through: 100, by: 10) { guard let energyWh = interpolatedTypicalCurvePoint( for: Double(percentBin), anchors: anchors ) else { continue } groupedEnergyByBin[percentBin, default: []].append(energyWh) } } let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in guard let energies = groupedEnergyByBin[percentBin], !energies.isEmpty else { return nil } return TypicalChargeCurvePoint( percentBin: percentBin, averageEnergyWh: energies.reduce(0, +) / Double(energies.count), sampleCount: energies.count ) } var runningMaximumEnergyWh = 0.0 return averagedPoints.map { point in runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh) return TypicalChargeCurvePoint( percentBin: point.percentBin, averageEnergyWh: runningMaximumEnergyWh, sampleCount: point.sampleCount ) } } private func normalizedTypicalCurveAnchors( for session: ChargeSessionSummary ) -> [(percent: Double, energyWh: Double)] { struct Anchor { let percent: Double let energyWh: Double let timestamp: Date } var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in guard checkpoint.batteryPercent.isFinite, checkpoint.measuredEnergyWh.isFinite, checkpoint.batteryPercent >= 0, checkpoint.batteryPercent <= 100, checkpoint.measuredEnergyWh >= 0 else { return nil } return Anchor( percent: checkpoint.batteryPercent, energyWh: checkpoint.measuredEnergyWh, timestamp: checkpoint.timestamp ) } if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent.isFinite, startBatteryPercent >= 0, startBatteryPercent <= 100 { anchors.append( Anchor( percent: startBatteryPercent, energyWh: 0, timestamp: session.startedAt ) ) } if let endBatteryPercent = session.endBatteryPercent, endBatteryPercent.isFinite, endBatteryPercent >= 0, endBatteryPercent <= 100 { anchors.append( Anchor( percent: endBatteryPercent, energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh, timestamp: session.endedAt ?? session.lastObservedAt ) ) } let sortedAnchors = anchors.sorted { lhs, rhs in if lhs.percent != rhs.percent { return lhs.percent < rhs.percent } if lhs.energyWh != rhs.energyWh { return lhs.energyWh < rhs.energyWh } return lhs.timestamp < rhs.timestamp } var collapsedAnchors: [(percent: Double, energyWh: Double)] = [] for anchor in sortedAnchors { if let lastIndex = collapsedAnchors.indices.last, abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 { collapsedAnchors[lastIndex] = ( percent: collapsedAnchors[lastIndex].percent, energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh) ) } else { collapsedAnchors.append( (percent: anchor.percent, energyWh: anchor.energyWh) ) } } var runningMaximumEnergyWh = 0.0 return collapsedAnchors.map { anchor in runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh) return ( percent: anchor.percent, energyWh: runningMaximumEnergyWh ) } } private func interpolatedTypicalCurvePoint( for percent: Double, anchors: [(percent: Double, energyWh: Double)] ) -> Double? { guard let firstAnchor = anchors.first, let lastAnchor = anchors.last, percent >= firstAnchor.percent, percent <= lastAnchor.percent else { return nil } if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) { return exactAnchor.energyWh } guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }), upperIndex > 0 else { return nil } let lowerAnchor = anchors[upperIndex - 1] let upperAnchor = anchors[upperIndex] let span = upperAnchor.percent - lowerAnchor.percent guard span > 0.000_1 else { return nil } let ratio = (percent - lowerAnchor.percent) / span return lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio) } 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, pausedAt: dateValue(object, key: "pausedAt"), status: status, sourceMode: sourceMode, chargingTransportMode: chargingTransportMode, chargingStateMode: chargingStateMode(for: object), autoStopEnabled: boolValue(object, key: "autoStopEnabled"), measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"), effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"), meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"), meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"), meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"), minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"), maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"), maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"), maximumObservedVoltageVolts: chargingTransportMode == .wired ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts") : nil, hasObservedChargeFlow: boolValue(object, key: "hasObservedChargeFlow"), 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), trimStart: dateValue(object, key: "trimStart"), trimEnd: dateValue(object, key: "trimEnd"), wasConflictHealed: boolValue(object, key: "wasConflictHealed"), 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"), 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"), sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0) ) } private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? { fetchSessionObject( predicate: NSPredicate( format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)", normalizedMACAddress(meterMACAddress), ChargeSessionStatus.active.rawValue, ChargeSessionStatus.paused.rawValue ) ) } private func fetchOpenSessionObjects() -> [NSManagedObject] { let request = NSFetchRequest(entityName: EntityName.chargeSession) request.predicate = NSPredicate( format: "statusRawValue == %@ OR statusRawValue == %@", ChargeSessionStatus.active.rawValue, ChargeSessionStatus.paused.rawValue ) request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)] return (try? context.fetch(request)) ?? [] } private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? { fetchSessionObject( predicate: NSPredicate( format: "meterMACAddress == %@ AND statusRawValue == %@", normalizedMACAddress(meterMACAddress), ChargeSessionStatus.active.rawValue ) ) } private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? { let request = NSFetchRequest(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(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(entityName: EntityName.chargeSessionSample) request.predicate = NSPredicate(format: "sessionID == %@", sessionID) return (try? context.fetch(request)) ?? [] } private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] { guard !sessionIDs.isEmpty else { return [] } let request = NSFetchRequest(entityName: EntityName.chargeSessionSample) request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs) return (try? context.fetch(request)) ?? [] } private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? { let request = NSFetchRequest(entityName: EntityName.chargeCheckpoint) request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID) request.fetchLimit = 1 return (try? context.fetch(request))?.first } private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] { let request = NSFetchRequest(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(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(entityName: EntityName.chargeSession) request.predicate = NSPredicate(format: "chargerID == %@", chargerID) request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)] return (try? context.fetch(request)) ?? [] } private func sampleBackedSessionIDs( devices: [NSManagedObject], sessionsByDeviceID: [String: [NSManagedObject]], sessionsByChargerID: [String: [NSManagedObject]] ) -> Set { var sessionIDs: Set = [] for device in devices { guard let deviceID = stringValue(device, key: "id"), let rawClass = stringValue(device, key: "deviceClassRawValue"), let deviceClass = ChargedDeviceClass(rawValue: rawClass) else { continue } let relevantSessions = relevantSessionObjects( for: deviceID, deviceClass: deviceClass, sessionsByDeviceID: sessionsByDeviceID, sessionsByChargerID: sessionsByChargerID ) .sorted { lhs, rhs in let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed if lhsStatus.isOpen && !rhsStatus.isOpen { return true } if !lhsStatus.isOpen && rhsStatus.isOpen { return false } return (dateValue(lhs, key: "startedAt") ?? .distantPast) > (dateValue(rhs, key: "startedAt") ?? .distantPast) } var recentCompletedSamplesIncluded = 0 for session in relevantSessions { guard let sessionID = stringValue(session, key: "id"), let status = statusValue(session, key: "statusRawValue") else { continue } if status.isOpen { sessionIDs.insert(sessionID) continue } guard recentCompletedSamplesIncluded < 2 else { continue } sessionIDs.insert(sessionID) recentCompletedSamplesIncluded += 1 } } return sessionIDs } 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() 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(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 isChargerObject(object) == expectsChargerClass } } private func isChargerObject(_ object: NSManagedObject) -> Bool { ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger } private func fetchChargedDeviceObject(id: String) -> NSManagedObject? { let request = NSFetchRequest(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(entityName: entityName) return (try? context.fetch(request)) ?? [] } private func resolvedStopThreshold( for chargedDevice: NSManagedObject, chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode, charger: NSManagedObject?, fallback: Double? ) -> Double? { if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil { return nil } let sessionKind = ChargeSessionKind( chargingTransportMode: chargingTransportMode, chargingStateMode: chargingStateMode ) let configuredCurrents = decodedCompletionCurrents( from: chargedDevice, key: "configuredCompletionCurrentsRawValue" ) let learnedCurrents = decodedCompletionCurrents( from: chargedDevice, key: "learnedCompletionCurrentsRawValue" ) let legacyCurrent: Double? switch chargingTransportMode { case .wired: legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps") ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps") ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps") case .wireless: legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps") ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps") ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps") } let resolvedCurrent = configuredCurrents[sessionKind] ?? learnedCurrents[sessionKind] ?? legacyCurrent ?? fallback guard let resolvedCurrent, resolvedCurrent > 0 else { return nil } return max(resolvedCurrent, 0.01) } private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode { let supportsWiredCharging = supportsWiredCharging(for: chargedDevice) let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice) return resolvedPreferredChargingTransportMode( .wired, supportsWiredCharging: supportsWiredCharging, supportsWirelessCharging: supportsWirelessCharging ) } private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass { ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other } private func normalizedTemplateID( _ templateID: String?, kind: ChargedDeviceKind ) -> String? { guard let templateID, let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID), templateDefinition.kind == kind else { return nil } return templateDefinition.id } private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? { guard let templateID = stringValue(chargedDevice, key: "deviceTemplateID"), let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID), templateDefinition.kind == deviceClass(for: chargedDevice).kind else { return nil } return templateDefinition } private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool { let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil ? true : boolValue(chargedDevice, key: "supportsWiredCharging") let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil ? false : boolValue(chargedDevice, key: "supportsWirelessCharging") return deviceClass(for: chargedDevice).normalizedChargingSupport( supportsWiredCharging: persistedWiredCharging, supportsWirelessCharging: persistedWirelessCharging ).wired } private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool { let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil ? true : boolValue(chargedDevice, key: "supportsWiredCharging") let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil ? false : boolValue(chargedDevice, key: "supportsWirelessCharging") return deviceClass(for: chargedDevice).normalizedChargingSupport( supportsWiredCharging: persistedWiredCharging, supportsWirelessCharging: persistedWirelessCharging ).wireless } private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability { let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue") .flatMap(ChargingStateAvailability.init(rawValue:)) ?? ChargingStateAvailability.fallback( for: boolValue(chargedDevice, key: "supportsChargingWhileOff") ) return deviceClass(for: chargedDevice).normalizedChargingStateAvailability(persistedAvailability) } private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode { if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"), let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) { let persistedChargingStateMode = stringValue(session, key: "chargingStateRawValue") .flatMap(ChargingStateMode.init(rawValue:)) ?? .on return resolvedChargingStateMode( persistedChargingStateMode, availability: chargingStateAvailability(for: chargedDevice) ) } if let rawValue = stringValue(session, key: "chargingStateRawValue"), let chargingStateMode = ChargingStateMode(rawValue: rawValue) { return chargingStateMode } return .on } private func resolvedChargingStateMode( _ chargingStateMode: ChargingStateMode, availability: ChargingStateAvailability ) -> ChargingStateMode { if availability.supportedModes.contains(chargingStateMode) { return chargingStateMode } return availability.supportedModes.first ?? .on } private func chargerType(for chargedDevice: NSManagedObject) -> ChargerType? { let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other guard deviceClass == .charger else { return nil } // Primary: chargerTypeRawValue (set on v13+) if let rawValue = stringValue(chargedDevice, key: "chargerTypeRawValue"), let type = ChargerType(rawValue: rawValue) { return type } // Migration fallback: derive from old deviceTemplateID switch stringValue(chargedDevice, key: "deviceTemplateID") { case "apple-magsafe-charger": return .appleMagSafe case "apple-watch-charger": return .appleWatch default: break } // Last resort: derive from wirelessChargingProfileRawValue if let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"), let profile = WirelessChargingProfile(rawValue: rawValue), profile == .magsafe { return .genericMagSafe } return .genericQi } private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile { if let type = chargerType(for: chargedDevice) { return type.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 encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? { let payload = Dictionary( uniqueKeysWithValues: currents.map { key, value in (key.rawValue, value) } ) guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else { return nil } return String(data: data, encoding: .utf8) } private func decodedCompletionCurrents( from object: NSManagedObject, key: String ) -> [ChargeSessionKind: Double] { guard let rawValue = stringValue(object, key: key), let data = rawValue.data(using: .utf8), let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else { return [:] } return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else { return } result[sessionKind] = entry.value } } private func legacyConfiguredCompletionCurrent( for currents: [ChargeSessionKind: Double], chargingTransportMode: ChargingTransportMode ) -> Double? { let candidates = currents .filter { $0.key.chargingTransportMode == chargingTransportMode } .sorted { lhs, rhs in lhs.key.rawValue < rhs.key.rawValue } .map(\.value) return candidates.first } private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? { guard let charger else { return nil } let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps") guard let idleCurrent, idleCurrent >= 0 else { return nil } return idleCurrent } private func effectiveCurrentAmps( fromMeasuredCurrent currentAmps: Double, chargingTransportMode: ChargingTransportMode, charger: NSManagedObject? ) -> Double { switch chargingTransportMode { case .wired: return max(currentAmps, 0) case .wireless: guard let idleCurrent = chargerIdleCurrent(for: charger) else { return max(currentAmps, 0) } return max(currentAmps - idleCurrent, 0) } } private func hasObservedChargeFlow( currentAmps: Double, chargingTransportMode: ChargingTransportMode, charger: NSManagedObject?, stopThreshold: Double? ) -> Bool { let effectiveCurrent = effectiveCurrentAmps( fromMeasuredCurrent: currentAmps, chargingTransportMode: chargingTransportMode, charger: charger ) return effectiveCurrent > max(stopThreshold ?? 0, 0.05) } private func hasSavableChargeData(_ session: NSManagedObject) -> Bool { if boolValue(session, key: "hasObservedChargeFlow") || doubleValue(session, key: "measuredEnergyWh") > 0 || doubleValue(session, key: "measuredChargeAh") > 0 || (optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? 0) > 0 || (optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? 0) > 0 { return true } guard let sessionID = stringValue(session, key: "id") else { return false } return fetchSessionSampleObjects(forSessionID: sessionID).contains { sample in doubleValue(sample, key: "measuredEnergyWh") > 0 || doubleValue(sample, key: "measuredChargeAh") > 0 || (optionalDoubleValue(sample, key: "averageCurrentAmps") ?? 0) > 0 || (optionalDoubleValue(sample, key: "averagePowerWatts") ?? 0) > 0 } } private func restoreMeasuredTotalsFromLatestSampleIfNeeded(_ session: NSManagedObject) { guard let sessionID = stringValue(session, key: "id"), let latestSample = fetchSessionSampleObjects(forSessionID: sessionID).max(by: { (dateValue($0, key: "timestamp") ?? .distantPast) < (dateValue($1, key: "timestamp") ?? .distantPast) }) else { return } let sampleEnergyWh = doubleValue(latestSample, key: "measuredEnergyWh") if doubleValue(session, key: "measuredEnergyWh") <= 0, sampleEnergyWh > 0 { session.setValue(sampleEnergyWh, forKey: "measuredEnergyWh") } let sampleChargeAh = doubleValue(latestSample, key: "measuredChargeAh") if doubleValue(session, key: "measuredChargeAh") <= 0, sampleChargeAh > 0 { session.setValue(sampleChargeAh, forKey: "measuredChargeAh") } } 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 (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 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 derivedCompletionCurrents(from sessions: [NSManagedObject]) -> [ChargeSessionKind: Double] { var groupedCurrents: [ChargeSessionKind: [Double]] = [:] for session in sessions { guard statusValue(session, key: "statusRawValue") == .completed else { continue } guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else { continue } guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else { continue } let sessionKind = ChargeSessionKind( chargingTransportMode: chargingTransportMode(for: session), chargingStateMode: chargingStateMode(for: session) ) groupedCurrents[sessionKind, default: []].append(completionCurrent) } return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in let recentCurrents = Array(entry.value.suffix(5)) guard !recentCurrents.isEmpty else { return } result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.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 chargedDeviceID = stringValue(session, key: "chargedDeviceID"), let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) { return resolvedPreferredChargingTransportMode( chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired, supportsWiredCharging: supportsWiredCharging(for: chargedDevice), supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice) ) } if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") { return persistedChargingTransportMode } 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 currentStatus != committedStatus { return .event } 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 shouldPersistAggregatedSample( _ sample: NSManagedObject, observedAt: Date ) -> Bool { if sample.isInserted { return true } let committedValues = sample.committedValues(forKeys: ["updatedAt"]) let lastPersistedAt = (committedValues["updatedAt"] as? Date) ?? dateValue(sample, key: "createdAt") ?? observedAt return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval } 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 rawValue(_ object: NSManagedObject, key: String) -> Any? { guard object.entity.propertiesByName[key] != nil else { return nil } return object.value(forKey: key) } private func setValue(_ value: Any?, on object: NSManagedObject, key: String) { guard object.entity.propertiesByName[key] != nil else { return } object.setValue(value, forKey: key) } private func stringValue(_ object: NSManagedObject, key: String) -> String? { guard let value = rawValue(object, key: key) as? String else { return nil } let normalized = normalizedOptionalText(value) return normalized } private func dateValue(_ object: NSManagedObject, key: String) -> Date? { rawValue(object, key: key) as? Date } private func doubleValue(_ object: NSManagedObject, key: String) -> Double { if let value = rawValue(object, key: key) as? Double { return value } if let value = rawValue(object, key: key) as? NSNumber { return value.doubleValue } return 0 } private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? { let value = rawValue(object, key: key) if value == nil { return nil } return doubleValue(object, key: key) } private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? { if let value = rawValue(object, key: key) as? Int16 { return value } if let value = rawValue(object, key: key) as? NSNumber { return value.int16Value } return nil } private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? { if let value = rawValue(object, key: key) as? Int32 { return value } if let value = rawValue(object, key: key) as? NSNumber { return value.int32Value } return nil } private func boolValue(_ object: NSManagedObject, key: String) -> Bool { if let value = rawValue(object, key: key) as? Bool { return value } if let value = rawValue(object, key: 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 }