USB-Meter / USB Meter / Model / ChargeInsightsStore.swift
Newer Older
3548 lines | 154.888kb
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

            
19
    private enum MeterAssignmentKind {
20
        case chargedDevice
21
        case charger
22

            
23
        var expectsChargerClass: Bool {
24
            switch self {
25
            case .chargedDevice:
26
                return false
27
            case .charger:
28
                return true
29
            }
30
        }
31
    }
32

            
Bogdan Timofte authored a month ago
33
    private static let maximumChargeSessionDuration: TimeInterval = 12 * 60 * 60
Bogdan Timofte authored a month ago
34
    private static let persistedSamplesPerHour = 360
Bogdan Timofte authored a month ago
35
    private static let aggregatedSampleBucketDuration = 3600.0 / Double(persistedSamplesPerHour)
36

            
37
    private let context: NSManagedObjectContext
38
    private let stopDetectionHoldDuration: TimeInterval = 20
Bogdan Timofte authored a month ago
39
    private let maximumLiveIntegrationGap: TimeInterval = 90
Bogdan Timofte authored a month ago
40
    private let activeSessionSaveInterval: TimeInterval = 60
41
    private let aggregatedSampleSaveInterval: TimeInterval = 30
Bogdan Timofte authored a month ago
42
    private let counterDecreaseTolerance = 0.002
43
    private let completionConfirmationCooldown: TimeInterval = 15 * 60
Bogdan Timofte authored a month ago
44
    private let pausedSessionTimeout: TimeInterval = 10 * 60
Bogdan Timofte authored a month ago
45
    private let defaultCompletionPercentThreshold = 95.0
46
    private let completionContradictionTolerancePercent = 2.0
47
    private let minimumWirelessEfficiencyFactor = 0.35
48
    private let maximumWirelessEfficiencyFactor = 0.95
49
    private let lowWirelessEfficiencyThreshold = 0.72
Bogdan Timofte authored a month ago
50
    private let unresolvedFlatBatteryPercent = -1.0
Bogdan Timofte authored a month ago
51

            
52
    init(context: NSManagedObjectContext) {
53
        self.context = context
54
    }
55

            
56
    func refreshContext() {
57
        context.performAndWait {
58
            context.processPendingChanges()
59
        }
60
    }
61

            
Bogdan Timofte authored a month ago
62
    func resetContext() {
63
        context.performAndWait {
64
            context.reset()
65
        }
66
    }
67

            
Bogdan Timofte authored a month ago
68
    @discardableResult
69
    func flushPendingChanges() -> Bool {
70
        var didSave = false
71
        context.performAndWait {
72
            context.processPendingChanges()
73
            didSave = saveContext()
74
        }
75
        return didSave
76
    }
77

            
Bogdan Timofte authored a month ago
78
    @discardableResult
79
    func completeExpiredOpenSessions(referenceDate: Date = Date()) -> Bool {
80
        var didSave = false
81

            
82
        context.performAndWait {
83
            let expiredSessions = fetchOpenSessionObjects().compactMap { session -> NSManagedObject? in
84
                guard automaticCompletionDate(for: session, referenceDate: referenceDate) != nil else {
85
                    return nil
86
                }
87
                return session
88
            }
89
            guard expiredSessions.isEmpty == false else {
90
                return
91
            }
92

            
93
            var chargedDeviceIDsToRefresh = Set<String>()
94
            for session in expiredSessions {
95
                guard let completionDate = automaticCompletionDate(for: session, referenceDate: referenceDate) else {
96
                    continue
97
                }
98
                finishSession(
99
                    session,
100
                    observedAt: completionDate,
101
                    finalBatteryPercent: nil,
102
                    status: .completed
103
                )
104
                if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
105
                    chargedDeviceIDsToRefresh.insert(chargedDeviceID)
106
                }
107
            }
108

            
109
            guard saveContext() else {
110
                return
111
            }
112

            
113
            for chargedDeviceID in chargedDeviceIDsToRefresh {
114
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
115
            }
116
            didSave = saveContext()
117
        }
118

            
119
        return didSave
120
    }
121

            
Bogdan Timofte authored a month ago
122
    // Heals the invariant "at most one open session per meter MAC".
123
    // Called after every remote CloudKit sync import to resolve sessions that were started
124
    // independently on different devices while offline.
125
    //
126
    // Scenario: session A started on Device 1 and forgotten; user starts session B on Device 2
127
    // while offline. After sync both appear open for the same meter.
128
    //
129
    // Winner = session with the latest startedAt (represents the user's intentional new session).
130
    // Loser endedAt is set to winner's startedAt so there is no time overlap.
131
    @discardableResult
132
    func healDuplicateOpenSessions() -> Bool {
133
        var didSave = false
134

            
135
        context.performAndWait {
136
            let openSessions = fetchOpenSessionObjects()
137

            
138
            var sessionsByMAC: [String: [NSManagedObject]] = [:]
139
            for session in openSessions {
140
                guard let mac = stringValue(session, key: "meterMACAddress") else { continue }
141
                sessionsByMAC[mac, default: []].append(session)
142
            }
143

            
144
            let duplicatedMACs = sessionsByMAC.filter { $0.value.count > 1 }
145
            guard !duplicatedMACs.isEmpty else { return }
146

            
147
            var chargedDeviceIDsToRefresh = Set<String>()
148

            
149
            for (_, sessions) in duplicatedMACs {
150
                // Winner = most recently started (explicit user intent); tie-break by measuredEnergyWh
151
                let winner = sessions.max { a, b in
152
                    let aDate = (a.value(forKey: "startedAt") as? Date) ?? .distantPast
153
                    let bDate = (b.value(forKey: "startedAt") as? Date) ?? .distantPast
154
                    if aDate != bDate { return aDate < bDate }
155
                    let aEnergy = (a.value(forKey: "measuredEnergyWh") as? Double) ?? 0
156
                    let bEnergy = (b.value(forKey: "measuredEnergyWh") as? Double) ?? 0
157
                    return aEnergy < bEnergy
158
                }
159
                let winnerStartedAt = (winner?.value(forKey: "startedAt") as? Date) ?? Date()
160

            
161
                for loser in sessions where loser !== winner {
162
                    // End the loser exactly when the winner began — no overlap.
163
                    finishSession(loser, observedAt: winnerStartedAt, finalBatteryPercent: nil, status: .abandoned)
164
                    loser.setValue(true, forKey: "wasConflictHealed")
165
                    if let chargedDeviceID = stringValue(loser, key: "chargedDeviceID") {
166
                        chargedDeviceIDsToRefresh.insert(chargedDeviceID)
167
                    }
168
                    track("ChargeInsightsStore: healed duplicate open session \(stringValue(loser, key: "id") ?? "?") for meter \(stringValue(loser, key: "meterMACAddress") ?? "?")")
169
                }
170
            }
171

            
172
            guard saveContext() else { return }
173

            
174
            for chargedDeviceID in chargedDeviceIDsToRefresh {
175
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
176
            }
177
            didSave = saveContext()
178
        }
179

            
180
        return didSave
181
    }
182

            
Bogdan Timofte authored a month ago
183
    @discardableResult
Bogdan Timofte authored a month ago
184
    func createDevice(
Bogdan Timofte authored a month ago
185
        name: String,
186
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
187
        templateID: String?,
Bogdan Timofte authored a month ago
188
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
189
        supportsWiredCharging: Bool,
190
        supportsWirelessCharging: Bool,
191
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
192
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
193
        notes: String?,
194
        assignTo meterMACAddress: String?
195
    ) -> Bool {
Bogdan Timofte authored a month ago
196
        guard deviceClass.kind == .device else { return false }
Bogdan Timofte authored a month ago
197
        let normalizedName = normalizedText(name)
Bogdan Timofte authored a month ago
198
        let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability)
199
        let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
200
            supportsWiredCharging: supportsWiredCharging,
201
            supportsWirelessCharging: supportsWirelessCharging
202
        )
203
        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device)
Bogdan Timofte authored a month ago
204
        guard !normalizedName.isEmpty else { return false }
Bogdan Timofte authored a month ago
205
        guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
Bogdan Timofte authored a month ago
206

            
207
        var didSave = false
208
        context.performAndWait {
209
            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
210
                return
211
            }
212

            
213
            let object = NSManagedObject(entity: entity, insertInto: context)
214
            let now = Date()
215
            object.setValue(UUID().uuidString, forKey: "id")
216
            object.setValue(normalizedName, forKey: "name")
217
            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
218
            object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
219
            object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
220
            object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
221
            object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging")
222
            object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging")
Bogdan Timofte authored a month ago
223
            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
224
            object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
225
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
226
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
227
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
228
            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
229
            object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC")
230
            object.setValue(now, forKey: "createdAt")
231
            object.setValue(now, forKey: "updatedAt")
232
            didSave = saveContext()
233
        }
234
        return didSave
235
    }
236

            
237
    @discardableResult
Bogdan Timofte authored a month ago
238
    func createCharger(
239
        name: String,
Bogdan Timofte authored a month ago
240
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
241
        notes: String?,
242
        assignTo meterMACAddress: String?
243
    ) -> Bool {
244
        let normalizedName = normalizedText(name)
245
        guard !normalizedName.isEmpty else { return false }
246

            
247
        var didSave = false
248
        context.performAndWait {
249
            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
250
                return
251
            }
252

            
253
            let object = NSManagedObject(entity: entity, insertInto: context)
254
            let now = Date()
255
            object.setValue(UUID().uuidString, forKey: "id")
256
            object.setValue(normalizedName, forKey: "name")
257
            object.setValue(ChargedDeviceClass.charger.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
258
            object.setValue(nil, forKey: "deviceTemplateID")
259
            object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue")
260
            object.setValue(false, forKey: "supportsChargingWhileOff")
261
            object.setValue(false, forKey: "supportsWiredCharging")
262
            object.setValue(true, forKey: "supportsWirelessCharging")
263
            if object.entity.attributesByName["chargerTypeRawValue"] != nil {
264
                object.setValue(chargerType.rawValue, forKey: "chargerTypeRawValue")
265
            }
266
            object.setValue(chargerType.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
267
            object.setValue(encodedCompletionCurrents([:]), forKey: "configuredCompletionCurrentsRawValue")
268
            object.setValue(nil, forKey: "wiredChargeCompletionCurrentAmps")
269
            object.setValue(nil, forKey: "wirelessChargeCompletionCurrentAmps")
270
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
271
            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
272
            object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC")
273
            object.setValue(now, forKey: "createdAt")
274
            object.setValue(now, forKey: "updatedAt")
275
            didSave = saveContext()
276
        }
277
        return didSave
278
    }
279

            
280
    @discardableResult
281
    func updateDevice(
Bogdan Timofte authored a month ago
282
        id: UUID,
283
        name: String,
284
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
285
        templateID: String?,
Bogdan Timofte authored a month ago
286
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
287
        supportsWiredCharging: Bool,
288
        supportsWirelessCharging: Bool,
289
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
290
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
291
        notes: String?
292
    ) -> Bool {
Bogdan Timofte authored a month ago
293
        guard deviceClass.kind == .device else { return false }
Bogdan Timofte authored a month ago
294
        let normalizedName = normalizedText(name)
Bogdan Timofte authored a month ago
295
        let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability)
296
        let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
297
            supportsWiredCharging: supportsWiredCharging,
298
            supportsWirelessCharging: supportsWirelessCharging
299
        )
300
        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device)
Bogdan Timofte authored a month ago
301
        guard !normalizedName.isEmpty else { return false }
Bogdan Timofte authored a month ago
302
        guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
Bogdan Timofte authored a month ago
303

            
304
        var didSave = false
305
        context.performAndWait {
306
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
307
                return
308
            }
Bogdan Timofte authored a month ago
309
            guard isChargerObject(object) == false else {
310
                return
311
            }
Bogdan Timofte authored a month ago
312

            
313
            let previousSupportsChargingWhileOff = boolValue(object, key: "supportsChargingWhileOff")
Bogdan Timofte authored a month ago
314
            let previousChargingStateAvailability = self.chargingStateAvailability(for: object)
Bogdan Timofte authored a month ago
315
            let previousSupportsWiredCharging = self.supportsWiredCharging(for: object)
316
            let previousSupportsWirelessCharging = self.supportsWirelessCharging(for: object)
317
            let now = Date()
318

            
319
            object.setValue(normalizedName, forKey: "name")
320
            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
321
            object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
322
            object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
323
            object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
324
            object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging")
325
            object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging")
Bogdan Timofte authored a month ago
326
            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
327
            object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
328
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
329
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
330
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
331
            object.setValue(now, forKey: "updatedAt")
332

            
Bogdan Timofte authored a month ago
333
            let supportsChargingWhileOff = normalizedChargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
334
            let shouldRecalculateSessionCapacity = previousSupportsChargingWhileOff != supportsChargingWhileOff
335
            let shouldRefreshActiveSessions = shouldRecalculateSessionCapacity
Bogdan Timofte authored a month ago
336
                || previousChargingStateAvailability != normalizedChargingStateAvailability
337
                || previousSupportsWiredCharging != normalizedChargingSupport.wired
338
                || previousSupportsWirelessCharging != normalizedChargingSupport.wireless
Bogdan Timofte authored a month ago
339

            
340
            if shouldRecalculateSessionCapacity || shouldRefreshActiveSessions {
341
                let sessions = fetchSessions(forChargedDeviceID: id.uuidString)
342
                for session in sessions {
Bogdan Timofte authored a month ago
343
                    let isOpen = statusValue(session, key: "statusRawValue")?.isOpen == true
Bogdan Timofte authored a month ago
344

            
345
                    if shouldRecalculateSessionCapacity {
346
                        session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
347
                        updateCapacityEstimate(for: session)
348
                        session.setValue(now, forKey: "updatedAt")
349
                    }
350

            
Bogdan Timofte authored a month ago
351
                    guard isOpen, shouldRefreshActiveSessions else {
Bogdan Timofte authored a month ago
352
                        continue
353
                    }
354

            
355
                    let resolvedSessionChargingTransportMode = resolvedPreferredChargingTransportMode(
356
                        chargingTransportMode(for: session),
Bogdan Timofte authored a month ago
357
                        supportsWiredCharging: normalizedChargingSupport.wired,
358
                        supportsWirelessCharging: normalizedChargingSupport.wireless
Bogdan Timofte authored a month ago
359
                    )
Bogdan Timofte authored a month ago
360
                    let resolvedSessionChargingStateMode = resolvedChargingStateMode(
361
                        chargingStateMode(for: session),
Bogdan Timofte authored a month ago
362
                        availability: normalizedChargingStateAvailability
Bogdan Timofte authored a month ago
363
                    )
364
                    let charger = stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
365

            
366
                    session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
367
                    session.setValue(resolvedSessionChargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
368
                    session.setValue(resolvedSessionChargingStateMode.rawValue, forKey: "chargingStateRawValue")
Bogdan Timofte authored a month ago
369
                    session.setValue(
370
                        resolvedStopThreshold(
371
                            for: object,
372
                            chargingTransportMode: resolvedSessionChargingTransportMode,
Bogdan Timofte authored a month ago
373
                            chargingStateMode: resolvedSessionChargingStateMode,
374
                            charger: charger,
375
                            fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
376
                        ) ?? 0,
Bogdan Timofte authored a month ago
377
                        forKey: "stopThresholdAmps"
378
                    )
379
                    session.setValue(now, forKey: "updatedAt")
380
                    updateCapacityEstimate(for: session)
381
                }
382
            }
383

            
384
            refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
385
            didSave = saveContext()
386
        }
387
        return didSave
388
    }
389

            
Bogdan Timofte authored a month ago
390
    @discardableResult
391
    func updateCharger(
392
        id: UUID,
393
        name: String,
Bogdan Timofte authored a month ago
394
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
395
        notes: String?
396
    ) -> Bool {
397
        let normalizedName = normalizedText(name)
398
        guard !normalizedName.isEmpty else { return false }
399

            
400
        var didSave = false
401
        context.performAndWait {
402
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
403
                return
404
            }
405
            guard isChargerObject(object) else {
406
                return
407
            }
408

            
409
            object.setValue(normalizedName, forKey: "name")
Bogdan Timofte authored a month ago
410
            object.setValue(nil, forKey: "deviceTemplateID")
411
            object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue")
412
            object.setValue(false, forKey: "supportsChargingWhileOff")
413
            object.setValue(false, forKey: "supportsWiredCharging")
414
            object.setValue(true, forKey: "supportsWirelessCharging")
415
            if object.entity.attributesByName["chargerTypeRawValue"] != nil {
416
                object.setValue(chargerType.rawValue, forKey: "chargerTypeRawValue")
417
            }
418
            object.setValue(chargerType.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
419
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
420
            object.setValue(Date(), forKey: "updatedAt")
Bogdan Timofte authored a month ago
421
            refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
Bogdan Timofte authored a month ago
422
            didSave = saveContext()
423
        }
424

            
425
        return didSave
426
    }
427

            
Bogdan Timofte authored a month ago
428
    @discardableResult
429
    func assignChargedDevice(id: UUID, to meterMACAddress: String) -> Bool {
430
        assign(itemWithID: id, to: meterMACAddress, kind: .chargedDevice)
431
    }
432

            
433
    @discardableResult
434
    func assignCharger(id: UUID, to meterMACAddress: String) -> Bool {
435
        assign(itemWithID: id, to: meterMACAddress, kind: .charger)
436
    }
437

            
438
    @discardableResult
439
    private func assign(
440
        itemWithID id: UUID,
441
        to meterMACAddress: String,
442
        kind: MeterAssignmentKind
443
    ) -> Bool {
444
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
445
        guard !normalizedMAC.isEmpty else { return false }
446

            
447
        var didSave = false
448
        context.performAndWait {
449
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
450
                return
451
            }
452

            
453
            let isCharger = ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
454
            guard isCharger == kind.expectsChargerClass else {
455
                return
456
            }
457

            
458
            let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
459
            request.predicate = NSPredicate(
460
                format: "lastAssociatedMeterMAC == %@ AND id != %@",
461
                normalizedMAC,
462
                id.uuidString
463
            )
464
            let previouslyAssignedDevices = (try? context.fetch(request)) ?? []
465
            for previousDevice in previouslyAssignedDevices {
466
                let previousIsCharger = ChargedDeviceClass(rawValue: stringValue(previousDevice, key: "deviceClassRawValue") ?? "") == .charger
467
                guard previousIsCharger == kind.expectsChargerClass else {
468
                    continue
469
                }
470
                previousDevice.setValue(nil, forKey: "lastAssociatedMeterMAC")
471
                previousDevice.setValue(Date(), forKey: "updatedAt")
472
            }
473

            
474
            object.setValue(normalizedMAC, forKey: "lastAssociatedMeterMAC")
475
            object.setValue(Date(), forKey: "updatedAt")
476

            
477
            if kind == .charger,
Bogdan Timofte authored a month ago
478
               let openSession = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
479
               chargingTransportMode(for: openSession) == .wireless {
480
                openSession.setValue(id.uuidString, forKey: "chargerID")
481
                openSession.setValue(Date(), forKey: "updatedAt")
Bogdan Timofte authored a month ago
482
            }
483

            
484
            didSave = saveContext()
485
        }
486
        return didSave
487
    }
488

            
489
    @discardableResult
Bogdan Timofte authored a month ago
490
    func startSession(
491
        for snapshot: ChargingMonitorSnapshot,
492
        chargedDeviceID: UUID,
493
        chargerID: UUID?,
494
        chargingTransportMode: ChargingTransportMode,
495
        chargingStateMode: ChargingStateMode,
496
        autoStopEnabled: Bool,
Bogdan Timofte authored a month ago
497
        initialBatteryPercent: Double?,
498
        startsFromFlatBattery: Bool
Bogdan Timofte authored a month ago
499
    ) -> Bool {
Bogdan Timofte authored a month ago
500
        if let initialBatteryPercent,
501
           (initialBatteryPercent.isFinite == false || initialBatteryPercent < 0 || initialBatteryPercent > 100) {
Bogdan Timofte authored a month ago
502
            return false
503
        }
504

            
Bogdan Timofte authored a month ago
505
        var didSave = false
506
        context.performAndWait {
Bogdan Timofte authored a month ago
507
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
Bogdan Timofte authored a month ago
508
                return
509
            }
Bogdan Timofte authored a month ago
510
            guard isChargerObject(chargedDevice) == false else {
511
                return
512
            }
Bogdan Timofte authored a month ago
513

            
Bogdan Timofte authored a month ago
514
            guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
Bogdan Timofte authored a month ago
515
                return
516
            }
517

            
Bogdan Timofte authored a month ago
518
            let resolvedChargingTransportMode = resolvedPreferredChargingTransportMode(
519
                chargingTransportMode,
520
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
521
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
Bogdan Timofte authored a month ago
522
            )
Bogdan Timofte authored a month ago
523
            let resolvedChargingStateMode = resolvedChargingStateMode(
524
                chargingStateMode,
525
                availability: chargingStateAvailability(for: chargedDevice)
526
            )
527
            let charger = resolvedChargingTransportMode == .wireless
528
                ? chargerID.flatMap { fetchChargedDeviceObject(id: $0.uuidString) }
529
                : nil
Bogdan Timofte authored a month ago
530
            if let charger, isChargerObject(charger) == false {
531
                return
532
            }
Bogdan Timofte authored a month ago
533
            guard resolvedChargingTransportMode == .wired || charger != nil else {
Bogdan Timofte authored a month ago
534
                return
535
            }
Bogdan Timofte authored a month ago
536
            let stopThreshold = resolvedStopThreshold(
Bogdan Timofte authored a month ago
537
                for: chargedDevice,
538
                chargingTransportMode: resolvedChargingTransportMode,
539
                chargingStateMode: resolvedChargingStateMode,
540
                charger: charger,
Bogdan Timofte authored a month ago
541
                fallback: snapshot.fallbackStopThresholdAmps > 0 ? snapshot.fallbackStopThresholdAmps : nil
542
            )
Bogdan Timofte authored a month ago
543
            guard let session = createSessionObject(
544
                for: chargedDevice,
Bogdan Timofte authored a month ago
545
                charger: charger,
546
                snapshot: snapshot,
547
                stopThreshold: stopThreshold,
Bogdan Timofte authored a month ago
548
                chargingTransportMode: resolvedChargingTransportMode,
549
                chargingStateMode: resolvedChargingStateMode,
550
                autoStopEnabled: autoStopEnabled
551
            ) else {
552
                return
553
            }
554

            
Bogdan Timofte authored a month ago
555
            if startsFromFlatBattery {
556
                session.setValue(unresolvedFlatBatteryPercent, forKey: "startBatteryPercent")
557
                session.setValue(nil, forKey: "endBatteryPercent")
558
            } else if let initialBatteryPercent {
559
                guard insertBatteryCheckpoint(
560
                    percent: initialBatteryPercent,
Bogdan Timofte authored a month ago
561
                    flag: .initial,
Bogdan Timofte authored a month ago
562
                    timestamp: snapshot.observedAt,
563
                    to: session
564
                ) != nil else {
565
                    return
566
                }
Bogdan Timofte authored a month ago
567
            }
Bogdan Timofte authored a month ago
568
            didSave = saveContext()
569
        }
570
        return didSave
571
    }
572

            
Bogdan Timofte authored a month ago
573
    @discardableResult
574
    func pauseSession(id sessionID: UUID, observedAt: Date) -> Bool {
575
        var didSave = false
576
        context.performAndWait {
577
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
578
                return
579
            }
580

            
581
            guard statusValue(session, key: "statusRawValue") == .active else {
582
                return
583
            }
584

            
585
            session.setValue(ChargeSessionStatus.paused.rawValue, forKey: "statusRawValue")
586
            session.setValue(observedAt, forKey: "pausedAt")
587
            session.setValue(nil, forKey: "belowThresholdSince")
588
            clearCompletionConfirmationState(for: session)
589
            session.setValue(observedAt, forKey: "updatedAt")
590
            didSave = saveContext()
591
        }
592
        return didSave
593
    }
594

            
595
    @discardableResult
596
    func resumeSession(id sessionID: UUID, snapshot: ChargingMonitorSnapshot?) -> Bool {
597
        var didSave = false
598
        context.performAndWait {
599
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
600
                return
601
            }
602

            
603
            guard statusValue(session, key: "statusRawValue") == .paused else {
604
                return
605
            }
606

            
607
            let resumedAt = snapshot?.observedAt ?? Date()
Bogdan Timofte authored a month ago
608
            if let completionDate = automaticCompletionDate(for: session, referenceDate: resumedAt) {
Bogdan Timofte authored a month ago
609
                finishSession(
610
                    session,
Bogdan Timofte authored a month ago
611
                    observedAt: completionDate,
Bogdan Timofte authored a month ago
612
                    finalBatteryPercent: nil,
613
                    status: .completed
614
                )
615
                guard saveContext() else {
616
                    return
617
                }
618
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
619
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
620
                    didSave = saveContext()
621
                } else {
622
                    didSave = true
623
                }
624
                return
625
            }
626

            
627
            session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
628
            session.setValue(nil, forKey: "pausedAt")
629
            session.setValue(nil, forKey: "belowThresholdSince")
630
            clearCompletionConfirmationState(for: session)
631
            session.setValue(resumedAt, forKey: "lastObservedAt")
632
            if let snapshot {
633
                session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
634
                session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
635
                session.setValue(
636
                    chargingTransportMode(for: session) == .wired ? snapshot.voltageVolts : nil,
637
                    forKey: "lastObservedVoltageVolts"
638
                )
639
            } else {
640
                session.setValue(0, forKey: "lastObservedCurrentAmps")
641
                session.setValue(0, forKey: "lastObservedPowerWatts")
642
                session.setValue(nil, forKey: "lastObservedVoltageVolts")
643
            }
644
            session.setValue(resumedAt, forKey: "updatedAt")
645
            didSave = saveContext()
646
        }
647
        return didSave
648
    }
649

            
650
    @discardableResult
651
    func stopSession(
652
        id sessionID: UUID,
Bogdan Timofte authored a month ago
653
        finalBatteryPercent: Double? = nil
Bogdan Timofte authored a month ago
654
    ) -> Bool {
Bogdan Timofte authored a month ago
655
        if let finalBatteryPercent {
656
            guard finalBatteryPercent.isFinite, finalBatteryPercent >= 0, finalBatteryPercent <= 100 else {
657
                return false
658
            }
Bogdan Timofte authored a month ago
659
        }
660

            
661
        var didSave = false
Bogdan Timofte authored a month ago
662
        var deviceIDToRefresh: String?
663

            
Bogdan Timofte authored a month ago
664
        context.performAndWait {
665
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
666
                return
667
            }
668

            
669
            guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
670
                return
671
            }
672

            
Bogdan Timofte authored a month ago
673
            restoreMeasuredTotalsFromLatestSampleIfNeeded(session)
674

            
Bogdan Timofte authored a month ago
675
            guard hasSavableChargeData(session) else {
676
                return
677
            }
678

            
Bogdan Timofte authored a month ago
679
            let observedAt = snapshotDateForManualStop(session)
680
            finishSession(
681
                session,
682
                observedAt: observedAt,
683
                finalBatteryPercent: finalBatteryPercent,
684
                status: .completed
685
            )
686

            
687
            guard saveContext() else {
688
                return
689
            }
690

            
Bogdan Timofte authored a month ago
691
            didSave = true
692
            deviceIDToRefresh = stringValue(session, key: "chargedDeviceID")
693
        }
694

            
695
        if let deviceID = deviceIDToRefresh {
696
            context.perform { [weak self] in
697
                guard let self else { return }
698
                self.refreshDerivedMetrics(forChargedDeviceID: deviceID)
699
                self.saveContext()
Bogdan Timofte authored a month ago
700
            }
701
        }
Bogdan Timofte authored a month ago
702

            
Bogdan Timofte authored a month ago
703
        return didSave
704
    }
705

            
Bogdan Timofte authored a month ago
706
    @discardableResult
707
    func addBatteryCheckpoint(
708
        percent: Double,
Bogdan Timofte authored a month ago
709
        for meterMACAddress: String,
710
        measuredEnergyWh: Double? = nil,
711
        measuredChargeAh: Double? = nil
Bogdan Timofte authored a month ago
712
    ) -> Bool {
713
        guard percent.isFinite, percent >= 0, percent <= 100 else {
714
            return false
715
        }
716

            
717
        var didSave = false
718
        context.performAndWait {
Bogdan Timofte authored a month ago
719
            guard let session = fetchOpenSessionObject(forMeterMACAddress: meterMACAddress) else {
Bogdan Timofte authored a month ago
720
                return
721
            }
722

            
Bogdan Timofte authored a month ago
723
            didSave = addBatteryCheckpoint(
724
                percent: percent,
725
                measuredEnergyWh: measuredEnergyWh,
726
                measuredChargeAh: measuredChargeAh,
Bogdan Timofte authored a month ago
727
                flag: .intermediate,
Bogdan Timofte authored a month ago
728
                to: session
729
            )
Bogdan Timofte authored a month ago
730
        }
731
        return didSave
732
    }
733

            
734
    @discardableResult
735
    func addBatteryCheckpoint(
736
        percent: Double,
Bogdan Timofte authored a month ago
737
        for sessionID: UUID,
738
        measuredEnergyWh: Double? = nil,
739
        measuredChargeAh: Double? = nil
Bogdan Timofte authored a month ago
740
    ) -> Bool {
741
        guard percent.isFinite, percent >= 0, percent <= 100 else {
742
            return false
743
        }
744

            
745
        var didSave = false
746
        context.performAndWait {
747
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
748
                return
749
            }
750

            
Bogdan Timofte authored a month ago
751
            didSave = addBatteryCheckpoint(
752
                percent: percent,
753
                measuredEnergyWh: measuredEnergyWh,
754
                measuredChargeAh: measuredChargeAh,
Bogdan Timofte authored a month ago
755
                flag: .intermediate,
Bogdan Timofte authored a month ago
756
                to: session
757
            )
Bogdan Timofte authored a month ago
758
        }
759
        return didSave
760
    }
761

            
Bogdan Timofte authored a month ago
762
    @discardableResult
763
    func deleteBatteryCheckpoint(
764
        id checkpointID: UUID,
765
        from sessionID: UUID
766
    ) -> Bool {
767
        var didSave = false
768
        context.performAndWait {
769
            guard let session = fetchSessionObject(id: sessionID.uuidString),
770
                  let checkpoint = fetchCheckpointObject(
771
                    id: checkpointID.uuidString,
772
                    sessionID: sessionID.uuidString
773
                  ) else {
774
                return
775
            }
776

            
777
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
778
            context.delete(checkpoint)
779
            refreshCheckpointDerivedValues(for: session)
780

            
781
            if let chargedDeviceID {
782
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
783
            }
Bogdan Timofte authored a month ago
784

            
785
            didSave = saveContext()
Bogdan Timofte authored a month ago
786
        }
787
        return didSave
788
    }
789

            
Bogdan Timofte authored a month ago
790
    @discardableResult
791
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
792
        if let percent, (!percent.isFinite || percent <= 0 || percent > 100) {
793
            return false
794
        }
795

            
796
        var didSave = false
797
        context.performAndWait {
798
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
799
                return
800
            }
801

            
802
            session.setValue(percent, forKey: "targetBatteryPercent")
803
            session.setValue(nil, forKey: "targetBatteryAlertTriggeredAt")
804
            session.setValue(Date(), forKey: "updatedAt")
805
            didSave = saveContext()
806
        }
807
        return didSave
808
    }
809

            
810
    @discardableResult
811
    func confirmCompletion(for sessionID: UUID) -> Bool {
812
        var didSave = false
813
        context.performAndWait {
814
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
815
                return
816
            }
817

            
818
            guard statusValue(session, key: "statusRawValue") == .active else {
819
                return
820
            }
821

            
Bogdan Timofte authored a month ago
822
            finishSession(
823
                session,
824
                observedAt: dateValue(session, key: "lastObservedAt") ?? Date(),
825
                finalBatteryPercent: nil,
826
                status: .completed
827
            )
Bogdan Timofte authored a month ago
828

            
829
            if saveContext() {
830
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
831
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
832
                    didSave = saveContext()
833
                } else {
834
                    didSave = true
835
                }
836
            }
837
        }
838
        return didSave
839
    }
840

            
841
    @discardableResult
842
    func continueMonitoringDespiteCompletionContradiction(for sessionID: UUID) -> Bool {
843
        var didSave = false
844
        context.performAndWait {
845
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
846
                return
847
            }
848

            
849
            guard statusValue(session, key: "statusRawValue") == .active else {
850
                return
851
            }
852

            
853
            clearCompletionConfirmationState(for: session)
Bogdan Timofte authored a month ago
854
            session.setValue(nil, forKey: "belowThresholdSince")
Bogdan Timofte authored a month ago
855
            session.setValue(Date().addingTimeInterval(completionConfirmationCooldown), forKey: "completionConfirmationCooldownUntil")
856
            session.setValue(Date(), forKey: "updatedAt")
857
            didSave = saveContext()
858
        }
859
        return didSave
860
    }
861

            
Bogdan Timofte authored a month ago
862
    @discardableResult
863
    func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
864
        var didSave = false
865
        context.performAndWait {
866
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
867
                return
868
            }
869

            
870
            let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast
871
            let sessionEnd   = dateValue(session, key: "endedAt")
872
                ?? dateValue(session, key: "lastObservedAt")
873
                ?? Date.distantFuture
874

            
875
            let effectiveStart = min(max(start ?? sessionStart, sessionStart), sessionEnd)
876
            let effectiveEnd   = max(min(end ?? sessionEnd, sessionEnd), effectiveStart)
877
            let persistedStart = effectiveStart == sessionStart ? nil : effectiveStart
878
            let persistedEnd   = effectiveEnd == sessionEnd ? nil : effectiveEnd
879

            
880
            let allSamples = fetchSessionSampleObjects(forSessionID: sessionID.uuidString)
881
                .compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in
882
                    guard let ts = dateValue(obj, key: "timestamp") else { return nil }
883
                    return (
884
                        timestamp: ts,
885
                        energy: doubleValue(obj, key: "measuredEnergyWh"),
886
                        charge: doubleValue(obj, key: "measuredChargeAh")
887
                    )
888
                }
889
                .sorted { $0.timestamp < $1.timestamp }
890

            
891
            // Each sample stores cumulative energy since session start.
892
            // Trimmed energy = value at trimEnd  -  value just before trimStart.
893
            let baselineSample = allSamples.last { $0.timestamp <= effectiveStart }
894
            let endSample      = allSamples.last { $0.timestamp <= effectiveEnd }
895
            let baselineEnergy = baselineSample?.energy ?? 0
896
            let baselineCharge = baselineSample?.charge ?? 0
897

            
898
            if let endSample {
899
                let trimmedEnergy  = max(endSample.energy - baselineEnergy, 0)
900
                let trimmedCharge  = max(endSample.charge - baselineCharge, 0)
901
                session.setValue(trimmedEnergy, forKey: "measuredEnergyWh")
902
                session.setValue(trimmedCharge, forKey: "measuredChargeAh")
903
            } else {
904
                session.setValue(0, forKey: "measuredEnergyWh")
905
                session.setValue(0, forKey: "measuredChargeAh")
906
            }
907

            
908
            session.setValue(persistedStart, forKey: "trimStart")
909
            session.setValue(persistedEnd,   forKey: "trimEnd")
910
            session.setValue(Date(), forKey: "updatedAt")
911

            
912
            let checkpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
913
            for checkpoint in checkpoints {
914
                guard let timestamp = dateValue(checkpoint, key: "timestamp") else { continue }
915

            
916
                if timestamp < effectiveStart || timestamp > effectiveEnd {
917
                    context.delete(checkpoint)
918
                    continue
919
                }
920

            
921
                let checkpointSample = allSamples.last { $0.timestamp <= timestamp }
922
                let rebasedEnergy = max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0)
923
                let rebasedCharge = max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0)
924
                checkpoint.setValue(rebasedEnergy, forKey: "measuredEnergyWh")
925
                checkpoint.setValue(rebasedCharge, forKey: "measuredChargeAh")
926
            }
927

            
928
            let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
929
                .sorted {
930
                    (dateValue($0, key: "timestamp") ?? .distantPast)
931
                        < (dateValue($1, key: "timestamp") ?? .distantPast)
932
                }
933
            let restoredInitialCheckpoint = remainingCheckpoints.first { checkpoint in
934
                let label = stringValue(checkpoint, key: "label")
935
                let timestamp = dateValue(checkpoint, key: "timestamp") ?? .distantFuture
936
                return ChargeCheckpointFlag.fromStoredLabel(label) == .initial || timestamp <= effectiveStart
937
            }
938

            
939
            if persistedStart == nil {
940
                if let restoredInitialCheckpoint,
941
                   let percent = optionalDoubleValue(restoredInitialCheckpoint, key: "batteryPercent"),
942
                   percent >= 0 {
943
                    session.setValue(percent, forKey: "startBatteryPercent")
944
                }
945
            } else {
946
                session.setValue(nil, forKey: "startBatteryPercent")
947
            }
948

            
949
            refreshCheckpointDerivedValues(for: session)
950

            
951
            if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
952
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
953
            }
954

            
955
            didSave = saveContext()
956
        }
957
        return didSave
958
    }
959

            
Bogdan Timofte authored a month ago
960
    @discardableResult
961
    func deleteChargeSession(id sessionID: UUID) -> Bool {
962
        var didSave = false
963
        context.performAndWait {
964
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
965
                return
966
            }
967

            
968
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
969

            
970
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
971
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
972
            context.delete(session)
973

            
974
            guard saveContext() else {
975
                return
976
            }
977

            
978
            if let chargedDeviceID {
979
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
980
                didSave = saveContext()
981
            } else {
982
                didSave = true
983
            }
984
        }
985
        return didSave
986
    }
987

            
988
    @discardableResult
989
    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
990
        var didSave = false
991

            
992
        context.performAndWait {
993
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
994
                return
995
            }
996

            
997
            let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
998
            let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
999
            let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
1000

            
1001
            var impactedChargedDeviceIDs = Set<String>()
1002

            
1003
            for session in deviceSessions {
1004
                if let impactedID = stringValue(session, key: "chargedDeviceID") {
1005
                    impactedChargedDeviceIDs.insert(impactedID)
1006
                }
1007
                if let impactedChargerID = stringValue(session, key: "chargerID") {
1008
                    impactedChargedDeviceIDs.insert(impactedChargerID)
1009
                }
1010
                if let sessionID = stringValue(session, key: "id") {
1011
                    fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete)
1012
                    fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete)
1013
                }
1014
                context.delete(session)
1015
            }
1016

            
1017
            if deviceClass == .charger {
1018
                for session in linkedWirelessSessions {
1019
                    guard stringValue(session, key: "chargedDeviceID") != chargedDeviceID.uuidString else {
1020
                        continue
1021
                    }
1022
                    if let impactedID = stringValue(session, key: "chargedDeviceID") {
1023
                        impactedChargedDeviceIDs.insert(impactedID)
1024
                    }
1025
                    session.setValue(nil, forKey: "chargerID")
1026
                    session.setValue(Date(), forKey: "updatedAt")
1027
                }
1028
            }
1029

            
1030
            context.delete(chargedDevice)
1031

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

            
1036
            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
1037
            for impactedID in impactedChargedDeviceIDs {
1038
                refreshDerivedMetrics(forChargedDeviceID: impactedID)
1039
            }
1040
            didSave = saveContext()
1041
        }
1042

            
1043
        return didSave
1044
    }
1045

            
1046
    @discardableResult
1047
    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
1048
        var didSave = false
1049

            
1050
        context.performAndWait {
Bogdan Timofte authored a month ago
1051
            guard let session = fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress),
1052
                  let resolvedDevice = stringValue(session, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:)) else {
1053
                return
1054
            }
Bogdan Timofte authored a month ago
1055

            
Bogdan Timofte authored a month ago
1056
            if statusValue(session, key: "statusRawValue") == .paused {
Bogdan Timofte authored a month ago
1057
                if maybeCompleteOpenSession(session, observedAt: snapshot.observedAt) {
Bogdan Timofte authored a month ago
1058
                    didSave = true
1059
                }
Bogdan Timofte authored a month ago
1060
                return
1061
            }
1062

            
Bogdan Timofte authored a month ago
1063
            let chargingTransportMode = self.chargingTransportMode(for: session)
1064
            let chargingStateMode = self.chargingStateMode(for: session)
Bogdan Timofte authored a month ago
1065
            let charger = chargingTransportMode == .wireless
Bogdan Timofte authored a month ago
1066
                ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
1067
                : nil
1068
            guard chargingTransportMode == .wired || charger != nil else {
1069
                return
1070
            }
1071
            let stopThreshold = resolvedStopThreshold(
1072
                for: resolvedDevice,
1073
                chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
1074
                chargingStateMode: chargingStateMode,
1075
                charger: charger,
1076
                fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1077
            )
1078

            
Bogdan Timofte authored a month ago
1079
            let sessionSnapshot = snapshotClampedToMaximumDuration(snapshot, for: session)
1080
            update(session: session, with: sessionSnapshot, stopThreshold: stopThreshold, charger: charger)
1081
            let aggregatedSample = updateAggregatedSample(session: session, with: sessionSnapshot)
1082
            if let completionDate = automaticCompletionDate(for: session, referenceDate: snapshot.observedAt),
1083
               statusValue(session, key: "statusRawValue")?.isOpen == true {
1084
                finishSession(
1085
                    session,
1086
                    observedAt: completionDate,
1087
                    finalBatteryPercent: nil,
1088
                    status: .completed
1089
                )
1090
            }
Bogdan Timofte authored a month ago
1091

            
Bogdan Timofte authored a month ago
1092
            let saveReason = saveReason(for: session, observedAt: sessionSnapshot.observedAt)
Bogdan Timofte authored a month ago
1093
            let shouldPersistAggregatedCurve = aggregatedSample.map {
Bogdan Timofte authored a month ago
1094
                shouldPersistAggregatedSample($0, observedAt: sessionSnapshot.observedAt)
Bogdan Timofte authored a month ago
1095
            } ?? false
1096

            
1097
            guard saveReason != .none || shouldPersistAggregatedCurve else {
Bogdan Timofte authored a month ago
1098
                return
1099
            }
1100

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

            
1103
            if saveContext() {
1104
                if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
1105
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
1106
                    didSave = saveContext()
1107
                } else {
1108
                    didSave = true
1109
                }
1110
            }
1111
        }
1112

            
1113
        return didSave
1114
    }
1115

            
1116
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
1117
        var summaries: [ChargedDeviceSummary] = []
1118

            
1119
        context.performAndWait {
1120
            let devices = fetchObjects(entityName: EntityName.chargedDevice)
1121
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
1122
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
1123

            
1124
            let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
1125
            let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
1126
            let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
Bogdan Timofte authored a month ago
1127
            let sampleBackedSessionIDs = sampleBackedSessionIDs(
1128
                devices: devices,
1129
                sessionsByDeviceID: sessionsByDeviceID,
1130
                sessionsByChargerID: sessionsByChargerID
1131
            )
1132
            let samplesBySessionID = Dictionary(
1133
                grouping: fetchSessionSampleObjects(forSessionIDs: Array(sampleBackedSessionIDs))
1134
            ) { stringValue($0, key: "sessionID") ?? "" }
Bogdan Timofte authored a month ago
1135

            
1136
            summaries = devices.compactMap { device in
1137
                guard
1138
                    let id = uuidValue(device, key: "id"),
1139
                    let name = stringValue(device, key: "name"),
1140
                    let qrIdentifier = stringValue(device, key: "qrIdentifier"),
1141
                    let rawClass = stringValue(device, key: "deviceClassRawValue"),
1142
                    let deviceClass = ChargedDeviceClass(rawValue: rawClass)
1143
                else {
1144
                    return nil
1145
                }
1146

            
Bogdan Timofte authored a month ago
1147
                let chargingStateAvailability = chargingStateAvailability(for: device)
1148
                let supportsWiredCharging = supportsWiredCharging(for: device)
1149
                let supportsWirelessCharging = supportsWirelessCharging(for: device)
1150
                let templateDefinition = templateDefinition(for: device)
1151

            
Bogdan Timofte authored a month ago
1152
                let sessionObjects = relevantSessionObjects(
1153
                    for: id.uuidString,
1154
                    deviceClass: deviceClass,
1155
                    sessionsByDeviceID: sessionsByDeviceID,
1156
                    sessionsByChargerID: sessionsByChargerID
1157
                )
1158
                let sessionSummaries = sessionObjects
1159
                    .compactMap { session in
1160
                        makeSessionSummary(
1161
                            from: session,
1162
                            checkpoints: checkpointsBySessionID[stringValue(session, key: "id") ?? ""] ?? [],
1163
                            samples: samplesBySessionID[stringValue(session, key: "id") ?? ""] ?? []
1164
                        )
1165
                    }
1166
                    .sorted { lhs, rhs in
Bogdan Timofte authored a month ago
1167
                        if lhs.status.isOpen && !rhs.status.isOpen {
Bogdan Timofte authored a month ago
1168
                            return true
1169
                        }
Bogdan Timofte authored a month ago
1170
                        if !lhs.status.isOpen && rhs.status.isOpen {
1171
                            return false
1172
                        }
1173
                        if lhs.status == .active && rhs.status == .paused {
1174
                            return true
1175
                        }
1176
                        if lhs.status == .paused && rhs.status == .active {
Bogdan Timofte authored a month ago
1177
                            return false
1178
                        }
1179
                        return lhs.startedAt > rhs.startedAt
1180
                    }
1181

            
1182
                return ChargedDeviceSummary(
1183
                    id: id,
1184
                    qrIdentifier: qrIdentifier,
1185
                    name: name,
1186
                    deviceClass: deviceClass,
Bogdan Timofte authored a month ago
1187
                    deviceTemplateID: stringValue(device, key: "deviceTemplateID"),
1188
                    templateDefinition: templateDefinition,
1189
                    supportsChargingWhileOff: chargingStateAvailability.supportsChargingWhileOff,
1190
                    chargingStateAvailability: chargingStateAvailability,
1191
                    supportsWiredCharging: supportsWiredCharging,
1192
                    supportsWirelessCharging: supportsWirelessCharging,
Bogdan Timofte authored a month ago
1193
                    chargerType: chargerType(for: device),
Bogdan Timofte authored a month ago
1194
                    wirelessChargingProfile: wirelessChargingProfile(for: device),
Bogdan Timofte authored a month ago
1195
                    configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"),
1196
                    learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"),
Bogdan Timofte authored a month ago
1197
                    wirelessChargerEfficiencyFactor: optionalDoubleValue(device, key: "wirelessChargerEfficiencyFactor"),
1198
                    wiredChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wiredChargeCompletionCurrentAmps"),
1199
                    wirelessChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wirelessChargeCompletionCurrentAmps"),
1200
                    chargerObservedVoltageSelections: decodedObservedVoltageSelections(from: device),
1201
                    chargerIdleCurrentAmps: optionalDoubleValue(device, key: "chargerIdleCurrentAmps"),
1202
                    chargerEfficiencyFactor: optionalDoubleValue(device, key: "chargerEfficiencyFactor"),
1203
                    chargerMaximumPowerWatts: optionalDoubleValue(device, key: "chargerMaximumPowerWatts"),
1204
                    notes: stringValue(device, key: "notes"),
1205
                    minimumCurrentAmps: optionalDoubleValue(device, key: "minimumCurrentAmps"),
1206
                    estimatedBatteryCapacityWh: optionalDoubleValue(device, key: "estimatedBatteryCapacityWh"),
1207
                    wiredMinimumCurrentAmps: optionalDoubleValue(device, key: "wiredMinimumCurrentAmps"),
1208
                    wirelessMinimumCurrentAmps: optionalDoubleValue(device, key: "wirelessMinimumCurrentAmps"),
1209
                    wiredEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wiredEstimatedBatteryCapacityWh"),
1210
                    wirelessEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wirelessEstimatedBatteryCapacityWh"),
1211
                    lastAssociatedMeterMAC: stringValue(device, key: "lastAssociatedMeterMAC"),
1212
                    createdAt: dateValue(device, key: "createdAt") ?? .distantPast,
1213
                    updatedAt: dateValue(device, key: "updatedAt") ?? .distantPast,
1214
                    sessions: sessionSummaries,
1215
                    capacityHistory: buildCapacityHistory(from: sessionSummaries),
Bogdan Timofte authored a month ago
1216
                    typicalCurve: buildTypicalCurve(from: sessionSummaries),
1217
                    standbyPowerMeasurements: []
Bogdan Timofte authored a month ago
1218
                )
1219
            }
1220
            .sorted { lhs, rhs in
1221
                if lhs.activeSession != nil && rhs.activeSession == nil {
1222
                    return true
1223
                }
1224
                if lhs.activeSession == nil && rhs.activeSession != nil {
1225
                    return false
1226
                }
1227
                if lhs.updatedAt != rhs.updatedAt {
1228
                    return lhs.updatedAt > rhs.updatedAt
1229
                }
1230
                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
1231
            }
1232
        }
1233

            
1234
        return summaries
1235
    }
1236

            
1237
    func resolvedChargedDeviceSummary(forMeterMACAddress meterMACAddress: String) -> ChargedDeviceSummary? {
1238
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1239
        guard !normalizedMAC.isEmpty else { return nil }
1240

            
1241
        let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
1242

            
1243
        if let activeMatch = summaries.first(where: { summary in
1244
            summary.activeSession?.meterMACAddress == normalizedMAC
1245
        }) {
1246
            return activeMatch
1247
        }
1248

            
1249
        return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
1250
    }
1251

            
1252
    func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
1253
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1254
        guard !normalizedMAC.isEmpty else { return nil }
1255

            
Bogdan Timofte authored a month ago
1256
        var summary: ChargeSessionSummary?
1257

            
1258
        context.performAndWait {
Bogdan Timofte authored a month ago
1259
            guard let session = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
Bogdan Timofte authored a month ago
1260
                  let sessionID = stringValue(session, key: "id") else {
1261
                return
1262
            }
1263

            
1264
            summary = makeSessionSummary(
1265
                from: session,
1266
                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
1267
                samples: fetchSessionSampleObjects(forSessionID: sessionID)
1268
            )
1269
        }
1270

            
1271
        return summary
Bogdan Timofte authored a month ago
1272
    }
1273

            
1274
    private func createSessionObject(
1275
        for chargedDevice: NSManagedObject,
1276
        charger: NSManagedObject?,
1277
        snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1278
        stopThreshold: Double?,
1279
        chargingTransportMode: ChargingTransportMode,
1280
        chargingStateMode: ChargingStateMode,
1281
        autoStopEnabled: Bool
Bogdan Timofte authored a month ago
1282
    ) -> NSManagedObject? {
1283
        guard
1284
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
1285
            let chargedDeviceID = stringValue(chargedDevice, key: "id")
1286
        else {
1287
            return nil
1288
        }
1289

            
1290
        let session = NSManagedObject(entity: entity, insertInto: context)
1291
        let now = snapshot.observedAt
1292
        session.setValue(UUID().uuidString, forKey: "id")
1293
        session.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1294
        session.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
1295
        session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress")
1296
        session.setValue(snapshot.meterName, forKey: "meterName")
1297
        session.setValue(snapshot.meterModel, forKey: "meterModel")
1298
        session.setValue(now, forKey: "startedAt")
1299
        session.setValue(now, forKey: "lastObservedAt")
1300
        session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
Bogdan Timofte authored a month ago
1301
        let usesMeterCounters = snapshot.meterEnergyCounterWh != nil || snapshot.meterChargeCounterAh != nil
1302
        session.setValue(
1303
            (usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue,
1304
            forKey: "sourceModeRawValue"
1305
        )
Bogdan Timofte authored a month ago
1306
        session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
1307
        session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue")
1308
        session.setValue(autoStopEnabled, forKey: "autoStopEnabled")
1309
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1310
        session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
1311
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1312
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1313
        session.setValue(
1314
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1315
            forKey: "lastObservedVoltageVolts"
1316
        )
Bogdan Timofte authored a month ago
1317
        session.setValue(
1318
            hasObservedChargeFlow(
1319
                currentAmps: snapshot.currentAmps,
1320
                chargingTransportMode: chargingTransportMode,
1321
                charger: charger,
1322
                stopThreshold: stopThreshold
1323
            ),
1324
            forKey: "hasObservedChargeFlow"
1325
        )
Bogdan Timofte authored a month ago
1326
        session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
1327
        session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
1328
        session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
1329
        session.setValue(
1330
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1331
            forKey: "maximumObservedVoltageVolts"
1332
        )
1333
        session.setValue(boolValue(chargedDevice, key: "supportsChargingWhileOff"), forKey: "supportsChargingWhileOff")
1334
        if let selectedDataGroup = snapshot.selectedDataGroup {
1335
            session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup")
1336
        }
1337
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1338
            session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1339
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1340
        }
1341
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1342
            session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1343
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1344
        }
Bogdan Timofte authored a month ago
1345
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1346
            setValue(meterRecordingDurationSeconds, on: session, key: "meterDurationBaselineSeconds")
1347
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1348
        }
Bogdan Timofte authored a month ago
1349
        session.setValue(now, forKey: "createdAt")
1350
        session.setValue(now, forKey: "updatedAt")
1351

            
1352
        chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
1353
        chargedDevice.setValue(now, forKey: "updatedAt")
1354
        return session
1355
    }
1356

            
1357
    private func update(
1358
        session: NSManagedObject,
1359
        with snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1360
        stopThreshold: Double?,
1361
        charger: NSManagedObject?
Bogdan Timofte authored a month ago
1362
    ) {
1363
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1364
        let lastObservedAt = dateValue(session, key: "lastObservedAt")
1365
        let previousPower = doubleValue(session, key: "lastObservedPowerWatts")
1366
        let previousCurrent = doubleValue(session, key: "lastObservedCurrentAmps")
1367
        var measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1368
        var measuredChargeAh = doubleValue(session, key: "measuredChargeAh")
1369
        var sourceMode = ChargeSessionSourceMode(rawValue: stringValue(session, key: "sourceModeRawValue") ?? "") ?? .live
1370
        var usedOfflineMeterCounters = boolValue(session, key: "usedOfflineMeterCounters")
1371

            
1372
        if let lastObservedAt {
1373
            let deltaSeconds = max(snapshot.observedAt.timeIntervalSince(lastObservedAt), 0)
1374
            if deltaSeconds > 0, deltaSeconds <= maximumLiveIntegrationGap {
1375
                measuredEnergyWh += max(previousPower, 0) * deltaSeconds / 3600
1376
                measuredChargeAh += max(previousCurrent, 0) * deltaSeconds / 3600
1377
                if sourceMode == .offline {
1378
                    sourceMode = .blended
1379
                }
1380
            }
1381
        }
1382

            
1383
        if let counterGroup = snapshot.selectedDataGroup,
1384
           let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
1385
           UInt8(storedGroup) != counterGroup {
1386
            session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
1387
            session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1388
            session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1389
        }
1390

            
1391
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1392
            let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
1393
            let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
1394
            if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
1395
                session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
1396
            }
1397

            
1398
            if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
1399
                let offlineEnergy = meterEnergyCounterWh - baselineEnergy
Bogdan Timofte authored a month ago
1400
                measuredEnergyWh = max(offlineEnergy, 0)
Bogdan Timofte authored a month ago
1401
                usedOfflineMeterCounters = true
Bogdan Timofte authored a month ago
1402
                sourceMode = .offline
Bogdan Timofte authored a month ago
1403
            } else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
1404
                let delta = meterEnergyCounterWh - lastEnergy
1405
                if delta > 0 {
1406
                    measuredEnergyWh += delta
1407
                    usedOfflineMeterCounters = true
1408
                    sourceMode = .blended
1409
                }
1410
            }
1411
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1412
        }
1413

            
1414
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1415
            let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
1416
            let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
1417
            if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
1418
                session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
1419
            }
1420

            
1421
            if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
1422
                let offlineCharge = meterChargeCounterAh - baselineCharge
Bogdan Timofte authored a month ago
1423
                measuredChargeAh = max(offlineCharge, 0)
Bogdan Timofte authored a month ago
1424
                usedOfflineMeterCounters = true
1425
            } else if let lastCharge, meterChargeCounterAh > lastCharge {
1426
                let delta = meterChargeCounterAh - lastCharge
1427
                if delta > 0 {
1428
                    measuredChargeAh += delta
1429
                    usedOfflineMeterCounters = true
1430
                }
1431
            }
1432
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1433
        }
1434

            
Bogdan Timofte authored a month ago
1435
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1436
            let baselineDuration = optionalDoubleValue(session, key: "meterDurationBaselineSeconds") ?? meterRecordingDurationSeconds
1437
            if optionalDoubleValue(session, key: "meterDurationBaselineSeconds") == nil {
1438
                setValue(baselineDuration, on: session, key: "meterDurationBaselineSeconds")
1439
            }
1440
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1441
        }
1442

            
Bogdan Timofte authored a month ago
1443
        let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps")
1444
        let updatedMinimum: Double
1445
        if snapshot.currentAmps > 0 {
1446
            updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps)
1447
        } else {
1448
            updatedMinimum = existingMinimum ?? 0
1449
        }
1450

            
Bogdan Timofte authored a month ago
1451
        let effectiveCurrent = effectiveCurrentAmps(
1452
            fromMeasuredCurrent: snapshot.currentAmps,
1453
            chargingTransportMode: sessionChargingTransportMode,
1454
            charger: charger
1455
        )
1456
        let observedChargeFlow = boolValue(session, key: "hasObservedChargeFlow")
1457
            || hasObservedChargeFlow(
1458
                currentAmps: snapshot.currentAmps,
1459
                chargingTransportMode: sessionChargingTransportMode,
1460
                charger: charger,
1461
                stopThreshold: stopThreshold
1462
            )
1463

            
Bogdan Timofte authored a month ago
1464
        session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh")
1465
        session.setValue(measuredChargeAh, forKey: "measuredChargeAh")
1466
        session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps")
Bogdan Timofte authored a month ago
1467
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1468
        session.setValue(snapshot.observedAt, forKey: "lastObservedAt")
1469
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1470
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1471
        session.setValue(
1472
            sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1473
            forKey: "lastObservedVoltageVolts"
1474
        )
Bogdan Timofte authored a month ago
1475
        session.setValue(observedChargeFlow, forKey: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
1476
        session.setValue(
1477
            max(optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? snapshot.currentAmps, snapshot.currentAmps),
1478
            forKey: "maximumObservedCurrentAmps"
1479
        )
1480
        session.setValue(
1481
            max(optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? snapshot.powerWatts, snapshot.powerWatts),
1482
            forKey: "maximumObservedPowerWatts"
1483
        )
1484
        session.setValue(
1485
            sessionChargingTransportMode == .wired
1486
                ? max(optionalDoubleValue(session, key: "maximumObservedVoltageVolts") ?? snapshot.voltageVolts, snapshot.voltageVolts)
1487
                : nil,
1488
            forKey: "maximumObservedVoltageVolts"
1489
        )
1490
        session.setValue(usedOfflineMeterCounters, forKey: "usedOfflineMeterCounters")
1491
        session.setValue(sourceMode.rawValue, forKey: "sourceModeRawValue")
1492
        maybeTriggerTargetBatteryAlert(for: session, observedAt: snapshot.observedAt)
1493

            
Bogdan Timofte authored a month ago
1494
        guard boolValue(session, key: "autoStopEnabled"), let stopThreshold, stopThreshold > 0, observedChargeFlow else {
1495
            session.setValue(nil, forKey: "belowThresholdSince")
1496
            clearCompletionConfirmationState(for: session)
1497
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1498
            return
1499
        }
1500

            
1501
        if effectiveCurrent <= stopThreshold {
Bogdan Timofte authored a month ago
1502
            let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt
1503
            session.setValue(belowThresholdSince, forKey: "belowThresholdSince")
1504
            if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
1505
                if boolValue(session, key: "requiresCompletionConfirmation") {
1506
                    // Leave the session active until the user explicitly confirms or charging resumes.
1507
                    return
1508
                }
1509

            
1510
                if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
1511
                    requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt)
1512
                } else {
Bogdan Timofte authored a month ago
1513
                    finishSession(
1514
                        session,
1515
                        observedAt: snapshot.observedAt,
1516
                        finalBatteryPercent: nil,
1517
                        status: .completed
1518
                    )
Bogdan Timofte authored a month ago
1519
                }
1520
            }
1521
        } else {
1522
            session.setValue(nil, forKey: "belowThresholdSince")
1523
            clearCompletionConfirmationState(for: session)
1524
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1525
        }
1526
    }
1527

            
1528
    private func updateAggregatedSample(
1529
        session: NSManagedObject,
1530
        with snapshot: ChargingMonitorSnapshot
Bogdan Timofte authored a month ago
1531
    ) -> NSManagedObject? {
Bogdan Timofte authored a month ago
1532
        guard
1533
            let sessionID = stringValue(session, key: "id"),
1534
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1535
            let startedAt = dateValue(session, key: "startedAt"),
1536
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context)
1537
        else {
Bogdan Timofte authored a month ago
1538
            return nil
Bogdan Timofte authored a month ago
1539
        }
1540

            
1541
        let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0)
1542
        let bucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
1543
        let bucketStart = startedAt.addingTimeInterval(Double(bucketIndex) * Self.aggregatedSampleBucketDuration)
1544
        let bucketIdentifier = "\(sessionID)-\(bucketIndex)"
1545
        let sample = fetchSessionSampleObject(sessionID: sessionID, bucketIndex: bucketIndex)
1546
            ?? NSManagedObject(entity: entity, insertInto: context)
1547
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1548
        let sampleVoltage = sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil
1549

            
1550
        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
1551
        let updatedCount = existingCount + 1
1552

            
1553
        sample.setValue(bucketIdentifier, forKey: "id")
1554
        sample.setValue(sessionID, forKey: "sessionID")
1555
        sample.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1556
        sample.setValue(bucketIndex, forKey: "bucketIndex")
1557
        sample.setValue(max(snapshot.observedAt, bucketStart), forKey: "timestamp")
1558
        sample.setValue(
1559
            runningAverage(
1560
                currentAverage: optionalDoubleValue(sample, key: "averageCurrentAmps") ?? snapshot.currentAmps,
1561
                currentCount: Int(existingCount),
1562
                newValue: snapshot.currentAmps
1563
            ),
1564
            forKey: "averageCurrentAmps"
1565
        )
1566
        sample.setValue(
1567
            sampleVoltage.flatMap { voltage in
1568
                runningAverage(
1569
                    currentAverage: optionalDoubleValue(sample, key: "averageVoltageVolts") ?? voltage,
1570
                    currentCount: Int(existingCount),
1571
                    newValue: voltage
1572
                )
1573
            },
1574
            forKey: "averageVoltageVolts"
1575
        )
1576
        sample.setValue(
1577
            runningAverage(
1578
                currentAverage: optionalDoubleValue(sample, key: "averagePowerWatts") ?? snapshot.powerWatts,
1579
                currentCount: Int(existingCount),
1580
                newValue: snapshot.powerWatts
1581
            ),
1582
            forKey: "averagePowerWatts"
1583
        )
1584
        sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh")
1585
        sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
1586
        sample.setValue(Int16(updatedCount), forKey: "sampleCount")
1587
        sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
1588
        sample.setValue(snapshot.observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1589
        return sample
Bogdan Timofte authored a month ago
1590
    }
1591

            
Bogdan Timofte authored a month ago
1592
    private func maybeTriggerTargetBatteryAlert(
1593
        for session: NSManagedObject,
1594
        observedAt: Date,
1595
        completionFallbackPercent: Double? = nil
1596
    ) {
Bogdan Timofte authored a month ago
1597
        guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
1598
            return
1599
        }
1600

            
1601
        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
1602
            return
1603
        }
1604

            
1605
        let predictedBatteryPercent = predictedBatteryPercent(for: session)
1606
            ?? optionalDoubleValue(session, key: "endBatteryPercent")
Bogdan Timofte authored a month ago
1607
            ?? completionFallbackPercent
Bogdan Timofte authored a month ago
1608

            
1609
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
1610
            return
1611
        }
1612

            
1613
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
1614
    }
1615

            
1616
    private func shouldRequireCompletionConfirmation(
1617
        for session: NSManagedObject,
1618
        observedAt: Date
1619
    ) -> Bool {
1620
        if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"),
1621
           cooldownUntil > observedAt {
1622
            return false
1623
        }
1624

            
1625
        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
1626
            return false
1627
        }
1628

            
1629
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
1630
            ?? defaultCompletionPercentThreshold
1631

            
1632
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1633
    }
1634

            
1635
    private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
1636
        guard !boolValue(session, key: "requiresCompletionConfirmation") else {
1637
            return
1638
        }
1639

            
1640
        session.setValue(true, forKey: "requiresCompletionConfirmation")
1641
        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
1642
        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
1643
    }
1644

            
1645
    private func clearCompletionConfirmationState(for session: NSManagedObject) {
1646
        session.setValue(false, forKey: "requiresCompletionConfirmation")
1647
        session.setValue(nil, forKey: "completionConfirmationRequestedAt")
1648
        session.setValue(nil, forKey: "completionContradictionPercent")
1649
    }
1650

            
Bogdan Timofte authored a month ago
1651
    private func snapshotDateForManualStop(_ session: NSManagedObject) -> Date {
1652
        if statusValue(session, key: "statusRawValue") == .paused {
1653
            return dateValue(session, key: "pausedAt")
1654
                ?? dateValue(session, key: "lastObservedAt")
1655
                ?? Date()
1656
        }
1657
        return dateValue(session, key: "lastObservedAt") ?? Date()
1658
    }
1659

            
Bogdan Timofte authored a month ago
1660
    private func snapshotClampedToMaximumDuration(
1661
        _ snapshot: ChargingMonitorSnapshot,
1662
        for session: NSManagedObject
1663
    ) -> ChargingMonitorSnapshot {
1664
        guard let maximumEndDate = maximumEndDate(for: session),
1665
              snapshot.observedAt > maximumEndDate else {
1666
            return snapshot
1667
        }
1668

            
1669
        return ChargingMonitorSnapshot(
1670
            meterMACAddress: snapshot.meterMACAddress,
1671
            meterName: snapshot.meterName,
1672
            meterModel: snapshot.meterModel,
1673
            observedAt: maximumEndDate,
1674
            voltageVolts: snapshot.voltageVolts,
1675
            currentAmps: snapshot.currentAmps,
1676
            powerWatts: snapshot.powerWatts,
1677
            selectedDataGroup: snapshot.selectedDataGroup,
1678
            meterChargeCounterAh: snapshot.meterChargeCounterAh,
1679
            meterEnergyCounterWh: snapshot.meterEnergyCounterWh,
1680
            meterRecordingDurationSeconds: snapshot.meterRecordingDurationSeconds,
1681
            fallbackStopThresholdAmps: snapshot.fallbackStopThresholdAmps
1682
        )
1683
    }
1684

            
1685
    private func automaticCompletionDate(
1686
        for session: NSManagedObject,
1687
        referenceDate: Date
1688
    ) -> Date? {
1689
        guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
1690
            return nil
Bogdan Timofte authored a month ago
1691
        }
1692

            
Bogdan Timofte authored a month ago
1693
        var completionDates: [Date] = []
1694

            
1695
        if let maximumEndDate = maximumEndDate(for: session) {
1696
            completionDates.append(maximumEndDate)
1697
        }
1698

            
1699
        if statusValue(session, key: "statusRawValue") == .paused,
1700
           let pausedAt = dateValue(session, key: "pausedAt") {
1701
            completionDates.append(pausedAt.addingTimeInterval(pausedSessionTimeout))
1702
        }
1703

            
1704
        guard let completionDate = completionDates.min(),
1705
              referenceDate >= completionDate else {
1706
            return nil
1707
        }
1708

            
1709
        return completionDate
1710
    }
1711

            
1712
    private func maximumEndDate(for session: NSManagedObject) -> Date? {
1713
        dateValue(session, key: "startedAt")?.addingTimeInterval(Self.maximumChargeSessionDuration)
1714
    }
1715

            
1716
    @discardableResult
1717
    private func maybeCompleteOpenSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
1718
        guard statusValue(session, key: "statusRawValue")?.isOpen == true,
1719
              let completionDate = automaticCompletionDate(for: session, referenceDate: observedAt) else {
Bogdan Timofte authored a month ago
1720
            return false
1721
        }
1722

            
1723
        finishSession(
1724
            session,
Bogdan Timofte authored a month ago
1725
            observedAt: completionDate,
Bogdan Timofte authored a month ago
1726
            finalBatteryPercent: nil,
1727
            status: .completed
1728
        )
1729

            
1730
        guard saveContext() else {
1731
            return false
1732
        }
1733

            
1734
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1735
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1736
            return saveContext()
1737
        }
1738

            
1739
        return true
1740
    }
1741

            
1742
    private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
1743
        let chargingTransportMode = chargingTransportMode(for: session)
1744
        let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps")
1745
            ?? doubleValue(session, key: "lastObservedCurrentAmps")
1746

            
1747
        guard measuredCurrent > 0 else {
1748
            return nil
1749
        }
1750

            
1751
        let charger = chargingTransportMode == .wireless
1752
            ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
1753
            : nil
1754

            
1755
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
1756
            return nil
1757
        }
1758

            
1759
        let effectiveCurrent = effectiveCurrentAmps(
1760
            fromMeasuredCurrent: measuredCurrent,
1761
            chargingTransportMode: chargingTransportMode,
1762
            charger: charger
1763
        )
1764
        guard effectiveCurrent > 0 else {
1765
            return nil
1766
        }
1767
        return effectiveCurrent
1768
    }
1769

            
1770
    private func finishSession(
1771
        _ session: NSManagedObject,
1772
        observedAt: Date,
1773
        finalBatteryPercent: Double?,
1774
        status: ChargeSessionStatus
1775
    ) {
1776
        if let finalBatteryPercent {
1777
            _ = insertBatteryCheckpoint(
1778
                percent: finalBatteryPercent,
Bogdan Timofte authored a month ago
1779
                flag: .final,
Bogdan Timofte authored a month ago
1780
                timestamp: observedAt,
1781
                to: session
1782
            )
1783
        }
1784

            
1785
        session.setValue(status.rawValue, forKey: "statusRawValue")
1786
        session.setValue(nil, forKey: "pausedAt")
1787
        session.setValue(nil, forKey: "belowThresholdSince")
1788
        session.setValue(observedAt, forKey: "endedAt")
1789
        session.setValue(observedAt, forKey: "lastObservedAt")
1790
        session.setValue(completionCurrentForSessionEnd(session), forKey: "completionCurrentAmps")
1791
        clearCompletionConfirmationState(for: session)
1792
        session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1793
        updateCapacityEstimate(for: session)
1794
        session.setValue(observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1795

            
1796
        if status == .completed {
1797
            maybeTriggerTargetBatteryAlert(
1798
                for: session,
1799
                observedAt: observedAt,
1800
                completionFallbackPercent: defaultCompletionPercentThreshold
1801
            )
1802
        }
Bogdan Timofte authored a month ago
1803
    }
1804

            
Bogdan Timofte authored a month ago
1805
    private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
1806
        guard
1807
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1808
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
1809
            let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice),
1810
            estimatedCapacityWh > 0
1811
        else {
1812
            return nil
1813
        }
1814

            
Bogdan Timofte authored a month ago
1815
        // Compute effective battery energy dynamically so the prediction uses the
1816
        // most current measuredEnergyWh rather than the stale effectiveBatteryEnergyWh
1817
        // (which is only refreshed at session start, checkpoint insertion, and finish).
1818
        let rawMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1819
        let measuredEnergyWh: Double
1820
        switch chargingTransportMode(for: session) {
1821
        case .wired:
1822
            measuredEnergyWh = rawMeasuredEnergyWh
1823
        case .wireless:
1824
            if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 {
1825
                measuredEnergyWh = rawMeasuredEnergyWh * factor
1826
            } else {
1827
                measuredEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1828
                    ?? rawMeasuredEnergyWh
1829
            }
1830
        }
Bogdan Timofte authored a month ago
1831
        let sessionID = stringValue(session, key: "id") ?? ""
1832

            
1833
        struct Anchor {
1834
            let percent: Double
1835
            let energyWh: Double
Bogdan Timofte authored a month ago
1836
            let timestamp: Date
1837
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1838
        }
1839

            
1840
        var anchors: [Anchor] = []
Bogdan Timofte authored a month ago
1841
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1842
           startBatteryPercent >= 0 {
1843
            anchors.append(
1844
                Anchor(
1845
                    percent: startBatteryPercent,
1846
                    energyWh: 0,
Bogdan Timofte authored a month ago
1847
                    timestamp: dateValue(session, key: "trimStart")
1848
                        ?? dateValue(session, key: "startedAt")
1849
                        ?? Date.distantPast,
Bogdan Timofte authored a month ago
1850
                    isCheckpoint: false
1851
                )
1852
            )
Bogdan Timofte authored a month ago
1853
        }
1854

            
1855
        let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
1856
            .compactMap(makeCheckpointSummary(from:))
1857
            .sorted { lhs, rhs in
1858
                if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1859
                    return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1860
                }
1861
                return lhs.timestamp < rhs.timestamp
1862
            }
Bogdan Timofte authored a month ago
1863
            .filter { $0.batteryPercent >= 0 }
1864
            .map {
1865
                Anchor(
1866
                    percent: $0.batteryPercent,
1867
                    energyWh: $0.measuredEnergyWh,
1868
                    timestamp: $0.timestamp,
1869
                    isCheckpoint: true
1870
                )
1871
            }
Bogdan Timofte authored a month ago
1872
        anchors.append(contentsOf: checkpointAnchors)
1873

            
1874
        guard !anchors.isEmpty else {
1875
            return optionalDoubleValue(session, key: "endBatteryPercent")
1876
        }
1877

            
1878
        let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
Bogdan Timofte authored a month ago
1879
        return BatteryLevelPredictionTuning.predictedPercent(
1880
            anchorPercent: anchor.percent,
1881
            anchorEnergyWh: anchor.energyWh,
1882
            anchorTimestamp: anchor.timestamp,
1883
            anchorIsCheckpoint: anchor.isCheckpoint,
1884
            effectiveEnergyWh: measuredEnergyWh,
1885
            referenceTimestamp: dateValue(session, key: "lastObservedAt") ?? anchor.timestamp,
1886
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1887
        )
1888
    }
1889

            
1890
    private func resolvedEstimatedBatteryCapacityWh(
1891
        for session: NSManagedObject,
1892
        chargedDevice: NSManagedObject
1893
    ) -> Double? {
1894
        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
1895
           sessionCapacityEstimate > 0 {
1896
            return sessionCapacityEstimate
1897
        }
1898

            
1899
        switch chargingTransportMode(for: session) {
1900
        case .wired:
1901
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1902
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1903
        case .wireless:
1904
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1905
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1906
        }
1907
    }
1908

            
1909
    private func updateCapacityEstimate(for session: NSManagedObject) {
1910
        guard
1911
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1912
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
1913
        else {
1914
            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
1915
            session.setValue(nil, forKey: "capacityEstimateWh")
1916
            return
1917
        }
1918

            
1919
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1920
        let chargingMode = chargingTransportMode(for: session)
1921
        let wirelessResolution = chargingMode == .wireless
1922
            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
1923
            : nil
1924
        let effectiveBatteryEnergyWh = chargingMode == .wired
1925
            ? measuredEnergyWh
1926
            : wirelessResolution.map { measuredEnergyWh * $0.factor }
1927

            
1928
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
1929
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
1930
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
1931
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
1932

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

            
1935
        guard let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
Bogdan Timofte authored a month ago
1936
            session.setValue(nil, forKey: "capacityEstimateWh")
1937
            return
1938
        }
1939

            
Bogdan Timofte authored a month ago
1940
        struct CapacityAnchor {
1941
            let percent: Double
1942
            let energyWh: Double
1943
            let timestamp: Date
1944
        }
1945

            
1946
        var anchors: [CapacityAnchor] = []
1947

            
1948
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1949
           startBatteryPercent >= 0 {
1950
            anchors.append(
1951
                CapacityAnchor(
1952
                    percent: startBatteryPercent,
1953
                    energyWh: 0,
1954
                    timestamp: dateValue(session, key: "trimStart")
1955
                        ?? dateValue(session, key: "startedAt")
1956
                        ?? Date.distantPast
1957
                )
1958
            )
1959
        }
1960

            
1961
        if let sessionID = stringValue(session, key: "id") {
1962
            anchors.append(
1963
                contentsOf: fetchCheckpointObjects(forSessionID: sessionID).compactMap { checkpoint in
1964
                    guard
1965
                        let percent = optionalDoubleValue(checkpoint, key: "batteryPercent"),
1966
                        percent >= 0,
1967
                        let timestamp = dateValue(checkpoint, key: "timestamp")
1968
                    else {
1969
                        return nil
1970
                    }
1971

            
1972
                    return CapacityAnchor(
1973
                        percent: percent,
1974
                        energyWh: doubleValue(checkpoint, key: "measuredEnergyWh"),
1975
                        timestamp: timestamp
1976
                    )
1977
                }
1978
            )
1979
        }
1980

            
1981
        if let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"),
1982
           endBatteryPercent >= 0 {
1983
            anchors.append(
1984
                CapacityAnchor(
1985
                    percent: endBatteryPercent,
1986
                    energyWh: effectiveBatteryEnergyWh,
1987
                    timestamp: dateValue(session, key: "endedAt")
1988
                        ?? dateValue(session, key: "lastObservedAt")
1989
                        ?? Date.distantPast
1990
                )
1991
            )
1992
        }
1993

            
1994
        let sortedAnchors = anchors.sorted { lhs, rhs in
1995
            if lhs.energyWh != rhs.energyWh {
1996
                return lhs.energyWh < rhs.energyWh
1997
            }
1998
            return lhs.timestamp < rhs.timestamp
1999
        }
2000

            
2001
        guard let firstAnchor = sortedAnchors.first,
2002
              let lastAnchor = sortedAnchors.last else {
Bogdan Timofte authored a month ago
2003
            session.setValue(nil, forKey: "capacityEstimateWh")
2004
            return
2005
        }
2006

            
Bogdan Timofte authored a month ago
2007
        let percentDelta = lastAnchor.percent - firstAnchor.percent
2008
        let energyDelta = lastAnchor.energyWh - firstAnchor.energyWh
Bogdan Timofte authored a month ago
2009

            
Bogdan Timofte authored a month ago
2010
        guard percentDelta >= 20, energyDelta > 0 else {
Bogdan Timofte authored a month ago
2011
            session.setValue(nil, forKey: "capacityEstimateWh")
2012
            return
2013
        }
2014

            
Bogdan Timofte authored a month ago
2015
        if !supportsChargingWhileOff && lastAnchor.percent >= 99.5 {
Bogdan Timofte authored a month ago
2016
            session.setValue(nil, forKey: "capacityEstimateWh")
2017
            return
2018
        }
2019

            
Bogdan Timofte authored a month ago
2020
        let capacityEstimateWh = energyDelta / (percentDelta / 100)
Bogdan Timofte authored a month ago
2021
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
2022
    }
2023

            
2024
    @discardableResult
Bogdan Timofte authored a month ago
2025
    private func insertBatteryCheckpoint(
Bogdan Timofte authored a month ago
2026
        percent: Double,
Bogdan Timofte authored a month ago
2027
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
2028
        timestamp: Date = Date(),
Bogdan Timofte authored a month ago
2029
        measuredEnergyWhOverride: Double? = nil,
2030
        measuredChargeAhOverride: Double? = nil,
Bogdan Timofte authored a month ago
2031
        to session: NSManagedObject
Bogdan Timofte authored a month ago
2032
    ) -> String? {
Bogdan Timofte authored a month ago
2033
        guard
2034
            let sessionID = stringValue(session, key: "id"),
2035
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2036
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
2037
        else {
Bogdan Timofte authored a month ago
2038
            return nil
Bogdan Timofte authored a month ago
2039
        }
2040

            
2041
        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
Bogdan Timofte authored a month ago
2042
        let checkpointEnergyWh = measuredEnergyWhOverride
2043
            ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
Bogdan Timofte authored a month ago
2044
            ?? doubleValue(session, key: "measuredEnergyWh")
Bogdan Timofte authored a month ago
2045
        let checkpointChargeAh = measuredChargeAhOverride
2046
            ?? doubleValue(session, key: "measuredChargeAh")
Bogdan Timofte authored a month ago
2047
        checkpoint.setValue(UUID().uuidString, forKey: "id")
2048
        checkpoint.setValue(sessionID, forKey: "sessionID")
2049
        checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
Bogdan Timofte authored a month ago
2050
        checkpoint.setValue(timestamp, forKey: "timestamp")
Bogdan Timofte authored a month ago
2051
        checkpoint.setValue(percent, forKey: "batteryPercent")
2052
        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
Bogdan Timofte authored a month ago
2053
        checkpoint.setValue(checkpointChargeAh, forKey: "measuredChargeAh")
Bogdan Timofte authored a month ago
2054
        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
2055
        checkpoint.setValue(
2056
            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
2057
            forKey: "voltageVolts"
2058
        )
Bogdan Timofte authored a month ago
2059
        checkpoint.setValue(flag.rawValue, forKey: "label")
Bogdan Timofte authored a month ago
2060
        checkpoint.setValue(timestamp, forKey: "createdAt")
Bogdan Timofte authored a month ago
2061

            
Bogdan Timofte authored a month ago
2062
        let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
2063
        if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
Bogdan Timofte authored a month ago
2064
            session.setValue(percent, forKey: "startBatteryPercent")
2065
        }
Bogdan Timofte authored a month ago
2066
        if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
2067
            session.setValue(percent, forKey: "endBatteryPercent")
2068
        }
Bogdan Timofte authored a month ago
2069
        session.setValue(timestamp, forKey: "updatedAt")
Bogdan Timofte authored a month ago
2070
        updateCapacityEstimate(for: session)
2071

            
Bogdan Timofte authored a month ago
2072
        return chargedDeviceID
2073
    }
2074

            
Bogdan Timofte authored a month ago
2075
    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
2076
        guard let sessionID = stringValue(session, key: "id") else {
2077
            return
2078
        }
2079

            
2080
        let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
2081
        if let latestCheckpoint = remainingCheckpoints.last {
2082
            session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
2083
        } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2084
                  startBatteryPercent >= 0 {
2085
            session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
2086
        } else {
2087
            session.setValue(nil, forKey: "endBatteryPercent")
2088
        }
2089

            
2090
        session.setValue(Date(), forKey: "updatedAt")
2091
        updateCapacityEstimate(for: session)
2092
    }
2093

            
Bogdan Timofte authored a month ago
2094
    @discardableResult
2095
    private func addBatteryCheckpoint(
2096
        percent: Double,
Bogdan Timofte authored a month ago
2097
        measuredEnergyWh: Double? = nil,
2098
        measuredChargeAh: Double? = nil,
Bogdan Timofte authored a month ago
2099
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
2100
        to session: NSManagedObject,
2101
        timestamp: Date = Date()
2102
    ) -> Bool {
Bogdan Timofte authored a month ago
2103
        if let measuredEnergyWh, measuredEnergyWh.isFinite {
2104
            session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
2105
        }
2106
        if let measuredChargeAh, measuredChargeAh.isFinite {
2107
            session.setValue(max(measuredChargeAh, 0), forKey: "measuredChargeAh")
2108
        }
2109

            
Bogdan Timofte authored a month ago
2110
        guard let chargedDeviceID = insertBatteryCheckpoint(
2111
            percent: percent,
Bogdan Timofte authored a month ago
2112
            flag: flag,
Bogdan Timofte authored a month ago
2113
            timestamp: timestamp,
Bogdan Timofte authored a month ago
2114
            measuredEnergyWhOverride: measuredEnergyWh,
2115
            measuredChargeAhOverride: measuredChargeAh,
Bogdan Timofte authored a month ago
2116
            to: session
2117
        ) else {
2118
            return false
2119
        }
2120

            
Bogdan Timofte authored a month ago
2121
        guard saveContext() else {
2122
            return false
2123
        }
2124

            
2125
        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
2126
        return saveContext()
2127
    }
2128

            
2129
    private func resolvedWirelessEfficiency(
2130
        for session: NSManagedObject,
2131
        chargedDevice: NSManagedObject
2132
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
2133
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
2134
           storedFactor > 0 {
2135
            return (
2136
                factor: storedFactor,
2137
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
2138
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
2139
            )
2140
        }
2141

            
2142
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
2143
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2144
        guard measuredEnergyWh > 0 else {
2145
            return nil
2146
        }
2147

            
2148
        if chargingProfile == .magsafe,
2149
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
2150
           calibratedFactor > 0 {
2151
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
2152
        }
2153

            
2154
        guard
2155
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2156
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
2157
        else {
2158
            return nil
2159
        }
2160

            
2161
        let percentDelta = endBatteryPercent - startBatteryPercent
2162
        guard percentDelta >= 20 else {
2163
            return nil
2164
        }
2165

            
2166
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
Bogdan Timofte authored a month ago
2167
            ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
Bogdan Timofte authored a month ago
2168
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2169
                : nil),
2170
              wiredCapacityWh > 0
2171
        else {
2172
            return nil
2173
        }
2174

            
2175
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
2176
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
2177
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
2178
        let usesEstimated = chargingProfile != .magsafe
2179
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
2180

            
2181
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
2182
    }
2183

            
2184
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
2185
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
2186
            return
2187
        }
2188

            
Bogdan Timofte authored a month ago
2189
        let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
Bogdan Timofte authored a month ago
2190
        let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
2191
        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
2192
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
2193
        let sessions = relevantSessionObjects(
2194
            for: chargedDeviceID,
2195
            deviceClass: deviceClass,
2196
            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
2197
            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
2198
        )
Bogdan Timofte authored a month ago
2199
        let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
Bogdan Timofte authored a month ago
2200
        let wiredMinimumCurrent = derivedMinimumCurrent(
2201
            from: sessions,
2202
            chargingTransportMode: .wired
2203
        )
2204
        let wirelessMinimumCurrent = derivedMinimumCurrent(
2205
            from: sessions,
2206
            chargingTransportMode: .wireless
2207
        )
2208

            
2209
        let wiredCapacity = derivedCapacity(
2210
            from: sessions,
2211
            chargingTransportMode: .wired,
2212
            supportsChargingWhileOff: supportsChargingWhileOff
2213
        )
2214
        let wirelessCapacity = derivedCapacity(
2215
            from: sessions,
2216
            chargingTransportMode: .wireless,
2217
            supportsChargingWhileOff: supportsChargingWhileOff
2218
        )
2219
        let wirelessEfficiency = derivedWirelessEfficiency(
2220
            from: sessions,
2221
            chargingProfile: wirelessProfile
2222
        )
Bogdan Timofte authored a month ago
2223
        let configuredCompletionCurrents = decodedCompletionCurrents(
2224
            from: chargedDevice,
2225
            key: "configuredCompletionCurrentsRawValue"
2226
        )
Bogdan Timofte authored a month ago
2227
        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
2228
        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
2229
        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
2230
        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
2231
        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
2232
        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
2233

            
Bogdan Timofte authored a month ago
2234
        let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
2235
        let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
Bogdan Timofte authored a month ago
2236
        let preferredMinimumCurrent: Double?
2237
        let preferredCapacity: Double?
2238
        switch preferredChargingTransportMode {
2239
        case .wired:
Bogdan Timofte authored a month ago
2240
            preferredMinimumCurrent = configuredCompletionCurrents[
2241
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2242
            ] ?? learnedCompletionCurrents[
2243
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2244
            ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
Bogdan Timofte authored a month ago
2245
            preferredCapacity = wiredCapacity ?? wirelessCapacity
2246
        case .wireless:
Bogdan Timofte authored a month ago
2247
            preferredMinimumCurrent = configuredCompletionCurrents[
2248
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2249
            ] ?? learnedCompletionCurrents[
2250
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2251
            ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
Bogdan Timofte authored a month ago
2252
            preferredCapacity = wirelessCapacity ?? wiredCapacity
2253
        }
2254

            
Bogdan Timofte authored a month ago
2255
        setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
2256
        setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
2257
        setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
2258
        setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
2259
        setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
2260
        setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
2261
        setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
2262
        setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
2263
        setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
2264
        setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
2265
        setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
2266
        setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
2267
        setValue(Date(), on: chargedDevice, key: "updatedAt")
Bogdan Timofte authored a month ago
2268
    }
2269

            
2270
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
2271
        sessions
2272
            .filter { $0.status == .completed }
2273
            .compactMap { session in
2274
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
2275
                let timestamp = session.endedAt ?? session.lastObservedAt
2276
                return CapacityTrendPoint(
2277
                    sessionID: session.id,
2278
                    timestamp: timestamp,
2279
                    capacityWh: capacityEstimateWh,
2280
                    chargingTransportMode: session.chargingTransportMode
2281
                )
2282
            }
2283
            .sorted { $0.timestamp < $1.timestamp }
2284
    }
2285

            
2286
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
2287
        var groupedEnergyByBin: [Int: [Double]] = [:]
2288
        var groupedChargeByBin: [Int: [Double]] = [:]
2289

            
2290
        for session in sessions where session.status == .completed {
Bogdan Timofte authored a month ago
2291
            let anchors = normalizedTypicalCurveAnchors(for: session)
2292
            guard anchors.count >= 2 else {
2293
                continue
Bogdan Timofte authored a month ago
2294
            }
2295

            
Bogdan Timofte authored a month ago
2296
            for percentBin in stride(from: 0, through: 100, by: 10) {
2297
                guard let interpolatedPoint = interpolatedTypicalCurvePoint(
2298
                    for: Double(percentBin),
2299
                    anchors: anchors
2300
                ) else {
2301
                    continue
2302
                }
Bogdan Timofte authored a month ago
2303

            
Bogdan Timofte authored a month ago
2304
                groupedEnergyByBin[percentBin, default: []].append(interpolatedPoint.energyWh)
2305
                groupedChargeByBin[percentBin, default: []].append(interpolatedPoint.chargeAh)
Bogdan Timofte authored a month ago
2306
            }
2307
        }
2308

            
Bogdan Timofte authored a month ago
2309
        let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
Bogdan Timofte authored a month ago
2310
            guard
2311
                let energies = groupedEnergyByBin[percentBin],
2312
                let charges = groupedChargeByBin[percentBin],
2313
                !energies.isEmpty,
2314
                !charges.isEmpty
2315
            else {
2316
                return nil
2317
            }
2318

            
2319
            return TypicalChargeCurvePoint(
2320
                percentBin: percentBin,
2321
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
2322
                averageChargeAh: charges.reduce(0, +) / Double(charges.count),
2323
                sampleCount: min(energies.count, charges.count)
2324
            )
2325
        }
Bogdan Timofte authored a month ago
2326

            
2327
        var runningMaximumEnergyWh = 0.0
2328
        var runningMaximumChargeAh = 0.0
2329

            
2330
        return averagedPoints.map { point in
2331
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
2332
            runningMaximumChargeAh = max(runningMaximumChargeAh, point.averageChargeAh)
2333
            return TypicalChargeCurvePoint(
2334
                percentBin: point.percentBin,
2335
                averageEnergyWh: runningMaximumEnergyWh,
2336
                averageChargeAh: runningMaximumChargeAh,
2337
                sampleCount: point.sampleCount
2338
            )
2339
        }
2340
    }
2341

            
2342
    private func normalizedTypicalCurveAnchors(
2343
        for session: ChargeSessionSummary
2344
    ) -> [(percent: Double, energyWh: Double, chargeAh: Double)] {
2345
        struct Anchor {
2346
            let percent: Double
2347
            let energyWh: Double
2348
            let chargeAh: Double
2349
            let timestamp: Date
2350
        }
2351

            
2352
        var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
2353
            guard checkpoint.batteryPercent.isFinite,
2354
                  checkpoint.measuredEnergyWh.isFinite,
2355
                  checkpoint.measuredChargeAh.isFinite,
2356
                  checkpoint.batteryPercent >= 0,
2357
                  checkpoint.batteryPercent <= 100,
2358
                  checkpoint.measuredEnergyWh >= 0,
2359
                  checkpoint.measuredChargeAh >= 0 else {
2360
                return nil
2361
            }
2362

            
2363
            return Anchor(
2364
                percent: checkpoint.batteryPercent,
2365
                energyWh: checkpoint.measuredEnergyWh,
2366
                chargeAh: checkpoint.measuredChargeAh,
2367
                timestamp: checkpoint.timestamp
2368
            )
2369
        }
2370

            
2371
        if let startBatteryPercent = session.startBatteryPercent,
2372
           startBatteryPercent.isFinite,
2373
           startBatteryPercent >= 0,
2374
           startBatteryPercent <= 100 {
2375
            anchors.append(
2376
                Anchor(
2377
                    percent: startBatteryPercent,
2378
                    energyWh: 0,
2379
                    chargeAh: 0,
2380
                    timestamp: session.startedAt
2381
                )
2382
            )
2383
        }
2384

            
2385
        if let endBatteryPercent = session.endBatteryPercent,
2386
           endBatteryPercent.isFinite,
2387
           endBatteryPercent >= 0,
2388
           endBatteryPercent <= 100 {
2389
            anchors.append(
2390
                Anchor(
2391
                    percent: endBatteryPercent,
2392
                    energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
2393
                    chargeAh: session.measuredChargeAh,
2394
                    timestamp: session.endedAt ?? session.lastObservedAt
2395
                )
2396
            )
2397
        }
2398

            
2399
        let sortedAnchors = anchors.sorted { lhs, rhs in
2400
            if lhs.percent != rhs.percent {
2401
                return lhs.percent < rhs.percent
2402
            }
2403
            if lhs.energyWh != rhs.energyWh {
2404
                return lhs.energyWh < rhs.energyWh
2405
            }
2406
            return lhs.timestamp < rhs.timestamp
2407
        }
2408

            
2409
        var collapsedAnchors: [(percent: Double, energyWh: Double, chargeAh: Double)] = []
2410

            
2411
        for anchor in sortedAnchors {
2412
            if let lastIndex = collapsedAnchors.indices.last,
2413
               abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
2414
                collapsedAnchors[lastIndex] = (
2415
                    percent: collapsedAnchors[lastIndex].percent,
2416
                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh),
2417
                    chargeAh: max(collapsedAnchors[lastIndex].chargeAh, anchor.chargeAh)
2418
                )
2419
            } else {
2420
                collapsedAnchors.append(
2421
                    (percent: anchor.percent, energyWh: anchor.energyWh, chargeAh: anchor.chargeAh)
2422
                )
2423
            }
2424
        }
2425

            
2426
        var runningMaximumEnergyWh = 0.0
2427
        var runningMaximumChargeAh = 0.0
2428

            
2429
        return collapsedAnchors.map { anchor in
2430
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
2431
            runningMaximumChargeAh = max(runningMaximumChargeAh, anchor.chargeAh)
2432
            return (
2433
                percent: anchor.percent,
2434
                energyWh: runningMaximumEnergyWh,
2435
                chargeAh: runningMaximumChargeAh
2436
            )
2437
        }
2438
    }
2439

            
2440
    private func interpolatedTypicalCurvePoint(
2441
        for percent: Double,
2442
        anchors: [(percent: Double, energyWh: Double, chargeAh: Double)]
2443
    ) -> (energyWh: Double, chargeAh: Double)? {
2444
        guard
2445
            let firstAnchor = anchors.first,
2446
            let lastAnchor = anchors.last,
2447
            percent >= firstAnchor.percent,
2448
            percent <= lastAnchor.percent
2449
        else {
2450
            return nil
2451
        }
2452

            
2453
        if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
2454
            return (energyWh: exactAnchor.energyWh, chargeAh: exactAnchor.chargeAh)
2455
        }
2456

            
2457
        guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
2458
              upperIndex > 0 else {
2459
            return nil
2460
        }
2461

            
2462
        let lowerAnchor = anchors[upperIndex - 1]
2463
        let upperAnchor = anchors[upperIndex]
2464
        let span = upperAnchor.percent - lowerAnchor.percent
2465
        guard span > 0.000_1 else {
2466
            return nil
2467
        }
2468

            
2469
        let ratio = (percent - lowerAnchor.percent) / span
2470
        let energyWh = lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
2471
        let chargeAh = lowerAnchor.chargeAh + ((upperAnchor.chargeAh - lowerAnchor.chargeAh) * ratio)
2472
        return (energyWh: energyWh, chargeAh: chargeAh)
Bogdan Timofte authored a month ago
2473
    }
2474

            
2475
    private func makeSessionSummary(
2476
        from object: NSManagedObject,
2477
        checkpoints: [NSManagedObject],
2478
        samples: [NSManagedObject]
2479
    ) -> ChargeSessionSummary? {
2480
        let chargingTransportMode = chargingTransportMode(for: object)
2481

            
2482
        guard
2483
            let id = uuidValue(object, key: "id"),
2484
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2485
            let startedAt = dateValue(object, key: "startedAt"),
2486
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
2487
            let status = statusValue(object, key: "statusRawValue"),
2488
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
2489
        else {
2490
            return nil
2491
        }
2492

            
2493
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
2494
            .sorted { $0.timestamp < $1.timestamp }
2495
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
2496
            .sorted { lhs, rhs in
2497
                if lhs.bucketIndex != rhs.bucketIndex {
2498
                    return lhs.bucketIndex < rhs.bucketIndex
2499
                }
2500
                return lhs.timestamp < rhs.timestamp
2501
            }
2502

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

            
2558
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
2559
        guard
2560
            let id = uuidValue(object, key: "id"),
2561
            let sessionID = uuidValue(object, key: "sessionID"),
2562
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2563
            let timestamp = dateValue(object, key: "timestamp")
2564
        else {
2565
            return nil
2566
        }
2567

            
2568
        return ChargeCheckpointSummary(
2569
            id: id,
2570
            sessionID: sessionID,
2571
            chargedDeviceID: chargedDeviceID,
2572
            timestamp: timestamp,
2573
            batteryPercent: doubleValue(object, key: "batteryPercent"),
2574
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2575
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2576
            currentAmps: doubleValue(object, key: "currentAmps"),
2577
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
2578
            label: stringValue(object, key: "label")
2579
        )
2580
    }
2581

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
2732
                if lhsStatus.isOpen && !rhsStatus.isOpen {
2733
                    return true
2734
                }
2735
                if !lhsStatus.isOpen && rhsStatus.isOpen {
2736
                    return false
2737
                }
2738

            
2739
                return (dateValue(lhs, key: "startedAt") ?? .distantPast)
2740
                    > (dateValue(rhs, key: "startedAt") ?? .distantPast)
2741
            }
2742

            
2743
            var recentCompletedSamplesIncluded = 0
2744

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

            
2751
                if status.isOpen {
2752
                    sessionIDs.insert(sessionID)
2753
                    continue
2754
                }
2755

            
2756
                guard recentCompletedSamplesIncluded < 2 else {
2757
                    continue
2758
                }
2759

            
2760
                sessionIDs.insert(sessionID)
2761
                recentCompletedSamplesIncluded += 1
2762
            }
2763
        }
2764

            
2765
        return sessionIDs
2766
    }
2767

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

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

            
2792
    private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
2793
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false)
2794
    }
2795

            
2796
    private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
2797
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true)
2798
    }
2799

            
2800
    private func resolvedAssignedObject(
2801
        for meterMACAddress: String,
2802
        expectsChargerClass: Bool
2803
    ) -> NSManagedObject? {
2804
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
2805
        guard !normalizedMAC.isEmpty else { return nil }
2806

            
2807
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2808
        request.predicate = NSPredicate(format: "lastAssociatedMeterMAC == %@", normalizedMAC)
2809
        request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
2810
        let matches = (try? context.fetch(request)) ?? []
2811
        return matches.first { object in
Bogdan Timofte authored a month ago
2812
            isChargerObject(object) == expectsChargerClass
Bogdan Timofte authored a month ago
2813
        }
2814
    }
2815

            
Bogdan Timofte authored a month ago
2816
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
2817
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2818
    }
2819

            
Bogdan Timofte authored a month ago
2820
    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
2821
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2822
        request.predicate = NSPredicate(format: "id == %@", id)
2823
        request.fetchLimit = 1
2824
        return (try? context.fetch(request))?.first
2825
    }
2826

            
2827
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
2828
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
2829
        return (try? context.fetch(request)) ?? []
2830
    }
2831

            
2832
    private func resolvedStopThreshold(
2833
        for chargedDevice: NSManagedObject,
2834
        chargingTransportMode: ChargingTransportMode,
Bogdan Timofte authored a month ago
2835
        chargingStateMode: ChargingStateMode,
2836
        charger: NSManagedObject?,
2837
        fallback: Double?
2838
    ) -> Double? {
2839
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
2840
            return nil
2841
        }
2842

            
2843
        let sessionKind = ChargeSessionKind(
2844
            chargingTransportMode: chargingTransportMode,
2845
            chargingStateMode: chargingStateMode
2846
        )
2847
        let configuredCurrents = decodedCompletionCurrents(
2848
            from: chargedDevice,
2849
            key: "configuredCompletionCurrentsRawValue"
2850
        )
2851
        let learnedCurrents = decodedCompletionCurrents(
2852
            from: chargedDevice,
2853
            key: "learnedCompletionCurrentsRawValue"
2854
        )
2855
        let legacyCurrent: Double?
Bogdan Timofte authored a month ago
2856
        switch chargingTransportMode {
2857
        case .wired:
Bogdan Timofte authored a month ago
2858
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2859
                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
2860
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2861
        case .wireless:
Bogdan Timofte authored a month ago
2862
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2863
                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
2864
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2865
        }
Bogdan Timofte authored a month ago
2866

            
2867
        let resolvedCurrent = configuredCurrents[sessionKind]
2868
            ?? learnedCurrents[sessionKind]
2869
            ?? legacyCurrent
2870
            ?? fallback
2871
        guard let resolvedCurrent, resolvedCurrent > 0 else {
2872
            return nil
2873
        }
2874
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
2875
    }
2876

            
Bogdan Timofte authored a month ago
2877
    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
Bogdan Timofte authored a month ago
2878
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
2879
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
2880
        return resolvedPreferredChargingTransportMode(
Bogdan Timofte authored a month ago
2881
            .wired,
Bogdan Timofte authored a month ago
2882
            supportsWiredCharging: supportsWiredCharging,
2883
            supportsWirelessCharging: supportsWirelessCharging
2884
        )
2885
    }
2886

            
Bogdan Timofte authored a month ago
2887
    private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
2888
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other
2889
    }
2890

            
2891
    private func normalizedTemplateID(
2892
        _ templateID: String?,
2893
        kind: ChargedDeviceKind
2894
    ) -> String? {
2895
        guard let templateID,
2896
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2897
              templateDefinition.kind == kind else {
2898
            return nil
Bogdan Timofte authored a month ago
2899
        }
Bogdan Timofte authored a month ago
2900
        return templateDefinition.id
Bogdan Timofte authored a month ago
2901
    }
2902

            
Bogdan Timofte authored a month ago
2903
    private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
2904
        guard let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
2905
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2906
              templateDefinition.kind == deviceClass(for: chargedDevice).kind else {
2907
            return nil
Bogdan Timofte authored a month ago
2908
        }
Bogdan Timofte authored a month ago
2909
        return templateDefinition
2910
    }
2911

            
2912
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
2913
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2914
            ? true
2915
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2916
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2917
            ? false
2918
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2919
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2920
            supportsWiredCharging: persistedWiredCharging,
2921
            supportsWirelessCharging: persistedWirelessCharging
2922
        ).wired
2923
    }
2924

            
2925
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
2926
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2927
            ? true
2928
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2929
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2930
            ? false
2931
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2932
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2933
            supportsWiredCharging: persistedWiredCharging,
2934
            supportsWirelessCharging: persistedWirelessCharging
2935
        ).wireless
2936
    }
2937

            
2938
    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
2939
        let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue")
2940
            .flatMap(ChargingStateAvailability.init(rawValue:))
2941
            ?? ChargingStateAvailability.fallback(
Bogdan Timofte authored a month ago
2942
            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
2943
        )
Bogdan Timofte authored a month ago
2944
        return deviceClass(for: chargedDevice).normalizedChargingStateAvailability(persistedAvailability)
Bogdan Timofte authored a month ago
2945
    }
2946

            
2947
    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
Bogdan Timofte authored a month ago
2948
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2949
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2950
            let persistedChargingStateMode = stringValue(session, key: "chargingStateRawValue")
2951
                .flatMap(ChargingStateMode.init(rawValue:))
2952
                ?? .on
2953
            return resolvedChargingStateMode(
2954
                persistedChargingStateMode,
2955
                availability: chargingStateAvailability(for: chargedDevice)
2956
            )
2957
        }
2958

            
Bogdan Timofte authored a month ago
2959
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
2960
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
2961
            return chargingStateMode
2962
        }
2963

            
2964
        return .on
2965
    }
2966

            
2967
    private func resolvedChargingStateMode(
2968
        _ chargingStateMode: ChargingStateMode,
2969
        availability: ChargingStateAvailability
2970
    ) -> ChargingStateMode {
2971
        if availability.supportedModes.contains(chargingStateMode) {
2972
            return chargingStateMode
2973
        }
2974
        return availability.supportedModes.first ?? .on
2975
    }
2976

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

            
2981
        // Primary: chargerTypeRawValue (set on v13+)
2982
        if let rawValue = stringValue(chargedDevice, key: "chargerTypeRawValue"),
2983
           let type = ChargerType(rawValue: rawValue) {
2984
            return type
2985
        }
2986

            
2987
        // Migration fallback: derive from old deviceTemplateID
2988
        switch stringValue(chargedDevice, key: "deviceTemplateID") {
2989
        case "apple-magsafe-charger": return .appleMagSafe
2990
        case "apple-watch-charger": return .appleWatch
2991
        default: break
2992
        }
2993

            
2994
        // Last resort: derive from wirelessChargingProfileRawValue
2995
        if let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2996
           let profile = WirelessChargingProfile(rawValue: rawValue),
2997
           profile == .magsafe {
2998
            return .genericMagSafe
2999
        }
3000

            
3001
        return .genericQi
3002
    }
3003

            
Bogdan Timofte authored a month ago
3004
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
Bogdan Timofte authored a month ago
3005
        if let type = chargerType(for: chargedDevice) {
3006
            return type.wirelessChargingProfile
3007
        }
Bogdan Timofte authored a month ago
3008
        guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
3009
              let profile = WirelessChargingProfile(rawValue: rawValue) else {
3010
            return .genericQi
3011
        }
3012
        return profile
3013
    }
3014

            
3015
    private func resolvedPreferredChargingTransportMode(
3016
        _ preferredChargingTransportMode: ChargingTransportMode,
3017
        supportsWiredCharging: Bool,
3018
        supportsWirelessCharging: Bool
3019
    ) -> ChargingTransportMode {
3020
        switch preferredChargingTransportMode {
3021
        case .wired where supportsWiredCharging:
3022
            return .wired
3023
        case .wireless where supportsWirelessCharging:
3024
            return .wireless
3025
        default:
3026
            if supportsWiredCharging {
3027
                return .wired
3028
            }
3029
            if supportsWirelessCharging {
3030
                return .wireless
3031
            }
3032
            return .wired
3033
        }
3034
    }
3035

            
Bogdan Timofte authored a month ago
3036
    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
3037
        let payload = Dictionary(
3038
            uniqueKeysWithValues: currents.map { key, value in
3039
                (key.rawValue, value)
3040
            }
3041
        )
3042
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
3043
            return nil
3044
        }
3045
        return String(data: data, encoding: .utf8)
3046
    }
3047

            
3048
    private func decodedCompletionCurrents(
3049
        from object: NSManagedObject,
3050
        key: String
3051
    ) -> [ChargeSessionKind: Double] {
3052
        guard let rawValue = stringValue(object, key: key),
3053
              let data = rawValue.data(using: .utf8),
3054
              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
3055
            return [:]
3056
        }
3057

            
3058
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
3059
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
3060
                return
3061
            }
3062
            result[sessionKind] = entry.value
3063
        }
3064
    }
3065

            
3066
    private func legacyConfiguredCompletionCurrent(
3067
        for currents: [ChargeSessionKind: Double],
3068
        chargingTransportMode: ChargingTransportMode
3069
    ) -> Double? {
3070
        let candidates = currents
3071
            .filter { $0.key.chargingTransportMode == chargingTransportMode }
3072
            .sorted { lhs, rhs in
3073
                lhs.key.rawValue < rhs.key.rawValue
3074
            }
3075
            .map(\.value)
3076
        return candidates.first
3077
    }
3078

            
3079
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
3080
        guard let charger else {
3081
            return nil
3082
        }
3083
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
3084
        guard let idleCurrent, idleCurrent >= 0 else {
3085
            return nil
3086
        }
3087
        return idleCurrent
3088
    }
3089

            
3090
    private func effectiveCurrentAmps(
3091
        fromMeasuredCurrent currentAmps: Double,
3092
        chargingTransportMode: ChargingTransportMode,
3093
        charger: NSManagedObject?
3094
    ) -> Double {
3095
        switch chargingTransportMode {
3096
        case .wired:
3097
            return max(currentAmps, 0)
3098
        case .wireless:
3099
            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
3100
                return max(currentAmps, 0)
3101
            }
3102
            return max(currentAmps - idleCurrent, 0)
3103
        }
3104
    }
3105

            
3106
    private func hasObservedChargeFlow(
3107
        currentAmps: Double,
3108
        chargingTransportMode: ChargingTransportMode,
3109
        charger: NSManagedObject?,
3110
        stopThreshold: Double?
3111
    ) -> Bool {
3112
        let effectiveCurrent = effectiveCurrentAmps(
3113
            fromMeasuredCurrent: currentAmps,
3114
            chargingTransportMode: chargingTransportMode,
3115
            charger: charger
3116
        )
3117
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
3118
    }
3119

            
Bogdan Timofte authored a month ago
3120
    private func hasSavableChargeData(_ session: NSManagedObject) -> Bool {
Bogdan Timofte authored a month ago
3121
        if boolValue(session, key: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
3122
            || doubleValue(session, key: "measuredEnergyWh") > 0
3123
            || doubleValue(session, key: "measuredChargeAh") > 0
3124
            || (optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? 0) > 0
Bogdan Timofte authored a month ago
3125
            || (optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? 0) > 0 {
3126
            return true
3127
        }
3128

            
3129
        guard let sessionID = stringValue(session, key: "id") else {
3130
            return false
3131
        }
3132

            
3133
        return fetchSessionSampleObjects(forSessionID: sessionID).contains { sample in
3134
            doubleValue(sample, key: "measuredEnergyWh") > 0
3135
                || doubleValue(sample, key: "measuredChargeAh") > 0
3136
                || (optionalDoubleValue(sample, key: "averageCurrentAmps") ?? 0) > 0
3137
                || (optionalDoubleValue(sample, key: "averagePowerWatts") ?? 0) > 0
3138
        }
3139
    }
3140

            
3141
    private func restoreMeasuredTotalsFromLatestSampleIfNeeded(_ session: NSManagedObject) {
3142
        guard let sessionID = stringValue(session, key: "id"),
3143
              let latestSample = fetchSessionSampleObjects(forSessionID: sessionID).max(by: {
3144
                  (dateValue($0, key: "timestamp") ?? .distantPast) < (dateValue($1, key: "timestamp") ?? .distantPast)
3145
              }) else {
3146
            return
3147
        }
3148

            
3149
        let sampleEnergyWh = doubleValue(latestSample, key: "measuredEnergyWh")
3150
        if doubleValue(session, key: "measuredEnergyWh") <= 0, sampleEnergyWh > 0 {
3151
            session.setValue(sampleEnergyWh, forKey: "measuredEnergyWh")
3152
        }
3153

            
3154
        let sampleChargeAh = doubleValue(latestSample, key: "measuredChargeAh")
3155
        if doubleValue(session, key: "measuredChargeAh") <= 0, sampleChargeAh > 0 {
3156
            session.setValue(sampleChargeAh, forKey: "measuredChargeAh")
3157
        }
Bogdan Timofte authored a month ago
3158
    }
3159

            
Bogdan Timofte authored a month ago
3160
    private func derivedMinimumCurrent(
3161
        from sessions: [NSManagedObject],
3162
        chargingTransportMode: ChargingTransportMode
3163
    ) -> Double? {
3164
        let completionCurrents = sessions.compactMap { session -> Double? in
3165
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3166
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
3167
                return nil
3168
            }
Bogdan Timofte authored a month ago
3169
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3170
                return nil
3171
            }
Bogdan Timofte authored a month ago
3172
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
3173
                return nil
3174
            }
3175
            return completionCurrent
3176
        }
3177

            
3178
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
3179
        guard !recentCompletionCurrents.isEmpty else { return nil }
3180
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
3181
    }
3182

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

            
3186
        for session in sessions {
3187
            guard statusValue(session, key: "statusRawValue") == .completed else {
3188
                continue
3189
            }
3190
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3191
                continue
3192
            }
3193
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
3194
                  completionCurrent > 0 else {
3195
                continue
3196
            }
3197

            
3198
            let sessionKind = ChargeSessionKind(
3199
                chargingTransportMode: chargingTransportMode(for: session),
3200
                chargingStateMode: chargingStateMode(for: session)
3201
            )
3202
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
3203
        }
3204

            
3205
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
3206
            let recentCurrents = Array(entry.value.suffix(5))
3207
            guard !recentCurrents.isEmpty else {
3208
                return
3209
            }
3210
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
3211
        }
3212
    }
3213

            
Bogdan Timofte authored a month ago
3214
    private func derivedCapacity(
3215
        from sessions: [NSManagedObject],
3216
        chargingTransportMode: ChargingTransportMode,
3217
        supportsChargingWhileOff: Bool
3218
    ) -> Double? {
3219
        let capacityCandidates = sessions.compactMap { session -> Double? in
3220
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3221
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
3222
                return nil
3223
            }
3224
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
3225
                return nil
3226
            }
3227
            if supportsChargingWhileOff {
3228
                return capacityEstimate
3229
            }
3230
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
3231
                return nil
3232
            }
3233
            return capacityEstimate
3234
        }
3235

            
3236
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
3237
        guard !recentCapacityCandidates.isEmpty else { return nil }
3238
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
3239
    }
3240

            
3241
    private func derivedWirelessEfficiency(
3242
        from sessions: [NSManagedObject],
3243
        chargingProfile: WirelessChargingProfile
3244
    ) -> Double? {
3245
        guard chargingProfile == .magsafe else {
3246
            return nil
3247
        }
3248

            
3249
        let candidates = sessions.compactMap { session -> Double? in
3250
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3251
            guard chargingTransportMode(for: session) == .wireless else { return nil }
3252
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
3253
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3254
                return nil
3255
            }
3256
            return factor
3257
        }
3258

            
3259
        let recentCandidates = Array(candidates.suffix(6))
3260
        guard !recentCandidates.isEmpty else { return nil }
3261
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3262
    }
3263

            
3264
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
3265
        let candidates = sessions.compactMap { session -> Double? in
3266
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3267
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
3268
                return nil
3269
            }
3270
            return (sourceVoltage * 10).rounded() / 10
3271
        }
3272

            
3273
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
3274
        return counts.keys.sorted()
3275
    }
3276

            
3277
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
3278
        let candidates = sessions.compactMap { session -> Double? in
3279
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3280
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
3281
                return nil
3282
            }
3283
            return minimumObservedCurrent
3284
        }
3285

            
3286
        let recentCandidates = Array(candidates.suffix(6))
3287
        guard !recentCandidates.isEmpty else { return nil }
3288
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3289
    }
3290

            
3291
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
3292
        let candidates = sessions.compactMap { session -> Double? in
3293
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3294
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3295
                return nil
3296
            }
3297
            return factor
3298
        }
3299

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

            
3305
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
3306
        sessions.compactMap { session -> Double? in
3307
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3308
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
3309
                return nil
3310
            }
3311
            return maximumObservedPower
3312
        }
3313
        .max()
3314
    }
3315

            
3316
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
3317
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
3318
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
3319
            return resolvedPreferredChargingTransportMode(
3320
                chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired,
3321
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
3322
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
3323
            )
3324
        }
3325

            
3326
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
3327
            return persistedChargingTransportMode
Bogdan Timofte authored a month ago
3328
        }
3329

            
3330
        return .wired
3331
    }
3332

            
3333
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
3334
        if session.isInserted {
3335
            return .created
3336
        }
3337

            
3338
        let committedValues = session.committedValues(
3339
            forKeys: [
3340
                "statusRawValue",
3341
                "updatedAt",
3342
                "targetBatteryAlertTriggeredAt",
3343
                "requiresCompletionConfirmation"
3344
            ]
3345
        )
3346
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
3347
        let currentStatus = statusValue(session, key: "statusRawValue")
3348
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
3349
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
3350
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
3351
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
3352
            ?? false
3353
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
3354

            
3355
        if currentStatus == .completed, committedStatus != .completed {
3356
            return .completed
3357
        }
3358

            
Bogdan Timofte authored a month ago
3359
        if currentStatus != committedStatus {
3360
            return .event
3361
        }
3362

            
Bogdan Timofte authored a month ago
3363
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
3364
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
3365
            return .event
3366
        }
3367

            
3368
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3369
            ?? dateValue(session, key: "createdAt")
3370
            ?? observedAt
3371

            
3372
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
3373
            return .periodic
3374
        }
3375

            
3376
        return .none
3377
    }
3378

            
Bogdan Timofte authored a month ago
3379
    private func shouldPersistAggregatedSample(
3380
        _ sample: NSManagedObject,
3381
        observedAt: Date
3382
    ) -> Bool {
3383
        if sample.isInserted {
3384
            return true
3385
        }
3386

            
3387
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
3388
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3389
            ?? dateValue(sample, key: "createdAt")
3390
            ?? observedAt
3391

            
3392
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
3393
    }
3394

            
Bogdan Timofte authored a month ago
3395
    private func generateQRIdentifier() -> String {
3396
        "device:\(UUID().uuidString)"
3397
    }
3398

            
3399
    @discardableResult
3400
    private func saveContext() -> Bool {
3401
        guard context.hasChanges else { return true }
3402
        do {
3403
            try context.save()
3404
            return true
3405
        } catch {
3406
            track("Failed saving charge insights context: \(error)")
3407
            context.rollback()
3408
            return false
3409
        }
3410
    }
3411

            
3412
    private func normalizedText(_ text: String) -> String {
3413
        text.trimmingCharacters(in: .whitespacesAndNewlines)
3414
    }
3415

            
3416
    private func normalizedOptionalText(_ text: String?) -> String? {
3417
        guard let text else { return nil }
3418
        let normalized = normalizedText(text)
3419
        return normalized.isEmpty ? nil : normalized
3420
    }
3421

            
3422
    private func normalizedMACAddress(_ macAddress: String) -> String {
3423
        normalizedText(macAddress).uppercased()
3424
    }
3425

            
Bogdan Timofte authored a month ago
3426
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
3427
        guard object.entity.propertiesByName[key] != nil else {
3428
            return nil
3429
        }
3430
        return object.value(forKey: key)
3431
    }
3432

            
3433
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
3434
        guard object.entity.propertiesByName[key] != nil else {
3435
            return
3436
        }
3437
        object.setValue(value, forKey: key)
3438
    }
3439

            
Bogdan Timofte authored a month ago
3440
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
3441
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
3442
        let normalized = normalizedOptionalText(value)
3443
        return normalized
3444
    }
3445

            
3446
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
3447
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
3448
    }
3449

            
3450
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
3451
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
3452
            return value
3453
        }
Bogdan Timofte authored a month ago
3454
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3455
            return value.doubleValue
3456
        }
3457
        return 0
3458
    }
3459

            
3460
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
3461
        let value = rawValue(object, key: key)
3462
        if value == nil {
Bogdan Timofte authored a month ago
3463
            return nil
3464
        }
3465
        return doubleValue(object, key: key)
3466
    }
3467

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

            
3478
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
3479
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
3480
            return value
3481
        }
Bogdan Timofte authored a month ago
3482
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3483
            return value.int32Value
3484
        }
3485
        return nil
3486
    }
3487

            
3488
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
Bogdan Timofte authored a month ago
3489
        if let value = rawValue(object, key: key) as? Bool {
Bogdan Timofte authored a month ago
3490
            return value
3491
        }
Bogdan Timofte authored a month ago
3492
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3493
            return value.boolValue
3494
        }
3495
        return false
3496
    }
3497

            
3498
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
3499
        guard let value = stringValue(object, key: key) else { return nil }
3500
        return UUID(uuidString: value)
3501
    }
3502

            
3503
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
3504
        guard let value = stringValue(object, key: key) else { return nil }
3505
        return ChargeSessionStatus(rawValue: value)
3506
    }
3507

            
3508
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
3509
        guard let value = stringValue(object, key: key) else { return nil }
3510
        return ChargingTransportMode(rawValue: value)
3511
    }
3512

            
3513
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
3514
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
3515
            return []
3516
        }
3517
        return rawValue
3518
            .split(separator: ",")
3519
            .compactMap { Double($0) }
3520
            .sorted()
3521
    }
3522

            
3523
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
3524
        let uniqueVoltages = Array(Set(voltages)).sorted()
3525
        guard !uniqueVoltages.isEmpty else {
3526
            return nil
3527
        }
3528
        return uniqueVoltages
3529
            .map { String(format: "%.1f", $0) }
3530
            .joined(separator: ",")
3531
    }
3532

            
3533
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
3534
        guard currentCount > 0 else {
3535
            return newValue
3536
        }
3537
        let total = (currentAverage * Double(currentCount)) + newValue
3538
        return total / Double(currentCount + 1)
3539
    }
3540
}
3541

            
3542
private enum ObservationSaveReason {
3543
    case none
3544
    case created
3545
    case periodic
3546
    case completed
3547
    case event
3548
}