USB-Meter / USB Meter / Model / ChargeInsightsStore.swift
Newer Older
3508 lines | 152.415kb
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,
Bogdan Timofte authored a month ago
710
        measuredEnergyWh: Double? = nil
Bogdan Timofte authored a month ago
711
    ) -> Bool {
712
        guard percent.isFinite, percent >= 0, percent <= 100 else {
713
            return false
714
        }
715

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

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

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

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

            
Bogdan Timofte authored a month ago
748
            didSave = addBatteryCheckpoint(
749
                percent: percent,
750
                measuredEnergyWh: measuredEnergyWh,
Bogdan Timofte authored a month ago
751
                flag: .intermediate,
Bogdan Timofte authored a month ago
752
                to: session
753
            )
Bogdan Timofte authored a month ago
754
        }
755
        return didSave
756
    }
757

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

            
773
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
774
            context.delete(checkpoint)
775
            refreshCheckpointDerivedValues(for: session)
776

            
777
            if let chargedDeviceID {
778
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
779
            }
Bogdan Timofte authored a month ago
780

            
781
            didSave = saveContext()
Bogdan Timofte authored a month ago
782
        }
783
        return didSave
784
    }
785

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

            
792
        var didSave = false
793
        context.performAndWait {
794
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
795
                return
796
            }
797

            
798
            session.setValue(percent, forKey: "targetBatteryPercent")
799
            session.setValue(nil, forKey: "targetBatteryAlertTriggeredAt")
800
            session.setValue(Date(), forKey: "updatedAt")
801
            didSave = saveContext()
802
        }
803
        return didSave
804
    }
805

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

            
814
            guard statusValue(session, key: "statusRawValue") == .active else {
815
                return
816
            }
817

            
Bogdan Timofte authored a month ago
818
            finishSession(
819
                session,
820
                observedAt: dateValue(session, key: "lastObservedAt") ?? Date(),
821
                finalBatteryPercent: nil,
822
                status: .completed
823
            )
Bogdan Timofte authored a month ago
824

            
825
            if saveContext() {
826
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
827
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
828
                    didSave = saveContext()
829
                } else {
830
                    didSave = true
831
                }
832
            }
833
        }
834
        return didSave
835
    }
836

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

            
845
            guard statusValue(session, key: "statusRawValue") == .active else {
846
                return
847
            }
848

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

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

            
866
            let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast
867
            let sessionEnd   = dateValue(session, key: "endedAt")
868
                ?? dateValue(session, key: "lastObservedAt")
869
                ?? Date.distantFuture
870

            
871
            let effectiveStart = min(max(start ?? sessionStart, sessionStart), sessionEnd)
872
            let effectiveEnd   = max(min(end ?? sessionEnd, sessionEnd), effectiveStart)
873
            let persistedStart = effectiveStart == sessionStart ? nil : effectiveStart
874
            let persistedEnd   = effectiveEnd == sessionEnd ? nil : effectiveEnd
875

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

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

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

            
904
            session.setValue(persistedStart, forKey: "trimStart")
905
            session.setValue(persistedEnd,   forKey: "trimEnd")
906
            session.setValue(Date(), forKey: "updatedAt")
907

            
908
            let checkpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
909
            for checkpoint in checkpoints {
910
                guard let timestamp = dateValue(checkpoint, key: "timestamp") else { continue }
911

            
912
                if timestamp < effectiveStart || timestamp > effectiveEnd {
913
                    context.delete(checkpoint)
914
                    continue
915
                }
916

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

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

            
935
            if persistedStart == nil {
936
                if let restoredInitialCheckpoint,
937
                   let percent = optionalDoubleValue(restoredInitialCheckpoint, key: "batteryPercent"),
938
                   percent >= 0 {
939
                    session.setValue(percent, forKey: "startBatteryPercent")
940
                }
941
            } else {
942
                session.setValue(nil, forKey: "startBatteryPercent")
943
            }
944

            
945
            refreshCheckpointDerivedValues(for: session)
946

            
947
            if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
948
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
949
            }
950

            
951
            didSave = saveContext()
952
        }
953
        return didSave
954
    }
955

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

            
964
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
965

            
966
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
967
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
968
            context.delete(session)
969

            
970
            guard saveContext() else {
971
                return
972
            }
973

            
974
            if let chargedDeviceID {
975
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
976
                didSave = saveContext()
977
            } else {
978
                didSave = true
979
            }
980
        }
981
        return didSave
982
    }
983

            
984
    @discardableResult
985
    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
986
        var didSave = false
987

            
988
        context.performAndWait {
989
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
990
                return
991
            }
992

            
993
            let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
994
            let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
995
            let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
996

            
997
            var impactedChargedDeviceIDs = Set<String>()
998

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

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

            
1026
            context.delete(chargedDevice)
1027

            
1028
            guard saveContext() else {
1029
                return
1030
            }
1031

            
1032
            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
1033
            for impactedID in impactedChargedDeviceIDs {
1034
                refreshDerivedMetrics(forChargedDeviceID: impactedID)
1035
            }
1036
            didSave = saveContext()
1037
        }
1038

            
1039
        return didSave
1040
    }
1041

            
1042
    @discardableResult
1043
    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
1044
        var didSave = false
1045

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

            
Bogdan Timofte authored a month ago
1052
            if statusValue(session, key: "statusRawValue") == .paused {
Bogdan Timofte authored a month ago
1053
                if maybeCompleteOpenSession(session, observedAt: snapshot.observedAt) {
Bogdan Timofte authored a month ago
1054
                    didSave = true
1055
                }
Bogdan Timofte authored a month ago
1056
                return
1057
            }
1058

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

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

            
Bogdan Timofte authored a month ago
1088
            let saveReason = saveReason(for: session, observedAt: sessionSnapshot.observedAt)
Bogdan Timofte authored a month ago
1089
            let shouldPersistAggregatedCurve = aggregatedSample.map {
Bogdan Timofte authored a month ago
1090
                shouldPersistAggregatedSample($0, observedAt: sessionSnapshot.observedAt)
Bogdan Timofte authored a month ago
1091
            } ?? false
1092

            
1093
            guard saveReason != .none || shouldPersistAggregatedCurve else {
Bogdan Timofte authored a month ago
1094
                return
1095
            }
1096

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

            
1099
            if saveContext() {
1100
                if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
1101
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
1102
                    didSave = saveContext()
1103
                } else {
1104
                    didSave = true
1105
                }
1106
            }
1107
        }
1108

            
1109
        return didSave
1110
    }
1111

            
1112
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
1113
        var summaries: [ChargedDeviceSummary] = []
1114

            
1115
        context.performAndWait {
1116
            let devices = fetchObjects(entityName: EntityName.chargedDevice)
1117
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
1118
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
1119

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

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

            
Bogdan Timofte authored a month ago
1143
                let chargingStateAvailability = chargingStateAvailability(for: device)
1144
                let supportsWiredCharging = supportsWiredCharging(for: device)
1145
                let supportsWirelessCharging = supportsWirelessCharging(for: device)
1146
                let templateDefinition = templateDefinition(for: device)
1147

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

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

            
1230
        return summaries
1231
    }
1232

            
1233
    func resolvedChargedDeviceSummary(forMeterMACAddress meterMACAddress: String) -> ChargedDeviceSummary? {
1234
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1235
        guard !normalizedMAC.isEmpty else { return nil }
1236

            
1237
        let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
1238

            
1239
        if let activeMatch = summaries.first(where: { summary in
1240
            summary.activeSession?.meterMACAddress == normalizedMAC
1241
        }) {
1242
            return activeMatch
1243
        }
1244

            
1245
        return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
1246
    }
1247

            
1248
    func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
1249
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1250
        guard !normalizedMAC.isEmpty else { return nil }
1251

            
Bogdan Timofte authored a month ago
1252
        var summary: ChargeSessionSummary?
1253

            
1254
        context.performAndWait {
Bogdan Timofte authored a month ago
1255
            guard let session = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
Bogdan Timofte authored a month ago
1256
                  let sessionID = stringValue(session, key: "id") else {
1257
                return
1258
            }
1259

            
1260
            summary = makeSessionSummary(
1261
                from: session,
1262
                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
1263
                samples: fetchSessionSampleObjects(forSessionID: sessionID)
1264
            )
1265
        }
1266

            
1267
        return summary
Bogdan Timofte authored a month ago
1268
    }
1269

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

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

            
1348
        chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
1349
        chargedDevice.setValue(now, forKey: "updatedAt")
1350
        return session
1351
    }
1352

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

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

            
1379
        if let counterGroup = snapshot.selectedDataGroup,
1380
           let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
1381
           UInt8(storedGroup) != counterGroup {
1382
            session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
1383
            session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1384
            session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1385
        }
1386

            
1387
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1388
            let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
1389
            let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
1390
            if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
1391
                session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
1392
            }
1393

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

            
1410
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1411
            let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
1412
            let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
1413
            if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
1414
                session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
1415
            }
1416

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

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

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

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

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

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

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

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

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

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

            
1546
        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
1547
        let updatedCount = existingCount + 1
1548

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

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

            
1597
        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
1598
            return
1599
        }
1600

            
1601
        let predictedBatteryPercent = predictedBatteryPercent(for: session)
1602
            ?? optionalDoubleValue(session, key: "endBatteryPercent")
Bogdan Timofte authored a month ago
1603
            ?? completionFallbackPercent
Bogdan Timofte authored a month ago
1604

            
1605
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
1606
            return
1607
        }
1608

            
1609
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
1610
    }
1611

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

            
1621
        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
1622
            return false
1623
        }
1624

            
1625
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
1626
            ?? defaultCompletionPercentThreshold
1627

            
1628
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1629
    }
1630

            
1631
    private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
1632
        guard !boolValue(session, key: "requiresCompletionConfirmation") else {
1633
            return
1634
        }
1635

            
1636
        session.setValue(true, forKey: "requiresCompletionConfirmation")
1637
        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
1638
        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
1639
    }
1640

            
1641
    private func clearCompletionConfirmationState(for session: NSManagedObject) {
1642
        session.setValue(false, forKey: "requiresCompletionConfirmation")
1643
        session.setValue(nil, forKey: "completionConfirmationRequestedAt")
1644
        session.setValue(nil, forKey: "completionContradictionPercent")
1645
    }
1646

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

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

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

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

            
Bogdan Timofte authored a month ago
1689
        var completionDates: [Date] = []
1690

            
1691
        if let maximumEndDate = maximumEndDate(for: session) {
1692
            completionDates.append(maximumEndDate)
1693
        }
1694

            
1695
        if statusValue(session, key: "statusRawValue") == .paused,
1696
           let pausedAt = dateValue(session, key: "pausedAt") {
1697
            completionDates.append(pausedAt.addingTimeInterval(pausedSessionTimeout))
1698
        }
1699

            
1700
        guard let completionDate = completionDates.min(),
1701
              referenceDate >= completionDate else {
1702
            return nil
1703
        }
1704

            
1705
        return completionDate
1706
    }
1707

            
1708
    private func maximumEndDate(for session: NSManagedObject) -> Date? {
1709
        dateValue(session, key: "startedAt")?.addingTimeInterval(Self.maximumChargeSessionDuration)
1710
    }
1711

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

            
1719
        finishSession(
1720
            session,
Bogdan Timofte authored a month ago
1721
            observedAt: completionDate,
Bogdan Timofte authored a month ago
1722
            finalBatteryPercent: nil,
1723
            status: .completed
1724
        )
1725

            
1726
        guard saveContext() else {
1727
            return false
1728
        }
1729

            
1730
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1731
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1732
            return saveContext()
1733
        }
1734

            
1735
        return true
1736
    }
1737

            
1738
    private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
1739
        let chargingTransportMode = chargingTransportMode(for: session)
1740
        let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps")
1741
            ?? doubleValue(session, key: "lastObservedCurrentAmps")
1742

            
1743
        guard measuredCurrent > 0 else {
1744
            return nil
1745
        }
1746

            
1747
        let charger = chargingTransportMode == .wireless
1748
            ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
1749
            : nil
1750

            
1751
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
1752
            return nil
1753
        }
1754

            
1755
        let effectiveCurrent = effectiveCurrentAmps(
1756
            fromMeasuredCurrent: measuredCurrent,
1757
            chargingTransportMode: chargingTransportMode,
1758
            charger: charger
1759
        )
1760
        guard effectiveCurrent > 0 else {
1761
            return nil
1762
        }
1763
        return effectiveCurrent
1764
    }
1765

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

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

            
1792
        if status == .completed {
1793
            maybeTriggerTargetBatteryAlert(
1794
                for: session,
1795
                observedAt: observedAt,
1796
                completionFallbackPercent: defaultCompletionPercentThreshold
1797
            )
1798
        }
Bogdan Timofte authored a month ago
1799
    }
1800

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

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

            
1829
        struct Anchor {
1830
            let percent: Double
1831
            let energyWh: Double
Bogdan Timofte authored a month ago
1832
            let timestamp: Date
1833
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1834
        }
1835

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

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

            
1870
        guard !anchors.isEmpty else {
1871
            return optionalDoubleValue(session, key: "endBatteryPercent")
1872
        }
1873

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

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

            
1895
        switch chargingTransportMode(for: session) {
1896
        case .wired:
1897
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1898
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1899
        case .wireless:
1900
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1901
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1902
        }
1903
    }
1904

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

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

            
1924
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
1925
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
1926
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
1927
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
1928

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

            
1931
        guard let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
Bogdan Timofte authored a month ago
1932
            session.setValue(nil, forKey: "capacityEstimateWh")
1933
            return
1934
        }
1935

            
Bogdan Timofte authored a month ago
1936
        struct CapacityAnchor {
1937
            let percent: Double
1938
            let energyWh: Double
1939
            let timestamp: Date
1940
        }
1941

            
1942
        var anchors: [CapacityAnchor] = []
1943

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

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

            
1968
                    return CapacityAnchor(
1969
                        percent: percent,
1970
                        energyWh: doubleValue(checkpoint, key: "measuredEnergyWh"),
1971
                        timestamp: timestamp
1972
                    )
1973
                }
1974
            )
1975
        }
1976

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

            
1990
        let sortedAnchors = anchors.sorted { lhs, rhs in
1991
            if lhs.energyWh != rhs.energyWh {
1992
                return lhs.energyWh < rhs.energyWh
1993
            }
1994
            return lhs.timestamp < rhs.timestamp
1995
        }
1996

            
1997
        guard let firstAnchor = sortedAnchors.first,
1998
              let lastAnchor = sortedAnchors.last else {
Bogdan Timofte authored a month ago
1999
            session.setValue(nil, forKey: "capacityEstimateWh")
2000
            return
2001
        }
2002

            
Bogdan Timofte authored a month ago
2003
        let percentDelta = lastAnchor.percent - firstAnchor.percent
2004
        let energyDelta = lastAnchor.energyWh - firstAnchor.energyWh
Bogdan Timofte authored a month ago
2005

            
Bogdan Timofte authored a month ago
2006
        guard percentDelta >= 20, energyDelta > 0 else {
Bogdan Timofte authored a month ago
2007
            session.setValue(nil, forKey: "capacityEstimateWh")
2008
            return
2009
        }
2010

            
Bogdan Timofte authored a month ago
2011
        if !supportsChargingWhileOff && lastAnchor.percent >= 99.5 {
Bogdan Timofte authored a month ago
2012
            session.setValue(nil, forKey: "capacityEstimateWh")
2013
            return
2014
        }
2015

            
Bogdan Timofte authored a month ago
2016
        let capacityEstimateWh = energyDelta / (percentDelta / 100)
Bogdan Timofte authored a month ago
2017
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
2018
    }
2019

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

            
2036
        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
Bogdan Timofte authored a month ago
2037
        let checkpointEnergyWh = measuredEnergyWhOverride
2038
            ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
Bogdan Timofte authored a month ago
2039
            ?? doubleValue(session, key: "measuredEnergyWh")
2040
        checkpoint.setValue(UUID().uuidString, forKey: "id")
2041
        checkpoint.setValue(sessionID, forKey: "sessionID")
2042
        checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
Bogdan Timofte authored a month ago
2043
        checkpoint.setValue(timestamp, forKey: "timestamp")
Bogdan Timofte authored a month ago
2044
        checkpoint.setValue(percent, forKey: "batteryPercent")
2045
        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
2046
        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
2047
        checkpoint.setValue(
2048
            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
2049
            forKey: "voltageVolts"
2050
        )
Bogdan Timofte authored a month ago
2051
        checkpoint.setValue(flag.rawValue, forKey: "label")
Bogdan Timofte authored a month ago
2052
        checkpoint.setValue(timestamp, forKey: "createdAt")
Bogdan Timofte authored a month ago
2053

            
Bogdan Timofte authored a month ago
2054
        let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
2055
        if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
Bogdan Timofte authored a month ago
2056
            session.setValue(percent, forKey: "startBatteryPercent")
2057
        }
Bogdan Timofte authored a month ago
2058
        if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
2059
            session.setValue(percent, forKey: "endBatteryPercent")
2060
        }
Bogdan Timofte authored a month ago
2061
        session.setValue(timestamp, forKey: "updatedAt")
Bogdan Timofte authored a month ago
2062
        updateCapacityEstimate(for: session)
2063

            
Bogdan Timofte authored a month ago
2064
        return chargedDeviceID
2065
    }
2066

            
Bogdan Timofte authored a month ago
2067
    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
2068
        guard let sessionID = stringValue(session, key: "id") else {
2069
            return
2070
        }
2071

            
2072
        let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
2073
        if let latestCheckpoint = remainingCheckpoints.last {
2074
            session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
2075
        } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2076
                  startBatteryPercent >= 0 {
2077
            session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
2078
        } else {
2079
            session.setValue(nil, forKey: "endBatteryPercent")
2080
        }
2081

            
2082
        session.setValue(Date(), forKey: "updatedAt")
2083
        updateCapacityEstimate(for: session)
2084
    }
2085

            
Bogdan Timofte authored a month ago
2086
    @discardableResult
2087
    private func addBatteryCheckpoint(
2088
        percent: Double,
Bogdan Timofte authored a month ago
2089
        measuredEnergyWh: Double? = nil,
Bogdan Timofte authored a month ago
2090
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
2091
        to session: NSManagedObject,
2092
        timestamp: Date = Date()
2093
    ) -> Bool {
Bogdan Timofte authored a month ago
2094
        if let measuredEnergyWh, measuredEnergyWh.isFinite {
2095
            session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
2096
        }
2097

            
Bogdan Timofte authored a month ago
2098
        guard let chargedDeviceID = insertBatteryCheckpoint(
2099
            percent: percent,
Bogdan Timofte authored a month ago
2100
            flag: flag,
Bogdan Timofte authored a month ago
2101
            timestamp: timestamp,
Bogdan Timofte authored a month ago
2102
            measuredEnergyWhOverride: measuredEnergyWh,
Bogdan Timofte authored a month ago
2103
            to: session
2104
        ) else {
2105
            return false
2106
        }
2107

            
Bogdan Timofte authored a month ago
2108
        guard saveContext() else {
2109
            return false
2110
        }
2111

            
2112
        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
2113
        return saveContext()
2114
    }
2115

            
2116
    private func resolvedWirelessEfficiency(
2117
        for session: NSManagedObject,
2118
        chargedDevice: NSManagedObject
2119
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
2120
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
2121
           storedFactor > 0 {
2122
            return (
2123
                factor: storedFactor,
2124
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
2125
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
2126
            )
2127
        }
2128

            
2129
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
2130
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2131
        guard measuredEnergyWh > 0 else {
2132
            return nil
2133
        }
2134

            
2135
        if chargingProfile == .magsafe,
2136
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
2137
           calibratedFactor > 0 {
2138
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
2139
        }
2140

            
2141
        guard
2142
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2143
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
2144
        else {
2145
            return nil
2146
        }
2147

            
2148
        let percentDelta = endBatteryPercent - startBatteryPercent
2149
        guard percentDelta >= 20 else {
2150
            return nil
2151
        }
2152

            
2153
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
Bogdan Timofte authored a month ago
2154
            ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
Bogdan Timofte authored a month ago
2155
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2156
                : nil),
2157
              wiredCapacityWh > 0
2158
        else {
2159
            return nil
2160
        }
2161

            
2162
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
2163
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
2164
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
2165
        let usesEstimated = chargingProfile != .magsafe
2166
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
2167

            
2168
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
2169
    }
2170

            
2171
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
2172
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
2173
            return
2174
        }
2175

            
Bogdan Timofte authored a month ago
2176
        let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
Bogdan Timofte authored a month ago
2177
        let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
2178
        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
2179
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
2180
        let sessions = relevantSessionObjects(
2181
            for: chargedDeviceID,
2182
            deviceClass: deviceClass,
2183
            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
2184
            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
2185
        )
Bogdan Timofte authored a month ago
2186
        let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
Bogdan Timofte authored a month ago
2187
        let wiredMinimumCurrent = derivedMinimumCurrent(
2188
            from: sessions,
2189
            chargingTransportMode: .wired
2190
        )
2191
        let wirelessMinimumCurrent = derivedMinimumCurrent(
2192
            from: sessions,
2193
            chargingTransportMode: .wireless
2194
        )
2195

            
2196
        let wiredCapacity = derivedCapacity(
2197
            from: sessions,
2198
            chargingTransportMode: .wired,
2199
            supportsChargingWhileOff: supportsChargingWhileOff
2200
        )
2201
        let wirelessCapacity = derivedCapacity(
2202
            from: sessions,
2203
            chargingTransportMode: .wireless,
2204
            supportsChargingWhileOff: supportsChargingWhileOff
2205
        )
2206
        let wirelessEfficiency = derivedWirelessEfficiency(
2207
            from: sessions,
2208
            chargingProfile: wirelessProfile
2209
        )
Bogdan Timofte authored a month ago
2210
        let configuredCompletionCurrents = decodedCompletionCurrents(
2211
            from: chargedDevice,
2212
            key: "configuredCompletionCurrentsRawValue"
2213
        )
Bogdan Timofte authored a month ago
2214
        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
2215
        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
2216
        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
2217
        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
2218
        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
2219
        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
2220

            
Bogdan Timofte authored a month ago
2221
        let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
2222
        let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
Bogdan Timofte authored a month ago
2223
        let preferredMinimumCurrent: Double?
2224
        let preferredCapacity: Double?
2225
        switch preferredChargingTransportMode {
2226
        case .wired:
Bogdan Timofte authored a month ago
2227
            preferredMinimumCurrent = configuredCompletionCurrents[
2228
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2229
            ] ?? learnedCompletionCurrents[
2230
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2231
            ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
Bogdan Timofte authored a month ago
2232
            preferredCapacity = wiredCapacity ?? wirelessCapacity
2233
        case .wireless:
Bogdan Timofte authored a month ago
2234
            preferredMinimumCurrent = configuredCompletionCurrents[
2235
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2236
            ] ?? learnedCompletionCurrents[
2237
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2238
            ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
Bogdan Timofte authored a month ago
2239
            preferredCapacity = wirelessCapacity ?? wiredCapacity
2240
        }
2241

            
Bogdan Timofte authored a month ago
2242
        setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
2243
        setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
2244
        setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
2245
        setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
2246
        setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
2247
        setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
2248
        setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
2249
        setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
2250
        setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
2251
        setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
2252
        setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
2253
        setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
2254
        setValue(Date(), on: chargedDevice, key: "updatedAt")
Bogdan Timofte authored a month ago
2255
    }
2256

            
2257
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
2258
        sessions
2259
            .filter { $0.status == .completed }
2260
            .compactMap { session in
2261
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
2262
                let timestamp = session.endedAt ?? session.lastObservedAt
2263
                return CapacityTrendPoint(
2264
                    sessionID: session.id,
2265
                    timestamp: timestamp,
2266
                    capacityWh: capacityEstimateWh,
2267
                    chargingTransportMode: session.chargingTransportMode
2268
                )
2269
            }
2270
            .sorted { $0.timestamp < $1.timestamp }
2271
    }
2272

            
2273
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
2274
        var groupedEnergyByBin: [Int: [Double]] = [:]
2275

            
2276
        for session in sessions where session.status == .completed {
Bogdan Timofte authored a month ago
2277
            let anchors = normalizedTypicalCurveAnchors(for: session)
2278
            guard anchors.count >= 2 else {
2279
                continue
Bogdan Timofte authored a month ago
2280
            }
2281

            
Bogdan Timofte authored a month ago
2282
            for percentBin in stride(from: 0, through: 100, by: 10) {
Bogdan Timofte authored a month ago
2283
                guard let energyWh = interpolatedTypicalCurvePoint(
Bogdan Timofte authored a month ago
2284
                    for: Double(percentBin),
2285
                    anchors: anchors
2286
                ) else {
2287
                    continue
2288
                }
Bogdan Timofte authored a month ago
2289

            
Bogdan Timofte authored a month ago
2290
                groupedEnergyByBin[percentBin, default: []].append(energyWh)
Bogdan Timofte authored a month ago
2291
            }
2292
        }
2293

            
Bogdan Timofte authored a month ago
2294
        let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
Bogdan Timofte authored a month ago
2295
            guard let energies = groupedEnergyByBin[percentBin], !energies.isEmpty else {
Bogdan Timofte authored a month ago
2296
                return nil
2297
            }
2298

            
2299
            return TypicalChargeCurvePoint(
2300
                percentBin: percentBin,
2301
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
Bogdan Timofte authored a month ago
2302
                sampleCount: energies.count
Bogdan Timofte authored a month ago
2303
            )
2304
        }
Bogdan Timofte authored a month ago
2305

            
2306
        var runningMaximumEnergyWh = 0.0
2307

            
2308
        return averagedPoints.map { point in
2309
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
2310
            return TypicalChargeCurvePoint(
2311
                percentBin: point.percentBin,
2312
                averageEnergyWh: runningMaximumEnergyWh,
2313
                sampleCount: point.sampleCount
2314
            )
2315
        }
2316
    }
2317

            
2318
    private func normalizedTypicalCurveAnchors(
2319
        for session: ChargeSessionSummary
Bogdan Timofte authored a month ago
2320
    ) -> [(percent: Double, energyWh: Double)] {
Bogdan Timofte authored a month ago
2321
        struct Anchor {
2322
            let percent: Double
2323
            let energyWh: Double
2324
            let timestamp: Date
2325
        }
2326

            
2327
        var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
2328
            guard checkpoint.batteryPercent.isFinite,
2329
                  checkpoint.measuredEnergyWh.isFinite,
2330
                  checkpoint.batteryPercent >= 0,
2331
                  checkpoint.batteryPercent <= 100,
Bogdan Timofte authored a month ago
2332
                  checkpoint.measuredEnergyWh >= 0 else {
Bogdan Timofte authored a month ago
2333
                return nil
2334
            }
2335

            
2336
            return Anchor(
2337
                percent: checkpoint.batteryPercent,
2338
                energyWh: checkpoint.measuredEnergyWh,
2339
                timestamp: checkpoint.timestamp
2340
            )
2341
        }
2342

            
2343
        if let startBatteryPercent = session.startBatteryPercent,
2344
           startBatteryPercent.isFinite,
2345
           startBatteryPercent >= 0,
2346
           startBatteryPercent <= 100 {
2347
            anchors.append(
2348
                Anchor(
2349
                    percent: startBatteryPercent,
2350
                    energyWh: 0,
2351
                    timestamp: session.startedAt
2352
                )
2353
            )
2354
        }
2355

            
2356
        if let endBatteryPercent = session.endBatteryPercent,
2357
           endBatteryPercent.isFinite,
2358
           endBatteryPercent >= 0,
2359
           endBatteryPercent <= 100 {
2360
            anchors.append(
2361
                Anchor(
2362
                    percent: endBatteryPercent,
2363
                    energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
2364
                    timestamp: session.endedAt ?? session.lastObservedAt
2365
                )
2366
            )
2367
        }
2368

            
2369
        let sortedAnchors = anchors.sorted { lhs, rhs in
2370
            if lhs.percent != rhs.percent {
2371
                return lhs.percent < rhs.percent
2372
            }
2373
            if lhs.energyWh != rhs.energyWh {
2374
                return lhs.energyWh < rhs.energyWh
2375
            }
2376
            return lhs.timestamp < rhs.timestamp
2377
        }
2378

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

            
2381
        for anchor in sortedAnchors {
2382
            if let lastIndex = collapsedAnchors.indices.last,
2383
               abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
2384
                collapsedAnchors[lastIndex] = (
2385
                    percent: collapsedAnchors[lastIndex].percent,
Bogdan Timofte authored a month ago
2386
                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh)
Bogdan Timofte authored a month ago
2387
                )
2388
            } else {
2389
                collapsedAnchors.append(
Bogdan Timofte authored a month ago
2390
                    (percent: anchor.percent, energyWh: anchor.energyWh)
Bogdan Timofte authored a month ago
2391
                )
2392
            }
2393
        }
2394

            
2395
        var runningMaximumEnergyWh = 0.0
2396

            
2397
        return collapsedAnchors.map { anchor in
2398
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
2399
            return (
2400
                percent: anchor.percent,
Bogdan Timofte authored a month ago
2401
                energyWh: runningMaximumEnergyWh
Bogdan Timofte authored a month ago
2402
            )
2403
        }
2404
    }
2405

            
2406
    private func interpolatedTypicalCurvePoint(
2407
        for percent: Double,
Bogdan Timofte authored a month ago
2408
        anchors: [(percent: Double, energyWh: Double)]
2409
    ) -> Double? {
Bogdan Timofte authored a month ago
2410
        guard
2411
            let firstAnchor = anchors.first,
2412
            let lastAnchor = anchors.last,
2413
            percent >= firstAnchor.percent,
2414
            percent <= lastAnchor.percent
2415
        else {
2416
            return nil
2417
        }
2418

            
2419
        if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
Bogdan Timofte authored a month ago
2420
            return exactAnchor.energyWh
Bogdan Timofte authored a month ago
2421
        }
2422

            
2423
        guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
2424
              upperIndex > 0 else {
2425
            return nil
2426
        }
2427

            
2428
        let lowerAnchor = anchors[upperIndex - 1]
2429
        let upperAnchor = anchors[upperIndex]
2430
        let span = upperAnchor.percent - lowerAnchor.percent
2431
        guard span > 0.000_1 else {
2432
            return nil
2433
        }
2434

            
2435
        let ratio = (percent - lowerAnchor.percent) / span
Bogdan Timofte authored a month ago
2436
        return lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
Bogdan Timofte authored a month ago
2437
    }
2438

            
2439
    private func makeSessionSummary(
2440
        from object: NSManagedObject,
2441
        checkpoints: [NSManagedObject],
2442
        samples: [NSManagedObject]
2443
    ) -> ChargeSessionSummary? {
2444
        let chargingTransportMode = chargingTransportMode(for: object)
2445

            
2446
        guard
2447
            let id = uuidValue(object, key: "id"),
2448
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2449
            let startedAt = dateValue(object, key: "startedAt"),
2450
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
2451
            let status = statusValue(object, key: "statusRawValue"),
2452
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
2453
        else {
2454
            return nil
2455
        }
2456

            
2457
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
2458
            .sorted { $0.timestamp < $1.timestamp }
2459
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
2460
            .sorted { lhs, rhs in
2461
                if lhs.bucketIndex != rhs.bucketIndex {
2462
                    return lhs.bucketIndex < rhs.bucketIndex
2463
                }
2464
                return lhs.timestamp < rhs.timestamp
2465
            }
2466

            
2467
        return ChargeSessionSummary(
2468
            id: id,
2469
            chargedDeviceID: chargedDeviceID,
2470
            chargerID: uuidValue(object, key: "chargerID"),
2471
            meterMACAddress: stringValue(object, key: "meterMACAddress"),
2472
            meterName: stringValue(object, key: "meterName"),
2473
            meterModel: stringValue(object, key: "meterModel"),
2474
            startedAt: startedAt,
2475
            endedAt: dateValue(object, key: "endedAt"),
2476
            lastObservedAt: lastObservedAt,
Bogdan Timofte authored a month ago
2477
            pausedAt: dateValue(object, key: "pausedAt"),
Bogdan Timofte authored a month ago
2478
            status: status,
2479
            sourceMode: sourceMode,
2480
            chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
2481
            chargingStateMode: chargingStateMode(for: object),
2482
            autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
Bogdan Timofte authored a month ago
2483
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2484
            effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
Bogdan Timofte authored a month ago
2485
            meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
Bogdan Timofte authored a month ago
2486
            meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"),
2487
            meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"),
Bogdan Timofte authored a month ago
2488
            minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
2489
            maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
2490
            maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
2491
            maximumObservedVoltageVolts: chargingTransportMode == .wired
2492
                ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
2493
                : nil,
Bogdan Timofte authored a month ago
2494
            hasObservedChargeFlow: boolValue(object, key: "hasObservedChargeFlow"),
Bogdan Timofte authored a month ago
2495
            selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
2496
            completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
2497
            stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
2498
            startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
2499
            endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
2500
            capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
2501
            wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
2502
            usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
2503
            shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
2504
            supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
2505
            usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
2506
            targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
2507
            targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
2508
            requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
2509
            completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
2510
            completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
2511
            selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
Bogdan Timofte authored a month ago
2512
            trimStart: dateValue(object, key: "trimStart"),
2513
            trimEnd: dateValue(object, key: "trimEnd"),
Bogdan Timofte authored a month ago
2514
            wasConflictHealed: boolValue(object, key: "wasConflictHealed"),
Bogdan Timofte authored a month ago
2515
            checkpoints: checkpointSummaries,
2516
            aggregatedSamples: sampleSummaries
2517
        )
2518
    }
2519

            
2520
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
2521
        guard
2522
            let id = uuidValue(object, key: "id"),
2523
            let sessionID = uuidValue(object, key: "sessionID"),
2524
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2525
            let timestamp = dateValue(object, key: "timestamp")
2526
        else {
2527
            return nil
2528
        }
2529

            
2530
        return ChargeCheckpointSummary(
2531
            id: id,
2532
            sessionID: sessionID,
2533
            chargedDeviceID: chargedDeviceID,
2534
            timestamp: timestamp,
2535
            batteryPercent: doubleValue(object, key: "batteryPercent"),
2536
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2537
            currentAmps: doubleValue(object, key: "currentAmps"),
2538
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
2539
            label: stringValue(object, key: "label")
2540
        )
2541
    }
2542

            
2543
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
2544
        guard
2545
            let sessionID = uuidValue(object, key: "sessionID"),
2546
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2547
            let timestamp = dateValue(object, key: "timestamp")
2548
        else {
2549
            return nil
2550
        }
2551

            
2552
        return ChargeSessionSampleSummary(
2553
            sessionID: sessionID,
2554
            chargedDeviceID: chargedDeviceID,
2555
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
2556
            timestamp: timestamp,
2557
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
2558
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
2559
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
2560
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2561
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
2562
        )
2563
    }
2564

            
Bogdan Timofte authored a month ago
2565
    private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2566
        fetchSessionObject(
2567
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2568
                format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
Bogdan Timofte authored a month ago
2569
                normalizedMACAddress(meterMACAddress),
Bogdan Timofte authored a month ago
2570
                ChargeSessionStatus.active.rawValue,
2571
                ChargeSessionStatus.paused.rawValue
Bogdan Timofte authored a month ago
2572
            )
2573
        )
2574
    }
2575

            
Bogdan Timofte authored a month ago
2576
    private func fetchOpenSessionObjects() -> [NSManagedObject] {
2577
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2578
        request.predicate = NSPredicate(
2579
            format: "statusRawValue == %@ OR statusRawValue == %@",
2580
            ChargeSessionStatus.active.rawValue,
2581
            ChargeSessionStatus.paused.rawValue
2582
        )
2583
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2584
        return (try? context.fetch(request)) ?? []
2585
    }
2586

            
Bogdan Timofte authored a month ago
2587
    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2588
        fetchSessionObject(
2589
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2590
                format: "meterMACAddress == %@ AND statusRawValue == %@",
2591
                normalizedMACAddress(meterMACAddress),
2592
                ChargeSessionStatus.active.rawValue
Bogdan Timofte authored a month ago
2593
            )
2594
        )
2595
    }
2596

            
2597
    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
2598
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2599
        request.predicate = predicate
2600
        request.fetchLimit = 1
2601
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
2602
        return (try? context.fetch(request))?.first
2603
    }
2604

            
2605
    private func fetchSessionObject(id: String) -> NSManagedObject? {
2606
        fetchSessionObject(
2607
            predicate: NSPredicate(format: "id == %@", id)
2608
        )
2609
    }
2610

            
2611
    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
2612
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2613
        request.predicate = NSPredicate(
2614
            format: "sessionID == %@ AND bucketIndex == %d",
2615
            sessionID,
2616
            bucketIndex
2617
        )
2618
        request.fetchLimit = 1
2619
        return (try? context.fetch(request))?.first
2620
    }
2621

            
2622
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2623
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2624
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2625
        return (try? context.fetch(request)) ?? []
2626
    }
2627

            
Bogdan Timofte authored a month ago
2628
    private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
2629
        guard !sessionIDs.isEmpty else {
2630
            return []
2631
        }
2632

            
2633
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2634
        request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs)
2635
        return (try? context.fetch(request)) ?? []
2636
    }
2637

            
Bogdan Timofte authored a month ago
2638
    private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
2639
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2640
        request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
2641
        request.fetchLimit = 1
2642
        return (try? context.fetch(request))?.first
2643
    }
2644

            
Bogdan Timofte authored a month ago
2645
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2646
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2647
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2648
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
2649
        return (try? context.fetch(request)) ?? []
2650
    }
2651

            
2652
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
2653
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2654
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
2655
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2656
        return (try? context.fetch(request)) ?? []
2657
    }
2658

            
2659
    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
2660
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2661
        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
2662
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2663
        return (try? context.fetch(request)) ?? []
2664
    }
2665

            
Bogdan Timofte authored a month ago
2666
    private func sampleBackedSessionIDs(
2667
        devices: [NSManagedObject],
2668
        sessionsByDeviceID: [String: [NSManagedObject]],
2669
        sessionsByChargerID: [String: [NSManagedObject]]
2670
    ) -> Set<String> {
2671
        var sessionIDs: Set<String> = []
2672

            
2673
        for device in devices {
2674
            guard
2675
                let deviceID = stringValue(device, key: "id"),
2676
                let rawClass = stringValue(device, key: "deviceClassRawValue"),
2677
                let deviceClass = ChargedDeviceClass(rawValue: rawClass)
2678
            else {
2679
                continue
2680
            }
2681

            
2682
            let relevantSessions = relevantSessionObjects(
2683
                for: deviceID,
2684
                deviceClass: deviceClass,
2685
                sessionsByDeviceID: sessionsByDeviceID,
2686
                sessionsByChargerID: sessionsByChargerID
2687
            )
2688
            .sorted { lhs, rhs in
2689
                let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed
2690
                let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed
2691

            
2692
                if lhsStatus.isOpen && !rhsStatus.isOpen {
2693
                    return true
2694
                }
2695
                if !lhsStatus.isOpen && rhsStatus.isOpen {
2696
                    return false
2697
                }
2698

            
2699
                return (dateValue(lhs, key: "startedAt") ?? .distantPast)
2700
                    > (dateValue(rhs, key: "startedAt") ?? .distantPast)
2701
            }
2702

            
2703
            var recentCompletedSamplesIncluded = 0
2704

            
2705
            for session in relevantSessions {
2706
                guard let sessionID = stringValue(session, key: "id"),
2707
                      let status = statusValue(session, key: "statusRawValue") else {
2708
                    continue
2709
                }
2710

            
2711
                if status.isOpen {
2712
                    sessionIDs.insert(sessionID)
2713
                    continue
2714
                }
2715

            
2716
                guard recentCompletedSamplesIncluded < 2 else {
2717
                    continue
2718
                }
2719

            
2720
                sessionIDs.insert(sessionID)
2721
                recentCompletedSamplesIncluded += 1
2722
            }
2723
        }
2724

            
2725
        return sessionIDs
2726
    }
2727

            
Bogdan Timofte authored a month ago
2728
    private func relevantSessionObjects(
2729
        for chargedDeviceID: String,
2730
        deviceClass: ChargedDeviceClass,
2731
        sessionsByDeviceID: [String: [NSManagedObject]],
2732
        sessionsByChargerID: [String: [NSManagedObject]]
2733
    ) -> [NSManagedObject] {
2734
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
2735
        guard deviceClass == .charger else {
2736
            return directSessions
2737
        }
2738

            
2739
        var seenSessionIDs = Set<String>()
2740
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
2741
            .filter { session in
2742
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
2743
                return seenSessionIDs.insert(sessionID).inserted
2744
            }
2745
            .sorted {
2746
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
2747
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
2748
                return lhsDate < rhsDate
2749
            }
2750
    }
2751

            
2752
    private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
2753
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false)
2754
    }
2755

            
2756
    private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
2757
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true)
2758
    }
2759

            
2760
    private func resolvedAssignedObject(
2761
        for meterMACAddress: String,
2762
        expectsChargerClass: Bool
2763
    ) -> NSManagedObject? {
2764
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
2765
        guard !normalizedMAC.isEmpty else { return nil }
2766

            
2767
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2768
        request.predicate = NSPredicate(format: "lastAssociatedMeterMAC == %@", normalizedMAC)
2769
        request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
2770
        let matches = (try? context.fetch(request)) ?? []
2771
        return matches.first { object in
Bogdan Timofte authored a month ago
2772
            isChargerObject(object) == expectsChargerClass
Bogdan Timofte authored a month ago
2773
        }
2774
    }
2775

            
Bogdan Timofte authored a month ago
2776
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
2777
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2778
    }
2779

            
Bogdan Timofte authored a month ago
2780
    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
2781
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2782
        request.predicate = NSPredicate(format: "id == %@", id)
2783
        request.fetchLimit = 1
2784
        return (try? context.fetch(request))?.first
2785
    }
2786

            
2787
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
2788
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
2789
        return (try? context.fetch(request)) ?? []
2790
    }
2791

            
2792
    private func resolvedStopThreshold(
2793
        for chargedDevice: NSManagedObject,
2794
        chargingTransportMode: ChargingTransportMode,
Bogdan Timofte authored a month ago
2795
        chargingStateMode: ChargingStateMode,
2796
        charger: NSManagedObject?,
2797
        fallback: Double?
2798
    ) -> Double? {
2799
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
2800
            return nil
2801
        }
2802

            
2803
        let sessionKind = ChargeSessionKind(
2804
            chargingTransportMode: chargingTransportMode,
2805
            chargingStateMode: chargingStateMode
2806
        )
2807
        let configuredCurrents = decodedCompletionCurrents(
2808
            from: chargedDevice,
2809
            key: "configuredCompletionCurrentsRawValue"
2810
        )
2811
        let learnedCurrents = decodedCompletionCurrents(
2812
            from: chargedDevice,
2813
            key: "learnedCompletionCurrentsRawValue"
2814
        )
2815
        let legacyCurrent: Double?
Bogdan Timofte authored a month ago
2816
        switch chargingTransportMode {
2817
        case .wired:
Bogdan Timofte authored a month ago
2818
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2819
                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
2820
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2821
        case .wireless:
Bogdan Timofte authored a month ago
2822
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2823
                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
2824
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2825
        }
Bogdan Timofte authored a month ago
2826

            
2827
        let resolvedCurrent = configuredCurrents[sessionKind]
2828
            ?? learnedCurrents[sessionKind]
2829
            ?? legacyCurrent
2830
            ?? fallback
2831
        guard let resolvedCurrent, resolvedCurrent > 0 else {
2832
            return nil
2833
        }
2834
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
2835
    }
2836

            
Bogdan Timofte authored a month ago
2837
    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
Bogdan Timofte authored a month ago
2838
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
2839
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
2840
        return resolvedPreferredChargingTransportMode(
Bogdan Timofte authored a month ago
2841
            .wired,
Bogdan Timofte authored a month ago
2842
            supportsWiredCharging: supportsWiredCharging,
2843
            supportsWirelessCharging: supportsWirelessCharging
2844
        )
2845
    }
2846

            
Bogdan Timofte authored a month ago
2847
    private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
2848
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other
2849
    }
2850

            
2851
    private func normalizedTemplateID(
2852
        _ templateID: String?,
2853
        kind: ChargedDeviceKind
2854
    ) -> String? {
2855
        guard let templateID,
2856
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2857
              templateDefinition.kind == kind else {
2858
            return nil
Bogdan Timofte authored a month ago
2859
        }
Bogdan Timofte authored a month ago
2860
        return templateDefinition.id
Bogdan Timofte authored a month ago
2861
    }
2862

            
Bogdan Timofte authored a month ago
2863
    private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
2864
        guard let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
2865
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2866
              templateDefinition.kind == deviceClass(for: chargedDevice).kind else {
2867
            return nil
Bogdan Timofte authored a month ago
2868
        }
Bogdan Timofte authored a month ago
2869
        return templateDefinition
2870
    }
2871

            
2872
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
2873
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2874
            ? true
2875
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2876
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2877
            ? false
2878
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2879
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2880
            supportsWiredCharging: persistedWiredCharging,
2881
            supportsWirelessCharging: persistedWirelessCharging
2882
        ).wired
2883
    }
2884

            
2885
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
2886
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2887
            ? true
2888
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2889
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2890
            ? false
2891
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2892
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2893
            supportsWiredCharging: persistedWiredCharging,
2894
            supportsWirelessCharging: persistedWirelessCharging
2895
        ).wireless
2896
    }
2897

            
2898
    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
2899
        let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue")
2900
            .flatMap(ChargingStateAvailability.init(rawValue:))
2901
            ?? ChargingStateAvailability.fallback(
Bogdan Timofte authored a month ago
2902
            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
2903
        )
Bogdan Timofte authored a month ago
2904
        return deviceClass(for: chargedDevice).normalizedChargingStateAvailability(persistedAvailability)
Bogdan Timofte authored a month ago
2905
    }
2906

            
2907
    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
Bogdan Timofte authored a month ago
2908
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2909
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2910
            let persistedChargingStateMode = stringValue(session, key: "chargingStateRawValue")
2911
                .flatMap(ChargingStateMode.init(rawValue:))
2912
                ?? .on
2913
            return resolvedChargingStateMode(
2914
                persistedChargingStateMode,
2915
                availability: chargingStateAvailability(for: chargedDevice)
2916
            )
2917
        }
2918

            
Bogdan Timofte authored a month ago
2919
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
2920
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
2921
            return chargingStateMode
2922
        }
2923

            
2924
        return .on
2925
    }
2926

            
2927
    private func resolvedChargingStateMode(
2928
        _ chargingStateMode: ChargingStateMode,
2929
        availability: ChargingStateAvailability
2930
    ) -> ChargingStateMode {
2931
        if availability.supportedModes.contains(chargingStateMode) {
2932
            return chargingStateMode
2933
        }
2934
        return availability.supportedModes.first ?? .on
2935
    }
2936

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

            
2941
        // Primary: chargerTypeRawValue (set on v13+)
2942
        if let rawValue = stringValue(chargedDevice, key: "chargerTypeRawValue"),
2943
           let type = ChargerType(rawValue: rawValue) {
2944
            return type
2945
        }
2946

            
2947
        // Migration fallback: derive from old deviceTemplateID
2948
        switch stringValue(chargedDevice, key: "deviceTemplateID") {
2949
        case "apple-magsafe-charger": return .appleMagSafe
2950
        case "apple-watch-charger": return .appleWatch
2951
        default: break
2952
        }
2953

            
2954
        // Last resort: derive from wirelessChargingProfileRawValue
2955
        if let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2956
           let profile = WirelessChargingProfile(rawValue: rawValue),
2957
           profile == .magsafe {
2958
            return .genericMagSafe
2959
        }
2960

            
2961
        return .genericQi
2962
    }
2963

            
Bogdan Timofte authored a month ago
2964
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
Bogdan Timofte authored a month ago
2965
        if let type = chargerType(for: chargedDevice) {
2966
            return type.wirelessChargingProfile
2967
        }
Bogdan Timofte authored a month ago
2968
        guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2969
              let profile = WirelessChargingProfile(rawValue: rawValue) else {
2970
            return .genericQi
2971
        }
2972
        return profile
2973
    }
2974

            
2975
    private func resolvedPreferredChargingTransportMode(
2976
        _ preferredChargingTransportMode: ChargingTransportMode,
2977
        supportsWiredCharging: Bool,
2978
        supportsWirelessCharging: Bool
2979
    ) -> ChargingTransportMode {
2980
        switch preferredChargingTransportMode {
2981
        case .wired where supportsWiredCharging:
2982
            return .wired
2983
        case .wireless where supportsWirelessCharging:
2984
            return .wireless
2985
        default:
2986
            if supportsWiredCharging {
2987
                return .wired
2988
            }
2989
            if supportsWirelessCharging {
2990
                return .wireless
2991
            }
2992
            return .wired
2993
        }
2994
    }
2995

            
Bogdan Timofte authored a month ago
2996
    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
2997
        let payload = Dictionary(
2998
            uniqueKeysWithValues: currents.map { key, value in
2999
                (key.rawValue, value)
3000
            }
3001
        )
3002
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
3003
            return nil
3004
        }
3005
        return String(data: data, encoding: .utf8)
3006
    }
3007

            
3008
    private func decodedCompletionCurrents(
3009
        from object: NSManagedObject,
3010
        key: String
3011
    ) -> [ChargeSessionKind: Double] {
3012
        guard let rawValue = stringValue(object, key: key),
3013
              let data = rawValue.data(using: .utf8),
3014
              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
3015
            return [:]
3016
        }
3017

            
3018
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
3019
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
3020
                return
3021
            }
3022
            result[sessionKind] = entry.value
3023
        }
3024
    }
3025

            
3026
    private func legacyConfiguredCompletionCurrent(
3027
        for currents: [ChargeSessionKind: Double],
3028
        chargingTransportMode: ChargingTransportMode
3029
    ) -> Double? {
3030
        let candidates = currents
3031
            .filter { $0.key.chargingTransportMode == chargingTransportMode }
3032
            .sorted { lhs, rhs in
3033
                lhs.key.rawValue < rhs.key.rawValue
3034
            }
3035
            .map(\.value)
3036
        return candidates.first
3037
    }
3038

            
3039
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
3040
        guard let charger else {
3041
            return nil
3042
        }
3043
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
3044
        guard let idleCurrent, idleCurrent >= 0 else {
3045
            return nil
3046
        }
3047
        return idleCurrent
3048
    }
3049

            
3050
    private func effectiveCurrentAmps(
3051
        fromMeasuredCurrent currentAmps: Double,
3052
        chargingTransportMode: ChargingTransportMode,
3053
        charger: NSManagedObject?
3054
    ) -> Double {
3055
        switch chargingTransportMode {
3056
        case .wired:
3057
            return max(currentAmps, 0)
3058
        case .wireless:
3059
            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
3060
                return max(currentAmps, 0)
3061
            }
3062
            return max(currentAmps - idleCurrent, 0)
3063
        }
3064
    }
3065

            
3066
    private func hasObservedChargeFlow(
3067
        currentAmps: Double,
3068
        chargingTransportMode: ChargingTransportMode,
3069
        charger: NSManagedObject?,
3070
        stopThreshold: Double?
3071
    ) -> Bool {
3072
        let effectiveCurrent = effectiveCurrentAmps(
3073
            fromMeasuredCurrent: currentAmps,
3074
            chargingTransportMode: chargingTransportMode,
3075
            charger: charger
3076
        )
3077
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
3078
    }
3079

            
Bogdan Timofte authored a month ago
3080
    private func hasSavableChargeData(_ session: NSManagedObject) -> Bool {
Bogdan Timofte authored a month ago
3081
        if boolValue(session, key: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
3082
            || doubleValue(session, key: "measuredEnergyWh") > 0
3083
            || doubleValue(session, key: "measuredChargeAh") > 0
3084
            || (optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? 0) > 0
Bogdan Timofte authored a month ago
3085
            || (optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? 0) > 0 {
3086
            return true
3087
        }
3088

            
3089
        guard let sessionID = stringValue(session, key: "id") else {
3090
            return false
3091
        }
3092

            
3093
        return fetchSessionSampleObjects(forSessionID: sessionID).contains { sample in
3094
            doubleValue(sample, key: "measuredEnergyWh") > 0
3095
                || doubleValue(sample, key: "measuredChargeAh") > 0
3096
                || (optionalDoubleValue(sample, key: "averageCurrentAmps") ?? 0) > 0
3097
                || (optionalDoubleValue(sample, key: "averagePowerWatts") ?? 0) > 0
3098
        }
3099
    }
3100

            
3101
    private func restoreMeasuredTotalsFromLatestSampleIfNeeded(_ session: NSManagedObject) {
3102
        guard let sessionID = stringValue(session, key: "id"),
3103
              let latestSample = fetchSessionSampleObjects(forSessionID: sessionID).max(by: {
3104
                  (dateValue($0, key: "timestamp") ?? .distantPast) < (dateValue($1, key: "timestamp") ?? .distantPast)
3105
              }) else {
3106
            return
3107
        }
3108

            
3109
        let sampleEnergyWh = doubleValue(latestSample, key: "measuredEnergyWh")
3110
        if doubleValue(session, key: "measuredEnergyWh") <= 0, sampleEnergyWh > 0 {
3111
            session.setValue(sampleEnergyWh, forKey: "measuredEnergyWh")
3112
        }
3113

            
3114
        let sampleChargeAh = doubleValue(latestSample, key: "measuredChargeAh")
3115
        if doubleValue(session, key: "measuredChargeAh") <= 0, sampleChargeAh > 0 {
3116
            session.setValue(sampleChargeAh, forKey: "measuredChargeAh")
3117
        }
Bogdan Timofte authored a month ago
3118
    }
3119

            
Bogdan Timofte authored a month ago
3120
    private func derivedMinimumCurrent(
3121
        from sessions: [NSManagedObject],
3122
        chargingTransportMode: ChargingTransportMode
3123
    ) -> Double? {
3124
        let completionCurrents = sessions.compactMap { session -> Double? in
3125
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3126
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
3127
                return nil
3128
            }
Bogdan Timofte authored a month ago
3129
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3130
                return nil
3131
            }
Bogdan Timofte authored a month ago
3132
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
3133
                return nil
3134
            }
3135
            return completionCurrent
3136
        }
3137

            
3138
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
3139
        guard !recentCompletionCurrents.isEmpty else { return nil }
3140
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
3141
    }
3142

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

            
3146
        for session in sessions {
3147
            guard statusValue(session, key: "statusRawValue") == .completed else {
3148
                continue
3149
            }
3150
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3151
                continue
3152
            }
3153
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
3154
                  completionCurrent > 0 else {
3155
                continue
3156
            }
3157

            
3158
            let sessionKind = ChargeSessionKind(
3159
                chargingTransportMode: chargingTransportMode(for: session),
3160
                chargingStateMode: chargingStateMode(for: session)
3161
            )
3162
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
3163
        }
3164

            
3165
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
3166
            let recentCurrents = Array(entry.value.suffix(5))
3167
            guard !recentCurrents.isEmpty else {
3168
                return
3169
            }
3170
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
3171
        }
3172
    }
3173

            
Bogdan Timofte authored a month ago
3174
    private func derivedCapacity(
3175
        from sessions: [NSManagedObject],
3176
        chargingTransportMode: ChargingTransportMode,
3177
        supportsChargingWhileOff: Bool
3178
    ) -> Double? {
3179
        let capacityCandidates = sessions.compactMap { session -> Double? in
3180
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3181
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
3182
                return nil
3183
            }
3184
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
3185
                return nil
3186
            }
3187
            if supportsChargingWhileOff {
3188
                return capacityEstimate
3189
            }
3190
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
3191
                return nil
3192
            }
3193
            return capacityEstimate
3194
        }
3195

            
3196
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
3197
        guard !recentCapacityCandidates.isEmpty else { return nil }
3198
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
3199
    }
3200

            
3201
    private func derivedWirelessEfficiency(
3202
        from sessions: [NSManagedObject],
3203
        chargingProfile: WirelessChargingProfile
3204
    ) -> Double? {
3205
        guard chargingProfile == .magsafe else {
3206
            return nil
3207
        }
3208

            
3209
        let candidates = sessions.compactMap { session -> Double? in
3210
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3211
            guard chargingTransportMode(for: session) == .wireless else { return nil }
3212
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
3213
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3214
                return nil
3215
            }
3216
            return factor
3217
        }
3218

            
3219
        let recentCandidates = Array(candidates.suffix(6))
3220
        guard !recentCandidates.isEmpty else { return nil }
3221
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3222
    }
3223

            
3224
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
3225
        let candidates = sessions.compactMap { session -> Double? in
3226
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3227
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
3228
                return nil
3229
            }
3230
            return (sourceVoltage * 10).rounded() / 10
3231
        }
3232

            
3233
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
3234
        return counts.keys.sorted()
3235
    }
3236

            
3237
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
3238
        let candidates = sessions.compactMap { session -> Double? in
3239
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3240
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
3241
                return nil
3242
            }
3243
            return minimumObservedCurrent
3244
        }
3245

            
3246
        let recentCandidates = Array(candidates.suffix(6))
3247
        guard !recentCandidates.isEmpty else { return nil }
3248
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3249
    }
3250

            
3251
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
3252
        let candidates = sessions.compactMap { session -> Double? in
3253
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3254
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3255
                return nil
3256
            }
3257
            return factor
3258
        }
3259

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

            
3265
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
3266
        sessions.compactMap { session -> Double? in
3267
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3268
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
3269
                return nil
3270
            }
3271
            return maximumObservedPower
3272
        }
3273
        .max()
3274
    }
3275

            
3276
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
3277
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
3278
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
3279
            return resolvedPreferredChargingTransportMode(
3280
                chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired,
3281
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
3282
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
3283
            )
3284
        }
3285

            
3286
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
3287
            return persistedChargingTransportMode
Bogdan Timofte authored a month ago
3288
        }
3289

            
3290
        return .wired
3291
    }
3292

            
3293
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
3294
        if session.isInserted {
3295
            return .created
3296
        }
3297

            
3298
        let committedValues = session.committedValues(
3299
            forKeys: [
3300
                "statusRawValue",
3301
                "updatedAt",
3302
                "targetBatteryAlertTriggeredAt",
3303
                "requiresCompletionConfirmation"
3304
            ]
3305
        )
3306
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
3307
        let currentStatus = statusValue(session, key: "statusRawValue")
3308
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
3309
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
3310
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
3311
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
3312
            ?? false
3313
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
3314

            
3315
        if currentStatus == .completed, committedStatus != .completed {
3316
            return .completed
3317
        }
3318

            
Bogdan Timofte authored a month ago
3319
        if currentStatus != committedStatus {
3320
            return .event
3321
        }
3322

            
Bogdan Timofte authored a month ago
3323
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
3324
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
3325
            return .event
3326
        }
3327

            
3328
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3329
            ?? dateValue(session, key: "createdAt")
3330
            ?? observedAt
3331

            
3332
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
3333
            return .periodic
3334
        }
3335

            
3336
        return .none
3337
    }
3338

            
Bogdan Timofte authored a month ago
3339
    private func shouldPersistAggregatedSample(
3340
        _ sample: NSManagedObject,
3341
        observedAt: Date
3342
    ) -> Bool {
3343
        if sample.isInserted {
3344
            return true
3345
        }
3346

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

            
3352
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
3353
    }
3354

            
Bogdan Timofte authored a month ago
3355
    private func generateQRIdentifier() -> String {
3356
        "device:\(UUID().uuidString)"
3357
    }
3358

            
3359
    @discardableResult
3360
    private func saveContext() -> Bool {
3361
        guard context.hasChanges else { return true }
3362
        do {
3363
            try context.save()
3364
            return true
3365
        } catch {
3366
            track("Failed saving charge insights context: \(error)")
3367
            context.rollback()
3368
            return false
3369
        }
3370
    }
3371

            
3372
    private func normalizedText(_ text: String) -> String {
3373
        text.trimmingCharacters(in: .whitespacesAndNewlines)
3374
    }
3375

            
3376
    private func normalizedOptionalText(_ text: String?) -> String? {
3377
        guard let text else { return nil }
3378
        let normalized = normalizedText(text)
3379
        return normalized.isEmpty ? nil : normalized
3380
    }
3381

            
3382
    private func normalizedMACAddress(_ macAddress: String) -> String {
3383
        normalizedText(macAddress).uppercased()
3384
    }
3385

            
Bogdan Timofte authored a month ago
3386
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
3387
        guard object.entity.propertiesByName[key] != nil else {
3388
            return nil
3389
        }
3390
        return object.value(forKey: key)
3391
    }
3392

            
3393
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
3394
        guard object.entity.propertiesByName[key] != nil else {
3395
            return
3396
        }
3397
        object.setValue(value, forKey: key)
3398
    }
3399

            
Bogdan Timofte authored a month ago
3400
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
3401
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
3402
        let normalized = normalizedOptionalText(value)
3403
        return normalized
3404
    }
3405

            
3406
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
3407
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
3408
    }
3409

            
3410
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
3411
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
3412
            return value
3413
        }
Bogdan Timofte authored a month ago
3414
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3415
            return value.doubleValue
3416
        }
3417
        return 0
3418
    }
3419

            
3420
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
3421
        let value = rawValue(object, key: key)
3422
        if value == nil {
Bogdan Timofte authored a month ago
3423
            return nil
3424
        }
3425
        return doubleValue(object, key: key)
3426
    }
3427

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

            
3438
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
3439
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
3440
            return value
3441
        }
Bogdan Timofte authored a month ago
3442
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3443
            return value.int32Value
3444
        }
3445
        return nil
3446
    }
3447

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

            
3458
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
3459
        guard let value = stringValue(object, key: key) else { return nil }
3460
        return UUID(uuidString: value)
3461
    }
3462

            
3463
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
3464
        guard let value = stringValue(object, key: key) else { return nil }
3465
        return ChargeSessionStatus(rawValue: value)
3466
    }
3467

            
3468
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
3469
        guard let value = stringValue(object, key: key) else { return nil }
3470
        return ChargingTransportMode(rawValue: value)
3471
    }
3472

            
3473
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
3474
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
3475
            return []
3476
        }
3477
        return rawValue
3478
            .split(separator: ",")
3479
            .compactMap { Double($0) }
3480
            .sorted()
3481
    }
3482

            
3483
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
3484
        let uniqueVoltages = Array(Set(voltages)).sorted()
3485
        guard !uniqueVoltages.isEmpty else {
3486
            return nil
3487
        }
3488
        return uniqueVoltages
3489
            .map { String(format: "%.1f", $0) }
3490
            .joined(separator: ",")
3491
    }
3492

            
3493
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
3494
        guard currentCount > 0 else {
3495
            return newValue
3496
        }
3497
        let total = (currentAverage * Double(currentCount)) + newValue
3498
        return total / Double(currentCount + 1)
3499
    }
3500
}
3501

            
3502
private enum ObservationSaveReason {
3503
    case none
3504
    case created
3505
    case periodic
3506
    case completed
3507
    case event
3508
}