USB-Meter / USB Meter / Model / ChargeInsightsStore.swift
Newer Older
3644 lines | 158.782kb
Bogdan Timofte authored a month ago
1
//
2
//  ChargeInsightsStore.swift
3
//  USB Meter
4
//
5
//  Created by Codex on 10/04/2026.
6
//
7

            
8
import CoreData
9
import Foundation
10

            
11
final class ChargeInsightsStore {
12
    private enum EntityName {
13
        static let chargedDevice = "ChargedDevice"
14
        static let chargeSession = "ChargeSession"
15
        static let chargeCheckpoint = "ChargeCheckpoint"
16
        static let chargeSessionSample = "ChargeSessionSample"
17
    }
18

            
Bogdan Timofte authored a month ago
19
    private static let maximumChargeSessionDuration: TimeInterval = 12 * 60 * 60
Bogdan Timofte authored a month ago
20
    private static let persistedSamplesPerHour = 300
Bogdan Timofte authored a month ago
21
    private static let aggregatedSampleBucketDuration = 3600.0 / Double(persistedSamplesPerHour)
22

            
23
    private let context: NSManagedObjectContext
24
    private let stopDetectionHoldDuration: TimeInterval = 20
Bogdan Timofte authored a month ago
25
    private let maximumLiveIntegrationGap: TimeInterval = 90
Bogdan Timofte authored a month ago
26
    private let activeSessionSaveInterval: TimeInterval = 60
27
    private let aggregatedSampleSaveInterval: TimeInterval = 30
Bogdan Timofte authored a month ago
28
    private let counterDecreaseTolerance = 0.002
29
    private let completionConfirmationCooldown: TimeInterval = 15 * 60
Bogdan Timofte authored a month ago
30
    private let pausedSessionTimeout: TimeInterval = 10 * 60
Bogdan Timofte authored a month ago
31
    private let defaultCompletionPercentThreshold = 95.0
32
    private let completionContradictionTolerancePercent = 2.0
33
    private let minimumWirelessEfficiencyFactor = 0.35
34
    private let maximumWirelessEfficiencyFactor = 0.95
35
    private let lowWirelessEfficiencyThreshold = 0.72
Bogdan Timofte authored a month ago
36
    private let unresolvedFlatBatteryPercent = -1.0
Bogdan Timofte authored a month ago
37

            
38
    init(context: NSManagedObjectContext) {
39
        self.context = context
40
    }
41

            
42
    func refreshContext() {
43
        context.performAndWait {
44
            context.processPendingChanges()
45
        }
46
    }
47

            
Bogdan Timofte authored a month ago
48
    func resetContext() {
49
        context.performAndWait {
50
            context.reset()
51
        }
52
    }
53

            
Bogdan Timofte authored a month ago
54
    @discardableResult
55
    func flushPendingChanges() -> Bool {
56
        var didSave = false
57
        context.performAndWait {
58
            context.processPendingChanges()
59
            didSave = saveContext()
60
        }
61
        return didSave
62
    }
63

            
Bogdan Timofte authored a month ago
64
    @discardableResult
65
    func completeExpiredOpenSessions(referenceDate: Date = Date()) -> Bool {
66
        var didSave = false
67

            
68
        context.performAndWait {
69
            let expiredSessions = fetchOpenSessionObjects().compactMap { session -> NSManagedObject? in
70
                guard automaticCompletionDate(for: session, referenceDate: referenceDate) != nil else {
71
                    return nil
72
                }
73
                return session
74
            }
75
            guard expiredSessions.isEmpty == false else {
76
                return
77
            }
78

            
79
            var chargedDeviceIDsToRefresh = Set<String>()
80
            for session in expiredSessions {
81
                guard let completionDate = automaticCompletionDate(for: session, referenceDate: referenceDate) else {
82
                    continue
83
                }
84
                finishSession(
85
                    session,
86
                    observedAt: completionDate,
87
                    finalBatteryPercent: nil,
88
                    status: .completed
89
                )
90
                if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
91
                    chargedDeviceIDsToRefresh.insert(chargedDeviceID)
92
                }
93
            }
94

            
95
            guard saveContext() else {
96
                return
97
            }
98

            
99
            for chargedDeviceID in chargedDeviceIDsToRefresh {
100
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
101
            }
102
            didSave = saveContext()
103
        }
104

            
105
        return didSave
106
    }
107

            
Bogdan Timofte authored a month ago
108
    // Heals the invariant "at most one open session per meter MAC".
109
    // Called after every remote CloudKit sync import to resolve sessions that were started
110
    // independently on different devices while offline.
111
    //
112
    // Scenario: session A started on Device 1 and forgotten; user starts session B on Device 2
113
    // while offline. After sync both appear open for the same meter.
114
    //
115
    // Winner = session with the latest startedAt (represents the user's intentional new session).
116
    // Loser endedAt is set to winner's startedAt so there is no time overlap.
117
    @discardableResult
118
    func healDuplicateOpenSessions() -> Bool {
119
        var didSave = false
120

            
121
        context.performAndWait {
122
            let openSessions = fetchOpenSessionObjects()
123

            
124
            var sessionsByMAC: [String: [NSManagedObject]] = [:]
125
            for session in openSessions {
126
                guard let mac = stringValue(session, key: "meterMACAddress") else { continue }
127
                sessionsByMAC[mac, default: []].append(session)
128
            }
129

            
130
            let duplicatedMACs = sessionsByMAC.filter { $0.value.count > 1 }
131
            guard !duplicatedMACs.isEmpty else { return }
132

            
133
            var chargedDeviceIDsToRefresh = Set<String>()
134

            
135
            for (_, sessions) in duplicatedMACs {
136
                // Winner = most recently started (explicit user intent); tie-break by measuredEnergyWh
137
                let winner = sessions.max { a, b in
138
                    let aDate = (a.value(forKey: "startedAt") as? Date) ?? .distantPast
139
                    let bDate = (b.value(forKey: "startedAt") as? Date) ?? .distantPast
140
                    if aDate != bDate { return aDate < bDate }
141
                    let aEnergy = (a.value(forKey: "measuredEnergyWh") as? Double) ?? 0
142
                    let bEnergy = (b.value(forKey: "measuredEnergyWh") as? Double) ?? 0
143
                    return aEnergy < bEnergy
144
                }
145
                let winnerStartedAt = (winner?.value(forKey: "startedAt") as? Date) ?? Date()
146

            
147
                for loser in sessions where loser !== winner {
148
                    // End the loser exactly when the winner began — no overlap.
149
                    finishSession(loser, observedAt: winnerStartedAt, finalBatteryPercent: nil, status: .abandoned)
150
                    loser.setValue(true, forKey: "wasConflictHealed")
151
                    if let chargedDeviceID = stringValue(loser, key: "chargedDeviceID") {
152
                        chargedDeviceIDsToRefresh.insert(chargedDeviceID)
153
                    }
154
                    track("ChargeInsightsStore: healed duplicate open session \(stringValue(loser, key: "id") ?? "?") for meter \(stringValue(loser, key: "meterMACAddress") ?? "?")")
155
                }
156
            }
157

            
158
            guard saveContext() else { return }
159

            
160
            for chargedDeviceID in chargedDeviceIDsToRefresh {
161
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
162
            }
163
            didSave = saveContext()
164
        }
165

            
166
        return didSave
167
    }
168

            
Bogdan Timofte authored a month ago
169
    @discardableResult
Bogdan Timofte authored a month ago
170
    func createDevice(
Bogdan Timofte authored a month ago
171
        name: String,
172
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
173
        templateID: String?,
Bogdan Timofte authored a month ago
174
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
175
        supportsWiredCharging: Bool,
176
        supportsWirelessCharging: Bool,
177
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
178
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
179
        notes: String?
Bogdan Timofte authored a month ago
180
    ) -> Bool {
Bogdan Timofte authored a month ago
181
        guard deviceClass.kind == .device else { return false }
Bogdan Timofte authored a month ago
182
        let normalizedName = normalizedText(name)
Bogdan Timofte authored a month ago
183
        let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability)
184
        let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
185
            supportsWiredCharging: supportsWiredCharging,
186
            supportsWirelessCharging: supportsWirelessCharging
187
        )
188
        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device)
Bogdan Timofte authored a month ago
189
        guard !normalizedName.isEmpty else { return false }
Bogdan Timofte authored a month ago
190
        guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
Bogdan Timofte authored a month ago
191

            
192
        var didSave = false
193
        context.performAndWait {
194
            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
195
                return
196
            }
197

            
198
            let object = NSManagedObject(entity: entity, insertInto: context)
199
            let now = Date()
200
            object.setValue(UUID().uuidString, forKey: "id")
201
            object.setValue(normalizedName, forKey: "name")
202
            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
203
            object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
204
            object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
205
            object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
206
            object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging")
207
            object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging")
Bogdan Timofte authored a month ago
208
            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
209
            object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
210
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
211
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
212
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
213
            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
214
            object.setValue(now, forKey: "createdAt")
215
            object.setValue(now, forKey: "updatedAt")
216
            didSave = saveContext()
217
        }
218
        return didSave
219
    }
220

            
221
    @discardableResult
Bogdan Timofte authored a month ago
222
    func createCharger(
223
        name: String,
Bogdan Timofte authored a month ago
224
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
225
        notes: String?
Bogdan Timofte authored a month ago
226
    ) -> Bool {
227
        let normalizedName = normalizedText(name)
228
        guard !normalizedName.isEmpty else { return false }
229

            
230
        var didSave = false
231
        context.performAndWait {
232
            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
233
                return
234
            }
235

            
236
            let object = NSManagedObject(entity: entity, insertInto: context)
237
            let now = Date()
238
            object.setValue(UUID().uuidString, forKey: "id")
239
            object.setValue(normalizedName, forKey: "name")
240
            object.setValue(ChargedDeviceClass.charger.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
241
            object.setValue(nil, forKey: "deviceTemplateID")
242
            object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue")
243
            object.setValue(false, forKey: "supportsChargingWhileOff")
244
            object.setValue(false, forKey: "supportsWiredCharging")
245
            object.setValue(true, forKey: "supportsWirelessCharging")
246
            if object.entity.attributesByName["chargerTypeRawValue"] != nil {
247
                object.setValue(chargerType.rawValue, forKey: "chargerTypeRawValue")
248
            }
249
            object.setValue(chargerType.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
250
            object.setValue(encodedCompletionCurrents([:]), forKey: "configuredCompletionCurrentsRawValue")
251
            object.setValue(nil, forKey: "wiredChargeCompletionCurrentAmps")
252
            object.setValue(nil, forKey: "wirelessChargeCompletionCurrentAmps")
253
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
254
            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
255
            object.setValue(now, forKey: "createdAt")
256
            object.setValue(now, forKey: "updatedAt")
257
            didSave = saveContext()
258
        }
259
        return didSave
260
    }
261

            
262
    @discardableResult
263
    func updateDevice(
Bogdan Timofte authored a month ago
264
        id: UUID,
265
        name: String,
266
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
267
        templateID: String?,
Bogdan Timofte authored a month ago
268
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
269
        supportsWiredCharging: Bool,
270
        supportsWirelessCharging: Bool,
271
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
272
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
273
        notes: String?
274
    ) -> Bool {
Bogdan Timofte authored a month ago
275
        guard deviceClass.kind == .device else { return false }
Bogdan Timofte authored a month ago
276
        let normalizedName = normalizedText(name)
Bogdan Timofte authored a month ago
277
        let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability)
278
        let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
279
            supportsWiredCharging: supportsWiredCharging,
280
            supportsWirelessCharging: supportsWirelessCharging
281
        )
282
        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device)
Bogdan Timofte authored a month ago
283
        guard !normalizedName.isEmpty else { return false }
Bogdan Timofte authored a month ago
284
        guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
Bogdan Timofte authored a month ago
285

            
286
        var didSave = false
287
        context.performAndWait {
288
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
289
                return
290
            }
Bogdan Timofte authored a month ago
291
            guard isChargerObject(object) == false else {
292
                return
293
            }
Bogdan Timofte authored a month ago
294

            
295
            let previousSupportsChargingWhileOff = boolValue(object, key: "supportsChargingWhileOff")
Bogdan Timofte authored a month ago
296
            let previousChargingStateAvailability = self.chargingStateAvailability(for: object)
Bogdan Timofte authored a month ago
297
            let previousSupportsWiredCharging = self.supportsWiredCharging(for: object)
298
            let previousSupportsWirelessCharging = self.supportsWirelessCharging(for: object)
299
            let now = Date()
300

            
301
            object.setValue(normalizedName, forKey: "name")
302
            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
303
            object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
304
            object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
305
            object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
306
            object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging")
307
            object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging")
Bogdan Timofte authored a month ago
308
            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
309
            object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
310
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
311
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
312
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
313
            object.setValue(now, forKey: "updatedAt")
314

            
Bogdan Timofte authored a month ago
315
            let supportsChargingWhileOff = normalizedChargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
316
            let shouldRecalculateSessionCapacity = previousSupportsChargingWhileOff != supportsChargingWhileOff
317
            let shouldRefreshActiveSessions = shouldRecalculateSessionCapacity
Bogdan Timofte authored a month ago
318
                || previousChargingStateAvailability != normalizedChargingStateAvailability
319
                || previousSupportsWiredCharging != normalizedChargingSupport.wired
320
                || previousSupportsWirelessCharging != normalizedChargingSupport.wireless
Bogdan Timofte authored a month ago
321

            
322
            if shouldRecalculateSessionCapacity || shouldRefreshActiveSessions {
323
                let sessions = fetchSessions(forChargedDeviceID: id.uuidString)
324
                for session in sessions {
Bogdan Timofte authored a month ago
325
                    let isOpen = statusValue(session, key: "statusRawValue")?.isOpen == true
Bogdan Timofte authored a month ago
326

            
327
                    if shouldRecalculateSessionCapacity {
328
                        session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
329
                        updateCapacityEstimate(for: session)
330
                        session.setValue(now, forKey: "updatedAt")
331
                    }
332

            
Bogdan Timofte authored a month ago
333
                    guard isOpen, shouldRefreshActiveSessions else {
Bogdan Timofte authored a month ago
334
                        continue
335
                    }
336

            
337
                    let resolvedSessionChargingTransportMode = resolvedPreferredChargingTransportMode(
338
                        chargingTransportMode(for: session),
Bogdan Timofte authored a month ago
339
                        supportsWiredCharging: normalizedChargingSupport.wired,
340
                        supportsWirelessCharging: normalizedChargingSupport.wireless
Bogdan Timofte authored a month ago
341
                    )
Bogdan Timofte authored a month ago
342
                    let resolvedSessionChargingStateMode = resolvedChargingStateMode(
343
                        chargingStateMode(for: session),
Bogdan Timofte authored a month ago
344
                        availability: normalizedChargingStateAvailability
Bogdan Timofte authored a month ago
345
                    )
346
                    let charger = stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
347

            
348
                    session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
349
                    session.setValue(resolvedSessionChargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
350
                    session.setValue(resolvedSessionChargingStateMode.rawValue, forKey: "chargingStateRawValue")
Bogdan Timofte authored a month ago
351
                    session.setValue(
352
                        resolvedStopThreshold(
353
                            for: object,
354
                            chargingTransportMode: resolvedSessionChargingTransportMode,
Bogdan Timofte authored a month ago
355
                            chargingStateMode: resolvedSessionChargingStateMode,
356
                            charger: charger,
357
                            fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
358
                        ) ?? 0,
Bogdan Timofte authored a month ago
359
                        forKey: "stopThresholdAmps"
360
                    )
361
                    session.setValue(now, forKey: "updatedAt")
362
                    updateCapacityEstimate(for: session)
363
                }
364
            }
365

            
366
            refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
367
            didSave = saveContext()
368
        }
369
        return didSave
370
    }
371

            
Bogdan Timofte authored a month ago
372
    @discardableResult
373
    func updateCharger(
374
        id: UUID,
375
        name: String,
Bogdan Timofte authored a month ago
376
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
377
        notes: String?
378
    ) -> Bool {
379
        let normalizedName = normalizedText(name)
380
        guard !normalizedName.isEmpty else { return false }
381

            
382
        var didSave = false
383
        context.performAndWait {
384
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
385
                return
386
            }
387
            guard isChargerObject(object) else {
388
                return
389
            }
390

            
391
            object.setValue(normalizedName, forKey: "name")
Bogdan Timofte authored a month ago
392
            object.setValue(nil, forKey: "deviceTemplateID")
393
            object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue")
394
            object.setValue(false, forKey: "supportsChargingWhileOff")
395
            object.setValue(false, forKey: "supportsWiredCharging")
396
            object.setValue(true, forKey: "supportsWirelessCharging")
397
            if object.entity.attributesByName["chargerTypeRawValue"] != nil {
398
                object.setValue(chargerType.rawValue, forKey: "chargerTypeRawValue")
399
            }
400
            object.setValue(chargerType.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
401
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
402
            object.setValue(Date(), forKey: "updatedAt")
Bogdan Timofte authored a month ago
403
            refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
Bogdan Timofte authored a month ago
404
            didSave = saveContext()
405
        }
406

            
407
        return didSave
408
    }
409

            
Bogdan Timofte authored a month ago
410
    @discardableResult
Bogdan Timofte authored a month ago
411
    func startSession(
412
        for snapshot: ChargingMonitorSnapshot,
413
        chargedDeviceID: UUID,
414
        chargerID: UUID?,
415
        chargingTransportMode: ChargingTransportMode,
416
        chargingStateMode: ChargingStateMode,
417
        autoStopEnabled: Bool,
Bogdan Timofte authored a month ago
418
        initialBatteryPercent: Double?,
419
        startsFromFlatBattery: Bool
Bogdan Timofte authored a month ago
420
    ) -> Bool {
Bogdan Timofte authored a month ago
421
        if let initialBatteryPercent,
422
           (initialBatteryPercent.isFinite == false || initialBatteryPercent < 0 || initialBatteryPercent > 100) {
Bogdan Timofte authored a month ago
423
            return false
424
        }
425

            
Bogdan Timofte authored a month ago
426
        var didSave = false
427
        context.performAndWait {
Bogdan Timofte authored a month ago
428
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
Bogdan Timofte authored a month ago
429
                return
430
            }
Bogdan Timofte authored a month ago
431
            guard isChargerObject(chargedDevice) == false else {
432
                return
433
            }
Bogdan Timofte authored a month ago
434

            
Bogdan Timofte authored a month ago
435
            guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
Bogdan Timofte authored a month ago
436
                return
437
            }
438

            
Bogdan Timofte authored a month ago
439
            let resolvedChargingTransportMode = resolvedPreferredChargingTransportMode(
440
                chargingTransportMode,
441
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
442
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
Bogdan Timofte authored a month ago
443
            )
Bogdan Timofte authored a month ago
444
            let resolvedChargingStateMode = resolvedChargingStateMode(
445
                chargingStateMode,
446
                availability: chargingStateAvailability(for: chargedDevice)
447
            )
448
            let charger = resolvedChargingTransportMode == .wireless
449
                ? chargerID.flatMap { fetchChargedDeviceObject(id: $0.uuidString) }
450
                : nil
Bogdan Timofte authored a month ago
451
            if let charger, isChargerObject(charger) == false {
452
                return
453
            }
Bogdan Timofte authored a month ago
454
            guard resolvedChargingTransportMode == .wired || charger != nil else {
Bogdan Timofte authored a month ago
455
                return
456
            }
Bogdan Timofte authored a month ago
457
            let stopThreshold = resolvedStopThreshold(
Bogdan Timofte authored a month ago
458
                for: chargedDevice,
459
                chargingTransportMode: resolvedChargingTransportMode,
460
                chargingStateMode: resolvedChargingStateMode,
461
                charger: charger,
Bogdan Timofte authored a month ago
462
                fallback: snapshot.fallbackStopThresholdAmps > 0 ? snapshot.fallbackStopThresholdAmps : nil
463
            )
Bogdan Timofte authored a month ago
464
            guard let session = createSessionObject(
465
                for: chargedDevice,
Bogdan Timofte authored a month ago
466
                charger: charger,
467
                snapshot: snapshot,
468
                stopThreshold: stopThreshold,
Bogdan Timofte authored a month ago
469
                chargingTransportMode: resolvedChargingTransportMode,
470
                chargingStateMode: resolvedChargingStateMode,
471
                autoStopEnabled: autoStopEnabled
472
            ) else {
473
                return
474
            }
475

            
Bogdan Timofte authored a month ago
476
            if startsFromFlatBattery {
477
                session.setValue(unresolvedFlatBatteryPercent, forKey: "startBatteryPercent")
478
                session.setValue(nil, forKey: "endBatteryPercent")
479
            } else if let initialBatteryPercent {
480
                guard insertBatteryCheckpoint(
481
                    percent: initialBatteryPercent,
Bogdan Timofte authored a month ago
482
                    flag: .initial,
Bogdan Timofte authored a month ago
483
                    timestamp: snapshot.observedAt,
484
                    to: session
485
                ) != nil else {
486
                    return
487
                }
Bogdan Timofte authored a month ago
488
            }
Bogdan Timofte authored a month ago
489
            didSave = saveContext()
490
        }
491
        return didSave
492
    }
493

            
Bogdan Timofte authored a month ago
494
    @discardableResult
495
    func pauseSession(id sessionID: UUID, observedAt: Date) -> Bool {
496
        var didSave = false
497
        context.performAndWait {
498
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
499
                return
500
            }
501

            
502
            guard statusValue(session, key: "statusRawValue") == .active else {
503
                return
504
            }
505

            
506
            session.setValue(ChargeSessionStatus.paused.rawValue, forKey: "statusRawValue")
507
            session.setValue(observedAt, forKey: "pausedAt")
508
            session.setValue(nil, forKey: "belowThresholdSince")
509
            clearCompletionConfirmationState(for: session)
510
            session.setValue(observedAt, forKey: "updatedAt")
511
            didSave = saveContext()
512
        }
513
        return didSave
514
    }
515

            
516
    @discardableResult
517
    func resumeSession(id sessionID: UUID, snapshot: ChargingMonitorSnapshot?) -> Bool {
518
        var didSave = false
519
        context.performAndWait {
520
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
521
                return
522
            }
523

            
524
            guard statusValue(session, key: "statusRawValue") == .paused else {
525
                return
526
            }
527

            
528
            let resumedAt = snapshot?.observedAt ?? Date()
Bogdan Timofte authored a month ago
529
            if let completionDate = automaticCompletionDate(for: session, referenceDate: resumedAt) {
Bogdan Timofte authored a month ago
530
                finishSession(
531
                    session,
Bogdan Timofte authored a month ago
532
                    observedAt: completionDate,
Bogdan Timofte authored a month ago
533
                    finalBatteryPercent: nil,
534
                    status: .completed
535
                )
536
                guard saveContext() else {
537
                    return
538
                }
539
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
540
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
541
                    didSave = saveContext()
542
                } else {
543
                    didSave = true
544
                }
545
                return
546
            }
547

            
548
            session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
549
            session.setValue(nil, forKey: "pausedAt")
550
            session.setValue(nil, forKey: "belowThresholdSince")
551
            clearCompletionConfirmationState(for: session)
552
            session.setValue(resumedAt, forKey: "lastObservedAt")
553
            if let snapshot {
554
                session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
555
                session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
556
                session.setValue(
557
                    chargingTransportMode(for: session) == .wired ? snapshot.voltageVolts : nil,
558
                    forKey: "lastObservedVoltageVolts"
559
                )
560
            } else {
561
                session.setValue(0, forKey: "lastObservedCurrentAmps")
562
                session.setValue(0, forKey: "lastObservedPowerWatts")
563
                session.setValue(nil, forKey: "lastObservedVoltageVolts")
564
            }
565
            session.setValue(resumedAt, forKey: "updatedAt")
566
            didSave = saveContext()
567
        }
568
        return didSave
569
    }
570

            
571
    @discardableResult
572
    func stopSession(
573
        id sessionID: UUID,
Bogdan Timofte authored a month ago
574
        finalBatteryPercent: Double? = nil
Bogdan Timofte authored a month ago
575
    ) -> Bool {
Bogdan Timofte authored a month ago
576
        if let finalBatteryPercent {
577
            guard finalBatteryPercent.isFinite, finalBatteryPercent >= 0, finalBatteryPercent <= 100 else {
578
                return false
579
            }
Bogdan Timofte authored a month ago
580
        }
581

            
582
        var didSave = false
Bogdan Timofte authored a month ago
583
        var deviceIDToRefresh: String?
584

            
Bogdan Timofte authored a month ago
585
        context.performAndWait {
586
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
587
                return
588
            }
589

            
590
            guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
591
                return
592
            }
593

            
Bogdan Timofte authored a month ago
594
            restoreMeasuredTotalsFromLatestSampleIfNeeded(session)
595

            
Bogdan Timofte authored a month ago
596
            guard hasSavableChargeData(session) else {
597
                return
598
            }
599

            
Bogdan Timofte authored a month ago
600
            let observedAt = snapshotDateForManualStop(session)
601
            finishSession(
602
                session,
603
                observedAt: observedAt,
604
                finalBatteryPercent: finalBatteryPercent,
605
                status: .completed
606
            )
607

            
608
            guard saveContext() else {
609
                return
610
            }
611

            
Bogdan Timofte authored a month ago
612
            didSave = true
613
            deviceIDToRefresh = stringValue(session, key: "chargedDeviceID")
614
        }
615

            
616
        if let deviceID = deviceIDToRefresh {
617
            context.perform { [weak self] in
618
                guard let self else { return }
619
                self.refreshDerivedMetrics(forChargedDeviceID: deviceID)
620
                self.saveContext()
Bogdan Timofte authored a month ago
621
            }
622
        }
Bogdan Timofte authored a month ago
623

            
Bogdan Timofte authored a month ago
624
        return didSave
625
    }
626

            
Bogdan Timofte authored a month ago
627
    @discardableResult
628
    func addBatteryCheckpoint(
629
        percent: Double,
Bogdan Timofte authored a month ago
630
        for meterMACAddress: String,
Bogdan Timofte authored a month ago
631
        measuredEnergyWh: Double? = nil
Bogdan Timofte authored a month ago
632
    ) -> Bool {
633
        guard percent.isFinite, percent >= 0, percent <= 100 else {
634
            return false
635
        }
636

            
637
        var didSave = false
638
        context.performAndWait {
Bogdan Timofte authored a month ago
639
            guard let session = fetchOpenSessionObject(forMeterMACAddress: meterMACAddress) else {
Bogdan Timofte authored a month ago
640
                return
641
            }
642

            
Bogdan Timofte authored a month ago
643
            didSave = addBatteryCheckpoint(
644
                percent: percent,
645
                measuredEnergyWh: measuredEnergyWh,
Bogdan Timofte authored a month ago
646
                flag: .intermediate,
Bogdan Timofte authored a month ago
647
                to: session
648
            )
Bogdan Timofte authored a month ago
649
        }
650
        return didSave
651
    }
652

            
653
    @discardableResult
654
    func addBatteryCheckpoint(
655
        percent: Double,
Bogdan Timofte authored a month ago
656
        for sessionID: UUID,
Bogdan Timofte authored a month ago
657
        measuredEnergyWh: Double? = nil
Bogdan Timofte authored a month ago
658
    ) -> Bool {
659
        guard percent.isFinite, percent >= 0, percent <= 100 else {
660
            return false
661
        }
662

            
663
        var didSave = false
664
        context.performAndWait {
665
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
666
                return
667
            }
668

            
Bogdan Timofte authored a month ago
669
            didSave = addBatteryCheckpoint(
670
                percent: percent,
671
                measuredEnergyWh: measuredEnergyWh,
Bogdan Timofte authored a month ago
672
                flag: .intermediate,
Bogdan Timofte authored a month ago
673
                to: session
674
            )
Bogdan Timofte authored a month ago
675
        }
676
        return didSave
677
    }
678

            
Bogdan Timofte authored a month ago
679
    @discardableResult
680
    func deleteBatteryCheckpoint(
681
        id checkpointID: UUID,
682
        from sessionID: UUID
683
    ) -> Bool {
684
        var didSave = false
685
        context.performAndWait {
686
            guard let session = fetchSessionObject(id: sessionID.uuidString),
687
                  let checkpoint = fetchCheckpointObject(
688
                    id: checkpointID.uuidString,
689
                    sessionID: sessionID.uuidString
690
                  ) else {
691
                return
692
            }
693

            
694
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
695
            context.delete(checkpoint)
696
            refreshCheckpointDerivedValues(for: session)
697

            
698
            if let chargedDeviceID {
699
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
700
            }
Bogdan Timofte authored a month ago
701

            
702
            didSave = saveContext()
Bogdan Timofte authored a month ago
703
        }
704
        return didSave
705
    }
706

            
Bogdan Timofte authored a month ago
707
    @discardableResult
708
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
709
        if let percent, (!percent.isFinite || percent <= 0 || percent > 100) {
710
            return false
711
        }
712

            
713
        var didSave = false
714
        context.performAndWait {
715
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
716
                return
717
            }
718

            
719
            session.setValue(percent, forKey: "targetBatteryPercent")
720
            session.setValue(nil, forKey: "targetBatteryAlertTriggeredAt")
721
            session.setValue(Date(), forKey: "updatedAt")
722
            didSave = saveContext()
723
        }
724
        return didSave
725
    }
726

            
727
    @discardableResult
728
    func confirmCompletion(for sessionID: UUID) -> Bool {
729
        var didSave = false
730
        context.performAndWait {
731
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
732
                return
733
            }
734

            
735
            guard statusValue(session, key: "statusRawValue") == .active else {
736
                return
737
            }
738

            
Bogdan Timofte authored a month ago
739
            finishSession(
740
                session,
741
                observedAt: dateValue(session, key: "lastObservedAt") ?? Date(),
742
                finalBatteryPercent: nil,
743
                status: .completed
744
            )
Bogdan Timofte authored a month ago
745

            
746
            if saveContext() {
747
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
748
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
749
                    didSave = saveContext()
750
                } else {
751
                    didSave = true
752
                }
753
            }
754
        }
755
        return didSave
756
    }
757

            
758
    @discardableResult
759
    func continueMonitoringDespiteCompletionContradiction(for sessionID: UUID) -> Bool {
760
        var didSave = false
761
        context.performAndWait {
762
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
763
                return
764
            }
765

            
766
            guard statusValue(session, key: "statusRawValue") == .active else {
767
                return
768
            }
769

            
770
            clearCompletionConfirmationState(for: session)
Bogdan Timofte authored a month ago
771
            session.setValue(nil, forKey: "belowThresholdSince")
Bogdan Timofte authored a month ago
772
            session.setValue(Date().addingTimeInterval(completionConfirmationCooldown), forKey: "completionConfirmationCooldownUntil")
773
            session.setValue(Date(), forKey: "updatedAt")
774
            didSave = saveContext()
775
        }
776
        return didSave
777
    }
778

            
Bogdan Timofte authored a month ago
779
    @discardableResult
780
    func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
781
        var didSave = false
782
        context.performAndWait {
783
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
784
                return
785
            }
786

            
787
            let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast
788
            let sessionEnd   = dateValue(session, key: "endedAt")
789
                ?? dateValue(session, key: "lastObservedAt")
790
                ?? Date.distantFuture
791

            
792
            let effectiveStart = min(max(start ?? sessionStart, sessionStart), sessionEnd)
793
            let effectiveEnd   = max(min(end ?? sessionEnd, sessionEnd), effectiveStart)
794
            let persistedStart = effectiveStart == sessionStart ? nil : effectiveStart
795
            let persistedEnd   = effectiveEnd == sessionEnd ? nil : effectiveEnd
796

            
797
            let allSamples = fetchSessionSampleObjects(forSessionID: sessionID.uuidString)
798
                .compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in
799
                    guard let ts = dateValue(obj, key: "timestamp") else { return nil }
800
                    return (
801
                        timestamp: ts,
802
                        energy: doubleValue(obj, key: "measuredEnergyWh"),
803
                        charge: doubleValue(obj, key: "measuredChargeAh")
804
                    )
805
                }
806
                .sorted { $0.timestamp < $1.timestamp }
807

            
808
            // Each sample stores cumulative energy since session start.
809
            // Trimmed energy = value at trimEnd  -  value just before trimStart.
810
            let baselineSample = allSamples.last { $0.timestamp <= effectiveStart }
811
            let endSample      = allSamples.last { $0.timestamp <= effectiveEnd }
812
            let baselineEnergy = baselineSample?.energy ?? 0
813
            let baselineCharge = baselineSample?.charge ?? 0
814

            
815
            if let endSample {
816
                let trimmedEnergy  = max(endSample.energy - baselineEnergy, 0)
817
                let trimmedCharge  = max(endSample.charge - baselineCharge, 0)
818
                session.setValue(trimmedEnergy, forKey: "measuredEnergyWh")
819
                session.setValue(trimmedCharge, forKey: "measuredChargeAh")
820
            } else {
821
                session.setValue(0, forKey: "measuredEnergyWh")
822
                session.setValue(0, forKey: "measuredChargeAh")
823
            }
824

            
825
            session.setValue(persistedStart, forKey: "trimStart")
826
            session.setValue(persistedEnd,   forKey: "trimEnd")
827
            session.setValue(Date(), forKey: "updatedAt")
828

            
829
            let checkpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
830
            for checkpoint in checkpoints {
831
                guard let timestamp = dateValue(checkpoint, key: "timestamp") else { continue }
832

            
833
                if timestamp < effectiveStart || timestamp > effectiveEnd {
834
                    context.delete(checkpoint)
835
                    continue
836
                }
837

            
838
                let checkpointSample = allSamples.last { $0.timestamp <= timestamp }
839
                let rebasedEnergy = max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0)
840
                let rebasedCharge = max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0)
841
                checkpoint.setValue(rebasedEnergy, forKey: "measuredEnergyWh")
842
                checkpoint.setValue(rebasedCharge, forKey: "measuredChargeAh")
843
            }
844

            
845
            let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
846
                .sorted {
847
                    (dateValue($0, key: "timestamp") ?? .distantPast)
848
                        < (dateValue($1, key: "timestamp") ?? .distantPast)
849
                }
850
            let restoredInitialCheckpoint = remainingCheckpoints.first { checkpoint in
851
                let label = stringValue(checkpoint, key: "label")
852
                let timestamp = dateValue(checkpoint, key: "timestamp") ?? .distantFuture
853
                return ChargeCheckpointFlag.fromStoredLabel(label) == .initial || timestamp <= effectiveStart
854
            }
855

            
856
            if persistedStart == nil {
857
                if let restoredInitialCheckpoint,
858
                   let percent = optionalDoubleValue(restoredInitialCheckpoint, key: "batteryPercent"),
859
                   percent >= 0 {
860
                    session.setValue(percent, forKey: "startBatteryPercent")
861
                }
862
            } else {
863
                session.setValue(nil, forKey: "startBatteryPercent")
864
            }
865

            
866
            refreshCheckpointDerivedValues(for: session)
867

            
868
            if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
869
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
870
            }
871

            
872
            didSave = saveContext()
873
        }
874
        return didSave
875
    }
876

            
Bogdan Timofte authored a month ago
877
    @discardableResult
878
    func commitSessionTrim(sessionID: UUID) -> Bool {
879
        var didSave = false
880
        context.performAndWait {
881
            guard let session = fetchSessionObject(id: sessionID.uuidString),
882
                  statusValue(session, key: "statusRawValue")?.isOpen == false else {
883
                return
884
            }
885

            
886
            guard dateValue(session, key: "trimStart") != nil
887
                    || dateValue(session, key: "trimEnd") != nil else {
888
                return
889
            }
890

            
891
            let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast
892
            let sessionEnd = dateValue(session, key: "endedAt")
893
                ?? dateValue(session, key: "lastObservedAt")
894
                ?? sessionStart
895

            
896
            let effectiveStart = min(max(dateValue(session, key: "trimStart") ?? sessionStart, sessionStart), sessionEnd)
897
            let effectiveEnd = max(
898
                min(dateValue(session, key: "trimEnd") ?? sessionEnd, sessionEnd),
899
                effectiveStart
900
            )
901

            
902
            let sampleObjects = fetchSessionSampleObjects(forSessionID: sessionID.uuidString)
903
            let allSamples = sampleObjects
904
                .compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in
905
                    guard let timestamp = dateValue(obj, key: "timestamp") else { return nil }
906
                    return (
907
                        timestamp: timestamp,
908
                        energy: doubleValue(obj, key: "measuredEnergyWh"),
909
                        charge: doubleValue(obj, key: "measuredChargeAh")
910
                    )
911
                }
912
                .sorted { $0.timestamp < $1.timestamp }
913

            
914
            let baselineSample = allSamples.last { $0.timestamp <= effectiveStart }
915
            let endSample = allSamples.last { $0.timestamp <= effectiveEnd }
916
            let baselineEnergy = baselineSample?.energy ?? 0
917
            let baselineCharge = baselineSample?.charge ?? 0
918
            let committedEnergy = endSample.map { max($0.energy - baselineEnergy, 0) }
919
                ?? doubleValue(session, key: "measuredEnergyWh")
920
            let committedCharge = endSample.map { max($0.charge - baselineCharge, 0) }
921
                ?? doubleValue(session, key: "measuredChargeAh")
922

            
923
            var retainedSamples: [(current: Double, power: Double, voltage: Double?)] = []
924
            for sample in sampleObjects {
925
                guard let timestamp = dateValue(sample, key: "timestamp") else {
926
                    context.delete(sample)
927
                    continue
928
                }
929

            
930
                guard timestamp >= effectiveStart && timestamp <= effectiveEnd else {
931
                    context.delete(sample)
932
                    continue
933
                }
934

            
935
                let rebasedEnergy = max(doubleValue(sample, key: "measuredEnergyWh") - baselineEnergy, 0)
936
                let rebasedCharge = max(doubleValue(sample, key: "measuredChargeAh") - baselineCharge, 0)
937
                let elapsed = max(timestamp.timeIntervalSince(effectiveStart), 0)
938
                let rebasedBucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
939

            
940
                sample.setValue("\(sessionID.uuidString)-\(rebasedBucketIndex)", forKey: "id")
941
                sample.setValue(rebasedBucketIndex, forKey: "bucketIndex")
942
                sample.setValue(rebasedEnergy, forKey: "measuredEnergyWh")
943
                sample.setValue(rebasedCharge, forKey: "measuredChargeAh")
944
                sample.setValue(Date(), forKey: "updatedAt")
945

            
946
                retainedSamples.append(
947
                    (
948
                        current: doubleValue(sample, key: "averageCurrentAmps"),
949
                        power: doubleValue(sample, key: "averagePowerWatts"),
950
                        voltage: optionalDoubleValue(sample, key: "averageVoltageVolts")
951
                    )
952
                )
953
            }
954

            
955
            for checkpoint in fetchCheckpointObjects(forSessionID: sessionID.uuidString) {
956
                guard let timestamp = dateValue(checkpoint, key: "timestamp") else {
957
                    context.delete(checkpoint)
958
                    continue
959
                }
960

            
961
                guard timestamp >= effectiveStart && timestamp <= effectiveEnd else {
962
                    context.delete(checkpoint)
963
                    continue
964
                }
965

            
966
                let checkpointSample = allSamples.last { $0.timestamp <= timestamp }
967
                checkpoint.setValue(
968
                    max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0),
969
                    forKey: "measuredEnergyWh"
970
                )
971
                checkpoint.setValue(
972
                    max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0),
973
                    forKey: "measuredChargeAh"
974
                )
975
            }
976

            
977
            if !retainedSamples.isEmpty {
978
                let positiveCurrents = retainedSamples.map { $0.current }.filter { $0 > 0 }
979
                session.setValue(positiveCurrents.min(), forKey: "minimumObservedCurrentAmps")
980
                session.setValue(retainedSamples.map { $0.current }.max(), forKey: "maximumObservedCurrentAmps")
981
                session.setValue(retainedSamples.map { $0.power }.max(), forKey: "maximumObservedPowerWatts")
982
                session.setValue(retainedSamples.compactMap { $0.voltage }.max(), forKey: "maximumObservedVoltageVolts")
983
                session.setValue(
984
                    retainedSamples.contains { $0.power > 0.05 || $0.current > 0.01 },
985
                    forKey: "hasObservedChargeFlow"
986
                )
987
            } else {
988
                session.setValue(nil, forKey: "minimumObservedCurrentAmps")
989
                session.setValue(nil, forKey: "maximumObservedCurrentAmps")
990
                session.setValue(nil, forKey: "maximumObservedPowerWatts")
991
                session.setValue(nil, forKey: "maximumObservedVoltageVolts")
992
                session.setValue(committedEnergy > 0 || committedCharge > 0, forKey: "hasObservedChargeFlow")
993
            }
994

            
995
            session.setValue(effectiveStart, forKey: "startedAt")
996
            session.setValue(effectiveEnd, forKey: "lastObservedAt")
997
            if dateValue(session, key: "endedAt") != nil {
998
                session.setValue(effectiveEnd, forKey: "endedAt")
999
            }
1000
            session.setValue(committedEnergy, forKey: "measuredEnergyWh")
1001
            session.setValue(committedCharge, forKey: "measuredChargeAh")
1002
            session.setValue(nil, forKey: "trimStart")
1003
            session.setValue(nil, forKey: "trimEnd")
1004
            session.setValue(Date(), forKey: "updatedAt")
1005

            
1006
            refreshCheckpointDerivedValues(for: session)
1007

            
1008
            if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1009
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1010
            }
1011

            
1012
            didSave = saveContext()
1013
        }
1014
        return didSave
1015
    }
1016

            
Bogdan Timofte authored a month ago
1017
    @discardableResult
1018
    func deleteChargeSession(id sessionID: UUID) -> Bool {
1019
        var didSave = false
1020
        context.performAndWait {
1021
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
1022
                return
1023
            }
1024

            
1025
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
1026

            
1027
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
1028
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
1029
            context.delete(session)
1030

            
1031
            guard saveContext() else {
1032
                return
1033
            }
1034

            
1035
            if let chargedDeviceID {
1036
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1037
                didSave = saveContext()
1038
            } else {
1039
                didSave = true
1040
            }
1041
        }
1042
        return didSave
1043
    }
1044

            
1045
    @discardableResult
1046
    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
1047
        var didSave = false
1048

            
1049
        context.performAndWait {
1050
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
1051
                return
1052
            }
1053

            
1054
            let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
1055
            let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
1056
            let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
1057

            
1058
            var impactedChargedDeviceIDs = Set<String>()
1059

            
1060
            for session in deviceSessions {
1061
                if let impactedID = stringValue(session, key: "chargedDeviceID") {
1062
                    impactedChargedDeviceIDs.insert(impactedID)
1063
                }
1064
                if let impactedChargerID = stringValue(session, key: "chargerID") {
1065
                    impactedChargedDeviceIDs.insert(impactedChargerID)
1066
                }
1067
                if let sessionID = stringValue(session, key: "id") {
1068
                    fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete)
1069
                    fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete)
1070
                }
1071
                context.delete(session)
1072
            }
1073

            
1074
            if deviceClass == .charger {
1075
                for session in linkedWirelessSessions {
1076
                    guard stringValue(session, key: "chargedDeviceID") != chargedDeviceID.uuidString else {
1077
                        continue
1078
                    }
1079
                    if let impactedID = stringValue(session, key: "chargedDeviceID") {
1080
                        impactedChargedDeviceIDs.insert(impactedID)
1081
                    }
1082
                    session.setValue(nil, forKey: "chargerID")
1083
                    session.setValue(Date(), forKey: "updatedAt")
1084
                }
1085
            }
1086

            
1087
            context.delete(chargedDevice)
1088

            
1089
            guard saveContext() else {
1090
                return
1091
            }
1092

            
1093
            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
1094
            for impactedID in impactedChargedDeviceIDs {
1095
                refreshDerivedMetrics(forChargedDeviceID: impactedID)
1096
            }
1097
            didSave = saveContext()
1098
        }
1099

            
1100
        return didSave
1101
    }
1102

            
1103
    @discardableResult
1104
    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
1105
        var didSave = false
1106

            
1107
        context.performAndWait {
Bogdan Timofte authored a month ago
1108
            guard let session = fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress),
1109
                  let resolvedDevice = stringValue(session, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:)) else {
1110
                return
1111
            }
Bogdan Timofte authored a month ago
1112

            
Bogdan Timofte authored a month ago
1113
            if statusValue(session, key: "statusRawValue") == .paused {
Bogdan Timofte authored a month ago
1114
                if maybeCompleteOpenSession(session, observedAt: snapshot.observedAt) {
Bogdan Timofte authored a month ago
1115
                    didSave = true
1116
                }
Bogdan Timofte authored a month ago
1117
                return
1118
            }
1119

            
Bogdan Timofte authored a month ago
1120
            let chargingTransportMode = self.chargingTransportMode(for: session)
1121
            let chargingStateMode = self.chargingStateMode(for: session)
Bogdan Timofte authored a month ago
1122
            let charger = chargingTransportMode == .wireless
Bogdan Timofte authored a month ago
1123
                ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
1124
                : nil
1125
            guard chargingTransportMode == .wired || charger != nil else {
1126
                return
1127
            }
1128
            let stopThreshold = resolvedStopThreshold(
1129
                for: resolvedDevice,
1130
                chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
1131
                chargingStateMode: chargingStateMode,
1132
                charger: charger,
1133
                fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1134
            )
1135

            
Bogdan Timofte authored a month ago
1136
            let sessionSnapshot = snapshotClampedToMaximumDuration(snapshot, for: session)
1137
            update(session: session, with: sessionSnapshot, stopThreshold: stopThreshold, charger: charger)
1138
            let aggregatedSample = updateAggregatedSample(session: session, with: sessionSnapshot)
1139
            if let completionDate = automaticCompletionDate(for: session, referenceDate: snapshot.observedAt),
1140
               statusValue(session, key: "statusRawValue")?.isOpen == true {
1141
                finishSession(
1142
                    session,
1143
                    observedAt: completionDate,
1144
                    finalBatteryPercent: nil,
1145
                    status: .completed
1146
                )
1147
            }
Bogdan Timofte authored a month ago
1148

            
Bogdan Timofte authored a month ago
1149
            let saveReason = saveReason(for: session, observedAt: sessionSnapshot.observedAt)
Bogdan Timofte authored a month ago
1150
            let shouldPersistAggregatedCurve = aggregatedSample.map {
Bogdan Timofte authored a month ago
1151
                shouldPersistAggregatedSample($0, observedAt: sessionSnapshot.observedAt)
Bogdan Timofte authored a month ago
1152
            } ?? false
1153

            
1154
            guard saveReason != .none || shouldPersistAggregatedCurve else {
Bogdan Timofte authored a month ago
1155
                return
1156
            }
1157

            
Bogdan Timofte authored a month ago
1158
            session.setValue(sessionSnapshot.observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1159

            
1160
            if saveContext() {
1161
                if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
1162
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
1163
                    didSave = saveContext()
1164
                } else {
1165
                    didSave = true
1166
                }
1167
            }
1168
        }
1169

            
1170
        return didSave
1171
    }
1172

            
1173
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
1174
        var summaries: [ChargedDeviceSummary] = []
1175

            
1176
        context.performAndWait {
1177
            let devices = fetchObjects(entityName: EntityName.chargedDevice)
1178
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
1179
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
1180

            
1181
            let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
1182
            let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
1183
            let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
Bogdan Timofte authored a month ago
1184
            let sampleBackedSessionIDs = sampleBackedSessionIDs(
1185
                devices: devices,
1186
                sessionsByDeviceID: sessionsByDeviceID,
1187
                sessionsByChargerID: sessionsByChargerID
1188
            )
1189
            let samplesBySessionID = Dictionary(
1190
                grouping: fetchSessionSampleObjects(forSessionIDs: Array(sampleBackedSessionIDs))
1191
            ) { stringValue($0, key: "sessionID") ?? "" }
Bogdan Timofte authored a month ago
1192

            
1193
            summaries = devices.compactMap { device in
1194
                guard
1195
                    let id = uuidValue(device, key: "id"),
1196
                    let name = stringValue(device, key: "name"),
1197
                    let qrIdentifier = stringValue(device, key: "qrIdentifier"),
1198
                    let rawClass = stringValue(device, key: "deviceClassRawValue"),
1199
                    let deviceClass = ChargedDeviceClass(rawValue: rawClass)
1200
                else {
1201
                    return nil
1202
                }
1203

            
Bogdan Timofte authored a month ago
1204
                let chargingStateAvailability = chargingStateAvailability(for: device)
1205
                let supportsWiredCharging = supportsWiredCharging(for: device)
1206
                let supportsWirelessCharging = supportsWirelessCharging(for: device)
1207
                let templateDefinition = templateDefinition(for: device)
1208

            
Bogdan Timofte authored a month ago
1209
                let sessionObjects = relevantSessionObjects(
1210
                    for: id.uuidString,
1211
                    deviceClass: deviceClass,
1212
                    sessionsByDeviceID: sessionsByDeviceID,
1213
                    sessionsByChargerID: sessionsByChargerID
1214
                )
1215
                let sessionSummaries = sessionObjects
1216
                    .compactMap { session in
1217
                        makeSessionSummary(
1218
                            from: session,
1219
                            checkpoints: checkpointsBySessionID[stringValue(session, key: "id") ?? ""] ?? [],
1220
                            samples: samplesBySessionID[stringValue(session, key: "id") ?? ""] ?? []
1221
                        )
1222
                    }
1223
                    .sorted { lhs, rhs in
Bogdan Timofte authored a month ago
1224
                        if lhs.status.isOpen && !rhs.status.isOpen {
Bogdan Timofte authored a month ago
1225
                            return true
1226
                        }
Bogdan Timofte authored a month ago
1227
                        if !lhs.status.isOpen && rhs.status.isOpen {
1228
                            return false
1229
                        }
1230
                        if lhs.status == .active && rhs.status == .paused {
1231
                            return true
1232
                        }
1233
                        if lhs.status == .paused && rhs.status == .active {
Bogdan Timofte authored a month ago
1234
                            return false
1235
                        }
1236
                        return lhs.startedAt > rhs.startedAt
1237
                    }
1238

            
1239
                return ChargedDeviceSummary(
1240
                    id: id,
1241
                    qrIdentifier: qrIdentifier,
1242
                    name: name,
1243
                    deviceClass: deviceClass,
Bogdan Timofte authored a month ago
1244
                    deviceTemplateID: stringValue(device, key: "deviceTemplateID"),
1245
                    templateDefinition: templateDefinition,
1246
                    supportsChargingWhileOff: chargingStateAvailability.supportsChargingWhileOff,
1247
                    chargingStateAvailability: chargingStateAvailability,
1248
                    supportsWiredCharging: supportsWiredCharging,
1249
                    supportsWirelessCharging: supportsWirelessCharging,
Bogdan Timofte authored a month ago
1250
                    chargerType: chargerType(for: device),
Bogdan Timofte authored a month ago
1251
                    wirelessChargingProfile: wirelessChargingProfile(for: device),
Bogdan Timofte authored a month ago
1252
                    configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"),
1253
                    learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"),
Bogdan Timofte authored a month ago
1254
                    wirelessChargerEfficiencyFactor: optionalDoubleValue(device, key: "wirelessChargerEfficiencyFactor"),
1255
                    wiredChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wiredChargeCompletionCurrentAmps"),
1256
                    wirelessChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wirelessChargeCompletionCurrentAmps"),
1257
                    chargerObservedVoltageSelections: decodedObservedVoltageSelections(from: device),
1258
                    chargerIdleCurrentAmps: optionalDoubleValue(device, key: "chargerIdleCurrentAmps"),
1259
                    chargerEfficiencyFactor: optionalDoubleValue(device, key: "chargerEfficiencyFactor"),
1260
                    chargerMaximumPowerWatts: optionalDoubleValue(device, key: "chargerMaximumPowerWatts"),
1261
                    notes: stringValue(device, key: "notes"),
1262
                    minimumCurrentAmps: optionalDoubleValue(device, key: "minimumCurrentAmps"),
1263
                    estimatedBatteryCapacityWh: optionalDoubleValue(device, key: "estimatedBatteryCapacityWh"),
1264
                    wiredMinimumCurrentAmps: optionalDoubleValue(device, key: "wiredMinimumCurrentAmps"),
1265
                    wirelessMinimumCurrentAmps: optionalDoubleValue(device, key: "wirelessMinimumCurrentAmps"),
1266
                    wiredEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wiredEstimatedBatteryCapacityWh"),
1267
                    wirelessEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wirelessEstimatedBatteryCapacityWh"),
1268
                    createdAt: dateValue(device, key: "createdAt") ?? .distantPast,
1269
                    updatedAt: dateValue(device, key: "updatedAt") ?? .distantPast,
1270
                    sessions: sessionSummaries,
1271
                    capacityHistory: buildCapacityHistory(from: sessionSummaries),
Bogdan Timofte authored a month ago
1272
                    typicalCurve: buildTypicalCurve(from: sessionSummaries),
1273
                    standbyPowerMeasurements: []
Bogdan Timofte authored a month ago
1274
                )
1275
            }
1276
            .sorted { lhs, rhs in
1277
                if lhs.activeSession != nil && rhs.activeSession == nil {
1278
                    return true
1279
                }
1280
                if lhs.activeSession == nil && rhs.activeSession != nil {
1281
                    return false
1282
                }
1283
                if lhs.updatedAt != rhs.updatedAt {
1284
                    return lhs.updatedAt > rhs.updatedAt
1285
                }
1286
                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
1287
            }
1288
        }
1289

            
1290
        return summaries
1291
    }
1292

            
1293
    func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
1294
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1295
        guard !normalizedMAC.isEmpty else { return nil }
1296

            
Bogdan Timofte authored a month ago
1297
        var summary: ChargeSessionSummary?
1298

            
1299
        context.performAndWait {
Bogdan Timofte authored a month ago
1300
            guard let session = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
Bogdan Timofte authored a month ago
1301
                  let sessionID = stringValue(session, key: "id") else {
1302
                return
1303
            }
1304

            
1305
            summary = makeSessionSummary(
1306
                from: session,
1307
                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
1308
                samples: fetchSessionSampleObjects(forSessionID: sessionID)
1309
            )
1310
        }
1311

            
1312
        return summary
Bogdan Timofte authored a month ago
1313
    }
1314

            
1315
    private func createSessionObject(
1316
        for chargedDevice: NSManagedObject,
1317
        charger: NSManagedObject?,
1318
        snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1319
        stopThreshold: Double?,
1320
        chargingTransportMode: ChargingTransportMode,
1321
        chargingStateMode: ChargingStateMode,
1322
        autoStopEnabled: Bool
Bogdan Timofte authored a month ago
1323
    ) -> NSManagedObject? {
1324
        guard
1325
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
1326
            let chargedDeviceID = stringValue(chargedDevice, key: "id")
1327
        else {
1328
            return nil
1329
        }
1330

            
1331
        let session = NSManagedObject(entity: entity, insertInto: context)
1332
        let now = snapshot.observedAt
1333
        session.setValue(UUID().uuidString, forKey: "id")
1334
        session.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1335
        session.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
1336
        session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress")
1337
        session.setValue(snapshot.meterName, forKey: "meterName")
1338
        session.setValue(snapshot.meterModel, forKey: "meterModel")
1339
        session.setValue(now, forKey: "startedAt")
1340
        session.setValue(now, forKey: "lastObservedAt")
1341
        session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
Bogdan Timofte authored a month ago
1342
        let usesMeterCounters = snapshot.meterEnergyCounterWh != nil || snapshot.meterChargeCounterAh != nil
1343
        session.setValue(
1344
            (usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue,
1345
            forKey: "sourceModeRawValue"
1346
        )
Bogdan Timofte authored a month ago
1347
        session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
1348
        session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue")
1349
        session.setValue(autoStopEnabled, forKey: "autoStopEnabled")
1350
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1351
        session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
1352
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1353
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1354
        session.setValue(
1355
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1356
            forKey: "lastObservedVoltageVolts"
1357
        )
Bogdan Timofte authored a month ago
1358
        session.setValue(
1359
            hasObservedChargeFlow(
1360
                currentAmps: snapshot.currentAmps,
1361
                chargingTransportMode: chargingTransportMode,
1362
                charger: charger,
1363
                stopThreshold: stopThreshold
1364
            ),
1365
            forKey: "hasObservedChargeFlow"
1366
        )
Bogdan Timofte authored a month ago
1367
        session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
1368
        session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
1369
        session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
1370
        session.setValue(
1371
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1372
            forKey: "maximumObservedVoltageVolts"
1373
        )
1374
        session.setValue(boolValue(chargedDevice, key: "supportsChargingWhileOff"), forKey: "supportsChargingWhileOff")
1375
        if let selectedDataGroup = snapshot.selectedDataGroup {
1376
            session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup")
1377
        }
1378
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1379
            session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1380
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1381
        }
1382
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1383
            session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1384
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1385
        }
Bogdan Timofte authored a month ago
1386
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1387
            setValue(meterRecordingDurationSeconds, on: session, key: "meterDurationBaselineSeconds")
1388
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1389
        }
Bogdan Timofte authored a month ago
1390
        session.setValue(now, forKey: "createdAt")
1391
        session.setValue(now, forKey: "updatedAt")
1392

            
1393
        return session
1394
    }
1395

            
1396
    private func update(
1397
        session: NSManagedObject,
1398
        with snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1399
        stopThreshold: Double?,
1400
        charger: NSManagedObject?
Bogdan Timofte authored a month ago
1401
    ) {
1402
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1403
        let lastObservedAt = dateValue(session, key: "lastObservedAt")
1404
        let previousPower = doubleValue(session, key: "lastObservedPowerWatts")
1405
        let previousCurrent = doubleValue(session, key: "lastObservedCurrentAmps")
1406
        var measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1407
        var measuredChargeAh = doubleValue(session, key: "measuredChargeAh")
1408
        var sourceMode = ChargeSessionSourceMode(rawValue: stringValue(session, key: "sourceModeRawValue") ?? "") ?? .live
1409
        var usedOfflineMeterCounters = boolValue(session, key: "usedOfflineMeterCounters")
1410

            
1411
        if let lastObservedAt {
1412
            let deltaSeconds = max(snapshot.observedAt.timeIntervalSince(lastObservedAt), 0)
1413
            if deltaSeconds > 0, deltaSeconds <= maximumLiveIntegrationGap {
1414
                measuredEnergyWh += max(previousPower, 0) * deltaSeconds / 3600
1415
                measuredChargeAh += max(previousCurrent, 0) * deltaSeconds / 3600
1416
                if sourceMode == .offline {
1417
                    sourceMode = .blended
1418
                }
1419
            }
1420
        }
1421

            
1422
        if let counterGroup = snapshot.selectedDataGroup,
1423
           let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
1424
           UInt8(storedGroup) != counterGroup {
1425
            session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
1426
            session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1427
            session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1428
        }
1429

            
1430
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1431
            let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
1432
            let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
1433
            if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
1434
                session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
1435
            }
1436

            
1437
            if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
1438
                let offlineEnergy = meterEnergyCounterWh - baselineEnergy
Bogdan Timofte authored a month ago
1439
                measuredEnergyWh = max(offlineEnergy, 0)
Bogdan Timofte authored a month ago
1440
                usedOfflineMeterCounters = true
Bogdan Timofte authored a month ago
1441
                sourceMode = .offline
Bogdan Timofte authored a month ago
1442
            } else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
1443
                let delta = meterEnergyCounterWh - lastEnergy
1444
                if delta > 0 {
1445
                    measuredEnergyWh += delta
1446
                    usedOfflineMeterCounters = true
1447
                    sourceMode = .blended
1448
                }
1449
            }
1450
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1451
        }
1452

            
1453
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1454
            let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
1455
            let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
1456
            if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
1457
                session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
1458
            }
1459

            
1460
            if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
1461
                let offlineCharge = meterChargeCounterAh - baselineCharge
Bogdan Timofte authored a month ago
1462
                measuredChargeAh = max(offlineCharge, 0)
Bogdan Timofte authored a month ago
1463
                usedOfflineMeterCounters = true
1464
            } else if let lastCharge, meterChargeCounterAh > lastCharge {
1465
                let delta = meterChargeCounterAh - lastCharge
1466
                if delta > 0 {
1467
                    measuredChargeAh += delta
1468
                    usedOfflineMeterCounters = true
1469
                }
1470
            }
1471
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1472
        }
1473

            
Bogdan Timofte authored a month ago
1474
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1475
            let baselineDuration = optionalDoubleValue(session, key: "meterDurationBaselineSeconds") ?? meterRecordingDurationSeconds
1476
            if optionalDoubleValue(session, key: "meterDurationBaselineSeconds") == nil {
1477
                setValue(baselineDuration, on: session, key: "meterDurationBaselineSeconds")
1478
            }
1479
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1480
        }
1481

            
Bogdan Timofte authored a month ago
1482
        let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps")
1483
        let updatedMinimum: Double
1484
        if snapshot.currentAmps > 0 {
1485
            updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps)
1486
        } else {
1487
            updatedMinimum = existingMinimum ?? 0
1488
        }
1489

            
Bogdan Timofte authored a month ago
1490
        let effectiveCurrent = effectiveCurrentAmps(
1491
            fromMeasuredCurrent: snapshot.currentAmps,
1492
            chargingTransportMode: sessionChargingTransportMode,
1493
            charger: charger
1494
        )
1495
        let observedChargeFlow = boolValue(session, key: "hasObservedChargeFlow")
1496
            || hasObservedChargeFlow(
1497
                currentAmps: snapshot.currentAmps,
1498
                chargingTransportMode: sessionChargingTransportMode,
1499
                charger: charger,
1500
                stopThreshold: stopThreshold
1501
            )
1502

            
Bogdan Timofte authored a month ago
1503
        session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh")
1504
        session.setValue(measuredChargeAh, forKey: "measuredChargeAh")
1505
        session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps")
Bogdan Timofte authored a month ago
1506
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1507
        session.setValue(snapshot.observedAt, forKey: "lastObservedAt")
1508
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1509
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1510
        session.setValue(
1511
            sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1512
            forKey: "lastObservedVoltageVolts"
1513
        )
Bogdan Timofte authored a month ago
1514
        session.setValue(observedChargeFlow, forKey: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
1515
        session.setValue(
1516
            max(optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? snapshot.currentAmps, snapshot.currentAmps),
1517
            forKey: "maximumObservedCurrentAmps"
1518
        )
1519
        session.setValue(
1520
            max(optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? snapshot.powerWatts, snapshot.powerWatts),
1521
            forKey: "maximumObservedPowerWatts"
1522
        )
1523
        session.setValue(
1524
            sessionChargingTransportMode == .wired
1525
                ? max(optionalDoubleValue(session, key: "maximumObservedVoltageVolts") ?? snapshot.voltageVolts, snapshot.voltageVolts)
1526
                : nil,
1527
            forKey: "maximumObservedVoltageVolts"
1528
        )
1529
        session.setValue(usedOfflineMeterCounters, forKey: "usedOfflineMeterCounters")
1530
        session.setValue(sourceMode.rawValue, forKey: "sourceModeRawValue")
1531
        maybeTriggerTargetBatteryAlert(for: session, observedAt: snapshot.observedAt)
1532

            
Bogdan Timofte authored a month ago
1533
        guard boolValue(session, key: "autoStopEnabled"), let stopThreshold, stopThreshold > 0, observedChargeFlow else {
1534
            session.setValue(nil, forKey: "belowThresholdSince")
1535
            clearCompletionConfirmationState(for: session)
1536
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1537
            return
1538
        }
1539

            
1540
        if effectiveCurrent <= stopThreshold {
Bogdan Timofte authored a month ago
1541
            let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt
1542
            session.setValue(belowThresholdSince, forKey: "belowThresholdSince")
1543
            if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
1544
                if boolValue(session, key: "requiresCompletionConfirmation") {
1545
                    // Leave the session active until the user explicitly confirms or charging resumes.
1546
                    return
1547
                }
1548

            
1549
                if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
1550
                    requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt)
1551
                } else {
Bogdan Timofte authored a month ago
1552
                    finishSession(
1553
                        session,
1554
                        observedAt: snapshot.observedAt,
1555
                        finalBatteryPercent: nil,
1556
                        status: .completed
1557
                    )
Bogdan Timofte authored a month ago
1558
                }
1559
            }
1560
        } else {
1561
            session.setValue(nil, forKey: "belowThresholdSince")
1562
            clearCompletionConfirmationState(for: session)
1563
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1564
        }
1565
    }
1566

            
1567
    private func updateAggregatedSample(
1568
        session: NSManagedObject,
1569
        with snapshot: ChargingMonitorSnapshot
Bogdan Timofte authored a month ago
1570
    ) -> NSManagedObject? {
Bogdan Timofte authored a month ago
1571
        guard
1572
            let sessionID = stringValue(session, key: "id"),
1573
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1574
            let startedAt = dateValue(session, key: "startedAt"),
1575
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context)
1576
        else {
Bogdan Timofte authored a month ago
1577
            return nil
Bogdan Timofte authored a month ago
1578
        }
1579

            
1580
        let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0)
1581
        let bucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
1582
        let bucketStart = startedAt.addingTimeInterval(Double(bucketIndex) * Self.aggregatedSampleBucketDuration)
1583
        let bucketIdentifier = "\(sessionID)-\(bucketIndex)"
1584
        let sample = fetchSessionSampleObject(sessionID: sessionID, bucketIndex: bucketIndex)
1585
            ?? NSManagedObject(entity: entity, insertInto: context)
1586
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1587
        let sampleVoltage = sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil
1588

            
1589
        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
1590
        let updatedCount = existingCount + 1
1591

            
1592
        sample.setValue(bucketIdentifier, forKey: "id")
1593
        sample.setValue(sessionID, forKey: "sessionID")
1594
        sample.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1595
        sample.setValue(bucketIndex, forKey: "bucketIndex")
1596
        sample.setValue(max(snapshot.observedAt, bucketStart), forKey: "timestamp")
1597
        sample.setValue(
1598
            runningAverage(
1599
                currentAverage: optionalDoubleValue(sample, key: "averageCurrentAmps") ?? snapshot.currentAmps,
1600
                currentCount: Int(existingCount),
1601
                newValue: snapshot.currentAmps
1602
            ),
1603
            forKey: "averageCurrentAmps"
1604
        )
1605
        sample.setValue(
1606
            sampleVoltage.flatMap { voltage in
1607
                runningAverage(
1608
                    currentAverage: optionalDoubleValue(sample, key: "averageVoltageVolts") ?? voltage,
1609
                    currentCount: Int(existingCount),
1610
                    newValue: voltage
1611
                )
1612
            },
1613
            forKey: "averageVoltageVolts"
1614
        )
1615
        sample.setValue(
1616
            runningAverage(
1617
                currentAverage: optionalDoubleValue(sample, key: "averagePowerWatts") ?? snapshot.powerWatts,
1618
                currentCount: Int(existingCount),
1619
                newValue: snapshot.powerWatts
1620
            ),
1621
            forKey: "averagePowerWatts"
1622
        )
1623
        sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh")
1624
        sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
Bogdan Timofte authored a month ago
1625
        setValue(predictedBatteryPercent(for: session), on: sample, key: "estimatedBatteryPercent")
Bogdan Timofte authored a month ago
1626
        sample.setValue(Int16(updatedCount), forKey: "sampleCount")
1627
        sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
1628
        sample.setValue(snapshot.observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1629
        return sample
Bogdan Timofte authored a month ago
1630
    }
1631

            
Bogdan Timofte authored a month ago
1632
    private func maybeTriggerTargetBatteryAlert(
1633
        for session: NSManagedObject,
1634
        observedAt: Date,
1635
        completionFallbackPercent: Double? = nil
1636
    ) {
Bogdan Timofte authored a month ago
1637
        guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
1638
            return
1639
        }
1640

            
1641
        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
1642
            return
1643
        }
1644

            
1645
        let predictedBatteryPercent = predictedBatteryPercent(for: session)
1646
            ?? optionalDoubleValue(session, key: "endBatteryPercent")
Bogdan Timofte authored a month ago
1647
            ?? completionFallbackPercent
Bogdan Timofte authored a month ago
1648

            
1649
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
1650
            return
1651
        }
1652

            
1653
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
1654
    }
1655

            
1656
    private func shouldRequireCompletionConfirmation(
1657
        for session: NSManagedObject,
1658
        observedAt: Date
1659
    ) -> Bool {
1660
        if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"),
1661
           cooldownUntil > observedAt {
1662
            return false
1663
        }
1664

            
1665
        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
1666
            return false
1667
        }
1668

            
1669
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
1670
            ?? defaultCompletionPercentThreshold
1671

            
1672
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1673
    }
1674

            
1675
    private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
1676
        guard !boolValue(session, key: "requiresCompletionConfirmation") else {
1677
            return
1678
        }
1679

            
1680
        session.setValue(true, forKey: "requiresCompletionConfirmation")
1681
        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
1682
        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
1683
    }
1684

            
1685
    private func clearCompletionConfirmationState(for session: NSManagedObject) {
1686
        session.setValue(false, forKey: "requiresCompletionConfirmation")
1687
        session.setValue(nil, forKey: "completionConfirmationRequestedAt")
1688
        session.setValue(nil, forKey: "completionContradictionPercent")
1689
    }
1690

            
Bogdan Timofte authored a month ago
1691
    private func snapshotDateForManualStop(_ session: NSManagedObject) -> Date {
1692
        if statusValue(session, key: "statusRawValue") == .paused {
1693
            return dateValue(session, key: "pausedAt")
1694
                ?? dateValue(session, key: "lastObservedAt")
1695
                ?? Date()
1696
        }
1697
        return dateValue(session, key: "lastObservedAt") ?? Date()
1698
    }
1699

            
Bogdan Timofte authored a month ago
1700
    private func snapshotClampedToMaximumDuration(
1701
        _ snapshot: ChargingMonitorSnapshot,
1702
        for session: NSManagedObject
1703
    ) -> ChargingMonitorSnapshot {
1704
        guard let maximumEndDate = maximumEndDate(for: session),
1705
              snapshot.observedAt > maximumEndDate else {
1706
            return snapshot
1707
        }
1708

            
1709
        return ChargingMonitorSnapshot(
1710
            meterMACAddress: snapshot.meterMACAddress,
1711
            meterName: snapshot.meterName,
1712
            meterModel: snapshot.meterModel,
1713
            observedAt: maximumEndDate,
1714
            voltageVolts: snapshot.voltageVolts,
1715
            currentAmps: snapshot.currentAmps,
1716
            powerWatts: snapshot.powerWatts,
1717
            selectedDataGroup: snapshot.selectedDataGroup,
1718
            meterChargeCounterAh: snapshot.meterChargeCounterAh,
1719
            meterEnergyCounterWh: snapshot.meterEnergyCounterWh,
1720
            meterRecordingDurationSeconds: snapshot.meterRecordingDurationSeconds,
1721
            fallbackStopThresholdAmps: snapshot.fallbackStopThresholdAmps
1722
        )
1723
    }
1724

            
1725
    private func automaticCompletionDate(
1726
        for session: NSManagedObject,
1727
        referenceDate: Date
1728
    ) -> Date? {
1729
        guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
1730
            return nil
Bogdan Timofte authored a month ago
1731
        }
1732

            
Bogdan Timofte authored a month ago
1733
        var completionDates: [Date] = []
1734

            
1735
        if let maximumEndDate = maximumEndDate(for: session) {
1736
            completionDates.append(maximumEndDate)
1737
        }
1738

            
1739
        if statusValue(session, key: "statusRawValue") == .paused,
1740
           let pausedAt = dateValue(session, key: "pausedAt") {
1741
            completionDates.append(pausedAt.addingTimeInterval(pausedSessionTimeout))
1742
        }
1743

            
1744
        guard let completionDate = completionDates.min(),
1745
              referenceDate >= completionDate else {
1746
            return nil
1747
        }
1748

            
1749
        return completionDate
1750
    }
1751

            
1752
    private func maximumEndDate(for session: NSManagedObject) -> Date? {
1753
        dateValue(session, key: "startedAt")?.addingTimeInterval(Self.maximumChargeSessionDuration)
1754
    }
1755

            
1756
    @discardableResult
1757
    private func maybeCompleteOpenSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
1758
        guard statusValue(session, key: "statusRawValue")?.isOpen == true,
1759
              let completionDate = automaticCompletionDate(for: session, referenceDate: observedAt) else {
Bogdan Timofte authored a month ago
1760
            return false
1761
        }
1762

            
1763
        finishSession(
1764
            session,
Bogdan Timofte authored a month ago
1765
            observedAt: completionDate,
Bogdan Timofte authored a month ago
1766
            finalBatteryPercent: nil,
1767
            status: .completed
1768
        )
1769

            
1770
        guard saveContext() else {
1771
            return false
1772
        }
1773

            
1774
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1775
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1776
            return saveContext()
1777
        }
1778

            
1779
        return true
1780
    }
1781

            
1782
    private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
1783
        let chargingTransportMode = chargingTransportMode(for: session)
1784
        let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps")
1785
            ?? doubleValue(session, key: "lastObservedCurrentAmps")
1786

            
1787
        guard measuredCurrent > 0 else {
1788
            return nil
1789
        }
1790

            
1791
        let charger = chargingTransportMode == .wireless
1792
            ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
1793
            : nil
1794

            
1795
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
1796
            return nil
1797
        }
1798

            
1799
        let effectiveCurrent = effectiveCurrentAmps(
1800
            fromMeasuredCurrent: measuredCurrent,
1801
            chargingTransportMode: chargingTransportMode,
1802
            charger: charger
1803
        )
1804
        guard effectiveCurrent > 0 else {
1805
            return nil
1806
        }
1807
        return effectiveCurrent
1808
    }
1809

            
1810
    private func finishSession(
1811
        _ session: NSManagedObject,
1812
        observedAt: Date,
1813
        finalBatteryPercent: Double?,
1814
        status: ChargeSessionStatus
1815
    ) {
1816
        if let finalBatteryPercent {
1817
            _ = insertBatteryCheckpoint(
1818
                percent: finalBatteryPercent,
Bogdan Timofte authored a month ago
1819
                flag: .final,
Bogdan Timofte authored a month ago
1820
                timestamp: observedAt,
1821
                to: session
1822
            )
1823
        }
1824

            
1825
        session.setValue(status.rawValue, forKey: "statusRawValue")
1826
        session.setValue(nil, forKey: "pausedAt")
1827
        session.setValue(nil, forKey: "belowThresholdSince")
1828
        session.setValue(observedAt, forKey: "endedAt")
1829
        session.setValue(observedAt, forKey: "lastObservedAt")
1830
        session.setValue(completionCurrentForSessionEnd(session), forKey: "completionCurrentAmps")
1831
        clearCompletionConfirmationState(for: session)
1832
        session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1833
        updateCapacityEstimate(for: session)
Bogdan Timofte authored a month ago
1834
        refreshEstimatedBatteryPercents(for: session)
Bogdan Timofte authored a month ago
1835
        session.setValue(observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1836

            
1837
        if status == .completed {
1838
            maybeTriggerTargetBatteryAlert(
1839
                for: session,
1840
                observedAt: observedAt,
1841
                completionFallbackPercent: defaultCompletionPercentThreshold
1842
            )
1843
        }
Bogdan Timofte authored a month ago
1844
    }
1845

            
Bogdan Timofte authored a month ago
1846
    private func predictedBatteryPercent(
1847
        for session: NSManagedObject,
1848
        effectiveEnergyWhOverride: Double? = nil,
1849
        referenceTimestamp: Date? = nil
1850
    ) -> Double? {
Bogdan Timofte authored a month ago
1851
        guard
1852
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
Bogdan Timofte authored a month ago
1853
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
Bogdan Timofte authored a month ago
1854
        else {
1855
            return nil
1856
        }
1857

            
Bogdan Timofte authored a month ago
1858
        let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(
1859
            for: session,
1860
            chargedDevice: chargedDevice
1861
        )
1862
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
1863
        let canUseCheckpointEnergyMap = deviceClass == .watch || deviceClass == .iphone
Bogdan Timofte authored a month ago
1864
        let measuredEnergyWh = effectiveEnergyWhOverride
1865
            ?? effectiveBatteryEnergyWh(
1866
                rawMeasuredEnergyWh: doubleValue(session, key: "measuredEnergyWh"),
1867
                for: session
1868
            )
Bogdan Timofte authored a month ago
1869
        let sessionID = stringValue(session, key: "id") ?? ""
1870

            
1871
        struct Anchor {
1872
            let percent: Double
1873
            let energyWh: Double
Bogdan Timofte authored a month ago
1874
            let timestamp: Date
1875
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1876
        }
1877

            
Bogdan Timofte authored a month ago
1878
        func anchorCapacityCandidates(from anchors: [Anchor]) -> [Double] {
1879
            var candidates: [Double] = []
1880

            
1881
            for lowerIndex in anchors.indices {
1882
                for upperIndex in anchors.indices where upperIndex > lowerIndex {
1883
                    let lower = anchors[lowerIndex]
1884
                    let upper = anchors[upperIndex]
1885
                    let percentDelta = upper.percent - lower.percent
1886
                    let energyDelta = upper.energyWh - lower.energyWh
1887

            
1888
                    guard percentDelta >= 3, energyDelta > 0.01 else {
1889
                        continue
1890
                    }
1891

            
1892
                    let capacityWh = energyDelta / (percentDelta / 100)
1893
                    guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
1894
                        continue
1895
                    }
1896

            
1897
                    candidates.append(capacityWh)
1898
                }
1899
            }
1900

            
1901
            return candidates
1902
        }
1903

            
1904
        func inferredCheckpointEnergyMapCapacityWh(from anchors: [Anchor]) -> Double? {
1905
            let candidates = anchorCapacityCandidates(from: anchors)
1906
            guard !candidates.isEmpty else {
1907
                return nil
1908
            }
1909

            
1910
            let sortedCandidates = candidates.sorted()
1911
            return sortedCandidates[sortedCandidates.count / 2]
1912
        }
1913

            
Bogdan Timofte authored a month ago
1914
        var anchors: [Anchor] = []
Bogdan Timofte authored a month ago
1915
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1916
           startBatteryPercent >= 0 {
1917
            anchors.append(
1918
                Anchor(
1919
                    percent: startBatteryPercent,
1920
                    energyWh: 0,
Bogdan Timofte authored a month ago
1921
                    timestamp: dateValue(session, key: "trimStart")
1922
                        ?? dateValue(session, key: "startedAt")
1923
                        ?? Date.distantPast,
Bogdan Timofte authored a month ago
1924
                    isCheckpoint: false
1925
                )
1926
            )
Bogdan Timofte authored a month ago
1927
        }
1928

            
1929
        let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
1930
            .compactMap(makeCheckpointSummary(from:))
1931
            .sorted { lhs, rhs in
1932
                if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1933
                    return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1934
                }
1935
                return lhs.timestamp < rhs.timestamp
1936
            }
Bogdan Timofte authored a month ago
1937
            .filter { $0.batteryPercent >= 0 }
1938
            .map {
1939
                Anchor(
1940
                    percent: $0.batteryPercent,
1941
                    energyWh: $0.measuredEnergyWh,
1942
                    timestamp: $0.timestamp,
1943
                    isCheckpoint: true
1944
                )
1945
            }
Bogdan Timofte authored a month ago
1946
        anchors.append(contentsOf: checkpointAnchors)
1947

            
Bogdan Timofte authored a month ago
1948
        let sortedAnchors = anchors.sorted { lhs, rhs in
1949
            if lhs.energyWh != rhs.energyWh {
1950
                return lhs.energyWh < rhs.energyWh
1951
            }
1952
            return lhs.timestamp < rhs.timestamp
1953
        }
1954

            
1955
        guard !sortedAnchors.isEmpty else {
Bogdan Timofte authored a month ago
1956
            return optionalDoubleValue(session, key: "endBatteryPercent")
1957
        }
1958

            
Bogdan Timofte authored a month ago
1959
        let inferredCapacityWh = estimatedCapacityWh
1960
            ?? (canUseCheckpointEnergyMap ? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors) : nil)
1961
        let lowerAnchor = sortedAnchors.last { $0.energyWh <= measuredEnergyWh + 0.05 }
1962
        let upperAnchor = sortedAnchors.first { $0.energyWh > measuredEnergyWh + 0.05 }
1963
        let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!
Bogdan Timofte authored a month ago
1964

            
1965
        if let lowerAnchor,
1966
           let upperAnchor,
1967
           upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
1968
            let interpolationProgress = min(
1969
                max(
1970
                    (measuredEnergyWh - lowerAnchor.energyWh) /
1971
                    (upperAnchor.energyWh - lowerAnchor.energyWh),
1972
                    0
1973
                ),
1974
                1
1975
            )
1976
            return min(
1977
                max(
1978
                    lowerAnchor.percent +
1979
                    (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
1980
                    0
1981
                ),
1982
                100
1983
            )
1984
        }
1985

            
Bogdan Timofte authored a month ago
1986
        guard let inferredCapacityWh, inferredCapacityWh > 0 else {
1987
            return nil
1988
        }
1989

            
Bogdan Timofte authored a month ago
1990
        return BatteryLevelPredictionTuning.predictedPercent(
1991
            anchorPercent: anchor.percent,
1992
            anchorEnergyWh: anchor.energyWh,
1993
            anchorTimestamp: anchor.timestamp,
1994
            anchorIsCheckpoint: anchor.isCheckpoint,
1995
            effectiveEnergyWh: measuredEnergyWh,
Bogdan Timofte authored a month ago
1996
            referenceTimestamp: referenceTimestamp
1997
                ?? dateValue(session, key: "lastObservedAt")
1998
                ?? anchor.timestamp,
Bogdan Timofte authored a month ago
1999
            estimatedCapacityWh: inferredCapacityWh
Bogdan Timofte authored a month ago
2000
        )
2001
    }
2002

            
Bogdan Timofte authored a month ago
2003
    private func effectiveBatteryEnergyWh(
2004
        rawMeasuredEnergyWh: Double,
2005
        for session: NSManagedObject
2006
    ) -> Double {
2007
        switch chargingTransportMode(for: session) {
2008
        case .wired:
2009
            return rawMeasuredEnergyWh
2010
        case .wireless:
2011
            if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 {
2012
                return rawMeasuredEnergyWh * factor
2013
            }
2014
            let sessionMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2015
            if let sessionEffectiveEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh"),
2016
               sessionMeasuredEnergyWh > 0 {
2017
                return rawMeasuredEnergyWh * (sessionEffectiveEnergyWh / sessionMeasuredEnergyWh)
2018
            }
2019
            return rawMeasuredEnergyWh
2020
        }
2021
    }
2022

            
2023
    private func refreshEstimatedBatteryPercents(for session: NSManagedObject) {
2024
        guard let sessionID = stringValue(session, key: "id") else {
2025
            return
2026
        }
2027

            
2028
        for sample in fetchSessionSampleObjects(forSessionID: sessionID) {
2029
            let effectiveEnergyWh = effectiveBatteryEnergyWh(
2030
                rawMeasuredEnergyWh: doubleValue(sample, key: "measuredEnergyWh"),
2031
                for: session
2032
            )
2033
            let percent = predictedBatteryPercent(
2034
                for: session,
2035
                effectiveEnergyWhOverride: effectiveEnergyWh,
2036
                referenceTimestamp: dateValue(sample, key: "timestamp")
2037
            )
2038
            setValue(percent, on: sample, key: "estimatedBatteryPercent")
2039
            setValue(Date(), on: sample, key: "updatedAt")
2040
        }
2041
    }
2042

            
Bogdan Timofte authored a month ago
2043
    private func resolvedEstimatedBatteryCapacityWh(
2044
        for session: NSManagedObject,
2045
        chargedDevice: NSManagedObject
2046
    ) -> Double? {
2047
        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
2048
           sessionCapacityEstimate > 0 {
2049
            return sessionCapacityEstimate
2050
        }
2051

            
2052
        switch chargingTransportMode(for: session) {
2053
        case .wired:
2054
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
2055
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2056
        case .wireless:
2057
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
2058
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2059
        }
2060
    }
2061

            
2062
    private func updateCapacityEstimate(for session: NSManagedObject) {
2063
        guard
2064
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2065
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
2066
        else {
2067
            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
2068
            session.setValue(nil, forKey: "capacityEstimateWh")
2069
            return
2070
        }
2071

            
2072
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2073
        let chargingMode = chargingTransportMode(for: session)
2074
        let wirelessResolution = chargingMode == .wireless
2075
            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
2076
            : nil
2077
        let effectiveBatteryEnergyWh = chargingMode == .wired
2078
            ? measuredEnergyWh
2079
            : wirelessResolution.map { measuredEnergyWh * $0.factor }
2080

            
2081
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
2082
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
2083
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
2084
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
2085

            
Bogdan Timofte authored a month ago
2086
        let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
2087

            
2088
        guard let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
Bogdan Timofte authored a month ago
2089
            session.setValue(nil, forKey: "capacityEstimateWh")
2090
            return
2091
        }
2092

            
Bogdan Timofte authored a month ago
2093
        struct CapacityAnchor {
2094
            let percent: Double
2095
            let energyWh: Double
2096
            let timestamp: Date
2097
        }
2098

            
2099
        var anchors: [CapacityAnchor] = []
2100

            
2101
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2102
           startBatteryPercent >= 0 {
2103
            anchors.append(
2104
                CapacityAnchor(
2105
                    percent: startBatteryPercent,
2106
                    energyWh: 0,
2107
                    timestamp: dateValue(session, key: "trimStart")
2108
                        ?? dateValue(session, key: "startedAt")
2109
                        ?? Date.distantPast
2110
                )
2111
            )
2112
        }
2113

            
2114
        if let sessionID = stringValue(session, key: "id") {
2115
            anchors.append(
2116
                contentsOf: fetchCheckpointObjects(forSessionID: sessionID).compactMap { checkpoint in
2117
                    guard
2118
                        let percent = optionalDoubleValue(checkpoint, key: "batteryPercent"),
2119
                        percent >= 0,
2120
                        let timestamp = dateValue(checkpoint, key: "timestamp")
2121
                    else {
2122
                        return nil
2123
                    }
2124

            
2125
                    return CapacityAnchor(
2126
                        percent: percent,
2127
                        energyWh: doubleValue(checkpoint, key: "measuredEnergyWh"),
2128
                        timestamp: timestamp
2129
                    )
2130
                }
2131
            )
2132
        }
2133

            
2134
        if let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"),
2135
           endBatteryPercent >= 0 {
2136
            anchors.append(
2137
                CapacityAnchor(
2138
                    percent: endBatteryPercent,
2139
                    energyWh: effectiveBatteryEnergyWh,
2140
                    timestamp: dateValue(session, key: "endedAt")
2141
                        ?? dateValue(session, key: "lastObservedAt")
2142
                        ?? Date.distantPast
2143
                )
2144
            )
2145
        }
2146

            
2147
        let sortedAnchors = anchors.sorted { lhs, rhs in
2148
            if lhs.energyWh != rhs.energyWh {
2149
                return lhs.energyWh < rhs.energyWh
2150
            }
2151
            return lhs.timestamp < rhs.timestamp
2152
        }
2153

            
2154
        guard let firstAnchor = sortedAnchors.first,
2155
              let lastAnchor = sortedAnchors.last else {
Bogdan Timofte authored a month ago
2156
            session.setValue(nil, forKey: "capacityEstimateWh")
2157
            return
2158
        }
2159

            
Bogdan Timofte authored a month ago
2160
        let percentDelta = lastAnchor.percent - firstAnchor.percent
2161
        let energyDelta = lastAnchor.energyWh - firstAnchor.energyWh
Bogdan Timofte authored a month ago
2162

            
Bogdan Timofte authored a month ago
2163
        guard percentDelta >= 20, energyDelta > 0 else {
Bogdan Timofte authored a month ago
2164
            session.setValue(nil, forKey: "capacityEstimateWh")
2165
            return
2166
        }
2167

            
Bogdan Timofte authored a month ago
2168
        if !supportsChargingWhileOff && lastAnchor.percent >= 99.5 {
Bogdan Timofte authored a month ago
2169
            session.setValue(nil, forKey: "capacityEstimateWh")
2170
            return
2171
        }
2172

            
Bogdan Timofte authored a month ago
2173
        let capacityEstimateWh = energyDelta / (percentDelta / 100)
Bogdan Timofte authored a month ago
2174
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
2175
    }
2176

            
2177
    @discardableResult
Bogdan Timofte authored a month ago
2178
    private func insertBatteryCheckpoint(
Bogdan Timofte authored a month ago
2179
        percent: Double,
Bogdan Timofte authored a month ago
2180
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
2181
        timestamp: Date = Date(),
Bogdan Timofte authored a month ago
2182
        measuredEnergyWhOverride: Double? = nil,
Bogdan Timofte authored a month ago
2183
        to session: NSManagedObject
Bogdan Timofte authored a month ago
2184
    ) -> String? {
Bogdan Timofte authored a month ago
2185
        guard
2186
            let sessionID = stringValue(session, key: "id"),
2187
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2188
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
2189
        else {
Bogdan Timofte authored a month ago
2190
            return nil
Bogdan Timofte authored a month ago
2191
        }
2192

            
2193
        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
Bogdan Timofte authored a month ago
2194
        let checkpointEnergyWh = measuredEnergyWhOverride
2195
            ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
Bogdan Timofte authored a month ago
2196
            ?? doubleValue(session, key: "measuredEnergyWh")
2197
        checkpoint.setValue(UUID().uuidString, forKey: "id")
2198
        checkpoint.setValue(sessionID, forKey: "sessionID")
2199
        checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
Bogdan Timofte authored a month ago
2200
        checkpoint.setValue(timestamp, forKey: "timestamp")
Bogdan Timofte authored a month ago
2201
        checkpoint.setValue(percent, forKey: "batteryPercent")
2202
        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
2203
        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
2204
        checkpoint.setValue(
2205
            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
2206
            forKey: "voltageVolts"
2207
        )
Bogdan Timofte authored a month ago
2208
        checkpoint.setValue(flag.rawValue, forKey: "label")
Bogdan Timofte authored a month ago
2209
        checkpoint.setValue(timestamp, forKey: "createdAt")
Bogdan Timofte authored a month ago
2210

            
Bogdan Timofte authored a month ago
2211
        let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
2212
        if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
Bogdan Timofte authored a month ago
2213
            session.setValue(percent, forKey: "startBatteryPercent")
2214
        }
Bogdan Timofte authored a month ago
2215
        if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
2216
            session.setValue(percent, forKey: "endBatteryPercent")
2217
        }
Bogdan Timofte authored a month ago
2218
        session.setValue(timestamp, forKey: "updatedAt")
Bogdan Timofte authored a month ago
2219
        updateCapacityEstimate(for: session)
Bogdan Timofte authored a month ago
2220
        refreshEstimatedBatteryPercents(for: session)
Bogdan Timofte authored a month ago
2221

            
Bogdan Timofte authored a month ago
2222
        return chargedDeviceID
2223
    }
2224

            
Bogdan Timofte authored a month ago
2225
    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
2226
        guard let sessionID = stringValue(session, key: "id") else {
2227
            return
2228
        }
2229

            
2230
        let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
2231
        if let latestCheckpoint = remainingCheckpoints.last {
2232
            session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
2233
        } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2234
                  startBatteryPercent >= 0 {
2235
            session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
2236
        } else {
2237
            session.setValue(nil, forKey: "endBatteryPercent")
2238
        }
2239

            
2240
        session.setValue(Date(), forKey: "updatedAt")
2241
        updateCapacityEstimate(for: session)
Bogdan Timofte authored a month ago
2242
        refreshEstimatedBatteryPercents(for: session)
Bogdan Timofte authored a month ago
2243
    }
2244

            
Bogdan Timofte authored a month ago
2245
    @discardableResult
2246
    private func addBatteryCheckpoint(
2247
        percent: Double,
Bogdan Timofte authored a month ago
2248
        measuredEnergyWh: Double? = nil,
Bogdan Timofte authored a month ago
2249
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
2250
        to session: NSManagedObject,
2251
        timestamp: Date = Date()
2252
    ) -> Bool {
Bogdan Timofte authored a month ago
2253
        if let measuredEnergyWh, measuredEnergyWh.isFinite {
2254
            session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
2255
        }
2256

            
Bogdan Timofte authored a month ago
2257
        guard let chargedDeviceID = insertBatteryCheckpoint(
2258
            percent: percent,
Bogdan Timofte authored a month ago
2259
            flag: flag,
Bogdan Timofte authored a month ago
2260
            timestamp: timestamp,
Bogdan Timofte authored a month ago
2261
            measuredEnergyWhOverride: measuredEnergyWh,
Bogdan Timofte authored a month ago
2262
            to: session
2263
        ) else {
2264
            return false
2265
        }
2266

            
Bogdan Timofte authored a month ago
2267
        guard saveContext() else {
2268
            return false
2269
        }
2270

            
2271
        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
2272
        return saveContext()
2273
    }
2274

            
2275
    private func resolvedWirelessEfficiency(
2276
        for session: NSManagedObject,
2277
        chargedDevice: NSManagedObject
2278
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
2279
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
2280
           storedFactor > 0 {
2281
            return (
2282
                factor: storedFactor,
2283
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
2284
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
2285
            )
2286
        }
2287

            
2288
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
2289
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2290
        guard measuredEnergyWh > 0 else {
2291
            return nil
2292
        }
2293

            
2294
        if chargingProfile == .magsafe,
2295
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
2296
           calibratedFactor > 0 {
2297
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
2298
        }
2299

            
2300
        guard
2301
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2302
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
2303
        else {
2304
            return nil
2305
        }
2306

            
2307
        let percentDelta = endBatteryPercent - startBatteryPercent
2308
        guard percentDelta >= 20 else {
2309
            return nil
2310
        }
2311

            
2312
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
Bogdan Timofte authored a month ago
2313
            ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
Bogdan Timofte authored a month ago
2314
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2315
                : nil),
2316
              wiredCapacityWh > 0
2317
        else {
2318
            return nil
2319
        }
2320

            
2321
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
2322
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
2323
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
2324
        let usesEstimated = chargingProfile != .magsafe
2325
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
2326

            
2327
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
2328
    }
2329

            
2330
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
2331
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
2332
            return
2333
        }
2334

            
Bogdan Timofte authored a month ago
2335
        let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
Bogdan Timofte authored a month ago
2336
        let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
2337
        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
2338
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
2339
        let sessions = relevantSessionObjects(
2340
            for: chargedDeviceID,
2341
            deviceClass: deviceClass,
2342
            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
2343
            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
2344
        )
Bogdan Timofte authored a month ago
2345
        let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
Bogdan Timofte authored a month ago
2346
        let wiredMinimumCurrent = derivedMinimumCurrent(
2347
            from: sessions,
2348
            chargingTransportMode: .wired
2349
        )
2350
        let wirelessMinimumCurrent = derivedMinimumCurrent(
2351
            from: sessions,
2352
            chargingTransportMode: .wireless
2353
        )
2354

            
2355
        let wiredCapacity = derivedCapacity(
2356
            from: sessions,
2357
            chargingTransportMode: .wired,
2358
            supportsChargingWhileOff: supportsChargingWhileOff
2359
        )
2360
        let wirelessCapacity = derivedCapacity(
2361
            from: sessions,
2362
            chargingTransportMode: .wireless,
2363
            supportsChargingWhileOff: supportsChargingWhileOff
2364
        )
2365
        let wirelessEfficiency = derivedWirelessEfficiency(
2366
            from: sessions,
2367
            chargingProfile: wirelessProfile
2368
        )
Bogdan Timofte authored a month ago
2369
        let configuredCompletionCurrents = decodedCompletionCurrents(
2370
            from: chargedDevice,
2371
            key: "configuredCompletionCurrentsRawValue"
2372
        )
Bogdan Timofte authored a month ago
2373
        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
2374
        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
2375
        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
2376
        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
2377
        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
2378
        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
2379

            
Bogdan Timofte authored a month ago
2380
        let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
2381
        let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
Bogdan Timofte authored a month ago
2382
        let preferredMinimumCurrent: Double?
2383
        let preferredCapacity: Double?
2384
        switch preferredChargingTransportMode {
2385
        case .wired:
Bogdan Timofte authored a month ago
2386
            preferredMinimumCurrent = configuredCompletionCurrents[
2387
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2388
            ] ?? learnedCompletionCurrents[
2389
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2390
            ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
Bogdan Timofte authored a month ago
2391
            preferredCapacity = wiredCapacity ?? wirelessCapacity
2392
        case .wireless:
Bogdan Timofte authored a month ago
2393
            preferredMinimumCurrent = configuredCompletionCurrents[
2394
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2395
            ] ?? learnedCompletionCurrents[
2396
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2397
            ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
Bogdan Timofte authored a month ago
2398
            preferredCapacity = wirelessCapacity ?? wiredCapacity
2399
        }
2400

            
Bogdan Timofte authored a month ago
2401
        setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
2402
        setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
2403
        setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
2404
        setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
2405
        setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
2406
        setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
2407
        setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
2408
        setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
2409
        setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
2410
        setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
2411
        setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
2412
        setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
2413
        setValue(Date(), on: chargedDevice, key: "updatedAt")
Bogdan Timofte authored a month ago
2414
    }
2415

            
2416
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
2417
        sessions
2418
            .filter { $0.status == .completed }
2419
            .compactMap { session in
2420
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
2421
                let timestamp = session.endedAt ?? session.lastObservedAt
2422
                return CapacityTrendPoint(
2423
                    sessionID: session.id,
2424
                    timestamp: timestamp,
2425
                    capacityWh: capacityEstimateWh,
2426
                    chargingTransportMode: session.chargingTransportMode
2427
                )
2428
            }
2429
            .sorted { $0.timestamp < $1.timestamp }
2430
    }
2431

            
2432
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
2433
        var groupedEnergyByBin: [Int: [Double]] = [:]
2434

            
2435
        for session in sessions where session.status == .completed {
Bogdan Timofte authored a month ago
2436
            let anchors = normalizedTypicalCurveAnchors(for: session)
2437
            guard anchors.count >= 2 else {
2438
                continue
Bogdan Timofte authored a month ago
2439
            }
2440

            
Bogdan Timofte authored a month ago
2441
            for percentBin in stride(from: 0, through: 100, by: 10) {
Bogdan Timofte authored a month ago
2442
                guard let energyWh = interpolatedTypicalCurvePoint(
Bogdan Timofte authored a month ago
2443
                    for: Double(percentBin),
2444
                    anchors: anchors
2445
                ) else {
2446
                    continue
2447
                }
Bogdan Timofte authored a month ago
2448

            
Bogdan Timofte authored a month ago
2449
                groupedEnergyByBin[percentBin, default: []].append(energyWh)
Bogdan Timofte authored a month ago
2450
            }
2451
        }
2452

            
Bogdan Timofte authored a month ago
2453
        let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
Bogdan Timofte authored a month ago
2454
            guard let energies = groupedEnergyByBin[percentBin], !energies.isEmpty else {
Bogdan Timofte authored a month ago
2455
                return nil
2456
            }
2457

            
2458
            return TypicalChargeCurvePoint(
2459
                percentBin: percentBin,
2460
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
Bogdan Timofte authored a month ago
2461
                sampleCount: energies.count
Bogdan Timofte authored a month ago
2462
            )
2463
        }
Bogdan Timofte authored a month ago
2464

            
2465
        var runningMaximumEnergyWh = 0.0
2466

            
2467
        return averagedPoints.map { point in
2468
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
2469
            return TypicalChargeCurvePoint(
2470
                percentBin: point.percentBin,
2471
                averageEnergyWh: runningMaximumEnergyWh,
2472
                sampleCount: point.sampleCount
2473
            )
2474
        }
2475
    }
2476

            
2477
    private func normalizedTypicalCurveAnchors(
2478
        for session: ChargeSessionSummary
Bogdan Timofte authored a month ago
2479
    ) -> [(percent: Double, energyWh: Double)] {
Bogdan Timofte authored a month ago
2480
        struct Anchor {
2481
            let percent: Double
2482
            let energyWh: Double
2483
            let timestamp: Date
2484
        }
2485

            
2486
        var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
2487
            guard checkpoint.batteryPercent.isFinite,
2488
                  checkpoint.measuredEnergyWh.isFinite,
2489
                  checkpoint.batteryPercent >= 0,
2490
                  checkpoint.batteryPercent <= 100,
Bogdan Timofte authored a month ago
2491
                  checkpoint.measuredEnergyWh >= 0 else {
Bogdan Timofte authored a month ago
2492
                return nil
2493
            }
2494

            
2495
            return Anchor(
2496
                percent: checkpoint.batteryPercent,
2497
                energyWh: checkpoint.measuredEnergyWh,
2498
                timestamp: checkpoint.timestamp
2499
            )
2500
        }
2501

            
2502
        if let startBatteryPercent = session.startBatteryPercent,
2503
           startBatteryPercent.isFinite,
2504
           startBatteryPercent >= 0,
2505
           startBatteryPercent <= 100 {
2506
            anchors.append(
2507
                Anchor(
2508
                    percent: startBatteryPercent,
2509
                    energyWh: 0,
2510
                    timestamp: session.startedAt
2511
                )
2512
            )
2513
        }
2514

            
2515
        if let endBatteryPercent = session.endBatteryPercent,
2516
           endBatteryPercent.isFinite,
2517
           endBatteryPercent >= 0,
2518
           endBatteryPercent <= 100 {
2519
            anchors.append(
2520
                Anchor(
2521
                    percent: endBatteryPercent,
2522
                    energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
2523
                    timestamp: session.endedAt ?? session.lastObservedAt
2524
                )
2525
            )
2526
        }
2527

            
2528
        let sortedAnchors = anchors.sorted { lhs, rhs in
2529
            if lhs.percent != rhs.percent {
2530
                return lhs.percent < rhs.percent
2531
            }
2532
            if lhs.energyWh != rhs.energyWh {
2533
                return lhs.energyWh < rhs.energyWh
2534
            }
2535
            return lhs.timestamp < rhs.timestamp
2536
        }
2537

            
Bogdan Timofte authored a month ago
2538
        var collapsedAnchors: [(percent: Double, energyWh: Double)] = []
Bogdan Timofte authored a month ago
2539

            
2540
        for anchor in sortedAnchors {
2541
            if let lastIndex = collapsedAnchors.indices.last,
2542
               abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
2543
                collapsedAnchors[lastIndex] = (
2544
                    percent: collapsedAnchors[lastIndex].percent,
Bogdan Timofte authored a month ago
2545
                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh)
Bogdan Timofte authored a month ago
2546
                )
2547
            } else {
2548
                collapsedAnchors.append(
Bogdan Timofte authored a month ago
2549
                    (percent: anchor.percent, energyWh: anchor.energyWh)
Bogdan Timofte authored a month ago
2550
                )
2551
            }
2552
        }
2553

            
2554
        var runningMaximumEnergyWh = 0.0
2555

            
2556
        return collapsedAnchors.map { anchor in
2557
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
2558
            return (
2559
                percent: anchor.percent,
Bogdan Timofte authored a month ago
2560
                energyWh: runningMaximumEnergyWh
Bogdan Timofte authored a month ago
2561
            )
2562
        }
2563
    }
2564

            
2565
    private func interpolatedTypicalCurvePoint(
2566
        for percent: Double,
Bogdan Timofte authored a month ago
2567
        anchors: [(percent: Double, energyWh: Double)]
2568
    ) -> Double? {
Bogdan Timofte authored a month ago
2569
        guard
2570
            let firstAnchor = anchors.first,
2571
            let lastAnchor = anchors.last,
2572
            percent >= firstAnchor.percent,
2573
            percent <= lastAnchor.percent
2574
        else {
2575
            return nil
2576
        }
2577

            
2578
        if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
Bogdan Timofte authored a month ago
2579
            return exactAnchor.energyWh
Bogdan Timofte authored a month ago
2580
        }
2581

            
2582
        guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
2583
              upperIndex > 0 else {
2584
            return nil
2585
        }
2586

            
2587
        let lowerAnchor = anchors[upperIndex - 1]
2588
        let upperAnchor = anchors[upperIndex]
2589
        let span = upperAnchor.percent - lowerAnchor.percent
2590
        guard span > 0.000_1 else {
2591
            return nil
2592
        }
2593

            
2594
        let ratio = (percent - lowerAnchor.percent) / span
Bogdan Timofte authored a month ago
2595
        return lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
Bogdan Timofte authored a month ago
2596
    }
2597

            
2598
    private func makeSessionSummary(
2599
        from object: NSManagedObject,
2600
        checkpoints: [NSManagedObject],
2601
        samples: [NSManagedObject]
2602
    ) -> ChargeSessionSummary? {
2603
        let chargingTransportMode = chargingTransportMode(for: object)
2604

            
2605
        guard
2606
            let id = uuidValue(object, key: "id"),
2607
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2608
            let startedAt = dateValue(object, key: "startedAt"),
2609
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
2610
            let status = statusValue(object, key: "statusRawValue"),
2611
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
2612
        else {
2613
            return nil
2614
        }
2615

            
2616
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
2617
            .sorted { $0.timestamp < $1.timestamp }
2618
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
2619
            .sorted { lhs, rhs in
2620
                if lhs.bucketIndex != rhs.bucketIndex {
2621
                    return lhs.bucketIndex < rhs.bucketIndex
2622
                }
2623
                return lhs.timestamp < rhs.timestamp
2624
            }
2625

            
2626
        return ChargeSessionSummary(
2627
            id: id,
2628
            chargedDeviceID: chargedDeviceID,
2629
            chargerID: uuidValue(object, key: "chargerID"),
2630
            meterMACAddress: stringValue(object, key: "meterMACAddress"),
2631
            meterName: stringValue(object, key: "meterName"),
2632
            meterModel: stringValue(object, key: "meterModel"),
2633
            startedAt: startedAt,
2634
            endedAt: dateValue(object, key: "endedAt"),
2635
            lastObservedAt: lastObservedAt,
Bogdan Timofte authored a month ago
2636
            pausedAt: dateValue(object, key: "pausedAt"),
Bogdan Timofte authored a month ago
2637
            status: status,
2638
            sourceMode: sourceMode,
2639
            chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
2640
            chargingStateMode: chargingStateMode(for: object),
2641
            autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
Bogdan Timofte authored a month ago
2642
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2643
            effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
Bogdan Timofte authored a month ago
2644
            meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
Bogdan Timofte authored a month ago
2645
            meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"),
2646
            meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"),
Bogdan Timofte authored a month ago
2647
            minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
2648
            maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
2649
            maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
2650
            maximumObservedVoltageVolts: chargingTransportMode == .wired
2651
                ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
2652
                : nil,
Bogdan Timofte authored a month ago
2653
            hasObservedChargeFlow: boolValue(object, key: "hasObservedChargeFlow"),
Bogdan Timofte authored a month ago
2654
            selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
2655
            completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
2656
            stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
2657
            startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
2658
            endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
2659
            capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
2660
            wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
2661
            usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
2662
            shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
2663
            supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
2664
            usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
2665
            targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
2666
            targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
2667
            requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
2668
            completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
2669
            completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
2670
            selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
Bogdan Timofte authored a month ago
2671
            trimStart: dateValue(object, key: "trimStart"),
2672
            trimEnd: dateValue(object, key: "trimEnd"),
Bogdan Timofte authored a month ago
2673
            wasConflictHealed: boolValue(object, key: "wasConflictHealed"),
Bogdan Timofte authored a month ago
2674
            checkpoints: checkpointSummaries,
2675
            aggregatedSamples: sampleSummaries
2676
        )
2677
    }
2678

            
2679
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
2680
        guard
2681
            let id = uuidValue(object, key: "id"),
2682
            let sessionID = uuidValue(object, key: "sessionID"),
2683
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2684
            let timestamp = dateValue(object, key: "timestamp")
2685
        else {
2686
            return nil
2687
        }
2688

            
2689
        return ChargeCheckpointSummary(
2690
            id: id,
2691
            sessionID: sessionID,
2692
            chargedDeviceID: chargedDeviceID,
2693
            timestamp: timestamp,
2694
            batteryPercent: doubleValue(object, key: "batteryPercent"),
2695
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2696
            currentAmps: doubleValue(object, key: "currentAmps"),
2697
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
2698
            label: stringValue(object, key: "label")
2699
        )
2700
    }
2701

            
2702
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
2703
        guard
2704
            let sessionID = uuidValue(object, key: "sessionID"),
2705
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2706
            let timestamp = dateValue(object, key: "timestamp")
2707
        else {
2708
            return nil
2709
        }
2710

            
2711
        return ChargeSessionSampleSummary(
2712
            sessionID: sessionID,
2713
            chargedDeviceID: chargedDeviceID,
2714
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
2715
            timestamp: timestamp,
2716
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
2717
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
2718
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
2719
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
Bogdan Timofte authored a month ago
2720
            estimatedBatteryPercent: optionalDoubleValue(object, key: "estimatedBatteryPercent"),
Bogdan Timofte authored a month ago
2721
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
2722
        )
2723
    }
2724

            
Bogdan Timofte authored a month ago
2725
    private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2726
        fetchSessionObject(
2727
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2728
                format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
Bogdan Timofte authored a month ago
2729
                normalizedMACAddress(meterMACAddress),
Bogdan Timofte authored a month ago
2730
                ChargeSessionStatus.active.rawValue,
2731
                ChargeSessionStatus.paused.rawValue
Bogdan Timofte authored a month ago
2732
            )
2733
        )
2734
    }
2735

            
Bogdan Timofte authored a month ago
2736
    private func fetchOpenSessionObjects() -> [NSManagedObject] {
2737
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2738
        request.predicate = NSPredicate(
2739
            format: "statusRawValue == %@ OR statusRawValue == %@",
2740
            ChargeSessionStatus.active.rawValue,
2741
            ChargeSessionStatus.paused.rawValue
2742
        )
2743
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2744
        return (try? context.fetch(request)) ?? []
2745
    }
2746

            
Bogdan Timofte authored a month ago
2747
    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2748
        fetchSessionObject(
2749
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2750
                format: "meterMACAddress == %@ AND statusRawValue == %@",
2751
                normalizedMACAddress(meterMACAddress),
2752
                ChargeSessionStatus.active.rawValue
Bogdan Timofte authored a month ago
2753
            )
2754
        )
2755
    }
2756

            
2757
    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
2758
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2759
        request.predicate = predicate
2760
        request.fetchLimit = 1
2761
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
2762
        return (try? context.fetch(request))?.first
2763
    }
2764

            
2765
    private func fetchSessionObject(id: String) -> NSManagedObject? {
2766
        fetchSessionObject(
2767
            predicate: NSPredicate(format: "id == %@", id)
2768
        )
2769
    }
2770

            
2771
    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
2772
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2773
        request.predicate = NSPredicate(
2774
            format: "sessionID == %@ AND bucketIndex == %d",
2775
            sessionID,
2776
            bucketIndex
2777
        )
2778
        request.fetchLimit = 1
2779
        return (try? context.fetch(request))?.first
2780
    }
2781

            
2782
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2783
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2784
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2785
        return (try? context.fetch(request)) ?? []
2786
    }
2787

            
Bogdan Timofte authored a month ago
2788
    private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
2789
        guard !sessionIDs.isEmpty else {
2790
            return []
2791
        }
2792

            
2793
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2794
        request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs)
2795
        return (try? context.fetch(request)) ?? []
2796
    }
2797

            
Bogdan Timofte authored a month ago
2798
    private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
2799
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2800
        request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
2801
        request.fetchLimit = 1
2802
        return (try? context.fetch(request))?.first
2803
    }
2804

            
Bogdan Timofte authored a month ago
2805
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2806
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2807
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2808
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
2809
        return (try? context.fetch(request)) ?? []
2810
    }
2811

            
2812
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
2813
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2814
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
2815
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2816
        return (try? context.fetch(request)) ?? []
2817
    }
2818

            
2819
    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
2820
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2821
        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
2822
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2823
        return (try? context.fetch(request)) ?? []
2824
    }
2825

            
Bogdan Timofte authored a month ago
2826
    private func sampleBackedSessionIDs(
2827
        devices: [NSManagedObject],
2828
        sessionsByDeviceID: [String: [NSManagedObject]],
2829
        sessionsByChargerID: [String: [NSManagedObject]]
2830
    ) -> Set<String> {
2831
        var sessionIDs: Set<String> = []
2832

            
2833
        for device in devices {
2834
            guard
2835
                let deviceID = stringValue(device, key: "id"),
2836
                let rawClass = stringValue(device, key: "deviceClassRawValue"),
2837
                let deviceClass = ChargedDeviceClass(rawValue: rawClass)
2838
            else {
2839
                continue
2840
            }
2841

            
2842
            let relevantSessions = relevantSessionObjects(
2843
                for: deviceID,
2844
                deviceClass: deviceClass,
2845
                sessionsByDeviceID: sessionsByDeviceID,
2846
                sessionsByChargerID: sessionsByChargerID
2847
            )
2848
            .sorted { lhs, rhs in
2849
                let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed
2850
                let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed
2851

            
2852
                if lhsStatus.isOpen && !rhsStatus.isOpen {
2853
                    return true
2854
                }
2855
                if !lhsStatus.isOpen && rhsStatus.isOpen {
2856
                    return false
2857
                }
2858

            
2859
                return (dateValue(lhs, key: "startedAt") ?? .distantPast)
2860
                    > (dateValue(rhs, key: "startedAt") ?? .distantPast)
2861
            }
2862

            
2863
            var recentCompletedSamplesIncluded = 0
2864

            
2865
            for session in relevantSessions {
2866
                guard let sessionID = stringValue(session, key: "id"),
2867
                      let status = statusValue(session, key: "statusRawValue") else {
2868
                    continue
2869
                }
2870

            
2871
                if status.isOpen {
2872
                    sessionIDs.insert(sessionID)
2873
                    continue
2874
                }
2875

            
2876
                guard recentCompletedSamplesIncluded < 2 else {
2877
                    continue
2878
                }
2879

            
2880
                sessionIDs.insert(sessionID)
2881
                recentCompletedSamplesIncluded += 1
2882
            }
2883
        }
2884

            
2885
        return sessionIDs
2886
    }
2887

            
Bogdan Timofte authored a month ago
2888
    private func relevantSessionObjects(
2889
        for chargedDeviceID: String,
2890
        deviceClass: ChargedDeviceClass,
2891
        sessionsByDeviceID: [String: [NSManagedObject]],
2892
        sessionsByChargerID: [String: [NSManagedObject]]
2893
    ) -> [NSManagedObject] {
2894
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
2895
        guard deviceClass == .charger else {
2896
            return directSessions
2897
        }
2898

            
2899
        var seenSessionIDs = Set<String>()
2900
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
2901
            .filter { session in
2902
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
2903
                return seenSessionIDs.insert(sessionID).inserted
2904
            }
2905
            .sorted {
2906
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
2907
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
2908
                return lhsDate < rhsDate
2909
            }
2910
    }
2911

            
Bogdan Timofte authored a month ago
2912
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
2913
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2914
    }
2915

            
Bogdan Timofte authored a month ago
2916
    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
2917
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2918
        request.predicate = NSPredicate(format: "id == %@", id)
2919
        request.fetchLimit = 1
2920
        return (try? context.fetch(request))?.first
2921
    }
2922

            
2923
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
2924
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
2925
        return (try? context.fetch(request)) ?? []
2926
    }
2927

            
2928
    private func resolvedStopThreshold(
2929
        for chargedDevice: NSManagedObject,
2930
        chargingTransportMode: ChargingTransportMode,
Bogdan Timofte authored a month ago
2931
        chargingStateMode: ChargingStateMode,
2932
        charger: NSManagedObject?,
2933
        fallback: Double?
2934
    ) -> Double? {
2935
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
2936
            return nil
2937
        }
2938

            
2939
        let sessionKind = ChargeSessionKind(
2940
            chargingTransportMode: chargingTransportMode,
2941
            chargingStateMode: chargingStateMode
2942
        )
2943
        let configuredCurrents = decodedCompletionCurrents(
2944
            from: chargedDevice,
2945
            key: "configuredCompletionCurrentsRawValue"
2946
        )
2947
        let learnedCurrents = decodedCompletionCurrents(
2948
            from: chargedDevice,
2949
            key: "learnedCompletionCurrentsRawValue"
2950
        )
2951
        let legacyCurrent: Double?
Bogdan Timofte authored a month ago
2952
        switch chargingTransportMode {
2953
        case .wired:
Bogdan Timofte authored a month ago
2954
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2955
                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
2956
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2957
        case .wireless:
Bogdan Timofte authored a month ago
2958
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2959
                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
2960
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2961
        }
Bogdan Timofte authored a month ago
2962

            
2963
        let resolvedCurrent = configuredCurrents[sessionKind]
2964
            ?? learnedCurrents[sessionKind]
2965
            ?? legacyCurrent
2966
            ?? fallback
2967
        guard let resolvedCurrent, resolvedCurrent > 0 else {
2968
            return nil
2969
        }
2970
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
2971
    }
2972

            
Bogdan Timofte authored a month ago
2973
    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
Bogdan Timofte authored a month ago
2974
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
2975
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
2976
        return resolvedPreferredChargingTransportMode(
Bogdan Timofte authored a month ago
2977
            .wired,
Bogdan Timofte authored a month ago
2978
            supportsWiredCharging: supportsWiredCharging,
2979
            supportsWirelessCharging: supportsWirelessCharging
2980
        )
2981
    }
2982

            
Bogdan Timofte authored a month ago
2983
    private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
2984
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other
2985
    }
2986

            
2987
    private func normalizedTemplateID(
2988
        _ templateID: String?,
2989
        kind: ChargedDeviceKind
2990
    ) -> String? {
2991
        guard let templateID,
2992
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2993
              templateDefinition.kind == kind else {
2994
            return nil
Bogdan Timofte authored a month ago
2995
        }
Bogdan Timofte authored a month ago
2996
        return templateDefinition.id
Bogdan Timofte authored a month ago
2997
    }
2998

            
Bogdan Timofte authored a month ago
2999
    private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
3000
        guard let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
3001
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
3002
              templateDefinition.kind == deviceClass(for: chargedDevice).kind else {
3003
            return nil
Bogdan Timofte authored a month ago
3004
        }
Bogdan Timofte authored a month ago
3005
        return templateDefinition
3006
    }
3007

            
3008
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
3009
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
3010
            ? true
3011
            : boolValue(chargedDevice, key: "supportsWiredCharging")
3012
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
3013
            ? false
3014
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
3015
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
3016
            supportsWiredCharging: persistedWiredCharging,
3017
            supportsWirelessCharging: persistedWirelessCharging
3018
        ).wired
3019
    }
3020

            
3021
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
3022
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
3023
            ? true
3024
            : boolValue(chargedDevice, key: "supportsWiredCharging")
3025
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
3026
            ? false
3027
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
3028
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
3029
            supportsWiredCharging: persistedWiredCharging,
3030
            supportsWirelessCharging: persistedWirelessCharging
3031
        ).wireless
3032
    }
3033

            
3034
    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
3035
        let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue")
3036
            .flatMap(ChargingStateAvailability.init(rawValue:))
3037
            ?? ChargingStateAvailability.fallback(
Bogdan Timofte authored a month ago
3038
            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
3039
        )
Bogdan Timofte authored a month ago
3040
        return deviceClass(for: chargedDevice).normalizedChargingStateAvailability(persistedAvailability)
Bogdan Timofte authored a month ago
3041
    }
3042

            
3043
    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
Bogdan Timofte authored a month ago
3044
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
3045
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
3046
            let persistedChargingStateMode = stringValue(session, key: "chargingStateRawValue")
3047
                .flatMap(ChargingStateMode.init(rawValue:))
3048
                ?? .on
3049
            return resolvedChargingStateMode(
3050
                persistedChargingStateMode,
3051
                availability: chargingStateAvailability(for: chargedDevice)
3052
            )
3053
        }
3054

            
Bogdan Timofte authored a month ago
3055
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
3056
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
3057
            return chargingStateMode
3058
        }
3059

            
3060
        return .on
3061
    }
3062

            
3063
    private func resolvedChargingStateMode(
3064
        _ chargingStateMode: ChargingStateMode,
3065
        availability: ChargingStateAvailability
3066
    ) -> ChargingStateMode {
3067
        if availability.supportedModes.contains(chargingStateMode) {
3068
            return chargingStateMode
3069
        }
3070
        return availability.supportedModes.first ?? .on
3071
    }
3072

            
Bogdan Timofte authored a month ago
3073
    private func chargerType(for chargedDevice: NSManagedObject) -> ChargerType? {
3074
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
3075
        guard deviceClass == .charger else { return nil }
3076

            
3077
        // Primary: chargerTypeRawValue (set on v13+)
3078
        if let rawValue = stringValue(chargedDevice, key: "chargerTypeRawValue"),
3079
           let type = ChargerType(rawValue: rawValue) {
3080
            return type
3081
        }
3082

            
3083
        // Migration fallback: derive from old deviceTemplateID
3084
        switch stringValue(chargedDevice, key: "deviceTemplateID") {
3085
        case "apple-magsafe-charger": return .appleMagSafe
3086
        case "apple-watch-charger": return .appleWatch
3087
        default: break
3088
        }
3089

            
3090
        // Last resort: derive from wirelessChargingProfileRawValue
3091
        if let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
3092
           let profile = WirelessChargingProfile(rawValue: rawValue),
3093
           profile == .magsafe {
3094
            return .genericMagSafe
3095
        }
3096

            
3097
        return .genericQi
3098
    }
3099

            
Bogdan Timofte authored a month ago
3100
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
Bogdan Timofte authored a month ago
3101
        if let type = chargerType(for: chargedDevice) {
3102
            return type.wirelessChargingProfile
3103
        }
Bogdan Timofte authored a month ago
3104
        guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
3105
              let profile = WirelessChargingProfile(rawValue: rawValue) else {
3106
            return .genericQi
3107
        }
3108
        return profile
3109
    }
3110

            
3111
    private func resolvedPreferredChargingTransportMode(
3112
        _ preferredChargingTransportMode: ChargingTransportMode,
3113
        supportsWiredCharging: Bool,
3114
        supportsWirelessCharging: Bool
3115
    ) -> ChargingTransportMode {
3116
        switch preferredChargingTransportMode {
3117
        case .wired where supportsWiredCharging:
3118
            return .wired
3119
        case .wireless where supportsWirelessCharging:
3120
            return .wireless
3121
        default:
3122
            if supportsWiredCharging {
3123
                return .wired
3124
            }
3125
            if supportsWirelessCharging {
3126
                return .wireless
3127
            }
3128
            return .wired
3129
        }
3130
    }
3131

            
Bogdan Timofte authored a month ago
3132
    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
3133
        let payload = Dictionary(
3134
            uniqueKeysWithValues: currents.map { key, value in
3135
                (key.rawValue, value)
3136
            }
3137
        )
3138
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
3139
            return nil
3140
        }
3141
        return String(data: data, encoding: .utf8)
3142
    }
3143

            
3144
    private func decodedCompletionCurrents(
3145
        from object: NSManagedObject,
3146
        key: String
3147
    ) -> [ChargeSessionKind: Double] {
3148
        guard let rawValue = stringValue(object, key: key),
3149
              let data = rawValue.data(using: .utf8),
3150
              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
3151
            return [:]
3152
        }
3153

            
3154
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
3155
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
3156
                return
3157
            }
3158
            result[sessionKind] = entry.value
3159
        }
3160
    }
3161

            
3162
    private func legacyConfiguredCompletionCurrent(
3163
        for currents: [ChargeSessionKind: Double],
3164
        chargingTransportMode: ChargingTransportMode
3165
    ) -> Double? {
3166
        let candidates = currents
3167
            .filter { $0.key.chargingTransportMode == chargingTransportMode }
3168
            .sorted { lhs, rhs in
3169
                lhs.key.rawValue < rhs.key.rawValue
3170
            }
3171
            .map(\.value)
3172
        return candidates.first
3173
    }
3174

            
3175
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
3176
        guard let charger else {
3177
            return nil
3178
        }
3179
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
3180
        guard let idleCurrent, idleCurrent >= 0 else {
3181
            return nil
3182
        }
3183
        return idleCurrent
3184
    }
3185

            
3186
    private func effectiveCurrentAmps(
3187
        fromMeasuredCurrent currentAmps: Double,
3188
        chargingTransportMode: ChargingTransportMode,
3189
        charger: NSManagedObject?
3190
    ) -> Double {
3191
        switch chargingTransportMode {
3192
        case .wired:
3193
            return max(currentAmps, 0)
3194
        case .wireless:
3195
            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
3196
                return max(currentAmps, 0)
3197
            }
3198
            return max(currentAmps - idleCurrent, 0)
3199
        }
3200
    }
3201

            
3202
    private func hasObservedChargeFlow(
3203
        currentAmps: Double,
3204
        chargingTransportMode: ChargingTransportMode,
3205
        charger: NSManagedObject?,
3206
        stopThreshold: Double?
3207
    ) -> Bool {
3208
        let effectiveCurrent = effectiveCurrentAmps(
3209
            fromMeasuredCurrent: currentAmps,
3210
            chargingTransportMode: chargingTransportMode,
3211
            charger: charger
3212
        )
3213
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
3214
    }
3215

            
Bogdan Timofte authored a month ago
3216
    private func hasSavableChargeData(_ session: NSManagedObject) -> Bool {
Bogdan Timofte authored a month ago
3217
        if boolValue(session, key: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
3218
            || doubleValue(session, key: "measuredEnergyWh") > 0
3219
            || doubleValue(session, key: "measuredChargeAh") > 0
3220
            || (optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? 0) > 0
Bogdan Timofte authored a month ago
3221
            || (optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? 0) > 0 {
3222
            return true
3223
        }
3224

            
3225
        guard let sessionID = stringValue(session, key: "id") else {
3226
            return false
3227
        }
3228

            
3229
        return fetchSessionSampleObjects(forSessionID: sessionID).contains { sample in
3230
            doubleValue(sample, key: "measuredEnergyWh") > 0
3231
                || doubleValue(sample, key: "measuredChargeAh") > 0
3232
                || (optionalDoubleValue(sample, key: "averageCurrentAmps") ?? 0) > 0
3233
                || (optionalDoubleValue(sample, key: "averagePowerWatts") ?? 0) > 0
3234
        }
3235
    }
3236

            
3237
    private func restoreMeasuredTotalsFromLatestSampleIfNeeded(_ session: NSManagedObject) {
3238
        guard let sessionID = stringValue(session, key: "id"),
3239
              let latestSample = fetchSessionSampleObjects(forSessionID: sessionID).max(by: {
3240
                  (dateValue($0, key: "timestamp") ?? .distantPast) < (dateValue($1, key: "timestamp") ?? .distantPast)
3241
              }) else {
3242
            return
3243
        }
3244

            
3245
        let sampleEnergyWh = doubleValue(latestSample, key: "measuredEnergyWh")
3246
        if doubleValue(session, key: "measuredEnergyWh") <= 0, sampleEnergyWh > 0 {
3247
            session.setValue(sampleEnergyWh, forKey: "measuredEnergyWh")
3248
        }
3249

            
3250
        let sampleChargeAh = doubleValue(latestSample, key: "measuredChargeAh")
3251
        if doubleValue(session, key: "measuredChargeAh") <= 0, sampleChargeAh > 0 {
3252
            session.setValue(sampleChargeAh, forKey: "measuredChargeAh")
3253
        }
Bogdan Timofte authored a month ago
3254
    }
3255

            
Bogdan Timofte authored a month ago
3256
    private func derivedMinimumCurrent(
3257
        from sessions: [NSManagedObject],
3258
        chargingTransportMode: ChargingTransportMode
3259
    ) -> Double? {
3260
        let completionCurrents = sessions.compactMap { session -> Double? in
3261
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3262
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
3263
                return nil
3264
            }
Bogdan Timofte authored a month ago
3265
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3266
                return nil
3267
            }
Bogdan Timofte authored a month ago
3268
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
3269
                return nil
3270
            }
3271
            return completionCurrent
3272
        }
3273

            
3274
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
3275
        guard !recentCompletionCurrents.isEmpty else { return nil }
3276
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
3277
    }
3278

            
Bogdan Timofte authored a month ago
3279
    private func derivedCompletionCurrents(from sessions: [NSManagedObject]) -> [ChargeSessionKind: Double] {
3280
        var groupedCurrents: [ChargeSessionKind: [Double]] = [:]
3281

            
3282
        for session in sessions {
3283
            guard statusValue(session, key: "statusRawValue") == .completed else {
3284
                continue
3285
            }
3286
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3287
                continue
3288
            }
3289
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
3290
                  completionCurrent > 0 else {
3291
                continue
3292
            }
3293

            
3294
            let sessionKind = ChargeSessionKind(
3295
                chargingTransportMode: chargingTransportMode(for: session),
3296
                chargingStateMode: chargingStateMode(for: session)
3297
            )
3298
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
3299
        }
3300

            
3301
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
3302
            let recentCurrents = Array(entry.value.suffix(5))
3303
            guard !recentCurrents.isEmpty else {
3304
                return
3305
            }
3306
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
3307
        }
3308
    }
3309

            
Bogdan Timofte authored a month ago
3310
    private func derivedCapacity(
3311
        from sessions: [NSManagedObject],
3312
        chargingTransportMode: ChargingTransportMode,
3313
        supportsChargingWhileOff: Bool
3314
    ) -> Double? {
3315
        let capacityCandidates = sessions.compactMap { session -> Double? in
3316
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3317
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
3318
                return nil
3319
            }
3320
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
3321
                return nil
3322
            }
3323
            if supportsChargingWhileOff {
3324
                return capacityEstimate
3325
            }
3326
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
3327
                return nil
3328
            }
3329
            return capacityEstimate
3330
        }
3331

            
3332
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
3333
        guard !recentCapacityCandidates.isEmpty else { return nil }
3334
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
3335
    }
3336

            
3337
    private func derivedWirelessEfficiency(
3338
        from sessions: [NSManagedObject],
3339
        chargingProfile: WirelessChargingProfile
3340
    ) -> Double? {
3341
        guard chargingProfile == .magsafe else {
3342
            return nil
3343
        }
3344

            
3345
        let candidates = sessions.compactMap { session -> Double? in
3346
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3347
            guard chargingTransportMode(for: session) == .wireless else { return nil }
3348
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
3349
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3350
                return nil
3351
            }
3352
            return factor
3353
        }
3354

            
3355
        let recentCandidates = Array(candidates.suffix(6))
3356
        guard !recentCandidates.isEmpty else { return nil }
3357
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3358
    }
3359

            
3360
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
3361
        let candidates = sessions.compactMap { session -> Double? in
3362
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3363
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
3364
                return nil
3365
            }
3366
            return (sourceVoltage * 10).rounded() / 10
3367
        }
3368

            
3369
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
3370
        return counts.keys.sorted()
3371
    }
3372

            
3373
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
3374
        let candidates = sessions.compactMap { session -> Double? in
3375
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3376
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
3377
                return nil
3378
            }
3379
            return minimumObservedCurrent
3380
        }
3381

            
3382
        let recentCandidates = Array(candidates.suffix(6))
3383
        guard !recentCandidates.isEmpty else { return nil }
3384
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3385
    }
3386

            
3387
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
3388
        let candidates = sessions.compactMap { session -> Double? in
3389
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3390
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3391
                return nil
3392
            }
3393
            return factor
3394
        }
3395

            
3396
        let recentCandidates = Array(candidates.suffix(6))
3397
        guard !recentCandidates.isEmpty else { return nil }
3398
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3399
    }
3400

            
3401
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
3402
        sessions.compactMap { session -> Double? in
3403
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3404
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
3405
                return nil
3406
            }
3407
            return maximumObservedPower
3408
        }
3409
        .max()
3410
    }
3411

            
3412
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
3413
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
3414
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
3415
            return resolvedPreferredChargingTransportMode(
3416
                chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired,
3417
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
3418
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
3419
            )
3420
        }
3421

            
3422
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
3423
            return persistedChargingTransportMode
Bogdan Timofte authored a month ago
3424
        }
3425

            
3426
        return .wired
3427
    }
3428

            
3429
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
3430
        if session.isInserted {
3431
            return .created
3432
        }
3433

            
3434
        let committedValues = session.committedValues(
3435
            forKeys: [
3436
                "statusRawValue",
3437
                "updatedAt",
3438
                "targetBatteryAlertTriggeredAt",
3439
                "requiresCompletionConfirmation"
3440
            ]
3441
        )
3442
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
3443
        let currentStatus = statusValue(session, key: "statusRawValue")
3444
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
3445
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
3446
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
3447
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
3448
            ?? false
3449
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
3450

            
3451
        if currentStatus == .completed, committedStatus != .completed {
3452
            return .completed
3453
        }
3454

            
Bogdan Timofte authored a month ago
3455
        if currentStatus != committedStatus {
3456
            return .event
3457
        }
3458

            
Bogdan Timofte authored a month ago
3459
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
3460
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
3461
            return .event
3462
        }
3463

            
3464
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3465
            ?? dateValue(session, key: "createdAt")
3466
            ?? observedAt
3467

            
3468
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
3469
            return .periodic
3470
        }
3471

            
3472
        return .none
3473
    }
3474

            
Bogdan Timofte authored a month ago
3475
    private func shouldPersistAggregatedSample(
3476
        _ sample: NSManagedObject,
3477
        observedAt: Date
3478
    ) -> Bool {
3479
        if sample.isInserted {
3480
            return true
3481
        }
3482

            
3483
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
3484
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3485
            ?? dateValue(sample, key: "createdAt")
3486
            ?? observedAt
3487

            
3488
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
3489
    }
3490

            
Bogdan Timofte authored a month ago
3491
    private func generateQRIdentifier() -> String {
3492
        "device:\(UUID().uuidString)"
3493
    }
3494

            
3495
    @discardableResult
3496
    private func saveContext() -> Bool {
3497
        guard context.hasChanges else { return true }
3498
        do {
3499
            try context.save()
3500
            return true
3501
        } catch {
3502
            track("Failed saving charge insights context: \(error)")
3503
            context.rollback()
3504
            return false
3505
        }
3506
    }
3507

            
3508
    private func normalizedText(_ text: String) -> String {
3509
        text.trimmingCharacters(in: .whitespacesAndNewlines)
3510
    }
3511

            
3512
    private func normalizedOptionalText(_ text: String?) -> String? {
3513
        guard let text else { return nil }
3514
        let normalized = normalizedText(text)
3515
        return normalized.isEmpty ? nil : normalized
3516
    }
3517

            
3518
    private func normalizedMACAddress(_ macAddress: String) -> String {
3519
        normalizedText(macAddress).uppercased()
3520
    }
3521

            
Bogdan Timofte authored a month ago
3522
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
3523
        guard object.entity.propertiesByName[key] != nil else {
3524
            return nil
3525
        }
3526
        return object.value(forKey: key)
3527
    }
3528

            
3529
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
3530
        guard object.entity.propertiesByName[key] != nil else {
3531
            return
3532
        }
3533
        object.setValue(value, forKey: key)
3534
    }
3535

            
Bogdan Timofte authored a month ago
3536
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
3537
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
3538
        let normalized = normalizedOptionalText(value)
3539
        return normalized
3540
    }
3541

            
3542
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
3543
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
3544
    }
3545

            
3546
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
3547
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
3548
            return value
3549
        }
Bogdan Timofte authored a month ago
3550
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3551
            return value.doubleValue
3552
        }
3553
        return 0
3554
    }
3555

            
3556
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
3557
        let value = rawValue(object, key: key)
3558
        if value == nil {
Bogdan Timofte authored a month ago
3559
            return nil
3560
        }
3561
        return doubleValue(object, key: key)
3562
    }
3563

            
3564
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
Bogdan Timofte authored a month ago
3565
        if let value = rawValue(object, key: key) as? Int16 {
Bogdan Timofte authored a month ago
3566
            return value
3567
        }
Bogdan Timofte authored a month ago
3568
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3569
            return value.int16Value
3570
        }
3571
        return nil
3572
    }
3573

            
3574
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
3575
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
3576
            return value
3577
        }
Bogdan Timofte authored a month ago
3578
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3579
            return value.int32Value
3580
        }
3581
        return nil
3582
    }
3583

            
3584
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
Bogdan Timofte authored a month ago
3585
        if let value = rawValue(object, key: key) as? Bool {
Bogdan Timofte authored a month ago
3586
            return value
3587
        }
Bogdan Timofte authored a month ago
3588
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3589
            return value.boolValue
3590
        }
3591
        return false
3592
    }
3593

            
3594
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
3595
        guard let value = stringValue(object, key: key) else { return nil }
3596
        return UUID(uuidString: value)
3597
    }
3598

            
3599
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
3600
        guard let value = stringValue(object, key: key) else { return nil }
3601
        return ChargeSessionStatus(rawValue: value)
3602
    }
3603

            
3604
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
3605
        guard let value = stringValue(object, key: key) else { return nil }
3606
        return ChargingTransportMode(rawValue: value)
3607
    }
3608

            
3609
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
3610
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
3611
            return []
3612
        }
3613
        return rawValue
3614
            .split(separator: ",")
3615
            .compactMap { Double($0) }
3616
            .sorted()
3617
    }
3618

            
3619
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
3620
        let uniqueVoltages = Array(Set(voltages)).sorted()
3621
        guard !uniqueVoltages.isEmpty else {
3622
            return nil
3623
        }
3624
        return uniqueVoltages
3625
            .map { String(format: "%.1f", $0) }
3626
            .joined(separator: ",")
3627
    }
3628

            
3629
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
3630
        guard currentCount > 0 else {
3631
            return newValue
3632
        }
3633
        let total = (currentAverage * Double(currentCount)) + newValue
3634
        return total / Double(currentCount + 1)
3635
    }
3636
}
3637

            
3638
private enum ObservationSaveReason {
3639
    case none
3640
    case created
3641
    case periodic
3642
    case completed
3643
    case event
3644
}