USB-Meter / USB Meter / Model / ChargeInsightsStore.swift
Newer Older
3527 lines | 154.404kb
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")
1625
        sample.setValue(Int16(updatedCount), forKey: "sampleCount")
1626
        sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
1627
        sample.setValue(snapshot.observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1628
        return sample
Bogdan Timofte authored a month ago
1629
    }
1630

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

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

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

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

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

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

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

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

            
1671
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1672
    }
1673

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

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

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

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

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

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

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

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

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

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

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

            
1748
        return completionDate
1749
    }
1750

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

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

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

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

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

            
1778
        return true
1779
    }
1780

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
1844
    private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
1845
        guard
1846
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1847
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
1848
            let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice),
1849
            estimatedCapacityWh > 0
1850
        else {
1851
            return nil
1852
        }
1853

            
Bogdan Timofte authored a month ago
1854
        // Compute effective battery energy dynamically so the prediction uses the
1855
        // most current measuredEnergyWh rather than the stale effectiveBatteryEnergyWh
1856
        // (which is only refreshed at session start, checkpoint insertion, and finish).
1857
        let rawMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1858
        let measuredEnergyWh: Double
1859
        switch chargingTransportMode(for: session) {
1860
        case .wired:
1861
            measuredEnergyWh = rawMeasuredEnergyWh
1862
        case .wireless:
1863
            if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 {
1864
                measuredEnergyWh = rawMeasuredEnergyWh * factor
1865
            } else {
1866
                measuredEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1867
                    ?? rawMeasuredEnergyWh
1868
            }
1869
        }
Bogdan Timofte authored a month ago
1870
        let sessionID = stringValue(session, key: "id") ?? ""
1871

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

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

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

            
1913
        guard !anchors.isEmpty else {
1914
            return optionalDoubleValue(session, key: "endBatteryPercent")
1915
        }
1916

            
1917
        let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
Bogdan Timofte authored a month ago
1918
        return BatteryLevelPredictionTuning.predictedPercent(
1919
            anchorPercent: anchor.percent,
1920
            anchorEnergyWh: anchor.energyWh,
1921
            anchorTimestamp: anchor.timestamp,
1922
            anchorIsCheckpoint: anchor.isCheckpoint,
1923
            effectiveEnergyWh: measuredEnergyWh,
1924
            referenceTimestamp: dateValue(session, key: "lastObservedAt") ?? anchor.timestamp,
1925
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1926
        )
1927
    }
1928

            
1929
    private func resolvedEstimatedBatteryCapacityWh(
1930
        for session: NSManagedObject,
1931
        chargedDevice: NSManagedObject
1932
    ) -> Double? {
1933
        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
1934
           sessionCapacityEstimate > 0 {
1935
            return sessionCapacityEstimate
1936
        }
1937

            
1938
        switch chargingTransportMode(for: session) {
1939
        case .wired:
1940
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1941
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1942
        case .wireless:
1943
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1944
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1945
        }
1946
    }
1947

            
1948
    private func updateCapacityEstimate(for session: NSManagedObject) {
1949
        guard
1950
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1951
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
1952
        else {
1953
            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
1954
            session.setValue(nil, forKey: "capacityEstimateWh")
1955
            return
1956
        }
1957

            
1958
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1959
        let chargingMode = chargingTransportMode(for: session)
1960
        let wirelessResolution = chargingMode == .wireless
1961
            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
1962
            : nil
1963
        let effectiveBatteryEnergyWh = chargingMode == .wired
1964
            ? measuredEnergyWh
1965
            : wirelessResolution.map { measuredEnergyWh * $0.factor }
1966

            
1967
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
1968
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
1969
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
1970
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
1971

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

            
1974
        guard let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
Bogdan Timofte authored a month ago
1975
            session.setValue(nil, forKey: "capacityEstimateWh")
1976
            return
1977
        }
1978

            
Bogdan Timofte authored a month ago
1979
        struct CapacityAnchor {
1980
            let percent: Double
1981
            let energyWh: Double
1982
            let timestamp: Date
1983
        }
1984

            
1985
        var anchors: [CapacityAnchor] = []
1986

            
1987
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1988
           startBatteryPercent >= 0 {
1989
            anchors.append(
1990
                CapacityAnchor(
1991
                    percent: startBatteryPercent,
1992
                    energyWh: 0,
1993
                    timestamp: dateValue(session, key: "trimStart")
1994
                        ?? dateValue(session, key: "startedAt")
1995
                        ?? Date.distantPast
1996
                )
1997
            )
1998
        }
1999

            
2000
        if let sessionID = stringValue(session, key: "id") {
2001
            anchors.append(
2002
                contentsOf: fetchCheckpointObjects(forSessionID: sessionID).compactMap { checkpoint in
2003
                    guard
2004
                        let percent = optionalDoubleValue(checkpoint, key: "batteryPercent"),
2005
                        percent >= 0,
2006
                        let timestamp = dateValue(checkpoint, key: "timestamp")
2007
                    else {
2008
                        return nil
2009
                    }
2010

            
2011
                    return CapacityAnchor(
2012
                        percent: percent,
2013
                        energyWh: doubleValue(checkpoint, key: "measuredEnergyWh"),
2014
                        timestamp: timestamp
2015
                    )
2016
                }
2017
            )
2018
        }
2019

            
2020
        if let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"),
2021
           endBatteryPercent >= 0 {
2022
            anchors.append(
2023
                CapacityAnchor(
2024
                    percent: endBatteryPercent,
2025
                    energyWh: effectiveBatteryEnergyWh,
2026
                    timestamp: dateValue(session, key: "endedAt")
2027
                        ?? dateValue(session, key: "lastObservedAt")
2028
                        ?? Date.distantPast
2029
                )
2030
            )
2031
        }
2032

            
2033
        let sortedAnchors = anchors.sorted { lhs, rhs in
2034
            if lhs.energyWh != rhs.energyWh {
2035
                return lhs.energyWh < rhs.energyWh
2036
            }
2037
            return lhs.timestamp < rhs.timestamp
2038
        }
2039

            
2040
        guard let firstAnchor = sortedAnchors.first,
2041
              let lastAnchor = sortedAnchors.last else {
Bogdan Timofte authored a month ago
2042
            session.setValue(nil, forKey: "capacityEstimateWh")
2043
            return
2044
        }
2045

            
Bogdan Timofte authored a month ago
2046
        let percentDelta = lastAnchor.percent - firstAnchor.percent
2047
        let energyDelta = lastAnchor.energyWh - firstAnchor.energyWh
Bogdan Timofte authored a month ago
2048

            
Bogdan Timofte authored a month ago
2049
        guard percentDelta >= 20, energyDelta > 0 else {
Bogdan Timofte authored a month ago
2050
            session.setValue(nil, forKey: "capacityEstimateWh")
2051
            return
2052
        }
2053

            
Bogdan Timofte authored a month ago
2054
        if !supportsChargingWhileOff && lastAnchor.percent >= 99.5 {
Bogdan Timofte authored a month ago
2055
            session.setValue(nil, forKey: "capacityEstimateWh")
2056
            return
2057
        }
2058

            
Bogdan Timofte authored a month ago
2059
        let capacityEstimateWh = energyDelta / (percentDelta / 100)
Bogdan Timofte authored a month ago
2060
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
2061
    }
2062

            
2063
    @discardableResult
Bogdan Timofte authored a month ago
2064
    private func insertBatteryCheckpoint(
Bogdan Timofte authored a month ago
2065
        percent: Double,
Bogdan Timofte authored a month ago
2066
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
2067
        timestamp: Date = Date(),
Bogdan Timofte authored a month ago
2068
        measuredEnergyWhOverride: Double? = nil,
Bogdan Timofte authored a month ago
2069
        to session: NSManagedObject
Bogdan Timofte authored a month ago
2070
    ) -> String? {
Bogdan Timofte authored a month ago
2071
        guard
2072
            let sessionID = stringValue(session, key: "id"),
2073
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2074
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
2075
        else {
Bogdan Timofte authored a month ago
2076
            return nil
Bogdan Timofte authored a month ago
2077
        }
2078

            
2079
        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
Bogdan Timofte authored a month ago
2080
        let checkpointEnergyWh = measuredEnergyWhOverride
2081
            ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
Bogdan Timofte authored a month ago
2082
            ?? doubleValue(session, key: "measuredEnergyWh")
2083
        checkpoint.setValue(UUID().uuidString, forKey: "id")
2084
        checkpoint.setValue(sessionID, forKey: "sessionID")
2085
        checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
Bogdan Timofte authored a month ago
2086
        checkpoint.setValue(timestamp, forKey: "timestamp")
Bogdan Timofte authored a month ago
2087
        checkpoint.setValue(percent, forKey: "batteryPercent")
2088
        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
2089
        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
2090
        checkpoint.setValue(
2091
            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
2092
            forKey: "voltageVolts"
2093
        )
Bogdan Timofte authored a month ago
2094
        checkpoint.setValue(flag.rawValue, forKey: "label")
Bogdan Timofte authored a month ago
2095
        checkpoint.setValue(timestamp, forKey: "createdAt")
Bogdan Timofte authored a month ago
2096

            
Bogdan Timofte authored a month ago
2097
        let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
2098
        if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
Bogdan Timofte authored a month ago
2099
            session.setValue(percent, forKey: "startBatteryPercent")
2100
        }
Bogdan Timofte authored a month ago
2101
        if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
2102
            session.setValue(percent, forKey: "endBatteryPercent")
2103
        }
Bogdan Timofte authored a month ago
2104
        session.setValue(timestamp, forKey: "updatedAt")
Bogdan Timofte authored a month ago
2105
        updateCapacityEstimate(for: session)
2106

            
Bogdan Timofte authored a month ago
2107
        return chargedDeviceID
2108
    }
2109

            
Bogdan Timofte authored a month ago
2110
    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
2111
        guard let sessionID = stringValue(session, key: "id") else {
2112
            return
2113
        }
2114

            
2115
        let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
2116
        if let latestCheckpoint = remainingCheckpoints.last {
2117
            session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
2118
        } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2119
                  startBatteryPercent >= 0 {
2120
            session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
2121
        } else {
2122
            session.setValue(nil, forKey: "endBatteryPercent")
2123
        }
2124

            
2125
        session.setValue(Date(), forKey: "updatedAt")
2126
        updateCapacityEstimate(for: session)
2127
    }
2128

            
Bogdan Timofte authored a month ago
2129
    @discardableResult
2130
    private func addBatteryCheckpoint(
2131
        percent: Double,
Bogdan Timofte authored a month ago
2132
        measuredEnergyWh: Double? = nil,
Bogdan Timofte authored a month ago
2133
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
2134
        to session: NSManagedObject,
2135
        timestamp: Date = Date()
2136
    ) -> Bool {
Bogdan Timofte authored a month ago
2137
        if let measuredEnergyWh, measuredEnergyWh.isFinite {
2138
            session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
2139
        }
2140

            
Bogdan Timofte authored a month ago
2141
        guard let chargedDeviceID = insertBatteryCheckpoint(
2142
            percent: percent,
Bogdan Timofte authored a month ago
2143
            flag: flag,
Bogdan Timofte authored a month ago
2144
            timestamp: timestamp,
Bogdan Timofte authored a month ago
2145
            measuredEnergyWhOverride: measuredEnergyWh,
Bogdan Timofte authored a month ago
2146
            to: session
2147
        ) else {
2148
            return false
2149
        }
2150

            
Bogdan Timofte authored a month ago
2151
        guard saveContext() else {
2152
            return false
2153
        }
2154

            
2155
        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
2156
        return saveContext()
2157
    }
2158

            
2159
    private func resolvedWirelessEfficiency(
2160
        for session: NSManagedObject,
2161
        chargedDevice: NSManagedObject
2162
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
2163
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
2164
           storedFactor > 0 {
2165
            return (
2166
                factor: storedFactor,
2167
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
2168
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
2169
            )
2170
        }
2171

            
2172
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
2173
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2174
        guard measuredEnergyWh > 0 else {
2175
            return nil
2176
        }
2177

            
2178
        if chargingProfile == .magsafe,
2179
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
2180
           calibratedFactor > 0 {
2181
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
2182
        }
2183

            
2184
        guard
2185
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2186
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
2187
        else {
2188
            return nil
2189
        }
2190

            
2191
        let percentDelta = endBatteryPercent - startBatteryPercent
2192
        guard percentDelta >= 20 else {
2193
            return nil
2194
        }
2195

            
2196
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
Bogdan Timofte authored a month ago
2197
            ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
Bogdan Timofte authored a month ago
2198
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2199
                : nil),
2200
              wiredCapacityWh > 0
2201
        else {
2202
            return nil
2203
        }
2204

            
2205
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
2206
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
2207
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
2208
        let usesEstimated = chargingProfile != .magsafe
2209
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
2210

            
2211
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
2212
    }
2213

            
2214
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
2215
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
2216
            return
2217
        }
2218

            
Bogdan Timofte authored a month ago
2219
        let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
Bogdan Timofte authored a month ago
2220
        let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
2221
        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
2222
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
2223
        let sessions = relevantSessionObjects(
2224
            for: chargedDeviceID,
2225
            deviceClass: deviceClass,
2226
            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
2227
            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
2228
        )
Bogdan Timofte authored a month ago
2229
        let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
Bogdan Timofte authored a month ago
2230
        let wiredMinimumCurrent = derivedMinimumCurrent(
2231
            from: sessions,
2232
            chargingTransportMode: .wired
2233
        )
2234
        let wirelessMinimumCurrent = derivedMinimumCurrent(
2235
            from: sessions,
2236
            chargingTransportMode: .wireless
2237
        )
2238

            
2239
        let wiredCapacity = derivedCapacity(
2240
            from: sessions,
2241
            chargingTransportMode: .wired,
2242
            supportsChargingWhileOff: supportsChargingWhileOff
2243
        )
2244
        let wirelessCapacity = derivedCapacity(
2245
            from: sessions,
2246
            chargingTransportMode: .wireless,
2247
            supportsChargingWhileOff: supportsChargingWhileOff
2248
        )
2249
        let wirelessEfficiency = derivedWirelessEfficiency(
2250
            from: sessions,
2251
            chargingProfile: wirelessProfile
2252
        )
Bogdan Timofte authored a month ago
2253
        let configuredCompletionCurrents = decodedCompletionCurrents(
2254
            from: chargedDevice,
2255
            key: "configuredCompletionCurrentsRawValue"
2256
        )
Bogdan Timofte authored a month ago
2257
        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
2258
        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
2259
        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
2260
        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
2261
        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
2262
        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
2263

            
Bogdan Timofte authored a month ago
2264
        let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
2265
        let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
Bogdan Timofte authored a month ago
2266
        let preferredMinimumCurrent: Double?
2267
        let preferredCapacity: Double?
2268
        switch preferredChargingTransportMode {
2269
        case .wired:
Bogdan Timofte authored a month ago
2270
            preferredMinimumCurrent = configuredCompletionCurrents[
2271
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2272
            ] ?? learnedCompletionCurrents[
2273
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2274
            ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
Bogdan Timofte authored a month ago
2275
            preferredCapacity = wiredCapacity ?? wirelessCapacity
2276
        case .wireless:
Bogdan Timofte authored a month ago
2277
            preferredMinimumCurrent = configuredCompletionCurrents[
2278
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2279
            ] ?? learnedCompletionCurrents[
2280
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2281
            ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
Bogdan Timofte authored a month ago
2282
            preferredCapacity = wirelessCapacity ?? wiredCapacity
2283
        }
2284

            
Bogdan Timofte authored a month ago
2285
        setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
2286
        setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
2287
        setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
2288
        setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
2289
        setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
2290
        setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
2291
        setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
2292
        setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
2293
        setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
2294
        setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
2295
        setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
2296
        setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
2297
        setValue(Date(), on: chargedDevice, key: "updatedAt")
Bogdan Timofte authored a month ago
2298
    }
2299

            
2300
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
2301
        sessions
2302
            .filter { $0.status == .completed }
2303
            .compactMap { session in
2304
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
2305
                let timestamp = session.endedAt ?? session.lastObservedAt
2306
                return CapacityTrendPoint(
2307
                    sessionID: session.id,
2308
                    timestamp: timestamp,
2309
                    capacityWh: capacityEstimateWh,
2310
                    chargingTransportMode: session.chargingTransportMode
2311
                )
2312
            }
2313
            .sorted { $0.timestamp < $1.timestamp }
2314
    }
2315

            
2316
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
2317
        var groupedEnergyByBin: [Int: [Double]] = [:]
2318

            
2319
        for session in sessions where session.status == .completed {
Bogdan Timofte authored a month ago
2320
            let anchors = normalizedTypicalCurveAnchors(for: session)
2321
            guard anchors.count >= 2 else {
2322
                continue
Bogdan Timofte authored a month ago
2323
            }
2324

            
Bogdan Timofte authored a month ago
2325
            for percentBin in stride(from: 0, through: 100, by: 10) {
Bogdan Timofte authored a month ago
2326
                guard let energyWh = interpolatedTypicalCurvePoint(
Bogdan Timofte authored a month ago
2327
                    for: Double(percentBin),
2328
                    anchors: anchors
2329
                ) else {
2330
                    continue
2331
                }
Bogdan Timofte authored a month ago
2332

            
Bogdan Timofte authored a month ago
2333
                groupedEnergyByBin[percentBin, default: []].append(energyWh)
Bogdan Timofte authored a month ago
2334
            }
2335
        }
2336

            
Bogdan Timofte authored a month ago
2337
        let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
Bogdan Timofte authored a month ago
2338
            guard let energies = groupedEnergyByBin[percentBin], !energies.isEmpty else {
Bogdan Timofte authored a month ago
2339
                return nil
2340
            }
2341

            
2342
            return TypicalChargeCurvePoint(
2343
                percentBin: percentBin,
2344
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
Bogdan Timofte authored a month ago
2345
                sampleCount: energies.count
Bogdan Timofte authored a month ago
2346
            )
2347
        }
Bogdan Timofte authored a month ago
2348

            
2349
        var runningMaximumEnergyWh = 0.0
2350

            
2351
        return averagedPoints.map { point in
2352
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
2353
            return TypicalChargeCurvePoint(
2354
                percentBin: point.percentBin,
2355
                averageEnergyWh: runningMaximumEnergyWh,
2356
                sampleCount: point.sampleCount
2357
            )
2358
        }
2359
    }
2360

            
2361
    private func normalizedTypicalCurveAnchors(
2362
        for session: ChargeSessionSummary
Bogdan Timofte authored a month ago
2363
    ) -> [(percent: Double, energyWh: Double)] {
Bogdan Timofte authored a month ago
2364
        struct Anchor {
2365
            let percent: Double
2366
            let energyWh: Double
2367
            let timestamp: Date
2368
        }
2369

            
2370
        var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
2371
            guard checkpoint.batteryPercent.isFinite,
2372
                  checkpoint.measuredEnergyWh.isFinite,
2373
                  checkpoint.batteryPercent >= 0,
2374
                  checkpoint.batteryPercent <= 100,
Bogdan Timofte authored a month ago
2375
                  checkpoint.measuredEnergyWh >= 0 else {
Bogdan Timofte authored a month ago
2376
                return nil
2377
            }
2378

            
2379
            return Anchor(
2380
                percent: checkpoint.batteryPercent,
2381
                energyWh: checkpoint.measuredEnergyWh,
2382
                timestamp: checkpoint.timestamp
2383
            )
2384
        }
2385

            
2386
        if let startBatteryPercent = session.startBatteryPercent,
2387
           startBatteryPercent.isFinite,
2388
           startBatteryPercent >= 0,
2389
           startBatteryPercent <= 100 {
2390
            anchors.append(
2391
                Anchor(
2392
                    percent: startBatteryPercent,
2393
                    energyWh: 0,
2394
                    timestamp: session.startedAt
2395
                )
2396
            )
2397
        }
2398

            
2399
        if let endBatteryPercent = session.endBatteryPercent,
2400
           endBatteryPercent.isFinite,
2401
           endBatteryPercent >= 0,
2402
           endBatteryPercent <= 100 {
2403
            anchors.append(
2404
                Anchor(
2405
                    percent: endBatteryPercent,
2406
                    energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
2407
                    timestamp: session.endedAt ?? session.lastObservedAt
2408
                )
2409
            )
2410
        }
2411

            
2412
        let sortedAnchors = anchors.sorted { lhs, rhs in
2413
            if lhs.percent != rhs.percent {
2414
                return lhs.percent < rhs.percent
2415
            }
2416
            if lhs.energyWh != rhs.energyWh {
2417
                return lhs.energyWh < rhs.energyWh
2418
            }
2419
            return lhs.timestamp < rhs.timestamp
2420
        }
2421

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

            
2424
        for anchor in sortedAnchors {
2425
            if let lastIndex = collapsedAnchors.indices.last,
2426
               abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
2427
                collapsedAnchors[lastIndex] = (
2428
                    percent: collapsedAnchors[lastIndex].percent,
Bogdan Timofte authored a month ago
2429
                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh)
Bogdan Timofte authored a month ago
2430
                )
2431
            } else {
2432
                collapsedAnchors.append(
Bogdan Timofte authored a month ago
2433
                    (percent: anchor.percent, energyWh: anchor.energyWh)
Bogdan Timofte authored a month ago
2434
                )
2435
            }
2436
        }
2437

            
2438
        var runningMaximumEnergyWh = 0.0
2439

            
2440
        return collapsedAnchors.map { anchor in
2441
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
2442
            return (
2443
                percent: anchor.percent,
Bogdan Timofte authored a month ago
2444
                energyWh: runningMaximumEnergyWh
Bogdan Timofte authored a month ago
2445
            )
2446
        }
2447
    }
2448

            
2449
    private func interpolatedTypicalCurvePoint(
2450
        for percent: Double,
Bogdan Timofte authored a month ago
2451
        anchors: [(percent: Double, energyWh: Double)]
2452
    ) -> Double? {
Bogdan Timofte authored a month ago
2453
        guard
2454
            let firstAnchor = anchors.first,
2455
            let lastAnchor = anchors.last,
2456
            percent >= firstAnchor.percent,
2457
            percent <= lastAnchor.percent
2458
        else {
2459
            return nil
2460
        }
2461

            
2462
        if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
Bogdan Timofte authored a month ago
2463
            return exactAnchor.energyWh
Bogdan Timofte authored a month ago
2464
        }
2465

            
2466
        guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
2467
              upperIndex > 0 else {
2468
            return nil
2469
        }
2470

            
2471
        let lowerAnchor = anchors[upperIndex - 1]
2472
        let upperAnchor = anchors[upperIndex]
2473
        let span = upperAnchor.percent - lowerAnchor.percent
2474
        guard span > 0.000_1 else {
2475
            return nil
2476
        }
2477

            
2478
        let ratio = (percent - lowerAnchor.percent) / span
Bogdan Timofte authored a month ago
2479
        return lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
Bogdan Timofte authored a month ago
2480
    }
2481

            
2482
    private func makeSessionSummary(
2483
        from object: NSManagedObject,
2484
        checkpoints: [NSManagedObject],
2485
        samples: [NSManagedObject]
2486
    ) -> ChargeSessionSummary? {
2487
        let chargingTransportMode = chargingTransportMode(for: object)
2488

            
2489
        guard
2490
            let id = uuidValue(object, key: "id"),
2491
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2492
            let startedAt = dateValue(object, key: "startedAt"),
2493
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
2494
            let status = statusValue(object, key: "statusRawValue"),
2495
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
2496
        else {
2497
            return nil
2498
        }
2499

            
2500
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
2501
            .sorted { $0.timestamp < $1.timestamp }
2502
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
2503
            .sorted { lhs, rhs in
2504
                if lhs.bucketIndex != rhs.bucketIndex {
2505
                    return lhs.bucketIndex < rhs.bucketIndex
2506
                }
2507
                return lhs.timestamp < rhs.timestamp
2508
            }
2509

            
2510
        return ChargeSessionSummary(
2511
            id: id,
2512
            chargedDeviceID: chargedDeviceID,
2513
            chargerID: uuidValue(object, key: "chargerID"),
2514
            meterMACAddress: stringValue(object, key: "meterMACAddress"),
2515
            meterName: stringValue(object, key: "meterName"),
2516
            meterModel: stringValue(object, key: "meterModel"),
2517
            startedAt: startedAt,
2518
            endedAt: dateValue(object, key: "endedAt"),
2519
            lastObservedAt: lastObservedAt,
Bogdan Timofte authored a month ago
2520
            pausedAt: dateValue(object, key: "pausedAt"),
Bogdan Timofte authored a month ago
2521
            status: status,
2522
            sourceMode: sourceMode,
2523
            chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
2524
            chargingStateMode: chargingStateMode(for: object),
2525
            autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
Bogdan Timofte authored a month ago
2526
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2527
            effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
Bogdan Timofte authored a month ago
2528
            meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
Bogdan Timofte authored a month ago
2529
            meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"),
2530
            meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"),
Bogdan Timofte authored a month ago
2531
            minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
2532
            maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
2533
            maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
2534
            maximumObservedVoltageVolts: chargingTransportMode == .wired
2535
                ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
2536
                : nil,
Bogdan Timofte authored a month ago
2537
            hasObservedChargeFlow: boolValue(object, key: "hasObservedChargeFlow"),
Bogdan Timofte authored a month ago
2538
            selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
2539
            completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
2540
            stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
2541
            startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
2542
            endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
2543
            capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
2544
            wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
2545
            usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
2546
            shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
2547
            supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
2548
            usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
2549
            targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
2550
            targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
2551
            requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
2552
            completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
2553
            completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
2554
            selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
Bogdan Timofte authored a month ago
2555
            trimStart: dateValue(object, key: "trimStart"),
2556
            trimEnd: dateValue(object, key: "trimEnd"),
Bogdan Timofte authored a month ago
2557
            wasConflictHealed: boolValue(object, key: "wasConflictHealed"),
Bogdan Timofte authored a month ago
2558
            checkpoints: checkpointSummaries,
2559
            aggregatedSamples: sampleSummaries
2560
        )
2561
    }
2562

            
2563
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
2564
        guard
2565
            let id = uuidValue(object, key: "id"),
2566
            let sessionID = uuidValue(object, key: "sessionID"),
2567
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2568
            let timestamp = dateValue(object, key: "timestamp")
2569
        else {
2570
            return nil
2571
        }
2572

            
2573
        return ChargeCheckpointSummary(
2574
            id: id,
2575
            sessionID: sessionID,
2576
            chargedDeviceID: chargedDeviceID,
2577
            timestamp: timestamp,
2578
            batteryPercent: doubleValue(object, key: "batteryPercent"),
2579
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2580
            currentAmps: doubleValue(object, key: "currentAmps"),
2581
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
2582
            label: stringValue(object, key: "label")
2583
        )
2584
    }
2585

            
2586
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
2587
        guard
2588
            let sessionID = uuidValue(object, key: "sessionID"),
2589
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2590
            let timestamp = dateValue(object, key: "timestamp")
2591
        else {
2592
            return nil
2593
        }
2594

            
2595
        return ChargeSessionSampleSummary(
2596
            sessionID: sessionID,
2597
            chargedDeviceID: chargedDeviceID,
2598
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
2599
            timestamp: timestamp,
2600
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
2601
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
2602
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
2603
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2604
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
2605
        )
2606
    }
2607

            
Bogdan Timofte authored a month ago
2608
    private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2609
        fetchSessionObject(
2610
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2611
                format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
Bogdan Timofte authored a month ago
2612
                normalizedMACAddress(meterMACAddress),
Bogdan Timofte authored a month ago
2613
                ChargeSessionStatus.active.rawValue,
2614
                ChargeSessionStatus.paused.rawValue
Bogdan Timofte authored a month ago
2615
            )
2616
        )
2617
    }
2618

            
Bogdan Timofte authored a month ago
2619
    private func fetchOpenSessionObjects() -> [NSManagedObject] {
2620
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2621
        request.predicate = NSPredicate(
2622
            format: "statusRawValue == %@ OR statusRawValue == %@",
2623
            ChargeSessionStatus.active.rawValue,
2624
            ChargeSessionStatus.paused.rawValue
2625
        )
2626
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2627
        return (try? context.fetch(request)) ?? []
2628
    }
2629

            
Bogdan Timofte authored a month ago
2630
    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2631
        fetchSessionObject(
2632
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2633
                format: "meterMACAddress == %@ AND statusRawValue == %@",
2634
                normalizedMACAddress(meterMACAddress),
2635
                ChargeSessionStatus.active.rawValue
Bogdan Timofte authored a month ago
2636
            )
2637
        )
2638
    }
2639

            
2640
    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
2641
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2642
        request.predicate = predicate
2643
        request.fetchLimit = 1
2644
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
2645
        return (try? context.fetch(request))?.first
2646
    }
2647

            
2648
    private func fetchSessionObject(id: String) -> NSManagedObject? {
2649
        fetchSessionObject(
2650
            predicate: NSPredicate(format: "id == %@", id)
2651
        )
2652
    }
2653

            
2654
    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
2655
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2656
        request.predicate = NSPredicate(
2657
            format: "sessionID == %@ AND bucketIndex == %d",
2658
            sessionID,
2659
            bucketIndex
2660
        )
2661
        request.fetchLimit = 1
2662
        return (try? context.fetch(request))?.first
2663
    }
2664

            
2665
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2666
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2667
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2668
        return (try? context.fetch(request)) ?? []
2669
    }
2670

            
Bogdan Timofte authored a month ago
2671
    private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
2672
        guard !sessionIDs.isEmpty else {
2673
            return []
2674
        }
2675

            
2676
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2677
        request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs)
2678
        return (try? context.fetch(request)) ?? []
2679
    }
2680

            
Bogdan Timofte authored a month ago
2681
    private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
2682
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2683
        request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
2684
        request.fetchLimit = 1
2685
        return (try? context.fetch(request))?.first
2686
    }
2687

            
Bogdan Timofte authored a month ago
2688
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2689
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2690
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2691
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
2692
        return (try? context.fetch(request)) ?? []
2693
    }
2694

            
2695
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
2696
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2697
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
2698
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2699
        return (try? context.fetch(request)) ?? []
2700
    }
2701

            
2702
    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
2703
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2704
        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
2705
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2706
        return (try? context.fetch(request)) ?? []
2707
    }
2708

            
Bogdan Timofte authored a month ago
2709
    private func sampleBackedSessionIDs(
2710
        devices: [NSManagedObject],
2711
        sessionsByDeviceID: [String: [NSManagedObject]],
2712
        sessionsByChargerID: [String: [NSManagedObject]]
2713
    ) -> Set<String> {
2714
        var sessionIDs: Set<String> = []
2715

            
2716
        for device in devices {
2717
            guard
2718
                let deviceID = stringValue(device, key: "id"),
2719
                let rawClass = stringValue(device, key: "deviceClassRawValue"),
2720
                let deviceClass = ChargedDeviceClass(rawValue: rawClass)
2721
            else {
2722
                continue
2723
            }
2724

            
2725
            let relevantSessions = relevantSessionObjects(
2726
                for: deviceID,
2727
                deviceClass: deviceClass,
2728
                sessionsByDeviceID: sessionsByDeviceID,
2729
                sessionsByChargerID: sessionsByChargerID
2730
            )
2731
            .sorted { lhs, rhs in
2732
                let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed
2733
                let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed
2734

            
2735
                if lhsStatus.isOpen && !rhsStatus.isOpen {
2736
                    return true
2737
                }
2738
                if !lhsStatus.isOpen && rhsStatus.isOpen {
2739
                    return false
2740
                }
2741

            
2742
                return (dateValue(lhs, key: "startedAt") ?? .distantPast)
2743
                    > (dateValue(rhs, key: "startedAt") ?? .distantPast)
2744
            }
2745

            
2746
            var recentCompletedSamplesIncluded = 0
2747

            
2748
            for session in relevantSessions {
2749
                guard let sessionID = stringValue(session, key: "id"),
2750
                      let status = statusValue(session, key: "statusRawValue") else {
2751
                    continue
2752
                }
2753

            
2754
                if status.isOpen {
2755
                    sessionIDs.insert(sessionID)
2756
                    continue
2757
                }
2758

            
2759
                guard recentCompletedSamplesIncluded < 2 else {
2760
                    continue
2761
                }
2762

            
2763
                sessionIDs.insert(sessionID)
2764
                recentCompletedSamplesIncluded += 1
2765
            }
2766
        }
2767

            
2768
        return sessionIDs
2769
    }
2770

            
Bogdan Timofte authored a month ago
2771
    private func relevantSessionObjects(
2772
        for chargedDeviceID: String,
2773
        deviceClass: ChargedDeviceClass,
2774
        sessionsByDeviceID: [String: [NSManagedObject]],
2775
        sessionsByChargerID: [String: [NSManagedObject]]
2776
    ) -> [NSManagedObject] {
2777
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
2778
        guard deviceClass == .charger else {
2779
            return directSessions
2780
        }
2781

            
2782
        var seenSessionIDs = Set<String>()
2783
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
2784
            .filter { session in
2785
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
2786
                return seenSessionIDs.insert(sessionID).inserted
2787
            }
2788
            .sorted {
2789
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
2790
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
2791
                return lhsDate < rhsDate
2792
            }
2793
    }
2794

            
Bogdan Timofte authored a month ago
2795
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
2796
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2797
    }
2798

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

            
2806
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
2807
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
2808
        return (try? context.fetch(request)) ?? []
2809
    }
2810

            
2811
    private func resolvedStopThreshold(
2812
        for chargedDevice: NSManagedObject,
2813
        chargingTransportMode: ChargingTransportMode,
Bogdan Timofte authored a month ago
2814
        chargingStateMode: ChargingStateMode,
2815
        charger: NSManagedObject?,
2816
        fallback: Double?
2817
    ) -> Double? {
2818
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
2819
            return nil
2820
        }
2821

            
2822
        let sessionKind = ChargeSessionKind(
2823
            chargingTransportMode: chargingTransportMode,
2824
            chargingStateMode: chargingStateMode
2825
        )
2826
        let configuredCurrents = decodedCompletionCurrents(
2827
            from: chargedDevice,
2828
            key: "configuredCompletionCurrentsRawValue"
2829
        )
2830
        let learnedCurrents = decodedCompletionCurrents(
2831
            from: chargedDevice,
2832
            key: "learnedCompletionCurrentsRawValue"
2833
        )
2834
        let legacyCurrent: Double?
Bogdan Timofte authored a month ago
2835
        switch chargingTransportMode {
2836
        case .wired:
Bogdan Timofte authored a month ago
2837
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2838
                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
2839
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2840
        case .wireless:
Bogdan Timofte authored a month ago
2841
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2842
                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
2843
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2844
        }
Bogdan Timofte authored a month ago
2845

            
2846
        let resolvedCurrent = configuredCurrents[sessionKind]
2847
            ?? learnedCurrents[sessionKind]
2848
            ?? legacyCurrent
2849
            ?? fallback
2850
        guard let resolvedCurrent, resolvedCurrent > 0 else {
2851
            return nil
2852
        }
2853
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
2854
    }
2855

            
Bogdan Timofte authored a month ago
2856
    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
Bogdan Timofte authored a month ago
2857
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
2858
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
2859
        return resolvedPreferredChargingTransportMode(
Bogdan Timofte authored a month ago
2860
            .wired,
Bogdan Timofte authored a month ago
2861
            supportsWiredCharging: supportsWiredCharging,
2862
            supportsWirelessCharging: supportsWirelessCharging
2863
        )
2864
    }
2865

            
Bogdan Timofte authored a month ago
2866
    private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
2867
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other
2868
    }
2869

            
2870
    private func normalizedTemplateID(
2871
        _ templateID: String?,
2872
        kind: ChargedDeviceKind
2873
    ) -> String? {
2874
        guard let templateID,
2875
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2876
              templateDefinition.kind == kind else {
2877
            return nil
Bogdan Timofte authored a month ago
2878
        }
Bogdan Timofte authored a month ago
2879
        return templateDefinition.id
Bogdan Timofte authored a month ago
2880
    }
2881

            
Bogdan Timofte authored a month ago
2882
    private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
2883
        guard let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
2884
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2885
              templateDefinition.kind == deviceClass(for: chargedDevice).kind else {
2886
            return nil
Bogdan Timofte authored a month ago
2887
        }
Bogdan Timofte authored a month ago
2888
        return templateDefinition
2889
    }
2890

            
2891
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
2892
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2893
            ? true
2894
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2895
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2896
            ? false
2897
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2898
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2899
            supportsWiredCharging: persistedWiredCharging,
2900
            supportsWirelessCharging: persistedWirelessCharging
2901
        ).wired
2902
    }
2903

            
2904
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
2905
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2906
            ? true
2907
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2908
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2909
            ? false
2910
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2911
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2912
            supportsWiredCharging: persistedWiredCharging,
2913
            supportsWirelessCharging: persistedWirelessCharging
2914
        ).wireless
2915
    }
2916

            
2917
    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
2918
        let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue")
2919
            .flatMap(ChargingStateAvailability.init(rawValue:))
2920
            ?? ChargingStateAvailability.fallback(
Bogdan Timofte authored a month ago
2921
            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
2922
        )
Bogdan Timofte authored a month ago
2923
        return deviceClass(for: chargedDevice).normalizedChargingStateAvailability(persistedAvailability)
Bogdan Timofte authored a month ago
2924
    }
2925

            
2926
    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
Bogdan Timofte authored a month ago
2927
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2928
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2929
            let persistedChargingStateMode = stringValue(session, key: "chargingStateRawValue")
2930
                .flatMap(ChargingStateMode.init(rawValue:))
2931
                ?? .on
2932
            return resolvedChargingStateMode(
2933
                persistedChargingStateMode,
2934
                availability: chargingStateAvailability(for: chargedDevice)
2935
            )
2936
        }
2937

            
Bogdan Timofte authored a month ago
2938
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
2939
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
2940
            return chargingStateMode
2941
        }
2942

            
2943
        return .on
2944
    }
2945

            
2946
    private func resolvedChargingStateMode(
2947
        _ chargingStateMode: ChargingStateMode,
2948
        availability: ChargingStateAvailability
2949
    ) -> ChargingStateMode {
2950
        if availability.supportedModes.contains(chargingStateMode) {
2951
            return chargingStateMode
2952
        }
2953
        return availability.supportedModes.first ?? .on
2954
    }
2955

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

            
2960
        // Primary: chargerTypeRawValue (set on v13+)
2961
        if let rawValue = stringValue(chargedDevice, key: "chargerTypeRawValue"),
2962
           let type = ChargerType(rawValue: rawValue) {
2963
            return type
2964
        }
2965

            
2966
        // Migration fallback: derive from old deviceTemplateID
2967
        switch stringValue(chargedDevice, key: "deviceTemplateID") {
2968
        case "apple-magsafe-charger": return .appleMagSafe
2969
        case "apple-watch-charger": return .appleWatch
2970
        default: break
2971
        }
2972

            
2973
        // Last resort: derive from wirelessChargingProfileRawValue
2974
        if let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2975
           let profile = WirelessChargingProfile(rawValue: rawValue),
2976
           profile == .magsafe {
2977
            return .genericMagSafe
2978
        }
2979

            
2980
        return .genericQi
2981
    }
2982

            
Bogdan Timofte authored a month ago
2983
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
Bogdan Timofte authored a month ago
2984
        if let type = chargerType(for: chargedDevice) {
2985
            return type.wirelessChargingProfile
2986
        }
Bogdan Timofte authored a month ago
2987
        guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2988
              let profile = WirelessChargingProfile(rawValue: rawValue) else {
2989
            return .genericQi
2990
        }
2991
        return profile
2992
    }
2993

            
2994
    private func resolvedPreferredChargingTransportMode(
2995
        _ preferredChargingTransportMode: ChargingTransportMode,
2996
        supportsWiredCharging: Bool,
2997
        supportsWirelessCharging: Bool
2998
    ) -> ChargingTransportMode {
2999
        switch preferredChargingTransportMode {
3000
        case .wired where supportsWiredCharging:
3001
            return .wired
3002
        case .wireless where supportsWirelessCharging:
3003
            return .wireless
3004
        default:
3005
            if supportsWiredCharging {
3006
                return .wired
3007
            }
3008
            if supportsWirelessCharging {
3009
                return .wireless
3010
            }
3011
            return .wired
3012
        }
3013
    }
3014

            
Bogdan Timofte authored a month ago
3015
    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
3016
        let payload = Dictionary(
3017
            uniqueKeysWithValues: currents.map { key, value in
3018
                (key.rawValue, value)
3019
            }
3020
        )
3021
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
3022
            return nil
3023
        }
3024
        return String(data: data, encoding: .utf8)
3025
    }
3026

            
3027
    private func decodedCompletionCurrents(
3028
        from object: NSManagedObject,
3029
        key: String
3030
    ) -> [ChargeSessionKind: Double] {
3031
        guard let rawValue = stringValue(object, key: key),
3032
              let data = rawValue.data(using: .utf8),
3033
              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
3034
            return [:]
3035
        }
3036

            
3037
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
3038
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
3039
                return
3040
            }
3041
            result[sessionKind] = entry.value
3042
        }
3043
    }
3044

            
3045
    private func legacyConfiguredCompletionCurrent(
3046
        for currents: [ChargeSessionKind: Double],
3047
        chargingTransportMode: ChargingTransportMode
3048
    ) -> Double? {
3049
        let candidates = currents
3050
            .filter { $0.key.chargingTransportMode == chargingTransportMode }
3051
            .sorted { lhs, rhs in
3052
                lhs.key.rawValue < rhs.key.rawValue
3053
            }
3054
            .map(\.value)
3055
        return candidates.first
3056
    }
3057

            
3058
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
3059
        guard let charger else {
3060
            return nil
3061
        }
3062
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
3063
        guard let idleCurrent, idleCurrent >= 0 else {
3064
            return nil
3065
        }
3066
        return idleCurrent
3067
    }
3068

            
3069
    private func effectiveCurrentAmps(
3070
        fromMeasuredCurrent currentAmps: Double,
3071
        chargingTransportMode: ChargingTransportMode,
3072
        charger: NSManagedObject?
3073
    ) -> Double {
3074
        switch chargingTransportMode {
3075
        case .wired:
3076
            return max(currentAmps, 0)
3077
        case .wireless:
3078
            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
3079
                return max(currentAmps, 0)
3080
            }
3081
            return max(currentAmps - idleCurrent, 0)
3082
        }
3083
    }
3084

            
3085
    private func hasObservedChargeFlow(
3086
        currentAmps: Double,
3087
        chargingTransportMode: ChargingTransportMode,
3088
        charger: NSManagedObject?,
3089
        stopThreshold: Double?
3090
    ) -> Bool {
3091
        let effectiveCurrent = effectiveCurrentAmps(
3092
            fromMeasuredCurrent: currentAmps,
3093
            chargingTransportMode: chargingTransportMode,
3094
            charger: charger
3095
        )
3096
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
3097
    }
3098

            
Bogdan Timofte authored a month ago
3099
    private func hasSavableChargeData(_ session: NSManagedObject) -> Bool {
Bogdan Timofte authored a month ago
3100
        if boolValue(session, key: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
3101
            || doubleValue(session, key: "measuredEnergyWh") > 0
3102
            || doubleValue(session, key: "measuredChargeAh") > 0
3103
            || (optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? 0) > 0
Bogdan Timofte authored a month ago
3104
            || (optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? 0) > 0 {
3105
            return true
3106
        }
3107

            
3108
        guard let sessionID = stringValue(session, key: "id") else {
3109
            return false
3110
        }
3111

            
3112
        return fetchSessionSampleObjects(forSessionID: sessionID).contains { sample in
3113
            doubleValue(sample, key: "measuredEnergyWh") > 0
3114
                || doubleValue(sample, key: "measuredChargeAh") > 0
3115
                || (optionalDoubleValue(sample, key: "averageCurrentAmps") ?? 0) > 0
3116
                || (optionalDoubleValue(sample, key: "averagePowerWatts") ?? 0) > 0
3117
        }
3118
    }
3119

            
3120
    private func restoreMeasuredTotalsFromLatestSampleIfNeeded(_ session: NSManagedObject) {
3121
        guard let sessionID = stringValue(session, key: "id"),
3122
              let latestSample = fetchSessionSampleObjects(forSessionID: sessionID).max(by: {
3123
                  (dateValue($0, key: "timestamp") ?? .distantPast) < (dateValue($1, key: "timestamp") ?? .distantPast)
3124
              }) else {
3125
            return
3126
        }
3127

            
3128
        let sampleEnergyWh = doubleValue(latestSample, key: "measuredEnergyWh")
3129
        if doubleValue(session, key: "measuredEnergyWh") <= 0, sampleEnergyWh > 0 {
3130
            session.setValue(sampleEnergyWh, forKey: "measuredEnergyWh")
3131
        }
3132

            
3133
        let sampleChargeAh = doubleValue(latestSample, key: "measuredChargeAh")
3134
        if doubleValue(session, key: "measuredChargeAh") <= 0, sampleChargeAh > 0 {
3135
            session.setValue(sampleChargeAh, forKey: "measuredChargeAh")
3136
        }
Bogdan Timofte authored a month ago
3137
    }
3138

            
Bogdan Timofte authored a month ago
3139
    private func derivedMinimumCurrent(
3140
        from sessions: [NSManagedObject],
3141
        chargingTransportMode: ChargingTransportMode
3142
    ) -> Double? {
3143
        let completionCurrents = sessions.compactMap { session -> Double? in
3144
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3145
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
3146
                return nil
3147
            }
Bogdan Timofte authored a month ago
3148
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3149
                return nil
3150
            }
Bogdan Timofte authored a month ago
3151
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
3152
                return nil
3153
            }
3154
            return completionCurrent
3155
        }
3156

            
3157
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
3158
        guard !recentCompletionCurrents.isEmpty else { return nil }
3159
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
3160
    }
3161

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

            
3165
        for session in sessions {
3166
            guard statusValue(session, key: "statusRawValue") == .completed else {
3167
                continue
3168
            }
3169
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3170
                continue
3171
            }
3172
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
3173
                  completionCurrent > 0 else {
3174
                continue
3175
            }
3176

            
3177
            let sessionKind = ChargeSessionKind(
3178
                chargingTransportMode: chargingTransportMode(for: session),
3179
                chargingStateMode: chargingStateMode(for: session)
3180
            )
3181
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
3182
        }
3183

            
3184
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
3185
            let recentCurrents = Array(entry.value.suffix(5))
3186
            guard !recentCurrents.isEmpty else {
3187
                return
3188
            }
3189
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
3190
        }
3191
    }
3192

            
Bogdan Timofte authored a month ago
3193
    private func derivedCapacity(
3194
        from sessions: [NSManagedObject],
3195
        chargingTransportMode: ChargingTransportMode,
3196
        supportsChargingWhileOff: Bool
3197
    ) -> Double? {
3198
        let capacityCandidates = sessions.compactMap { session -> Double? in
3199
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3200
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
3201
                return nil
3202
            }
3203
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
3204
                return nil
3205
            }
3206
            if supportsChargingWhileOff {
3207
                return capacityEstimate
3208
            }
3209
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
3210
                return nil
3211
            }
3212
            return capacityEstimate
3213
        }
3214

            
3215
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
3216
        guard !recentCapacityCandidates.isEmpty else { return nil }
3217
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
3218
    }
3219

            
3220
    private func derivedWirelessEfficiency(
3221
        from sessions: [NSManagedObject],
3222
        chargingProfile: WirelessChargingProfile
3223
    ) -> Double? {
3224
        guard chargingProfile == .magsafe else {
3225
            return nil
3226
        }
3227

            
3228
        let candidates = sessions.compactMap { session -> Double? in
3229
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3230
            guard chargingTransportMode(for: session) == .wireless else { return nil }
3231
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
3232
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3233
                return nil
3234
            }
3235
            return factor
3236
        }
3237

            
3238
        let recentCandidates = Array(candidates.suffix(6))
3239
        guard !recentCandidates.isEmpty else { return nil }
3240
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3241
    }
3242

            
3243
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
3244
        let candidates = sessions.compactMap { session -> Double? in
3245
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3246
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
3247
                return nil
3248
            }
3249
            return (sourceVoltage * 10).rounded() / 10
3250
        }
3251

            
3252
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
3253
        return counts.keys.sorted()
3254
    }
3255

            
3256
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
3257
        let candidates = sessions.compactMap { session -> Double? in
3258
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3259
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
3260
                return nil
3261
            }
3262
            return minimumObservedCurrent
3263
        }
3264

            
3265
        let recentCandidates = Array(candidates.suffix(6))
3266
        guard !recentCandidates.isEmpty else { return nil }
3267
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3268
    }
3269

            
3270
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
3271
        let candidates = sessions.compactMap { session -> Double? in
3272
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3273
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3274
                return nil
3275
            }
3276
            return factor
3277
        }
3278

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

            
3284
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
3285
        sessions.compactMap { session -> Double? in
3286
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3287
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
3288
                return nil
3289
            }
3290
            return maximumObservedPower
3291
        }
3292
        .max()
3293
    }
3294

            
3295
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
3296
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
3297
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
3298
            return resolvedPreferredChargingTransportMode(
3299
                chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired,
3300
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
3301
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
3302
            )
3303
        }
3304

            
3305
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
3306
            return persistedChargingTransportMode
Bogdan Timofte authored a month ago
3307
        }
3308

            
3309
        return .wired
3310
    }
3311

            
3312
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
3313
        if session.isInserted {
3314
            return .created
3315
        }
3316

            
3317
        let committedValues = session.committedValues(
3318
            forKeys: [
3319
                "statusRawValue",
3320
                "updatedAt",
3321
                "targetBatteryAlertTriggeredAt",
3322
                "requiresCompletionConfirmation"
3323
            ]
3324
        )
3325
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
3326
        let currentStatus = statusValue(session, key: "statusRawValue")
3327
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
3328
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
3329
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
3330
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
3331
            ?? false
3332
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
3333

            
3334
        if currentStatus == .completed, committedStatus != .completed {
3335
            return .completed
3336
        }
3337

            
Bogdan Timofte authored a month ago
3338
        if currentStatus != committedStatus {
3339
            return .event
3340
        }
3341

            
Bogdan Timofte authored a month ago
3342
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
3343
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
3344
            return .event
3345
        }
3346

            
3347
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3348
            ?? dateValue(session, key: "createdAt")
3349
            ?? observedAt
3350

            
3351
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
3352
            return .periodic
3353
        }
3354

            
3355
        return .none
3356
    }
3357

            
Bogdan Timofte authored a month ago
3358
    private func shouldPersistAggregatedSample(
3359
        _ sample: NSManagedObject,
3360
        observedAt: Date
3361
    ) -> Bool {
3362
        if sample.isInserted {
3363
            return true
3364
        }
3365

            
3366
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
3367
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3368
            ?? dateValue(sample, key: "createdAt")
3369
            ?? observedAt
3370

            
3371
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
3372
    }
3373

            
Bogdan Timofte authored a month ago
3374
    private func generateQRIdentifier() -> String {
3375
        "device:\(UUID().uuidString)"
3376
    }
3377

            
3378
    @discardableResult
3379
    private func saveContext() -> Bool {
3380
        guard context.hasChanges else { return true }
3381
        do {
3382
            try context.save()
3383
            return true
3384
        } catch {
3385
            track("Failed saving charge insights context: \(error)")
3386
            context.rollback()
3387
            return false
3388
        }
3389
    }
3390

            
3391
    private func normalizedText(_ text: String) -> String {
3392
        text.trimmingCharacters(in: .whitespacesAndNewlines)
3393
    }
3394

            
3395
    private func normalizedOptionalText(_ text: String?) -> String? {
3396
        guard let text else { return nil }
3397
        let normalized = normalizedText(text)
3398
        return normalized.isEmpty ? nil : normalized
3399
    }
3400

            
3401
    private func normalizedMACAddress(_ macAddress: String) -> String {
3402
        normalizedText(macAddress).uppercased()
3403
    }
3404

            
Bogdan Timofte authored a month ago
3405
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
3406
        guard object.entity.propertiesByName[key] != nil else {
3407
            return nil
3408
        }
3409
        return object.value(forKey: key)
3410
    }
3411

            
3412
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
3413
        guard object.entity.propertiesByName[key] != nil else {
3414
            return
3415
        }
3416
        object.setValue(value, forKey: key)
3417
    }
3418

            
Bogdan Timofte authored a month ago
3419
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
3420
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
3421
        let normalized = normalizedOptionalText(value)
3422
        return normalized
3423
    }
3424

            
3425
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
3426
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
3427
    }
3428

            
3429
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
3430
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
3431
            return value
3432
        }
Bogdan Timofte authored a month ago
3433
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3434
            return value.doubleValue
3435
        }
3436
        return 0
3437
    }
3438

            
3439
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
3440
        let value = rawValue(object, key: key)
3441
        if value == nil {
Bogdan Timofte authored a month ago
3442
            return nil
3443
        }
3444
        return doubleValue(object, key: key)
3445
    }
3446

            
3447
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
Bogdan Timofte authored a month ago
3448
        if let value = rawValue(object, key: key) as? Int16 {
Bogdan Timofte authored a month ago
3449
            return value
3450
        }
Bogdan Timofte authored a month ago
3451
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3452
            return value.int16Value
3453
        }
3454
        return nil
3455
    }
3456

            
3457
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
3458
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
3459
            return value
3460
        }
Bogdan Timofte authored a month ago
3461
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3462
            return value.int32Value
3463
        }
3464
        return nil
3465
    }
3466

            
3467
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
Bogdan Timofte authored a month ago
3468
        if let value = rawValue(object, key: key) as? Bool {
Bogdan Timofte authored a month ago
3469
            return value
3470
        }
Bogdan Timofte authored a month ago
3471
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3472
            return value.boolValue
3473
        }
3474
        return false
3475
    }
3476

            
3477
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
3478
        guard let value = stringValue(object, key: key) else { return nil }
3479
        return UUID(uuidString: value)
3480
    }
3481

            
3482
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
3483
        guard let value = stringValue(object, key: key) else { return nil }
3484
        return ChargeSessionStatus(rawValue: value)
3485
    }
3486

            
3487
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
3488
        guard let value = stringValue(object, key: key) else { return nil }
3489
        return ChargingTransportMode(rawValue: value)
3490
    }
3491

            
3492
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
3493
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
3494
            return []
3495
        }
3496
        return rawValue
3497
            .split(separator: ",")
3498
            .compactMap { Double($0) }
3499
            .sorted()
3500
    }
3501

            
3502
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
3503
        let uniqueVoltages = Array(Set(voltages)).sorted()
3504
        guard !uniqueVoltages.isEmpty else {
3505
            return nil
3506
        }
3507
        return uniqueVoltages
3508
            .map { String(format: "%.1f", $0) }
3509
            .joined(separator: ",")
3510
    }
3511

            
3512
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
3513
        guard currentCount > 0 else {
3514
            return newValue
3515
        }
3516
        let total = (currentAverage * Double(currentCount)) + newValue
3517
        return total / Double(currentCount + 1)
3518
    }
3519
}
3520

            
3521
private enum ObservationSaveReason {
3522
    case none
3523
    case created
3524
    case periodic
3525
    case completed
3526
    case event
3527
}