USB-Meter / USB Meter / Model / ChargeInsightsStore.swift
Newer Older
3591 lines | 156.724kb
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 = 360
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"),
1853
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
1854
            let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice),
1855
            estimatedCapacityWh > 0
1856
        else {
1857
            return nil
1858
        }
1859

            
Bogdan Timofte authored a month ago
1860
        let measuredEnergyWh = effectiveEnergyWhOverride
1861
            ?? effectiveBatteryEnergyWh(
1862
                rawMeasuredEnergyWh: doubleValue(session, key: "measuredEnergyWh"),
1863
                for: session
1864
            )
Bogdan Timofte authored a month ago
1865
        let sessionID = stringValue(session, key: "id") ?? ""
1866

            
1867
        struct Anchor {
1868
            let percent: Double
1869
            let energyWh: Double
Bogdan Timofte authored a month ago
1870
            let timestamp: Date
1871
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1872
        }
1873

            
1874
        var anchors: [Anchor] = []
Bogdan Timofte authored a month ago
1875
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1876
           startBatteryPercent >= 0 {
1877
            anchors.append(
1878
                Anchor(
1879
                    percent: startBatteryPercent,
1880
                    energyWh: 0,
Bogdan Timofte authored a month ago
1881
                    timestamp: dateValue(session, key: "trimStart")
1882
                        ?? dateValue(session, key: "startedAt")
1883
                        ?? Date.distantPast,
Bogdan Timofte authored a month ago
1884
                    isCheckpoint: false
1885
                )
1886
            )
Bogdan Timofte authored a month ago
1887
        }
1888

            
1889
        let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
1890
            .compactMap(makeCheckpointSummary(from:))
1891
            .sorted { lhs, rhs in
1892
                if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1893
                    return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1894
                }
1895
                return lhs.timestamp < rhs.timestamp
1896
            }
Bogdan Timofte authored a month ago
1897
            .filter { $0.batteryPercent >= 0 }
1898
            .map {
1899
                Anchor(
1900
                    percent: $0.batteryPercent,
1901
                    energyWh: $0.measuredEnergyWh,
1902
                    timestamp: $0.timestamp,
1903
                    isCheckpoint: true
1904
                )
1905
            }
Bogdan Timofte authored a month ago
1906
        anchors.append(contentsOf: checkpointAnchors)
1907

            
1908
        guard !anchors.isEmpty else {
1909
            return optionalDoubleValue(session, key: "endBatteryPercent")
1910
        }
1911

            
Bogdan Timofte authored a month ago
1912
        let lowerAnchor = anchors.last { $0.energyWh <= measuredEnergyWh + 0.05 }
1913
        let upperAnchor = anchors.first { $0.energyWh > measuredEnergyWh + 0.05 }
1914
        let anchor = lowerAnchor ?? upperAnchor ?? anchors.first!
1915

            
1916
        if let lowerAnchor,
1917
           let upperAnchor,
1918
           upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
1919
            let interpolationProgress = min(
1920
                max(
1921
                    (measuredEnergyWh - lowerAnchor.energyWh) /
1922
                    (upperAnchor.energyWh - lowerAnchor.energyWh),
1923
                    0
1924
                ),
1925
                1
1926
            )
1927
            return min(
1928
                max(
1929
                    lowerAnchor.percent +
1930
                    (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
1931
                    0
1932
                ),
1933
                100
1934
            )
1935
        }
1936

            
Bogdan Timofte authored a month ago
1937
        return BatteryLevelPredictionTuning.predictedPercent(
1938
            anchorPercent: anchor.percent,
1939
            anchorEnergyWh: anchor.energyWh,
1940
            anchorTimestamp: anchor.timestamp,
1941
            anchorIsCheckpoint: anchor.isCheckpoint,
1942
            effectiveEnergyWh: measuredEnergyWh,
Bogdan Timofte authored a month ago
1943
            referenceTimestamp: referenceTimestamp
1944
                ?? dateValue(session, key: "lastObservedAt")
1945
                ?? anchor.timestamp,
Bogdan Timofte authored a month ago
1946
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1947
        )
1948
    }
1949

            
Bogdan Timofte authored a month ago
1950
    private func effectiveBatteryEnergyWh(
1951
        rawMeasuredEnergyWh: Double,
1952
        for session: NSManagedObject
1953
    ) -> Double {
1954
        switch chargingTransportMode(for: session) {
1955
        case .wired:
1956
            return rawMeasuredEnergyWh
1957
        case .wireless:
1958
            if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 {
1959
                return rawMeasuredEnergyWh * factor
1960
            }
1961
            let sessionMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1962
            if let sessionEffectiveEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh"),
1963
               sessionMeasuredEnergyWh > 0 {
1964
                return rawMeasuredEnergyWh * (sessionEffectiveEnergyWh / sessionMeasuredEnergyWh)
1965
            }
1966
            return rawMeasuredEnergyWh
1967
        }
1968
    }
1969

            
1970
    private func refreshEstimatedBatteryPercents(for session: NSManagedObject) {
1971
        guard let sessionID = stringValue(session, key: "id") else {
1972
            return
1973
        }
1974

            
1975
        for sample in fetchSessionSampleObjects(forSessionID: sessionID) {
1976
            let effectiveEnergyWh = effectiveBatteryEnergyWh(
1977
                rawMeasuredEnergyWh: doubleValue(sample, key: "measuredEnergyWh"),
1978
                for: session
1979
            )
1980
            let percent = predictedBatteryPercent(
1981
                for: session,
1982
                effectiveEnergyWhOverride: effectiveEnergyWh,
1983
                referenceTimestamp: dateValue(sample, key: "timestamp")
1984
            )
1985
            setValue(percent, on: sample, key: "estimatedBatteryPercent")
1986
            setValue(Date(), on: sample, key: "updatedAt")
1987
        }
1988
    }
1989

            
Bogdan Timofte authored a month ago
1990
    private func resolvedEstimatedBatteryCapacityWh(
1991
        for session: NSManagedObject,
1992
        chargedDevice: NSManagedObject
1993
    ) -> Double? {
1994
        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
1995
           sessionCapacityEstimate > 0 {
1996
            return sessionCapacityEstimate
1997
        }
1998

            
1999
        switch chargingTransportMode(for: session) {
2000
        case .wired:
2001
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
2002
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2003
        case .wireless:
2004
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
2005
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2006
        }
2007
    }
2008

            
2009
    private func updateCapacityEstimate(for session: NSManagedObject) {
2010
        guard
2011
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2012
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
2013
        else {
2014
            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
2015
            session.setValue(nil, forKey: "capacityEstimateWh")
2016
            return
2017
        }
2018

            
2019
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2020
        let chargingMode = chargingTransportMode(for: session)
2021
        let wirelessResolution = chargingMode == .wireless
2022
            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
2023
            : nil
2024
        let effectiveBatteryEnergyWh = chargingMode == .wired
2025
            ? measuredEnergyWh
2026
            : wirelessResolution.map { measuredEnergyWh * $0.factor }
2027

            
2028
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
2029
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
2030
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
2031
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
2032

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

            
2035
        guard let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
Bogdan Timofte authored a month ago
2036
            session.setValue(nil, forKey: "capacityEstimateWh")
2037
            return
2038
        }
2039

            
Bogdan Timofte authored a month ago
2040
        struct CapacityAnchor {
2041
            let percent: Double
2042
            let energyWh: Double
2043
            let timestamp: Date
2044
        }
2045

            
2046
        var anchors: [CapacityAnchor] = []
2047

            
2048
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2049
           startBatteryPercent >= 0 {
2050
            anchors.append(
2051
                CapacityAnchor(
2052
                    percent: startBatteryPercent,
2053
                    energyWh: 0,
2054
                    timestamp: dateValue(session, key: "trimStart")
2055
                        ?? dateValue(session, key: "startedAt")
2056
                        ?? Date.distantPast
2057
                )
2058
            )
2059
        }
2060

            
2061
        if let sessionID = stringValue(session, key: "id") {
2062
            anchors.append(
2063
                contentsOf: fetchCheckpointObjects(forSessionID: sessionID).compactMap { checkpoint in
2064
                    guard
2065
                        let percent = optionalDoubleValue(checkpoint, key: "batteryPercent"),
2066
                        percent >= 0,
2067
                        let timestamp = dateValue(checkpoint, key: "timestamp")
2068
                    else {
2069
                        return nil
2070
                    }
2071

            
2072
                    return CapacityAnchor(
2073
                        percent: percent,
2074
                        energyWh: doubleValue(checkpoint, key: "measuredEnergyWh"),
2075
                        timestamp: timestamp
2076
                    )
2077
                }
2078
            )
2079
        }
2080

            
2081
        if let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"),
2082
           endBatteryPercent >= 0 {
2083
            anchors.append(
2084
                CapacityAnchor(
2085
                    percent: endBatteryPercent,
2086
                    energyWh: effectiveBatteryEnergyWh,
2087
                    timestamp: dateValue(session, key: "endedAt")
2088
                        ?? dateValue(session, key: "lastObservedAt")
2089
                        ?? Date.distantPast
2090
                )
2091
            )
2092
        }
2093

            
2094
        let sortedAnchors = anchors.sorted { lhs, rhs in
2095
            if lhs.energyWh != rhs.energyWh {
2096
                return lhs.energyWh < rhs.energyWh
2097
            }
2098
            return lhs.timestamp < rhs.timestamp
2099
        }
2100

            
2101
        guard let firstAnchor = sortedAnchors.first,
2102
              let lastAnchor = sortedAnchors.last else {
Bogdan Timofte authored a month ago
2103
            session.setValue(nil, forKey: "capacityEstimateWh")
2104
            return
2105
        }
2106

            
Bogdan Timofte authored a month ago
2107
        let percentDelta = lastAnchor.percent - firstAnchor.percent
2108
        let energyDelta = lastAnchor.energyWh - firstAnchor.energyWh
Bogdan Timofte authored a month ago
2109

            
Bogdan Timofte authored a month ago
2110
        guard percentDelta >= 20, energyDelta > 0 else {
Bogdan Timofte authored a month ago
2111
            session.setValue(nil, forKey: "capacityEstimateWh")
2112
            return
2113
        }
2114

            
Bogdan Timofte authored a month ago
2115
        if !supportsChargingWhileOff && lastAnchor.percent >= 99.5 {
Bogdan Timofte authored a month ago
2116
            session.setValue(nil, forKey: "capacityEstimateWh")
2117
            return
2118
        }
2119

            
Bogdan Timofte authored a month ago
2120
        let capacityEstimateWh = energyDelta / (percentDelta / 100)
Bogdan Timofte authored a month ago
2121
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
2122
    }
2123

            
2124
    @discardableResult
Bogdan Timofte authored a month ago
2125
    private func insertBatteryCheckpoint(
Bogdan Timofte authored a month ago
2126
        percent: Double,
Bogdan Timofte authored a month ago
2127
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
2128
        timestamp: Date = Date(),
Bogdan Timofte authored a month ago
2129
        measuredEnergyWhOverride: Double? = nil,
Bogdan Timofte authored a month ago
2130
        to session: NSManagedObject
Bogdan Timofte authored a month ago
2131
    ) -> String? {
Bogdan Timofte authored a month ago
2132
        guard
2133
            let sessionID = stringValue(session, key: "id"),
2134
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2135
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
2136
        else {
Bogdan Timofte authored a month ago
2137
            return nil
Bogdan Timofte authored a month ago
2138
        }
2139

            
2140
        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
Bogdan Timofte authored a month ago
2141
        let checkpointEnergyWh = measuredEnergyWhOverride
2142
            ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
Bogdan Timofte authored a month ago
2143
            ?? doubleValue(session, key: "measuredEnergyWh")
2144
        checkpoint.setValue(UUID().uuidString, forKey: "id")
2145
        checkpoint.setValue(sessionID, forKey: "sessionID")
2146
        checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
Bogdan Timofte authored a month ago
2147
        checkpoint.setValue(timestamp, forKey: "timestamp")
Bogdan Timofte authored a month ago
2148
        checkpoint.setValue(percent, forKey: "batteryPercent")
2149
        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
2150
        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
2151
        checkpoint.setValue(
2152
            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
2153
            forKey: "voltageVolts"
2154
        )
Bogdan Timofte authored a month ago
2155
        checkpoint.setValue(flag.rawValue, forKey: "label")
Bogdan Timofte authored a month ago
2156
        checkpoint.setValue(timestamp, forKey: "createdAt")
Bogdan Timofte authored a month ago
2157

            
Bogdan Timofte authored a month ago
2158
        let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
2159
        if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
Bogdan Timofte authored a month ago
2160
            session.setValue(percent, forKey: "startBatteryPercent")
2161
        }
Bogdan Timofte authored a month ago
2162
        if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
2163
            session.setValue(percent, forKey: "endBatteryPercent")
2164
        }
Bogdan Timofte authored a month ago
2165
        session.setValue(timestamp, forKey: "updatedAt")
Bogdan Timofte authored a month ago
2166
        updateCapacityEstimate(for: session)
Bogdan Timofte authored a month ago
2167
        refreshEstimatedBatteryPercents(for: session)
Bogdan Timofte authored a month ago
2168

            
Bogdan Timofte authored a month ago
2169
        return chargedDeviceID
2170
    }
2171

            
Bogdan Timofte authored a month ago
2172
    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
2173
        guard let sessionID = stringValue(session, key: "id") else {
2174
            return
2175
        }
2176

            
2177
        let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
2178
        if let latestCheckpoint = remainingCheckpoints.last {
2179
            session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
2180
        } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2181
                  startBatteryPercent >= 0 {
2182
            session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
2183
        } else {
2184
            session.setValue(nil, forKey: "endBatteryPercent")
2185
        }
2186

            
2187
        session.setValue(Date(), forKey: "updatedAt")
2188
        updateCapacityEstimate(for: session)
Bogdan Timofte authored a month ago
2189
        refreshEstimatedBatteryPercents(for: session)
Bogdan Timofte authored a month ago
2190
    }
2191

            
Bogdan Timofte authored a month ago
2192
    @discardableResult
2193
    private func addBatteryCheckpoint(
2194
        percent: Double,
Bogdan Timofte authored a month ago
2195
        measuredEnergyWh: Double? = nil,
Bogdan Timofte authored a month ago
2196
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
2197
        to session: NSManagedObject,
2198
        timestamp: Date = Date()
2199
    ) -> Bool {
Bogdan Timofte authored a month ago
2200
        if let measuredEnergyWh, measuredEnergyWh.isFinite {
2201
            session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
2202
        }
2203

            
Bogdan Timofte authored a month ago
2204
        guard let chargedDeviceID = insertBatteryCheckpoint(
2205
            percent: percent,
Bogdan Timofte authored a month ago
2206
            flag: flag,
Bogdan Timofte authored a month ago
2207
            timestamp: timestamp,
Bogdan Timofte authored a month ago
2208
            measuredEnergyWhOverride: measuredEnergyWh,
Bogdan Timofte authored a month ago
2209
            to: session
2210
        ) else {
2211
            return false
2212
        }
2213

            
Bogdan Timofte authored a month ago
2214
        guard saveContext() else {
2215
            return false
2216
        }
2217

            
2218
        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
2219
        return saveContext()
2220
    }
2221

            
2222
    private func resolvedWirelessEfficiency(
2223
        for session: NSManagedObject,
2224
        chargedDevice: NSManagedObject
2225
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
2226
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
2227
           storedFactor > 0 {
2228
            return (
2229
                factor: storedFactor,
2230
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
2231
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
2232
            )
2233
        }
2234

            
2235
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
2236
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2237
        guard measuredEnergyWh > 0 else {
2238
            return nil
2239
        }
2240

            
2241
        if chargingProfile == .magsafe,
2242
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
2243
           calibratedFactor > 0 {
2244
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
2245
        }
2246

            
2247
        guard
2248
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2249
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
2250
        else {
2251
            return nil
2252
        }
2253

            
2254
        let percentDelta = endBatteryPercent - startBatteryPercent
2255
        guard percentDelta >= 20 else {
2256
            return nil
2257
        }
2258

            
2259
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
Bogdan Timofte authored a month ago
2260
            ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
Bogdan Timofte authored a month ago
2261
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2262
                : nil),
2263
              wiredCapacityWh > 0
2264
        else {
2265
            return nil
2266
        }
2267

            
2268
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
2269
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
2270
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
2271
        let usesEstimated = chargingProfile != .magsafe
2272
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
2273

            
2274
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
2275
    }
2276

            
2277
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
2278
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
2279
            return
2280
        }
2281

            
Bogdan Timofte authored a month ago
2282
        let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
Bogdan Timofte authored a month ago
2283
        let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
2284
        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
2285
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
2286
        let sessions = relevantSessionObjects(
2287
            for: chargedDeviceID,
2288
            deviceClass: deviceClass,
2289
            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
2290
            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
2291
        )
Bogdan Timofte authored a month ago
2292
        let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
Bogdan Timofte authored a month ago
2293
        let wiredMinimumCurrent = derivedMinimumCurrent(
2294
            from: sessions,
2295
            chargingTransportMode: .wired
2296
        )
2297
        let wirelessMinimumCurrent = derivedMinimumCurrent(
2298
            from: sessions,
2299
            chargingTransportMode: .wireless
2300
        )
2301

            
2302
        let wiredCapacity = derivedCapacity(
2303
            from: sessions,
2304
            chargingTransportMode: .wired,
2305
            supportsChargingWhileOff: supportsChargingWhileOff
2306
        )
2307
        let wirelessCapacity = derivedCapacity(
2308
            from: sessions,
2309
            chargingTransportMode: .wireless,
2310
            supportsChargingWhileOff: supportsChargingWhileOff
2311
        )
2312
        let wirelessEfficiency = derivedWirelessEfficiency(
2313
            from: sessions,
2314
            chargingProfile: wirelessProfile
2315
        )
Bogdan Timofte authored a month ago
2316
        let configuredCompletionCurrents = decodedCompletionCurrents(
2317
            from: chargedDevice,
2318
            key: "configuredCompletionCurrentsRawValue"
2319
        )
Bogdan Timofte authored a month ago
2320
        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
2321
        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
2322
        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
2323
        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
2324
        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
2325
        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
2326

            
Bogdan Timofte authored a month ago
2327
        let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
2328
        let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
Bogdan Timofte authored a month ago
2329
        let preferredMinimumCurrent: Double?
2330
        let preferredCapacity: Double?
2331
        switch preferredChargingTransportMode {
2332
        case .wired:
Bogdan Timofte authored a month ago
2333
            preferredMinimumCurrent = configuredCompletionCurrents[
2334
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2335
            ] ?? learnedCompletionCurrents[
2336
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2337
            ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
Bogdan Timofte authored a month ago
2338
            preferredCapacity = wiredCapacity ?? wirelessCapacity
2339
        case .wireless:
Bogdan Timofte authored a month ago
2340
            preferredMinimumCurrent = configuredCompletionCurrents[
2341
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2342
            ] ?? learnedCompletionCurrents[
2343
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2344
            ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
Bogdan Timofte authored a month ago
2345
            preferredCapacity = wirelessCapacity ?? wiredCapacity
2346
        }
2347

            
Bogdan Timofte authored a month ago
2348
        setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
2349
        setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
2350
        setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
2351
        setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
2352
        setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
2353
        setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
2354
        setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
2355
        setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
2356
        setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
2357
        setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
2358
        setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
2359
        setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
2360
        setValue(Date(), on: chargedDevice, key: "updatedAt")
Bogdan Timofte authored a month ago
2361
    }
2362

            
2363
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
2364
        sessions
2365
            .filter { $0.status == .completed }
2366
            .compactMap { session in
2367
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
2368
                let timestamp = session.endedAt ?? session.lastObservedAt
2369
                return CapacityTrendPoint(
2370
                    sessionID: session.id,
2371
                    timestamp: timestamp,
2372
                    capacityWh: capacityEstimateWh,
2373
                    chargingTransportMode: session.chargingTransportMode
2374
                )
2375
            }
2376
            .sorted { $0.timestamp < $1.timestamp }
2377
    }
2378

            
2379
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
2380
        var groupedEnergyByBin: [Int: [Double]] = [:]
2381

            
2382
        for session in sessions where session.status == .completed {
Bogdan Timofte authored a month ago
2383
            let anchors = normalizedTypicalCurveAnchors(for: session)
2384
            guard anchors.count >= 2 else {
2385
                continue
Bogdan Timofte authored a month ago
2386
            }
2387

            
Bogdan Timofte authored a month ago
2388
            for percentBin in stride(from: 0, through: 100, by: 10) {
Bogdan Timofte authored a month ago
2389
                guard let energyWh = interpolatedTypicalCurvePoint(
Bogdan Timofte authored a month ago
2390
                    for: Double(percentBin),
2391
                    anchors: anchors
2392
                ) else {
2393
                    continue
2394
                }
Bogdan Timofte authored a month ago
2395

            
Bogdan Timofte authored a month ago
2396
                groupedEnergyByBin[percentBin, default: []].append(energyWh)
Bogdan Timofte authored a month ago
2397
            }
2398
        }
2399

            
Bogdan Timofte authored a month ago
2400
        let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
Bogdan Timofte authored a month ago
2401
            guard let energies = groupedEnergyByBin[percentBin], !energies.isEmpty else {
Bogdan Timofte authored a month ago
2402
                return nil
2403
            }
2404

            
2405
            return TypicalChargeCurvePoint(
2406
                percentBin: percentBin,
2407
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
Bogdan Timofte authored a month ago
2408
                sampleCount: energies.count
Bogdan Timofte authored a month ago
2409
            )
2410
        }
Bogdan Timofte authored a month ago
2411

            
2412
        var runningMaximumEnergyWh = 0.0
2413

            
2414
        return averagedPoints.map { point in
2415
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
2416
            return TypicalChargeCurvePoint(
2417
                percentBin: point.percentBin,
2418
                averageEnergyWh: runningMaximumEnergyWh,
2419
                sampleCount: point.sampleCount
2420
            )
2421
        }
2422
    }
2423

            
2424
    private func normalizedTypicalCurveAnchors(
2425
        for session: ChargeSessionSummary
Bogdan Timofte authored a month ago
2426
    ) -> [(percent: Double, energyWh: Double)] {
Bogdan Timofte authored a month ago
2427
        struct Anchor {
2428
            let percent: Double
2429
            let energyWh: Double
2430
            let timestamp: Date
2431
        }
2432

            
2433
        var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
2434
            guard checkpoint.batteryPercent.isFinite,
2435
                  checkpoint.measuredEnergyWh.isFinite,
2436
                  checkpoint.batteryPercent >= 0,
2437
                  checkpoint.batteryPercent <= 100,
Bogdan Timofte authored a month ago
2438
                  checkpoint.measuredEnergyWh >= 0 else {
Bogdan Timofte authored a month ago
2439
                return nil
2440
            }
2441

            
2442
            return Anchor(
2443
                percent: checkpoint.batteryPercent,
2444
                energyWh: checkpoint.measuredEnergyWh,
2445
                timestamp: checkpoint.timestamp
2446
            )
2447
        }
2448

            
2449
        if let startBatteryPercent = session.startBatteryPercent,
2450
           startBatteryPercent.isFinite,
2451
           startBatteryPercent >= 0,
2452
           startBatteryPercent <= 100 {
2453
            anchors.append(
2454
                Anchor(
2455
                    percent: startBatteryPercent,
2456
                    energyWh: 0,
2457
                    timestamp: session.startedAt
2458
                )
2459
            )
2460
        }
2461

            
2462
        if let endBatteryPercent = session.endBatteryPercent,
2463
           endBatteryPercent.isFinite,
2464
           endBatteryPercent >= 0,
2465
           endBatteryPercent <= 100 {
2466
            anchors.append(
2467
                Anchor(
2468
                    percent: endBatteryPercent,
2469
                    energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
2470
                    timestamp: session.endedAt ?? session.lastObservedAt
2471
                )
2472
            )
2473
        }
2474

            
2475
        let sortedAnchors = anchors.sorted { lhs, rhs in
2476
            if lhs.percent != rhs.percent {
2477
                return lhs.percent < rhs.percent
2478
            }
2479
            if lhs.energyWh != rhs.energyWh {
2480
                return lhs.energyWh < rhs.energyWh
2481
            }
2482
            return lhs.timestamp < rhs.timestamp
2483
        }
2484

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

            
2487
        for anchor in sortedAnchors {
2488
            if let lastIndex = collapsedAnchors.indices.last,
2489
               abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
2490
                collapsedAnchors[lastIndex] = (
2491
                    percent: collapsedAnchors[lastIndex].percent,
Bogdan Timofte authored a month ago
2492
                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh)
Bogdan Timofte authored a month ago
2493
                )
2494
            } else {
2495
                collapsedAnchors.append(
Bogdan Timofte authored a month ago
2496
                    (percent: anchor.percent, energyWh: anchor.energyWh)
Bogdan Timofte authored a month ago
2497
                )
2498
            }
2499
        }
2500

            
2501
        var runningMaximumEnergyWh = 0.0
2502

            
2503
        return collapsedAnchors.map { anchor in
2504
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
2505
            return (
2506
                percent: anchor.percent,
Bogdan Timofte authored a month ago
2507
                energyWh: runningMaximumEnergyWh
Bogdan Timofte authored a month ago
2508
            )
2509
        }
2510
    }
2511

            
2512
    private func interpolatedTypicalCurvePoint(
2513
        for percent: Double,
Bogdan Timofte authored a month ago
2514
        anchors: [(percent: Double, energyWh: Double)]
2515
    ) -> Double? {
Bogdan Timofte authored a month ago
2516
        guard
2517
            let firstAnchor = anchors.first,
2518
            let lastAnchor = anchors.last,
2519
            percent >= firstAnchor.percent,
2520
            percent <= lastAnchor.percent
2521
        else {
2522
            return nil
2523
        }
2524

            
2525
        if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
Bogdan Timofte authored a month ago
2526
            return exactAnchor.energyWh
Bogdan Timofte authored a month ago
2527
        }
2528

            
2529
        guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
2530
              upperIndex > 0 else {
2531
            return nil
2532
        }
2533

            
2534
        let lowerAnchor = anchors[upperIndex - 1]
2535
        let upperAnchor = anchors[upperIndex]
2536
        let span = upperAnchor.percent - lowerAnchor.percent
2537
        guard span > 0.000_1 else {
2538
            return nil
2539
        }
2540

            
2541
        let ratio = (percent - lowerAnchor.percent) / span
Bogdan Timofte authored a month ago
2542
        return lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
Bogdan Timofte authored a month ago
2543
    }
2544

            
2545
    private func makeSessionSummary(
2546
        from object: NSManagedObject,
2547
        checkpoints: [NSManagedObject],
2548
        samples: [NSManagedObject]
2549
    ) -> ChargeSessionSummary? {
2550
        let chargingTransportMode = chargingTransportMode(for: object)
2551

            
2552
        guard
2553
            let id = uuidValue(object, key: "id"),
2554
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2555
            let startedAt = dateValue(object, key: "startedAt"),
2556
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
2557
            let status = statusValue(object, key: "statusRawValue"),
2558
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
2559
        else {
2560
            return nil
2561
        }
2562

            
2563
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
2564
            .sorted { $0.timestamp < $1.timestamp }
2565
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
2566
            .sorted { lhs, rhs in
2567
                if lhs.bucketIndex != rhs.bucketIndex {
2568
                    return lhs.bucketIndex < rhs.bucketIndex
2569
                }
2570
                return lhs.timestamp < rhs.timestamp
2571
            }
2572

            
2573
        return ChargeSessionSummary(
2574
            id: id,
2575
            chargedDeviceID: chargedDeviceID,
2576
            chargerID: uuidValue(object, key: "chargerID"),
2577
            meterMACAddress: stringValue(object, key: "meterMACAddress"),
2578
            meterName: stringValue(object, key: "meterName"),
2579
            meterModel: stringValue(object, key: "meterModel"),
2580
            startedAt: startedAt,
2581
            endedAt: dateValue(object, key: "endedAt"),
2582
            lastObservedAt: lastObservedAt,
Bogdan Timofte authored a month ago
2583
            pausedAt: dateValue(object, key: "pausedAt"),
Bogdan Timofte authored a month ago
2584
            status: status,
2585
            sourceMode: sourceMode,
2586
            chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
2587
            chargingStateMode: chargingStateMode(for: object),
2588
            autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
Bogdan Timofte authored a month ago
2589
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2590
            effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
Bogdan Timofte authored a month ago
2591
            meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
Bogdan Timofte authored a month ago
2592
            meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"),
2593
            meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"),
Bogdan Timofte authored a month ago
2594
            minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
2595
            maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
2596
            maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
2597
            maximumObservedVoltageVolts: chargingTransportMode == .wired
2598
                ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
2599
                : nil,
Bogdan Timofte authored a month ago
2600
            hasObservedChargeFlow: boolValue(object, key: "hasObservedChargeFlow"),
Bogdan Timofte authored a month ago
2601
            selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
2602
            completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
2603
            stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
2604
            startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
2605
            endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
2606
            capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
2607
            wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
2608
            usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
2609
            shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
2610
            supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
2611
            usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
2612
            targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
2613
            targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
2614
            requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
2615
            completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
2616
            completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
2617
            selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
Bogdan Timofte authored a month ago
2618
            trimStart: dateValue(object, key: "trimStart"),
2619
            trimEnd: dateValue(object, key: "trimEnd"),
Bogdan Timofte authored a month ago
2620
            wasConflictHealed: boolValue(object, key: "wasConflictHealed"),
Bogdan Timofte authored a month ago
2621
            checkpoints: checkpointSummaries,
2622
            aggregatedSamples: sampleSummaries
2623
        )
2624
    }
2625

            
2626
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
2627
        guard
2628
            let id = uuidValue(object, key: "id"),
2629
            let sessionID = uuidValue(object, key: "sessionID"),
2630
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2631
            let timestamp = dateValue(object, key: "timestamp")
2632
        else {
2633
            return nil
2634
        }
2635

            
2636
        return ChargeCheckpointSummary(
2637
            id: id,
2638
            sessionID: sessionID,
2639
            chargedDeviceID: chargedDeviceID,
2640
            timestamp: timestamp,
2641
            batteryPercent: doubleValue(object, key: "batteryPercent"),
2642
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2643
            currentAmps: doubleValue(object, key: "currentAmps"),
2644
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
2645
            label: stringValue(object, key: "label")
2646
        )
2647
    }
2648

            
2649
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
2650
        guard
2651
            let sessionID = uuidValue(object, key: "sessionID"),
2652
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2653
            let timestamp = dateValue(object, key: "timestamp")
2654
        else {
2655
            return nil
2656
        }
2657

            
2658
        return ChargeSessionSampleSummary(
2659
            sessionID: sessionID,
2660
            chargedDeviceID: chargedDeviceID,
2661
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
2662
            timestamp: timestamp,
2663
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
2664
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
2665
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
2666
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
Bogdan Timofte authored a month ago
2667
            estimatedBatteryPercent: optionalDoubleValue(object, key: "estimatedBatteryPercent"),
Bogdan Timofte authored a month ago
2668
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
2669
        )
2670
    }
2671

            
Bogdan Timofte authored a month ago
2672
    private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2673
        fetchSessionObject(
2674
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2675
                format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
Bogdan Timofte authored a month ago
2676
                normalizedMACAddress(meterMACAddress),
Bogdan Timofte authored a month ago
2677
                ChargeSessionStatus.active.rawValue,
2678
                ChargeSessionStatus.paused.rawValue
Bogdan Timofte authored a month ago
2679
            )
2680
        )
2681
    }
2682

            
Bogdan Timofte authored a month ago
2683
    private func fetchOpenSessionObjects() -> [NSManagedObject] {
2684
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2685
        request.predicate = NSPredicate(
2686
            format: "statusRawValue == %@ OR statusRawValue == %@",
2687
            ChargeSessionStatus.active.rawValue,
2688
            ChargeSessionStatus.paused.rawValue
2689
        )
2690
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2691
        return (try? context.fetch(request)) ?? []
2692
    }
2693

            
Bogdan Timofte authored a month ago
2694
    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2695
        fetchSessionObject(
2696
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2697
                format: "meterMACAddress == %@ AND statusRawValue == %@",
2698
                normalizedMACAddress(meterMACAddress),
2699
                ChargeSessionStatus.active.rawValue
Bogdan Timofte authored a month ago
2700
            )
2701
        )
2702
    }
2703

            
2704
    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
2705
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2706
        request.predicate = predicate
2707
        request.fetchLimit = 1
2708
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
2709
        return (try? context.fetch(request))?.first
2710
    }
2711

            
2712
    private func fetchSessionObject(id: String) -> NSManagedObject? {
2713
        fetchSessionObject(
2714
            predicate: NSPredicate(format: "id == %@", id)
2715
        )
2716
    }
2717

            
2718
    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
2719
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2720
        request.predicate = NSPredicate(
2721
            format: "sessionID == %@ AND bucketIndex == %d",
2722
            sessionID,
2723
            bucketIndex
2724
        )
2725
        request.fetchLimit = 1
2726
        return (try? context.fetch(request))?.first
2727
    }
2728

            
2729
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2730
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2731
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2732
        return (try? context.fetch(request)) ?? []
2733
    }
2734

            
Bogdan Timofte authored a month ago
2735
    private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
2736
        guard !sessionIDs.isEmpty else {
2737
            return []
2738
        }
2739

            
2740
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2741
        request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs)
2742
        return (try? context.fetch(request)) ?? []
2743
    }
2744

            
Bogdan Timofte authored a month ago
2745
    private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
2746
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2747
        request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
2748
        request.fetchLimit = 1
2749
        return (try? context.fetch(request))?.first
2750
    }
2751

            
Bogdan Timofte authored a month ago
2752
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2753
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2754
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2755
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
2756
        return (try? context.fetch(request)) ?? []
2757
    }
2758

            
2759
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
2760
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2761
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
2762
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2763
        return (try? context.fetch(request)) ?? []
2764
    }
2765

            
2766
    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
2767
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2768
        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
2769
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2770
        return (try? context.fetch(request)) ?? []
2771
    }
2772

            
Bogdan Timofte authored a month ago
2773
    private func sampleBackedSessionIDs(
2774
        devices: [NSManagedObject],
2775
        sessionsByDeviceID: [String: [NSManagedObject]],
2776
        sessionsByChargerID: [String: [NSManagedObject]]
2777
    ) -> Set<String> {
2778
        var sessionIDs: Set<String> = []
2779

            
2780
        for device in devices {
2781
            guard
2782
                let deviceID = stringValue(device, key: "id"),
2783
                let rawClass = stringValue(device, key: "deviceClassRawValue"),
2784
                let deviceClass = ChargedDeviceClass(rawValue: rawClass)
2785
            else {
2786
                continue
2787
            }
2788

            
2789
            let relevantSessions = relevantSessionObjects(
2790
                for: deviceID,
2791
                deviceClass: deviceClass,
2792
                sessionsByDeviceID: sessionsByDeviceID,
2793
                sessionsByChargerID: sessionsByChargerID
2794
            )
2795
            .sorted { lhs, rhs in
2796
                let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed
2797
                let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed
2798

            
2799
                if lhsStatus.isOpen && !rhsStatus.isOpen {
2800
                    return true
2801
                }
2802
                if !lhsStatus.isOpen && rhsStatus.isOpen {
2803
                    return false
2804
                }
2805

            
2806
                return (dateValue(lhs, key: "startedAt") ?? .distantPast)
2807
                    > (dateValue(rhs, key: "startedAt") ?? .distantPast)
2808
            }
2809

            
2810
            var recentCompletedSamplesIncluded = 0
2811

            
2812
            for session in relevantSessions {
2813
                guard let sessionID = stringValue(session, key: "id"),
2814
                      let status = statusValue(session, key: "statusRawValue") else {
2815
                    continue
2816
                }
2817

            
2818
                if status.isOpen {
2819
                    sessionIDs.insert(sessionID)
2820
                    continue
2821
                }
2822

            
2823
                guard recentCompletedSamplesIncluded < 2 else {
2824
                    continue
2825
                }
2826

            
2827
                sessionIDs.insert(sessionID)
2828
                recentCompletedSamplesIncluded += 1
2829
            }
2830
        }
2831

            
2832
        return sessionIDs
2833
    }
2834

            
Bogdan Timofte authored a month ago
2835
    private func relevantSessionObjects(
2836
        for chargedDeviceID: String,
2837
        deviceClass: ChargedDeviceClass,
2838
        sessionsByDeviceID: [String: [NSManagedObject]],
2839
        sessionsByChargerID: [String: [NSManagedObject]]
2840
    ) -> [NSManagedObject] {
2841
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
2842
        guard deviceClass == .charger else {
2843
            return directSessions
2844
        }
2845

            
2846
        var seenSessionIDs = Set<String>()
2847
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
2848
            .filter { session in
2849
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
2850
                return seenSessionIDs.insert(sessionID).inserted
2851
            }
2852
            .sorted {
2853
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
2854
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
2855
                return lhsDate < rhsDate
2856
            }
2857
    }
2858

            
Bogdan Timofte authored a month ago
2859
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
2860
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2861
    }
2862

            
Bogdan Timofte authored a month ago
2863
    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
2864
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2865
        request.predicate = NSPredicate(format: "id == %@", id)
2866
        request.fetchLimit = 1
2867
        return (try? context.fetch(request))?.first
2868
    }
2869

            
2870
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
2871
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
2872
        return (try? context.fetch(request)) ?? []
2873
    }
2874

            
2875
    private func resolvedStopThreshold(
2876
        for chargedDevice: NSManagedObject,
2877
        chargingTransportMode: ChargingTransportMode,
Bogdan Timofte authored a month ago
2878
        chargingStateMode: ChargingStateMode,
2879
        charger: NSManagedObject?,
2880
        fallback: Double?
2881
    ) -> Double? {
2882
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
2883
            return nil
2884
        }
2885

            
2886
        let sessionKind = ChargeSessionKind(
2887
            chargingTransportMode: chargingTransportMode,
2888
            chargingStateMode: chargingStateMode
2889
        )
2890
        let configuredCurrents = decodedCompletionCurrents(
2891
            from: chargedDevice,
2892
            key: "configuredCompletionCurrentsRawValue"
2893
        )
2894
        let learnedCurrents = decodedCompletionCurrents(
2895
            from: chargedDevice,
2896
            key: "learnedCompletionCurrentsRawValue"
2897
        )
2898
        let legacyCurrent: Double?
Bogdan Timofte authored a month ago
2899
        switch chargingTransportMode {
2900
        case .wired:
Bogdan Timofte authored a month ago
2901
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2902
                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
2903
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2904
        case .wireless:
Bogdan Timofte authored a month ago
2905
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2906
                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
2907
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2908
        }
Bogdan Timofte authored a month ago
2909

            
2910
        let resolvedCurrent = configuredCurrents[sessionKind]
2911
            ?? learnedCurrents[sessionKind]
2912
            ?? legacyCurrent
2913
            ?? fallback
2914
        guard let resolvedCurrent, resolvedCurrent > 0 else {
2915
            return nil
2916
        }
2917
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
2918
    }
2919

            
Bogdan Timofte authored a month ago
2920
    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
Bogdan Timofte authored a month ago
2921
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
2922
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
2923
        return resolvedPreferredChargingTransportMode(
Bogdan Timofte authored a month ago
2924
            .wired,
Bogdan Timofte authored a month ago
2925
            supportsWiredCharging: supportsWiredCharging,
2926
            supportsWirelessCharging: supportsWirelessCharging
2927
        )
2928
    }
2929

            
Bogdan Timofte authored a month ago
2930
    private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
2931
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other
2932
    }
2933

            
2934
    private func normalizedTemplateID(
2935
        _ templateID: String?,
2936
        kind: ChargedDeviceKind
2937
    ) -> String? {
2938
        guard let templateID,
2939
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2940
              templateDefinition.kind == kind else {
2941
            return nil
Bogdan Timofte authored a month ago
2942
        }
Bogdan Timofte authored a month ago
2943
        return templateDefinition.id
Bogdan Timofte authored a month ago
2944
    }
2945

            
Bogdan Timofte authored a month ago
2946
    private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
2947
        guard let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
2948
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2949
              templateDefinition.kind == deviceClass(for: chargedDevice).kind else {
2950
            return nil
Bogdan Timofte authored a month ago
2951
        }
Bogdan Timofte authored a month ago
2952
        return templateDefinition
2953
    }
2954

            
2955
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
2956
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2957
            ? true
2958
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2959
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2960
            ? false
2961
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2962
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2963
            supportsWiredCharging: persistedWiredCharging,
2964
            supportsWirelessCharging: persistedWirelessCharging
2965
        ).wired
2966
    }
2967

            
2968
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
2969
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2970
            ? true
2971
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2972
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2973
            ? false
2974
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2975
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2976
            supportsWiredCharging: persistedWiredCharging,
2977
            supportsWirelessCharging: persistedWirelessCharging
2978
        ).wireless
2979
    }
2980

            
2981
    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
2982
        let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue")
2983
            .flatMap(ChargingStateAvailability.init(rawValue:))
2984
            ?? ChargingStateAvailability.fallback(
Bogdan Timofte authored a month ago
2985
            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
2986
        )
Bogdan Timofte authored a month ago
2987
        return deviceClass(for: chargedDevice).normalizedChargingStateAvailability(persistedAvailability)
Bogdan Timofte authored a month ago
2988
    }
2989

            
2990
    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
Bogdan Timofte authored a month ago
2991
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2992
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2993
            let persistedChargingStateMode = stringValue(session, key: "chargingStateRawValue")
2994
                .flatMap(ChargingStateMode.init(rawValue:))
2995
                ?? .on
2996
            return resolvedChargingStateMode(
2997
                persistedChargingStateMode,
2998
                availability: chargingStateAvailability(for: chargedDevice)
2999
            )
3000
        }
3001

            
Bogdan Timofte authored a month ago
3002
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
3003
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
3004
            return chargingStateMode
3005
        }
3006

            
3007
        return .on
3008
    }
3009

            
3010
    private func resolvedChargingStateMode(
3011
        _ chargingStateMode: ChargingStateMode,
3012
        availability: ChargingStateAvailability
3013
    ) -> ChargingStateMode {
3014
        if availability.supportedModes.contains(chargingStateMode) {
3015
            return chargingStateMode
3016
        }
3017
        return availability.supportedModes.first ?? .on
3018
    }
3019

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

            
3024
        // Primary: chargerTypeRawValue (set on v13+)
3025
        if let rawValue = stringValue(chargedDevice, key: "chargerTypeRawValue"),
3026
           let type = ChargerType(rawValue: rawValue) {
3027
            return type
3028
        }
3029

            
3030
        // Migration fallback: derive from old deviceTemplateID
3031
        switch stringValue(chargedDevice, key: "deviceTemplateID") {
3032
        case "apple-magsafe-charger": return .appleMagSafe
3033
        case "apple-watch-charger": return .appleWatch
3034
        default: break
3035
        }
3036

            
3037
        // Last resort: derive from wirelessChargingProfileRawValue
3038
        if let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
3039
           let profile = WirelessChargingProfile(rawValue: rawValue),
3040
           profile == .magsafe {
3041
            return .genericMagSafe
3042
        }
3043

            
3044
        return .genericQi
3045
    }
3046

            
Bogdan Timofte authored a month ago
3047
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
Bogdan Timofte authored a month ago
3048
        if let type = chargerType(for: chargedDevice) {
3049
            return type.wirelessChargingProfile
3050
        }
Bogdan Timofte authored a month ago
3051
        guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
3052
              let profile = WirelessChargingProfile(rawValue: rawValue) else {
3053
            return .genericQi
3054
        }
3055
        return profile
3056
    }
3057

            
3058
    private func resolvedPreferredChargingTransportMode(
3059
        _ preferredChargingTransportMode: ChargingTransportMode,
3060
        supportsWiredCharging: Bool,
3061
        supportsWirelessCharging: Bool
3062
    ) -> ChargingTransportMode {
3063
        switch preferredChargingTransportMode {
3064
        case .wired where supportsWiredCharging:
3065
            return .wired
3066
        case .wireless where supportsWirelessCharging:
3067
            return .wireless
3068
        default:
3069
            if supportsWiredCharging {
3070
                return .wired
3071
            }
3072
            if supportsWirelessCharging {
3073
                return .wireless
3074
            }
3075
            return .wired
3076
        }
3077
    }
3078

            
Bogdan Timofte authored a month ago
3079
    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
3080
        let payload = Dictionary(
3081
            uniqueKeysWithValues: currents.map { key, value in
3082
                (key.rawValue, value)
3083
            }
3084
        )
3085
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
3086
            return nil
3087
        }
3088
        return String(data: data, encoding: .utf8)
3089
    }
3090

            
3091
    private func decodedCompletionCurrents(
3092
        from object: NSManagedObject,
3093
        key: String
3094
    ) -> [ChargeSessionKind: Double] {
3095
        guard let rawValue = stringValue(object, key: key),
3096
              let data = rawValue.data(using: .utf8),
3097
              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
3098
            return [:]
3099
        }
3100

            
3101
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
3102
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
3103
                return
3104
            }
3105
            result[sessionKind] = entry.value
3106
        }
3107
    }
3108

            
3109
    private func legacyConfiguredCompletionCurrent(
3110
        for currents: [ChargeSessionKind: Double],
3111
        chargingTransportMode: ChargingTransportMode
3112
    ) -> Double? {
3113
        let candidates = currents
3114
            .filter { $0.key.chargingTransportMode == chargingTransportMode }
3115
            .sorted { lhs, rhs in
3116
                lhs.key.rawValue < rhs.key.rawValue
3117
            }
3118
            .map(\.value)
3119
        return candidates.first
3120
    }
3121

            
3122
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
3123
        guard let charger else {
3124
            return nil
3125
        }
3126
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
3127
        guard let idleCurrent, idleCurrent >= 0 else {
3128
            return nil
3129
        }
3130
        return idleCurrent
3131
    }
3132

            
3133
    private func effectiveCurrentAmps(
3134
        fromMeasuredCurrent currentAmps: Double,
3135
        chargingTransportMode: ChargingTransportMode,
3136
        charger: NSManagedObject?
3137
    ) -> Double {
3138
        switch chargingTransportMode {
3139
        case .wired:
3140
            return max(currentAmps, 0)
3141
        case .wireless:
3142
            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
3143
                return max(currentAmps, 0)
3144
            }
3145
            return max(currentAmps - idleCurrent, 0)
3146
        }
3147
    }
3148

            
3149
    private func hasObservedChargeFlow(
3150
        currentAmps: Double,
3151
        chargingTransportMode: ChargingTransportMode,
3152
        charger: NSManagedObject?,
3153
        stopThreshold: Double?
3154
    ) -> Bool {
3155
        let effectiveCurrent = effectiveCurrentAmps(
3156
            fromMeasuredCurrent: currentAmps,
3157
            chargingTransportMode: chargingTransportMode,
3158
            charger: charger
3159
        )
3160
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
3161
    }
3162

            
Bogdan Timofte authored a month ago
3163
    private func hasSavableChargeData(_ session: NSManagedObject) -> Bool {
Bogdan Timofte authored a month ago
3164
        if boolValue(session, key: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
3165
            || doubleValue(session, key: "measuredEnergyWh") > 0
3166
            || doubleValue(session, key: "measuredChargeAh") > 0
3167
            || (optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? 0) > 0
Bogdan Timofte authored a month ago
3168
            || (optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? 0) > 0 {
3169
            return true
3170
        }
3171

            
3172
        guard let sessionID = stringValue(session, key: "id") else {
3173
            return false
3174
        }
3175

            
3176
        return fetchSessionSampleObjects(forSessionID: sessionID).contains { sample in
3177
            doubleValue(sample, key: "measuredEnergyWh") > 0
3178
                || doubleValue(sample, key: "measuredChargeAh") > 0
3179
                || (optionalDoubleValue(sample, key: "averageCurrentAmps") ?? 0) > 0
3180
                || (optionalDoubleValue(sample, key: "averagePowerWatts") ?? 0) > 0
3181
        }
3182
    }
3183

            
3184
    private func restoreMeasuredTotalsFromLatestSampleIfNeeded(_ session: NSManagedObject) {
3185
        guard let sessionID = stringValue(session, key: "id"),
3186
              let latestSample = fetchSessionSampleObjects(forSessionID: sessionID).max(by: {
3187
                  (dateValue($0, key: "timestamp") ?? .distantPast) < (dateValue($1, key: "timestamp") ?? .distantPast)
3188
              }) else {
3189
            return
3190
        }
3191

            
3192
        let sampleEnergyWh = doubleValue(latestSample, key: "measuredEnergyWh")
3193
        if doubleValue(session, key: "measuredEnergyWh") <= 0, sampleEnergyWh > 0 {
3194
            session.setValue(sampleEnergyWh, forKey: "measuredEnergyWh")
3195
        }
3196

            
3197
        let sampleChargeAh = doubleValue(latestSample, key: "measuredChargeAh")
3198
        if doubleValue(session, key: "measuredChargeAh") <= 0, sampleChargeAh > 0 {
3199
            session.setValue(sampleChargeAh, forKey: "measuredChargeAh")
3200
        }
Bogdan Timofte authored a month ago
3201
    }
3202

            
Bogdan Timofte authored a month ago
3203
    private func derivedMinimumCurrent(
3204
        from sessions: [NSManagedObject],
3205
        chargingTransportMode: ChargingTransportMode
3206
    ) -> Double? {
3207
        let completionCurrents = sessions.compactMap { session -> Double? in
3208
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3209
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
3210
                return nil
3211
            }
Bogdan Timofte authored a month ago
3212
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3213
                return nil
3214
            }
Bogdan Timofte authored a month ago
3215
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
3216
                return nil
3217
            }
3218
            return completionCurrent
3219
        }
3220

            
3221
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
3222
        guard !recentCompletionCurrents.isEmpty else { return nil }
3223
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
3224
    }
3225

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

            
3229
        for session in sessions {
3230
            guard statusValue(session, key: "statusRawValue") == .completed else {
3231
                continue
3232
            }
3233
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3234
                continue
3235
            }
3236
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
3237
                  completionCurrent > 0 else {
3238
                continue
3239
            }
3240

            
3241
            let sessionKind = ChargeSessionKind(
3242
                chargingTransportMode: chargingTransportMode(for: session),
3243
                chargingStateMode: chargingStateMode(for: session)
3244
            )
3245
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
3246
        }
3247

            
3248
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
3249
            let recentCurrents = Array(entry.value.suffix(5))
3250
            guard !recentCurrents.isEmpty else {
3251
                return
3252
            }
3253
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
3254
        }
3255
    }
3256

            
Bogdan Timofte authored a month ago
3257
    private func derivedCapacity(
3258
        from sessions: [NSManagedObject],
3259
        chargingTransportMode: ChargingTransportMode,
3260
        supportsChargingWhileOff: Bool
3261
    ) -> Double? {
3262
        let capacityCandidates = sessions.compactMap { session -> Double? in
3263
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3264
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
3265
                return nil
3266
            }
3267
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
3268
                return nil
3269
            }
3270
            if supportsChargingWhileOff {
3271
                return capacityEstimate
3272
            }
3273
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
3274
                return nil
3275
            }
3276
            return capacityEstimate
3277
        }
3278

            
3279
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
3280
        guard !recentCapacityCandidates.isEmpty else { return nil }
3281
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
3282
    }
3283

            
3284
    private func derivedWirelessEfficiency(
3285
        from sessions: [NSManagedObject],
3286
        chargingProfile: WirelessChargingProfile
3287
    ) -> Double? {
3288
        guard chargingProfile == .magsafe else {
3289
            return nil
3290
        }
3291

            
3292
        let candidates = sessions.compactMap { session -> Double? in
3293
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3294
            guard chargingTransportMode(for: session) == .wireless else { return nil }
3295
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
3296
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3297
                return nil
3298
            }
3299
            return factor
3300
        }
3301

            
3302
        let recentCandidates = Array(candidates.suffix(6))
3303
        guard !recentCandidates.isEmpty else { return nil }
3304
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3305
    }
3306

            
3307
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
3308
        let candidates = sessions.compactMap { session -> Double? in
3309
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3310
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
3311
                return nil
3312
            }
3313
            return (sourceVoltage * 10).rounded() / 10
3314
        }
3315

            
3316
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
3317
        return counts.keys.sorted()
3318
    }
3319

            
3320
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
3321
        let candidates = sessions.compactMap { session -> Double? in
3322
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3323
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
3324
                return nil
3325
            }
3326
            return minimumObservedCurrent
3327
        }
3328

            
3329
        let recentCandidates = Array(candidates.suffix(6))
3330
        guard !recentCandidates.isEmpty else { return nil }
3331
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3332
    }
3333

            
3334
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
3335
        let candidates = sessions.compactMap { session -> Double? in
3336
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3337
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3338
                return nil
3339
            }
3340
            return factor
3341
        }
3342

            
3343
        let recentCandidates = Array(candidates.suffix(6))
3344
        guard !recentCandidates.isEmpty else { return nil }
3345
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3346
    }
3347

            
3348
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
3349
        sessions.compactMap { session -> Double? in
3350
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3351
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
3352
                return nil
3353
            }
3354
            return maximumObservedPower
3355
        }
3356
        .max()
3357
    }
3358

            
3359
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
3360
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
3361
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
3362
            return resolvedPreferredChargingTransportMode(
3363
                chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired,
3364
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
3365
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
3366
            )
3367
        }
3368

            
3369
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
3370
            return persistedChargingTransportMode
Bogdan Timofte authored a month ago
3371
        }
3372

            
3373
        return .wired
3374
    }
3375

            
3376
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
3377
        if session.isInserted {
3378
            return .created
3379
        }
3380

            
3381
        let committedValues = session.committedValues(
3382
            forKeys: [
3383
                "statusRawValue",
3384
                "updatedAt",
3385
                "targetBatteryAlertTriggeredAt",
3386
                "requiresCompletionConfirmation"
3387
            ]
3388
        )
3389
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
3390
        let currentStatus = statusValue(session, key: "statusRawValue")
3391
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
3392
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
3393
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
3394
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
3395
            ?? false
3396
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
3397

            
3398
        if currentStatus == .completed, committedStatus != .completed {
3399
            return .completed
3400
        }
3401

            
Bogdan Timofte authored a month ago
3402
        if currentStatus != committedStatus {
3403
            return .event
3404
        }
3405

            
Bogdan Timofte authored a month ago
3406
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
3407
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
3408
            return .event
3409
        }
3410

            
3411
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3412
            ?? dateValue(session, key: "createdAt")
3413
            ?? observedAt
3414

            
3415
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
3416
            return .periodic
3417
        }
3418

            
3419
        return .none
3420
    }
3421

            
Bogdan Timofte authored a month ago
3422
    private func shouldPersistAggregatedSample(
3423
        _ sample: NSManagedObject,
3424
        observedAt: Date
3425
    ) -> Bool {
3426
        if sample.isInserted {
3427
            return true
3428
        }
3429

            
3430
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
3431
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3432
            ?? dateValue(sample, key: "createdAt")
3433
            ?? observedAt
3434

            
3435
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
3436
    }
3437

            
Bogdan Timofte authored a month ago
3438
    private func generateQRIdentifier() -> String {
3439
        "device:\(UUID().uuidString)"
3440
    }
3441

            
3442
    @discardableResult
3443
    private func saveContext() -> Bool {
3444
        guard context.hasChanges else { return true }
3445
        do {
3446
            try context.save()
3447
            return true
3448
        } catch {
3449
            track("Failed saving charge insights context: \(error)")
3450
            context.rollback()
3451
            return false
3452
        }
3453
    }
3454

            
3455
    private func normalizedText(_ text: String) -> String {
3456
        text.trimmingCharacters(in: .whitespacesAndNewlines)
3457
    }
3458

            
3459
    private func normalizedOptionalText(_ text: String?) -> String? {
3460
        guard let text else { return nil }
3461
        let normalized = normalizedText(text)
3462
        return normalized.isEmpty ? nil : normalized
3463
    }
3464

            
3465
    private func normalizedMACAddress(_ macAddress: String) -> String {
3466
        normalizedText(macAddress).uppercased()
3467
    }
3468

            
Bogdan Timofte authored a month ago
3469
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
3470
        guard object.entity.propertiesByName[key] != nil else {
3471
            return nil
3472
        }
3473
        return object.value(forKey: key)
3474
    }
3475

            
3476
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
3477
        guard object.entity.propertiesByName[key] != nil else {
3478
            return
3479
        }
3480
        object.setValue(value, forKey: key)
3481
    }
3482

            
Bogdan Timofte authored a month ago
3483
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
3484
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
3485
        let normalized = normalizedOptionalText(value)
3486
        return normalized
3487
    }
3488

            
3489
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
3490
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
3491
    }
3492

            
3493
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
3494
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
3495
            return value
3496
        }
Bogdan Timofte authored a month ago
3497
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3498
            return value.doubleValue
3499
        }
3500
        return 0
3501
    }
3502

            
3503
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
3504
        let value = rawValue(object, key: key)
3505
        if value == nil {
Bogdan Timofte authored a month ago
3506
            return nil
3507
        }
3508
        return doubleValue(object, key: key)
3509
    }
3510

            
3511
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
Bogdan Timofte authored a month ago
3512
        if let value = rawValue(object, key: key) as? Int16 {
Bogdan Timofte authored a month ago
3513
            return value
3514
        }
Bogdan Timofte authored a month ago
3515
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3516
            return value.int16Value
3517
        }
3518
        return nil
3519
    }
3520

            
3521
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
3522
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
3523
            return value
3524
        }
Bogdan Timofte authored a month ago
3525
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3526
            return value.int32Value
3527
        }
3528
        return nil
3529
    }
3530

            
3531
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
Bogdan Timofte authored a month ago
3532
        if let value = rawValue(object, key: key) as? Bool {
Bogdan Timofte authored a month ago
3533
            return value
3534
        }
Bogdan Timofte authored a month ago
3535
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3536
            return value.boolValue
3537
        }
3538
        return false
3539
    }
3540

            
3541
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
3542
        guard let value = stringValue(object, key: key) else { return nil }
3543
        return UUID(uuidString: value)
3544
    }
3545

            
3546
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
3547
        guard let value = stringValue(object, key: key) else { return nil }
3548
        return ChargeSessionStatus(rawValue: value)
3549
    }
3550

            
3551
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
3552
        guard let value = stringValue(object, key: key) else { return nil }
3553
        return ChargingTransportMode(rawValue: value)
3554
    }
3555

            
3556
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
3557
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
3558
            return []
3559
        }
3560
        return rawValue
3561
            .split(separator: ",")
3562
            .compactMap { Double($0) }
3563
            .sorted()
3564
    }
3565

            
3566
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
3567
        let uniqueVoltages = Array(Set(voltages)).sorted()
3568
        guard !uniqueVoltages.isEmpty else {
3569
            return nil
3570
        }
3571
        return uniqueVoltages
3572
            .map { String(format: "%.1f", $0) }
3573
            .joined(separator: ",")
3574
    }
3575

            
3576
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
3577
        guard currentCount > 0 else {
3578
            return newValue
3579
        }
3580
        let total = (currentAverage * Double(currentCount)) + newValue
3581
        return total / Double(currentCount + 1)
3582
    }
3583
}
3584

            
3585
private enum ObservationSaveReason {
3586
    case none
3587
    case created
3588
    case periodic
3589
    case completed
3590
    case event
3591
}