USB-Meter / USB Meter / Model / ChargeInsightsStore.swift
Newer Older
3648 lines | 159.366kb
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 commitSessionTrim(sessionID: UUID) -> Bool {
958
        var didSave = false
959
        context.performAndWait {
960
            guard let session = fetchSessionObject(id: sessionID.uuidString),
961
                  statusValue(session, key: "statusRawValue")?.isOpen == false else {
962
                return
963
            }
964

            
965
            guard dateValue(session, key: "trimStart") != nil
966
                    || dateValue(session, key: "trimEnd") != nil else {
967
                return
968
            }
969

            
970
            let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast
971
            let sessionEnd = dateValue(session, key: "endedAt")
972
                ?? dateValue(session, key: "lastObservedAt")
973
                ?? sessionStart
974

            
975
            let effectiveStart = min(max(dateValue(session, key: "trimStart") ?? sessionStart, sessionStart), sessionEnd)
976
            let effectiveEnd = max(
977
                min(dateValue(session, key: "trimEnd") ?? sessionEnd, sessionEnd),
978
                effectiveStart
979
            )
980

            
981
            let sampleObjects = fetchSessionSampleObjects(forSessionID: sessionID.uuidString)
982
            let allSamples = sampleObjects
983
                .compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in
984
                    guard let timestamp = dateValue(obj, key: "timestamp") else { return nil }
985
                    return (
986
                        timestamp: timestamp,
987
                        energy: doubleValue(obj, key: "measuredEnergyWh"),
988
                        charge: doubleValue(obj, key: "measuredChargeAh")
989
                    )
990
                }
991
                .sorted { $0.timestamp < $1.timestamp }
992

            
993
            let baselineSample = allSamples.last { $0.timestamp <= effectiveStart }
994
            let endSample = allSamples.last { $0.timestamp <= effectiveEnd }
995
            let baselineEnergy = baselineSample?.energy ?? 0
996
            let baselineCharge = baselineSample?.charge ?? 0
997
            let committedEnergy = endSample.map { max($0.energy - baselineEnergy, 0) }
998
                ?? doubleValue(session, key: "measuredEnergyWh")
999
            let committedCharge = endSample.map { max($0.charge - baselineCharge, 0) }
1000
                ?? doubleValue(session, key: "measuredChargeAh")
1001

            
1002
            var retainedSamples: [(current: Double, power: Double, voltage: Double?)] = []
1003
            for sample in sampleObjects {
1004
                guard let timestamp = dateValue(sample, key: "timestamp") else {
1005
                    context.delete(sample)
1006
                    continue
1007
                }
1008

            
1009
                guard timestamp >= effectiveStart && timestamp <= effectiveEnd else {
1010
                    context.delete(sample)
1011
                    continue
1012
                }
1013

            
1014
                let rebasedEnergy = max(doubleValue(sample, key: "measuredEnergyWh") - baselineEnergy, 0)
1015
                let rebasedCharge = max(doubleValue(sample, key: "measuredChargeAh") - baselineCharge, 0)
1016
                let elapsed = max(timestamp.timeIntervalSince(effectiveStart), 0)
1017
                let rebasedBucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
1018

            
1019
                sample.setValue("\(sessionID.uuidString)-\(rebasedBucketIndex)", forKey: "id")
1020
                sample.setValue(rebasedBucketIndex, forKey: "bucketIndex")
1021
                sample.setValue(rebasedEnergy, forKey: "measuredEnergyWh")
1022
                sample.setValue(rebasedCharge, forKey: "measuredChargeAh")
1023
                sample.setValue(Date(), forKey: "updatedAt")
1024

            
1025
                retainedSamples.append(
1026
                    (
1027
                        current: doubleValue(sample, key: "averageCurrentAmps"),
1028
                        power: doubleValue(sample, key: "averagePowerWatts"),
1029
                        voltage: optionalDoubleValue(sample, key: "averageVoltageVolts")
1030
                    )
1031
                )
1032
            }
1033

            
1034
            for checkpoint in fetchCheckpointObjects(forSessionID: sessionID.uuidString) {
1035
                guard let timestamp = dateValue(checkpoint, key: "timestamp") else {
1036
                    context.delete(checkpoint)
1037
                    continue
1038
                }
1039

            
1040
                guard timestamp >= effectiveStart && timestamp <= effectiveEnd else {
1041
                    context.delete(checkpoint)
1042
                    continue
1043
                }
1044

            
1045
                let checkpointSample = allSamples.last { $0.timestamp <= timestamp }
1046
                checkpoint.setValue(
1047
                    max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0),
1048
                    forKey: "measuredEnergyWh"
1049
                )
1050
                checkpoint.setValue(
1051
                    max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0),
1052
                    forKey: "measuredChargeAh"
1053
                )
1054
            }
1055

            
1056
            if !retainedSamples.isEmpty {
1057
                let positiveCurrents = retainedSamples.map { $0.current }.filter { $0 > 0 }
1058
                session.setValue(positiveCurrents.min(), forKey: "minimumObservedCurrentAmps")
1059
                session.setValue(retainedSamples.map { $0.current }.max(), forKey: "maximumObservedCurrentAmps")
1060
                session.setValue(retainedSamples.map { $0.power }.max(), forKey: "maximumObservedPowerWatts")
1061
                session.setValue(retainedSamples.compactMap { $0.voltage }.max(), forKey: "maximumObservedVoltageVolts")
1062
                session.setValue(
1063
                    retainedSamples.contains { $0.power > 0.05 || $0.current > 0.01 },
1064
                    forKey: "hasObservedChargeFlow"
1065
                )
1066
            } else {
1067
                session.setValue(nil, forKey: "minimumObservedCurrentAmps")
1068
                session.setValue(nil, forKey: "maximumObservedCurrentAmps")
1069
                session.setValue(nil, forKey: "maximumObservedPowerWatts")
1070
                session.setValue(nil, forKey: "maximumObservedVoltageVolts")
1071
                session.setValue(committedEnergy > 0 || committedCharge > 0, forKey: "hasObservedChargeFlow")
1072
            }
1073

            
1074
            session.setValue(effectiveStart, forKey: "startedAt")
1075
            session.setValue(effectiveEnd, forKey: "lastObservedAt")
1076
            if dateValue(session, key: "endedAt") != nil {
1077
                session.setValue(effectiveEnd, forKey: "endedAt")
1078
            }
1079
            session.setValue(committedEnergy, forKey: "measuredEnergyWh")
1080
            session.setValue(committedCharge, forKey: "measuredChargeAh")
1081
            session.setValue(nil, forKey: "trimStart")
1082
            session.setValue(nil, forKey: "trimEnd")
1083
            session.setValue(Date(), forKey: "updatedAt")
1084

            
1085
            refreshCheckpointDerivedValues(for: session)
1086

            
1087
            if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1088
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1089
            }
1090

            
1091
            didSave = saveContext()
1092
        }
1093
        return didSave
1094
    }
1095

            
Bogdan Timofte authored a month ago
1096
    @discardableResult
1097
    func deleteChargeSession(id sessionID: UUID) -> Bool {
1098
        var didSave = false
1099
        context.performAndWait {
1100
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
1101
                return
1102
            }
1103

            
1104
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
1105

            
1106
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
1107
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
1108
            context.delete(session)
1109

            
1110
            guard saveContext() else {
1111
                return
1112
            }
1113

            
1114
            if let chargedDeviceID {
1115
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1116
                didSave = saveContext()
1117
            } else {
1118
                didSave = true
1119
            }
1120
        }
1121
        return didSave
1122
    }
1123

            
1124
    @discardableResult
1125
    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
1126
        var didSave = false
1127

            
1128
        context.performAndWait {
1129
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
1130
                return
1131
            }
1132

            
1133
            let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
1134
            let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
1135
            let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
1136

            
1137
            var impactedChargedDeviceIDs = Set<String>()
1138

            
1139
            for session in deviceSessions {
1140
                if let impactedID = stringValue(session, key: "chargedDeviceID") {
1141
                    impactedChargedDeviceIDs.insert(impactedID)
1142
                }
1143
                if let impactedChargerID = stringValue(session, key: "chargerID") {
1144
                    impactedChargedDeviceIDs.insert(impactedChargerID)
1145
                }
1146
                if let sessionID = stringValue(session, key: "id") {
1147
                    fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete)
1148
                    fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete)
1149
                }
1150
                context.delete(session)
1151
            }
1152

            
1153
            if deviceClass == .charger {
1154
                for session in linkedWirelessSessions {
1155
                    guard stringValue(session, key: "chargedDeviceID") != chargedDeviceID.uuidString else {
1156
                        continue
1157
                    }
1158
                    if let impactedID = stringValue(session, key: "chargedDeviceID") {
1159
                        impactedChargedDeviceIDs.insert(impactedID)
1160
                    }
1161
                    session.setValue(nil, forKey: "chargerID")
1162
                    session.setValue(Date(), forKey: "updatedAt")
1163
                }
1164
            }
1165

            
1166
            context.delete(chargedDevice)
1167

            
1168
            guard saveContext() else {
1169
                return
1170
            }
1171

            
1172
            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
1173
            for impactedID in impactedChargedDeviceIDs {
1174
                refreshDerivedMetrics(forChargedDeviceID: impactedID)
1175
            }
1176
            didSave = saveContext()
1177
        }
1178

            
1179
        return didSave
1180
    }
1181

            
1182
    @discardableResult
1183
    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
1184
        var didSave = false
1185

            
1186
        context.performAndWait {
Bogdan Timofte authored a month ago
1187
            guard let session = fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress),
1188
                  let resolvedDevice = stringValue(session, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:)) else {
1189
                return
1190
            }
Bogdan Timofte authored a month ago
1191

            
Bogdan Timofte authored a month ago
1192
            if statusValue(session, key: "statusRawValue") == .paused {
Bogdan Timofte authored a month ago
1193
                if maybeCompleteOpenSession(session, observedAt: snapshot.observedAt) {
Bogdan Timofte authored a month ago
1194
                    didSave = true
1195
                }
Bogdan Timofte authored a month ago
1196
                return
1197
            }
1198

            
Bogdan Timofte authored a month ago
1199
            let chargingTransportMode = self.chargingTransportMode(for: session)
1200
            let chargingStateMode = self.chargingStateMode(for: session)
Bogdan Timofte authored a month ago
1201
            let charger = chargingTransportMode == .wireless
Bogdan Timofte authored a month ago
1202
                ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
1203
                : nil
1204
            guard chargingTransportMode == .wired || charger != nil else {
1205
                return
1206
            }
1207
            let stopThreshold = resolvedStopThreshold(
1208
                for: resolvedDevice,
1209
                chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
1210
                chargingStateMode: chargingStateMode,
1211
                charger: charger,
1212
                fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1213
            )
1214

            
Bogdan Timofte authored a month ago
1215
            let sessionSnapshot = snapshotClampedToMaximumDuration(snapshot, for: session)
1216
            update(session: session, with: sessionSnapshot, stopThreshold: stopThreshold, charger: charger)
1217
            let aggregatedSample = updateAggregatedSample(session: session, with: sessionSnapshot)
1218
            if let completionDate = automaticCompletionDate(for: session, referenceDate: snapshot.observedAt),
1219
               statusValue(session, key: "statusRawValue")?.isOpen == true {
1220
                finishSession(
1221
                    session,
1222
                    observedAt: completionDate,
1223
                    finalBatteryPercent: nil,
1224
                    status: .completed
1225
                )
1226
            }
Bogdan Timofte authored a month ago
1227

            
Bogdan Timofte authored a month ago
1228
            let saveReason = saveReason(for: session, observedAt: sessionSnapshot.observedAt)
Bogdan Timofte authored a month ago
1229
            let shouldPersistAggregatedCurve = aggregatedSample.map {
Bogdan Timofte authored a month ago
1230
                shouldPersistAggregatedSample($0, observedAt: sessionSnapshot.observedAt)
Bogdan Timofte authored a month ago
1231
            } ?? false
1232

            
1233
            guard saveReason != .none || shouldPersistAggregatedCurve else {
Bogdan Timofte authored a month ago
1234
                return
1235
            }
1236

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

            
1239
            if saveContext() {
1240
                if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
1241
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
1242
                    didSave = saveContext()
1243
                } else {
1244
                    didSave = true
1245
                }
1246
            }
1247
        }
1248

            
1249
        return didSave
1250
    }
1251

            
1252
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
1253
        var summaries: [ChargedDeviceSummary] = []
1254

            
1255
        context.performAndWait {
1256
            let devices = fetchObjects(entityName: EntityName.chargedDevice)
1257
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
1258
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
1259

            
1260
            let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
1261
            let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
1262
            let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
Bogdan Timofte authored a month ago
1263
            let sampleBackedSessionIDs = sampleBackedSessionIDs(
1264
                devices: devices,
1265
                sessionsByDeviceID: sessionsByDeviceID,
1266
                sessionsByChargerID: sessionsByChargerID
1267
            )
1268
            let samplesBySessionID = Dictionary(
1269
                grouping: fetchSessionSampleObjects(forSessionIDs: Array(sampleBackedSessionIDs))
1270
            ) { stringValue($0, key: "sessionID") ?? "" }
Bogdan Timofte authored a month ago
1271

            
1272
            summaries = devices.compactMap { device in
1273
                guard
1274
                    let id = uuidValue(device, key: "id"),
1275
                    let name = stringValue(device, key: "name"),
1276
                    let qrIdentifier = stringValue(device, key: "qrIdentifier"),
1277
                    let rawClass = stringValue(device, key: "deviceClassRawValue"),
1278
                    let deviceClass = ChargedDeviceClass(rawValue: rawClass)
1279
                else {
1280
                    return nil
1281
                }
1282

            
Bogdan Timofte authored a month ago
1283
                let chargingStateAvailability = chargingStateAvailability(for: device)
1284
                let supportsWiredCharging = supportsWiredCharging(for: device)
1285
                let supportsWirelessCharging = supportsWirelessCharging(for: device)
1286
                let templateDefinition = templateDefinition(for: device)
1287

            
Bogdan Timofte authored a month ago
1288
                let sessionObjects = relevantSessionObjects(
1289
                    for: id.uuidString,
1290
                    deviceClass: deviceClass,
1291
                    sessionsByDeviceID: sessionsByDeviceID,
1292
                    sessionsByChargerID: sessionsByChargerID
1293
                )
1294
                let sessionSummaries = sessionObjects
1295
                    .compactMap { session in
1296
                        makeSessionSummary(
1297
                            from: session,
1298
                            checkpoints: checkpointsBySessionID[stringValue(session, key: "id") ?? ""] ?? [],
1299
                            samples: samplesBySessionID[stringValue(session, key: "id") ?? ""] ?? []
1300
                        )
1301
                    }
1302
                    .sorted { lhs, rhs in
Bogdan Timofte authored a month ago
1303
                        if lhs.status.isOpen && !rhs.status.isOpen {
Bogdan Timofte authored a month ago
1304
                            return true
1305
                        }
Bogdan Timofte authored a month ago
1306
                        if !lhs.status.isOpen && rhs.status.isOpen {
1307
                            return false
1308
                        }
1309
                        if lhs.status == .active && rhs.status == .paused {
1310
                            return true
1311
                        }
1312
                        if lhs.status == .paused && rhs.status == .active {
Bogdan Timofte authored a month ago
1313
                            return false
1314
                        }
1315
                        return lhs.startedAt > rhs.startedAt
1316
                    }
1317

            
1318
                return ChargedDeviceSummary(
1319
                    id: id,
1320
                    qrIdentifier: qrIdentifier,
1321
                    name: name,
1322
                    deviceClass: deviceClass,
Bogdan Timofte authored a month ago
1323
                    deviceTemplateID: stringValue(device, key: "deviceTemplateID"),
1324
                    templateDefinition: templateDefinition,
1325
                    supportsChargingWhileOff: chargingStateAvailability.supportsChargingWhileOff,
1326
                    chargingStateAvailability: chargingStateAvailability,
1327
                    supportsWiredCharging: supportsWiredCharging,
1328
                    supportsWirelessCharging: supportsWirelessCharging,
Bogdan Timofte authored a month ago
1329
                    chargerType: chargerType(for: device),
Bogdan Timofte authored a month ago
1330
                    wirelessChargingProfile: wirelessChargingProfile(for: device),
Bogdan Timofte authored a month ago
1331
                    configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"),
1332
                    learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"),
Bogdan Timofte authored a month ago
1333
                    wirelessChargerEfficiencyFactor: optionalDoubleValue(device, key: "wirelessChargerEfficiencyFactor"),
1334
                    wiredChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wiredChargeCompletionCurrentAmps"),
1335
                    wirelessChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wirelessChargeCompletionCurrentAmps"),
1336
                    chargerObservedVoltageSelections: decodedObservedVoltageSelections(from: device),
1337
                    chargerIdleCurrentAmps: optionalDoubleValue(device, key: "chargerIdleCurrentAmps"),
1338
                    chargerEfficiencyFactor: optionalDoubleValue(device, key: "chargerEfficiencyFactor"),
1339
                    chargerMaximumPowerWatts: optionalDoubleValue(device, key: "chargerMaximumPowerWatts"),
1340
                    notes: stringValue(device, key: "notes"),
1341
                    minimumCurrentAmps: optionalDoubleValue(device, key: "minimumCurrentAmps"),
1342
                    estimatedBatteryCapacityWh: optionalDoubleValue(device, key: "estimatedBatteryCapacityWh"),
1343
                    wiredMinimumCurrentAmps: optionalDoubleValue(device, key: "wiredMinimumCurrentAmps"),
1344
                    wirelessMinimumCurrentAmps: optionalDoubleValue(device, key: "wirelessMinimumCurrentAmps"),
1345
                    wiredEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wiredEstimatedBatteryCapacityWh"),
1346
                    wirelessEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wirelessEstimatedBatteryCapacityWh"),
1347
                    lastAssociatedMeterMAC: stringValue(device, key: "lastAssociatedMeterMAC"),
1348
                    createdAt: dateValue(device, key: "createdAt") ?? .distantPast,
1349
                    updatedAt: dateValue(device, key: "updatedAt") ?? .distantPast,
1350
                    sessions: sessionSummaries,
1351
                    capacityHistory: buildCapacityHistory(from: sessionSummaries),
Bogdan Timofte authored a month ago
1352
                    typicalCurve: buildTypicalCurve(from: sessionSummaries),
1353
                    standbyPowerMeasurements: []
Bogdan Timofte authored a month ago
1354
                )
1355
            }
1356
            .sorted { lhs, rhs in
1357
                if lhs.activeSession != nil && rhs.activeSession == nil {
1358
                    return true
1359
                }
1360
                if lhs.activeSession == nil && rhs.activeSession != nil {
1361
                    return false
1362
                }
1363
                if lhs.updatedAt != rhs.updatedAt {
1364
                    return lhs.updatedAt > rhs.updatedAt
1365
                }
1366
                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
1367
            }
1368
        }
1369

            
1370
        return summaries
1371
    }
1372

            
1373
    func resolvedChargedDeviceSummary(forMeterMACAddress meterMACAddress: String) -> ChargedDeviceSummary? {
1374
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1375
        guard !normalizedMAC.isEmpty else { return nil }
1376

            
1377
        let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
1378

            
1379
        if let activeMatch = summaries.first(where: { summary in
1380
            summary.activeSession?.meterMACAddress == normalizedMAC
1381
        }) {
1382
            return activeMatch
1383
        }
1384

            
1385
        return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
1386
    }
1387

            
1388
    func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
1389
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1390
        guard !normalizedMAC.isEmpty else { return nil }
1391

            
Bogdan Timofte authored a month ago
1392
        var summary: ChargeSessionSummary?
1393

            
1394
        context.performAndWait {
Bogdan Timofte authored a month ago
1395
            guard let session = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
Bogdan Timofte authored a month ago
1396
                  let sessionID = stringValue(session, key: "id") else {
1397
                return
1398
            }
1399

            
1400
            summary = makeSessionSummary(
1401
                from: session,
1402
                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
1403
                samples: fetchSessionSampleObjects(forSessionID: sessionID)
1404
            )
1405
        }
1406

            
1407
        return summary
Bogdan Timofte authored a month ago
1408
    }
1409

            
1410
    private func createSessionObject(
1411
        for chargedDevice: NSManagedObject,
1412
        charger: NSManagedObject?,
1413
        snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1414
        stopThreshold: Double?,
1415
        chargingTransportMode: ChargingTransportMode,
1416
        chargingStateMode: ChargingStateMode,
1417
        autoStopEnabled: Bool
Bogdan Timofte authored a month ago
1418
    ) -> NSManagedObject? {
1419
        guard
1420
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
1421
            let chargedDeviceID = stringValue(chargedDevice, key: "id")
1422
        else {
1423
            return nil
1424
        }
1425

            
1426
        let session = NSManagedObject(entity: entity, insertInto: context)
1427
        let now = snapshot.observedAt
1428
        session.setValue(UUID().uuidString, forKey: "id")
1429
        session.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1430
        session.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
1431
        session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress")
1432
        session.setValue(snapshot.meterName, forKey: "meterName")
1433
        session.setValue(snapshot.meterModel, forKey: "meterModel")
1434
        session.setValue(now, forKey: "startedAt")
1435
        session.setValue(now, forKey: "lastObservedAt")
1436
        session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
Bogdan Timofte authored a month ago
1437
        let usesMeterCounters = snapshot.meterEnergyCounterWh != nil || snapshot.meterChargeCounterAh != nil
1438
        session.setValue(
1439
            (usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue,
1440
            forKey: "sourceModeRawValue"
1441
        )
Bogdan Timofte authored a month ago
1442
        session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
1443
        session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue")
1444
        session.setValue(autoStopEnabled, forKey: "autoStopEnabled")
1445
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1446
        session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
1447
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1448
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1449
        session.setValue(
1450
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1451
            forKey: "lastObservedVoltageVolts"
1452
        )
Bogdan Timofte authored a month ago
1453
        session.setValue(
1454
            hasObservedChargeFlow(
1455
                currentAmps: snapshot.currentAmps,
1456
                chargingTransportMode: chargingTransportMode,
1457
                charger: charger,
1458
                stopThreshold: stopThreshold
1459
            ),
1460
            forKey: "hasObservedChargeFlow"
1461
        )
Bogdan Timofte authored a month ago
1462
        session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
1463
        session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
1464
        session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
1465
        session.setValue(
1466
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1467
            forKey: "maximumObservedVoltageVolts"
1468
        )
1469
        session.setValue(boolValue(chargedDevice, key: "supportsChargingWhileOff"), forKey: "supportsChargingWhileOff")
1470
        if let selectedDataGroup = snapshot.selectedDataGroup {
1471
            session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup")
1472
        }
1473
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1474
            session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1475
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1476
        }
1477
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1478
            session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1479
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1480
        }
Bogdan Timofte authored a month ago
1481
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1482
            setValue(meterRecordingDurationSeconds, on: session, key: "meterDurationBaselineSeconds")
1483
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1484
        }
Bogdan Timofte authored a month ago
1485
        session.setValue(now, forKey: "createdAt")
1486
        session.setValue(now, forKey: "updatedAt")
1487

            
1488
        chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
1489
        chargedDevice.setValue(now, forKey: "updatedAt")
1490
        return session
1491
    }
1492

            
1493
    private func update(
1494
        session: NSManagedObject,
1495
        with snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1496
        stopThreshold: Double?,
1497
        charger: NSManagedObject?
Bogdan Timofte authored a month ago
1498
    ) {
1499
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1500
        let lastObservedAt = dateValue(session, key: "lastObservedAt")
1501
        let previousPower = doubleValue(session, key: "lastObservedPowerWatts")
1502
        let previousCurrent = doubleValue(session, key: "lastObservedCurrentAmps")
1503
        var measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1504
        var measuredChargeAh = doubleValue(session, key: "measuredChargeAh")
1505
        var sourceMode = ChargeSessionSourceMode(rawValue: stringValue(session, key: "sourceModeRawValue") ?? "") ?? .live
1506
        var usedOfflineMeterCounters = boolValue(session, key: "usedOfflineMeterCounters")
1507

            
1508
        if let lastObservedAt {
1509
            let deltaSeconds = max(snapshot.observedAt.timeIntervalSince(lastObservedAt), 0)
1510
            if deltaSeconds > 0, deltaSeconds <= maximumLiveIntegrationGap {
1511
                measuredEnergyWh += max(previousPower, 0) * deltaSeconds / 3600
1512
                measuredChargeAh += max(previousCurrent, 0) * deltaSeconds / 3600
1513
                if sourceMode == .offline {
1514
                    sourceMode = .blended
1515
                }
1516
            }
1517
        }
1518

            
1519
        if let counterGroup = snapshot.selectedDataGroup,
1520
           let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
1521
           UInt8(storedGroup) != counterGroup {
1522
            session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
1523
            session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1524
            session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1525
        }
1526

            
1527
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1528
            let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
1529
            let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
1530
            if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
1531
                session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
1532
            }
1533

            
1534
            if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
1535
                let offlineEnergy = meterEnergyCounterWh - baselineEnergy
Bogdan Timofte authored a month ago
1536
                measuredEnergyWh = max(offlineEnergy, 0)
Bogdan Timofte authored a month ago
1537
                usedOfflineMeterCounters = true
Bogdan Timofte authored a month ago
1538
                sourceMode = .offline
Bogdan Timofte authored a month ago
1539
            } else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
1540
                let delta = meterEnergyCounterWh - lastEnergy
1541
                if delta > 0 {
1542
                    measuredEnergyWh += delta
1543
                    usedOfflineMeterCounters = true
1544
                    sourceMode = .blended
1545
                }
1546
            }
1547
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1548
        }
1549

            
1550
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1551
            let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
1552
            let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
1553
            if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
1554
                session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
1555
            }
1556

            
1557
            if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
1558
                let offlineCharge = meterChargeCounterAh - baselineCharge
Bogdan Timofte authored a month ago
1559
                measuredChargeAh = max(offlineCharge, 0)
Bogdan Timofte authored a month ago
1560
                usedOfflineMeterCounters = true
1561
            } else if let lastCharge, meterChargeCounterAh > lastCharge {
1562
                let delta = meterChargeCounterAh - lastCharge
1563
                if delta > 0 {
1564
                    measuredChargeAh += delta
1565
                    usedOfflineMeterCounters = true
1566
                }
1567
            }
1568
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1569
        }
1570

            
Bogdan Timofte authored a month ago
1571
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1572
            let baselineDuration = optionalDoubleValue(session, key: "meterDurationBaselineSeconds") ?? meterRecordingDurationSeconds
1573
            if optionalDoubleValue(session, key: "meterDurationBaselineSeconds") == nil {
1574
                setValue(baselineDuration, on: session, key: "meterDurationBaselineSeconds")
1575
            }
1576
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1577
        }
1578

            
Bogdan Timofte authored a month ago
1579
        let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps")
1580
        let updatedMinimum: Double
1581
        if snapshot.currentAmps > 0 {
1582
            updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps)
1583
        } else {
1584
            updatedMinimum = existingMinimum ?? 0
1585
        }
1586

            
Bogdan Timofte authored a month ago
1587
        let effectiveCurrent = effectiveCurrentAmps(
1588
            fromMeasuredCurrent: snapshot.currentAmps,
1589
            chargingTransportMode: sessionChargingTransportMode,
1590
            charger: charger
1591
        )
1592
        let observedChargeFlow = boolValue(session, key: "hasObservedChargeFlow")
1593
            || hasObservedChargeFlow(
1594
                currentAmps: snapshot.currentAmps,
1595
                chargingTransportMode: sessionChargingTransportMode,
1596
                charger: charger,
1597
                stopThreshold: stopThreshold
1598
            )
1599

            
Bogdan Timofte authored a month ago
1600
        session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh")
1601
        session.setValue(measuredChargeAh, forKey: "measuredChargeAh")
1602
        session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps")
Bogdan Timofte authored a month ago
1603
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1604
        session.setValue(snapshot.observedAt, forKey: "lastObservedAt")
1605
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1606
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1607
        session.setValue(
1608
            sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1609
            forKey: "lastObservedVoltageVolts"
1610
        )
Bogdan Timofte authored a month ago
1611
        session.setValue(observedChargeFlow, forKey: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
1612
        session.setValue(
1613
            max(optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? snapshot.currentAmps, snapshot.currentAmps),
1614
            forKey: "maximumObservedCurrentAmps"
1615
        )
1616
        session.setValue(
1617
            max(optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? snapshot.powerWatts, snapshot.powerWatts),
1618
            forKey: "maximumObservedPowerWatts"
1619
        )
1620
        session.setValue(
1621
            sessionChargingTransportMode == .wired
1622
                ? max(optionalDoubleValue(session, key: "maximumObservedVoltageVolts") ?? snapshot.voltageVolts, snapshot.voltageVolts)
1623
                : nil,
1624
            forKey: "maximumObservedVoltageVolts"
1625
        )
1626
        session.setValue(usedOfflineMeterCounters, forKey: "usedOfflineMeterCounters")
1627
        session.setValue(sourceMode.rawValue, forKey: "sourceModeRawValue")
1628
        maybeTriggerTargetBatteryAlert(for: session, observedAt: snapshot.observedAt)
1629

            
Bogdan Timofte authored a month ago
1630
        guard boolValue(session, key: "autoStopEnabled"), let stopThreshold, stopThreshold > 0, observedChargeFlow else {
1631
            session.setValue(nil, forKey: "belowThresholdSince")
1632
            clearCompletionConfirmationState(for: session)
1633
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1634
            return
1635
        }
1636

            
1637
        if effectiveCurrent <= stopThreshold {
Bogdan Timofte authored a month ago
1638
            let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt
1639
            session.setValue(belowThresholdSince, forKey: "belowThresholdSince")
1640
            if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
1641
                if boolValue(session, key: "requiresCompletionConfirmation") {
1642
                    // Leave the session active until the user explicitly confirms or charging resumes.
1643
                    return
1644
                }
1645

            
1646
                if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
1647
                    requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt)
1648
                } else {
Bogdan Timofte authored a month ago
1649
                    finishSession(
1650
                        session,
1651
                        observedAt: snapshot.observedAt,
1652
                        finalBatteryPercent: nil,
1653
                        status: .completed
1654
                    )
Bogdan Timofte authored a month ago
1655
                }
1656
            }
1657
        } else {
1658
            session.setValue(nil, forKey: "belowThresholdSince")
1659
            clearCompletionConfirmationState(for: session)
1660
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1661
        }
1662
    }
1663

            
1664
    private func updateAggregatedSample(
1665
        session: NSManagedObject,
1666
        with snapshot: ChargingMonitorSnapshot
Bogdan Timofte authored a month ago
1667
    ) -> NSManagedObject? {
Bogdan Timofte authored a month ago
1668
        guard
1669
            let sessionID = stringValue(session, key: "id"),
1670
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1671
            let startedAt = dateValue(session, key: "startedAt"),
1672
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context)
1673
        else {
Bogdan Timofte authored a month ago
1674
            return nil
Bogdan Timofte authored a month ago
1675
        }
1676

            
1677
        let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0)
1678
        let bucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
1679
        let bucketStart = startedAt.addingTimeInterval(Double(bucketIndex) * Self.aggregatedSampleBucketDuration)
1680
        let bucketIdentifier = "\(sessionID)-\(bucketIndex)"
1681
        let sample = fetchSessionSampleObject(sessionID: sessionID, bucketIndex: bucketIndex)
1682
            ?? NSManagedObject(entity: entity, insertInto: context)
1683
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1684
        let sampleVoltage = sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil
1685

            
1686
        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
1687
        let updatedCount = existingCount + 1
1688

            
1689
        sample.setValue(bucketIdentifier, forKey: "id")
1690
        sample.setValue(sessionID, forKey: "sessionID")
1691
        sample.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1692
        sample.setValue(bucketIndex, forKey: "bucketIndex")
1693
        sample.setValue(max(snapshot.observedAt, bucketStart), forKey: "timestamp")
1694
        sample.setValue(
1695
            runningAverage(
1696
                currentAverage: optionalDoubleValue(sample, key: "averageCurrentAmps") ?? snapshot.currentAmps,
1697
                currentCount: Int(existingCount),
1698
                newValue: snapshot.currentAmps
1699
            ),
1700
            forKey: "averageCurrentAmps"
1701
        )
1702
        sample.setValue(
1703
            sampleVoltage.flatMap { voltage in
1704
                runningAverage(
1705
                    currentAverage: optionalDoubleValue(sample, key: "averageVoltageVolts") ?? voltage,
1706
                    currentCount: Int(existingCount),
1707
                    newValue: voltage
1708
                )
1709
            },
1710
            forKey: "averageVoltageVolts"
1711
        )
1712
        sample.setValue(
1713
            runningAverage(
1714
                currentAverage: optionalDoubleValue(sample, key: "averagePowerWatts") ?? snapshot.powerWatts,
1715
                currentCount: Int(existingCount),
1716
                newValue: snapshot.powerWatts
1717
            ),
1718
            forKey: "averagePowerWatts"
1719
        )
1720
        sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh")
1721
        sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
1722
        sample.setValue(Int16(updatedCount), forKey: "sampleCount")
1723
        sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
1724
        sample.setValue(snapshot.observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1725
        return sample
Bogdan Timofte authored a month ago
1726
    }
1727

            
Bogdan Timofte authored a month ago
1728
    private func maybeTriggerTargetBatteryAlert(
1729
        for session: NSManagedObject,
1730
        observedAt: Date,
1731
        completionFallbackPercent: Double? = nil
1732
    ) {
Bogdan Timofte authored a month ago
1733
        guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
1734
            return
1735
        }
1736

            
1737
        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
1738
            return
1739
        }
1740

            
1741
        let predictedBatteryPercent = predictedBatteryPercent(for: session)
1742
            ?? optionalDoubleValue(session, key: "endBatteryPercent")
Bogdan Timofte authored a month ago
1743
            ?? completionFallbackPercent
Bogdan Timofte authored a month ago
1744

            
1745
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
1746
            return
1747
        }
1748

            
1749
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
1750
    }
1751

            
1752
    private func shouldRequireCompletionConfirmation(
1753
        for session: NSManagedObject,
1754
        observedAt: Date
1755
    ) -> Bool {
1756
        if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"),
1757
           cooldownUntil > observedAt {
1758
            return false
1759
        }
1760

            
1761
        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
1762
            return false
1763
        }
1764

            
1765
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
1766
            ?? defaultCompletionPercentThreshold
1767

            
1768
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1769
    }
1770

            
1771
    private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
1772
        guard !boolValue(session, key: "requiresCompletionConfirmation") else {
1773
            return
1774
        }
1775

            
1776
        session.setValue(true, forKey: "requiresCompletionConfirmation")
1777
        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
1778
        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
1779
    }
1780

            
1781
    private func clearCompletionConfirmationState(for session: NSManagedObject) {
1782
        session.setValue(false, forKey: "requiresCompletionConfirmation")
1783
        session.setValue(nil, forKey: "completionConfirmationRequestedAt")
1784
        session.setValue(nil, forKey: "completionContradictionPercent")
1785
    }
1786

            
Bogdan Timofte authored a month ago
1787
    private func snapshotDateForManualStop(_ session: NSManagedObject) -> Date {
1788
        if statusValue(session, key: "statusRawValue") == .paused {
1789
            return dateValue(session, key: "pausedAt")
1790
                ?? dateValue(session, key: "lastObservedAt")
1791
                ?? Date()
1792
        }
1793
        return dateValue(session, key: "lastObservedAt") ?? Date()
1794
    }
1795

            
Bogdan Timofte authored a month ago
1796
    private func snapshotClampedToMaximumDuration(
1797
        _ snapshot: ChargingMonitorSnapshot,
1798
        for session: NSManagedObject
1799
    ) -> ChargingMonitorSnapshot {
1800
        guard let maximumEndDate = maximumEndDate(for: session),
1801
              snapshot.observedAt > maximumEndDate else {
1802
            return snapshot
1803
        }
1804

            
1805
        return ChargingMonitorSnapshot(
1806
            meterMACAddress: snapshot.meterMACAddress,
1807
            meterName: snapshot.meterName,
1808
            meterModel: snapshot.meterModel,
1809
            observedAt: maximumEndDate,
1810
            voltageVolts: snapshot.voltageVolts,
1811
            currentAmps: snapshot.currentAmps,
1812
            powerWatts: snapshot.powerWatts,
1813
            selectedDataGroup: snapshot.selectedDataGroup,
1814
            meterChargeCounterAh: snapshot.meterChargeCounterAh,
1815
            meterEnergyCounterWh: snapshot.meterEnergyCounterWh,
1816
            meterRecordingDurationSeconds: snapshot.meterRecordingDurationSeconds,
1817
            fallbackStopThresholdAmps: snapshot.fallbackStopThresholdAmps
1818
        )
1819
    }
1820

            
1821
    private func automaticCompletionDate(
1822
        for session: NSManagedObject,
1823
        referenceDate: Date
1824
    ) -> Date? {
1825
        guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
1826
            return nil
Bogdan Timofte authored a month ago
1827
        }
1828

            
Bogdan Timofte authored a month ago
1829
        var completionDates: [Date] = []
1830

            
1831
        if let maximumEndDate = maximumEndDate(for: session) {
1832
            completionDates.append(maximumEndDate)
1833
        }
1834

            
1835
        if statusValue(session, key: "statusRawValue") == .paused,
1836
           let pausedAt = dateValue(session, key: "pausedAt") {
1837
            completionDates.append(pausedAt.addingTimeInterval(pausedSessionTimeout))
1838
        }
1839

            
1840
        guard let completionDate = completionDates.min(),
1841
              referenceDate >= completionDate else {
1842
            return nil
1843
        }
1844

            
1845
        return completionDate
1846
    }
1847

            
1848
    private func maximumEndDate(for session: NSManagedObject) -> Date? {
1849
        dateValue(session, key: "startedAt")?.addingTimeInterval(Self.maximumChargeSessionDuration)
1850
    }
1851

            
1852
    @discardableResult
1853
    private func maybeCompleteOpenSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
1854
        guard statusValue(session, key: "statusRawValue")?.isOpen == true,
1855
              let completionDate = automaticCompletionDate(for: session, referenceDate: observedAt) else {
Bogdan Timofte authored a month ago
1856
            return false
1857
        }
1858

            
1859
        finishSession(
1860
            session,
Bogdan Timofte authored a month ago
1861
            observedAt: completionDate,
Bogdan Timofte authored a month ago
1862
            finalBatteryPercent: nil,
1863
            status: .completed
1864
        )
1865

            
1866
        guard saveContext() else {
1867
            return false
1868
        }
1869

            
1870
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1871
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1872
            return saveContext()
1873
        }
1874

            
1875
        return true
1876
    }
1877

            
1878
    private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
1879
        let chargingTransportMode = chargingTransportMode(for: session)
1880
        let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps")
1881
            ?? doubleValue(session, key: "lastObservedCurrentAmps")
1882

            
1883
        guard measuredCurrent > 0 else {
1884
            return nil
1885
        }
1886

            
1887
        let charger = chargingTransportMode == .wireless
1888
            ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
1889
            : nil
1890

            
1891
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
1892
            return nil
1893
        }
1894

            
1895
        let effectiveCurrent = effectiveCurrentAmps(
1896
            fromMeasuredCurrent: measuredCurrent,
1897
            chargingTransportMode: chargingTransportMode,
1898
            charger: charger
1899
        )
1900
        guard effectiveCurrent > 0 else {
1901
            return nil
1902
        }
1903
        return effectiveCurrent
1904
    }
1905

            
1906
    private func finishSession(
1907
        _ session: NSManagedObject,
1908
        observedAt: Date,
1909
        finalBatteryPercent: Double?,
1910
        status: ChargeSessionStatus
1911
    ) {
1912
        if let finalBatteryPercent {
1913
            _ = insertBatteryCheckpoint(
1914
                percent: finalBatteryPercent,
Bogdan Timofte authored a month ago
1915
                flag: .final,
Bogdan Timofte authored a month ago
1916
                timestamp: observedAt,
1917
                to: session
1918
            )
1919
        }
1920

            
1921
        session.setValue(status.rawValue, forKey: "statusRawValue")
1922
        session.setValue(nil, forKey: "pausedAt")
1923
        session.setValue(nil, forKey: "belowThresholdSince")
1924
        session.setValue(observedAt, forKey: "endedAt")
1925
        session.setValue(observedAt, forKey: "lastObservedAt")
1926
        session.setValue(completionCurrentForSessionEnd(session), forKey: "completionCurrentAmps")
1927
        clearCompletionConfirmationState(for: session)
1928
        session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1929
        updateCapacityEstimate(for: session)
1930
        session.setValue(observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1931

            
1932
        if status == .completed {
1933
            maybeTriggerTargetBatteryAlert(
1934
                for: session,
1935
                observedAt: observedAt,
1936
                completionFallbackPercent: defaultCompletionPercentThreshold
1937
            )
1938
        }
Bogdan Timofte authored a month ago
1939
    }
1940

            
Bogdan Timofte authored a month ago
1941
    private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
1942
        guard
1943
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1944
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
1945
            let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice),
1946
            estimatedCapacityWh > 0
1947
        else {
1948
            return nil
1949
        }
1950

            
Bogdan Timofte authored a month ago
1951
        // Compute effective battery energy dynamically so the prediction uses the
1952
        // most current measuredEnergyWh rather than the stale effectiveBatteryEnergyWh
1953
        // (which is only refreshed at session start, checkpoint insertion, and finish).
1954
        let rawMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1955
        let measuredEnergyWh: Double
1956
        switch chargingTransportMode(for: session) {
1957
        case .wired:
1958
            measuredEnergyWh = rawMeasuredEnergyWh
1959
        case .wireless:
1960
            if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 {
1961
                measuredEnergyWh = rawMeasuredEnergyWh * factor
1962
            } else {
1963
                measuredEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1964
                    ?? rawMeasuredEnergyWh
1965
            }
1966
        }
Bogdan Timofte authored a month ago
1967
        let sessionID = stringValue(session, key: "id") ?? ""
1968

            
1969
        struct Anchor {
1970
            let percent: Double
1971
            let energyWh: Double
Bogdan Timofte authored a month ago
1972
            let timestamp: Date
1973
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1974
        }
1975

            
1976
        var anchors: [Anchor] = []
Bogdan Timofte authored a month ago
1977
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1978
           startBatteryPercent >= 0 {
1979
            anchors.append(
1980
                Anchor(
1981
                    percent: startBatteryPercent,
1982
                    energyWh: 0,
Bogdan Timofte authored a month ago
1983
                    timestamp: dateValue(session, key: "trimStart")
1984
                        ?? dateValue(session, key: "startedAt")
1985
                        ?? Date.distantPast,
Bogdan Timofte authored a month ago
1986
                    isCheckpoint: false
1987
                )
1988
            )
Bogdan Timofte authored a month ago
1989
        }
1990

            
1991
        let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
1992
            .compactMap(makeCheckpointSummary(from:))
1993
            .sorted { lhs, rhs in
1994
                if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1995
                    return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1996
                }
1997
                return lhs.timestamp < rhs.timestamp
1998
            }
Bogdan Timofte authored a month ago
1999
            .filter { $0.batteryPercent >= 0 }
2000
            .map {
2001
                Anchor(
2002
                    percent: $0.batteryPercent,
2003
                    energyWh: $0.measuredEnergyWh,
2004
                    timestamp: $0.timestamp,
2005
                    isCheckpoint: true
2006
                )
2007
            }
Bogdan Timofte authored a month ago
2008
        anchors.append(contentsOf: checkpointAnchors)
2009

            
2010
        guard !anchors.isEmpty else {
2011
            return optionalDoubleValue(session, key: "endBatteryPercent")
2012
        }
2013

            
2014
        let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
Bogdan Timofte authored a month ago
2015
        return BatteryLevelPredictionTuning.predictedPercent(
2016
            anchorPercent: anchor.percent,
2017
            anchorEnergyWh: anchor.energyWh,
2018
            anchorTimestamp: anchor.timestamp,
2019
            anchorIsCheckpoint: anchor.isCheckpoint,
2020
            effectiveEnergyWh: measuredEnergyWh,
2021
            referenceTimestamp: dateValue(session, key: "lastObservedAt") ?? anchor.timestamp,
2022
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
2023
        )
2024
    }
2025

            
2026
    private func resolvedEstimatedBatteryCapacityWh(
2027
        for session: NSManagedObject,
2028
        chargedDevice: NSManagedObject
2029
    ) -> Double? {
2030
        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
2031
           sessionCapacityEstimate > 0 {
2032
            return sessionCapacityEstimate
2033
        }
2034

            
2035
        switch chargingTransportMode(for: session) {
2036
        case .wired:
2037
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
2038
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2039
        case .wireless:
2040
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
2041
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2042
        }
2043
    }
2044

            
2045
    private func updateCapacityEstimate(for session: NSManagedObject) {
2046
        guard
2047
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2048
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
2049
        else {
2050
            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
2051
            session.setValue(nil, forKey: "capacityEstimateWh")
2052
            return
2053
        }
2054

            
2055
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2056
        let chargingMode = chargingTransportMode(for: session)
2057
        let wirelessResolution = chargingMode == .wireless
2058
            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
2059
            : nil
2060
        let effectiveBatteryEnergyWh = chargingMode == .wired
2061
            ? measuredEnergyWh
2062
            : wirelessResolution.map { measuredEnergyWh * $0.factor }
2063

            
2064
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
2065
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
2066
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
2067
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
2068

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

            
2071
        guard let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
Bogdan Timofte authored a month ago
2072
            session.setValue(nil, forKey: "capacityEstimateWh")
2073
            return
2074
        }
2075

            
Bogdan Timofte authored a month ago
2076
        struct CapacityAnchor {
2077
            let percent: Double
2078
            let energyWh: Double
2079
            let timestamp: Date
2080
        }
2081

            
2082
        var anchors: [CapacityAnchor] = []
2083

            
2084
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2085
           startBatteryPercent >= 0 {
2086
            anchors.append(
2087
                CapacityAnchor(
2088
                    percent: startBatteryPercent,
2089
                    energyWh: 0,
2090
                    timestamp: dateValue(session, key: "trimStart")
2091
                        ?? dateValue(session, key: "startedAt")
2092
                        ?? Date.distantPast
2093
                )
2094
            )
2095
        }
2096

            
2097
        if let sessionID = stringValue(session, key: "id") {
2098
            anchors.append(
2099
                contentsOf: fetchCheckpointObjects(forSessionID: sessionID).compactMap { checkpoint in
2100
                    guard
2101
                        let percent = optionalDoubleValue(checkpoint, key: "batteryPercent"),
2102
                        percent >= 0,
2103
                        let timestamp = dateValue(checkpoint, key: "timestamp")
2104
                    else {
2105
                        return nil
2106
                    }
2107

            
2108
                    return CapacityAnchor(
2109
                        percent: percent,
2110
                        energyWh: doubleValue(checkpoint, key: "measuredEnergyWh"),
2111
                        timestamp: timestamp
2112
                    )
2113
                }
2114
            )
2115
        }
2116

            
2117
        if let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"),
2118
           endBatteryPercent >= 0 {
2119
            anchors.append(
2120
                CapacityAnchor(
2121
                    percent: endBatteryPercent,
2122
                    energyWh: effectiveBatteryEnergyWh,
2123
                    timestamp: dateValue(session, key: "endedAt")
2124
                        ?? dateValue(session, key: "lastObservedAt")
2125
                        ?? Date.distantPast
2126
                )
2127
            )
2128
        }
2129

            
2130
        let sortedAnchors = anchors.sorted { lhs, rhs in
2131
            if lhs.energyWh != rhs.energyWh {
2132
                return lhs.energyWh < rhs.energyWh
2133
            }
2134
            return lhs.timestamp < rhs.timestamp
2135
        }
2136

            
2137
        guard let firstAnchor = sortedAnchors.first,
2138
              let lastAnchor = sortedAnchors.last else {
Bogdan Timofte authored a month ago
2139
            session.setValue(nil, forKey: "capacityEstimateWh")
2140
            return
2141
        }
2142

            
Bogdan Timofte authored a month ago
2143
        let percentDelta = lastAnchor.percent - firstAnchor.percent
2144
        let energyDelta = lastAnchor.energyWh - firstAnchor.energyWh
Bogdan Timofte authored a month ago
2145

            
Bogdan Timofte authored a month ago
2146
        guard percentDelta >= 20, energyDelta > 0 else {
Bogdan Timofte authored a month ago
2147
            session.setValue(nil, forKey: "capacityEstimateWh")
2148
            return
2149
        }
2150

            
Bogdan Timofte authored a month ago
2151
        if !supportsChargingWhileOff && lastAnchor.percent >= 99.5 {
Bogdan Timofte authored a month ago
2152
            session.setValue(nil, forKey: "capacityEstimateWh")
2153
            return
2154
        }
2155

            
Bogdan Timofte authored a month ago
2156
        let capacityEstimateWh = energyDelta / (percentDelta / 100)
Bogdan Timofte authored a month ago
2157
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
2158
    }
2159

            
2160
    @discardableResult
Bogdan Timofte authored a month ago
2161
    private func insertBatteryCheckpoint(
Bogdan Timofte authored a month ago
2162
        percent: Double,
Bogdan Timofte authored a month ago
2163
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
2164
        timestamp: Date = Date(),
Bogdan Timofte authored a month ago
2165
        measuredEnergyWhOverride: Double? = nil,
Bogdan Timofte authored a month ago
2166
        to session: NSManagedObject
Bogdan Timofte authored a month ago
2167
    ) -> String? {
Bogdan Timofte authored a month ago
2168
        guard
2169
            let sessionID = stringValue(session, key: "id"),
2170
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2171
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
2172
        else {
Bogdan Timofte authored a month ago
2173
            return nil
Bogdan Timofte authored a month ago
2174
        }
2175

            
2176
        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
Bogdan Timofte authored a month ago
2177
        let checkpointEnergyWh = measuredEnergyWhOverride
2178
            ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
Bogdan Timofte authored a month ago
2179
            ?? doubleValue(session, key: "measuredEnergyWh")
2180
        checkpoint.setValue(UUID().uuidString, forKey: "id")
2181
        checkpoint.setValue(sessionID, forKey: "sessionID")
2182
        checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
Bogdan Timofte authored a month ago
2183
        checkpoint.setValue(timestamp, forKey: "timestamp")
Bogdan Timofte authored a month ago
2184
        checkpoint.setValue(percent, forKey: "batteryPercent")
2185
        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
2186
        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
2187
        checkpoint.setValue(
2188
            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
2189
            forKey: "voltageVolts"
2190
        )
Bogdan Timofte authored a month ago
2191
        checkpoint.setValue(flag.rawValue, forKey: "label")
Bogdan Timofte authored a month ago
2192
        checkpoint.setValue(timestamp, forKey: "createdAt")
Bogdan Timofte authored a month ago
2193

            
Bogdan Timofte authored a month ago
2194
        let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
2195
        if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
Bogdan Timofte authored a month ago
2196
            session.setValue(percent, forKey: "startBatteryPercent")
2197
        }
Bogdan Timofte authored a month ago
2198
        if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
2199
            session.setValue(percent, forKey: "endBatteryPercent")
2200
        }
Bogdan Timofte authored a month ago
2201
        session.setValue(timestamp, forKey: "updatedAt")
Bogdan Timofte authored a month ago
2202
        updateCapacityEstimate(for: session)
2203

            
Bogdan Timofte authored a month ago
2204
        return chargedDeviceID
2205
    }
2206

            
Bogdan Timofte authored a month ago
2207
    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
2208
        guard let sessionID = stringValue(session, key: "id") else {
2209
            return
2210
        }
2211

            
2212
        let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
2213
        if let latestCheckpoint = remainingCheckpoints.last {
2214
            session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
2215
        } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2216
                  startBatteryPercent >= 0 {
2217
            session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
2218
        } else {
2219
            session.setValue(nil, forKey: "endBatteryPercent")
2220
        }
2221

            
2222
        session.setValue(Date(), forKey: "updatedAt")
2223
        updateCapacityEstimate(for: session)
2224
    }
2225

            
Bogdan Timofte authored a month ago
2226
    @discardableResult
2227
    private func addBatteryCheckpoint(
2228
        percent: Double,
Bogdan Timofte authored a month ago
2229
        measuredEnergyWh: Double? = nil,
Bogdan Timofte authored a month ago
2230
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
2231
        to session: NSManagedObject,
2232
        timestamp: Date = Date()
2233
    ) -> Bool {
Bogdan Timofte authored a month ago
2234
        if let measuredEnergyWh, measuredEnergyWh.isFinite {
2235
            session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
2236
        }
2237

            
Bogdan Timofte authored a month ago
2238
        guard let chargedDeviceID = insertBatteryCheckpoint(
2239
            percent: percent,
Bogdan Timofte authored a month ago
2240
            flag: flag,
Bogdan Timofte authored a month ago
2241
            timestamp: timestamp,
Bogdan Timofte authored a month ago
2242
            measuredEnergyWhOverride: measuredEnergyWh,
Bogdan Timofte authored a month ago
2243
            to: session
2244
        ) else {
2245
            return false
2246
        }
2247

            
Bogdan Timofte authored a month ago
2248
        guard saveContext() else {
2249
            return false
2250
        }
2251

            
2252
        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
2253
        return saveContext()
2254
    }
2255

            
2256
    private func resolvedWirelessEfficiency(
2257
        for session: NSManagedObject,
2258
        chargedDevice: NSManagedObject
2259
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
2260
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
2261
           storedFactor > 0 {
2262
            return (
2263
                factor: storedFactor,
2264
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
2265
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
2266
            )
2267
        }
2268

            
2269
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
2270
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2271
        guard measuredEnergyWh > 0 else {
2272
            return nil
2273
        }
2274

            
2275
        if chargingProfile == .magsafe,
2276
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
2277
           calibratedFactor > 0 {
2278
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
2279
        }
2280

            
2281
        guard
2282
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2283
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
2284
        else {
2285
            return nil
2286
        }
2287

            
2288
        let percentDelta = endBatteryPercent - startBatteryPercent
2289
        guard percentDelta >= 20 else {
2290
            return nil
2291
        }
2292

            
2293
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
Bogdan Timofte authored a month ago
2294
            ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
Bogdan Timofte authored a month ago
2295
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2296
                : nil),
2297
              wiredCapacityWh > 0
2298
        else {
2299
            return nil
2300
        }
2301

            
2302
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
2303
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
2304
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
2305
        let usesEstimated = chargingProfile != .magsafe
2306
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
2307

            
2308
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
2309
    }
2310

            
2311
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
2312
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
2313
            return
2314
        }
2315

            
Bogdan Timofte authored a month ago
2316
        let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
Bogdan Timofte authored a month ago
2317
        let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
2318
        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
2319
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
2320
        let sessions = relevantSessionObjects(
2321
            for: chargedDeviceID,
2322
            deviceClass: deviceClass,
2323
            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
2324
            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
2325
        )
Bogdan Timofte authored a month ago
2326
        let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
Bogdan Timofte authored a month ago
2327
        let wiredMinimumCurrent = derivedMinimumCurrent(
2328
            from: sessions,
2329
            chargingTransportMode: .wired
2330
        )
2331
        let wirelessMinimumCurrent = derivedMinimumCurrent(
2332
            from: sessions,
2333
            chargingTransportMode: .wireless
2334
        )
2335

            
2336
        let wiredCapacity = derivedCapacity(
2337
            from: sessions,
2338
            chargingTransportMode: .wired,
2339
            supportsChargingWhileOff: supportsChargingWhileOff
2340
        )
2341
        let wirelessCapacity = derivedCapacity(
2342
            from: sessions,
2343
            chargingTransportMode: .wireless,
2344
            supportsChargingWhileOff: supportsChargingWhileOff
2345
        )
2346
        let wirelessEfficiency = derivedWirelessEfficiency(
2347
            from: sessions,
2348
            chargingProfile: wirelessProfile
2349
        )
Bogdan Timofte authored a month ago
2350
        let configuredCompletionCurrents = decodedCompletionCurrents(
2351
            from: chargedDevice,
2352
            key: "configuredCompletionCurrentsRawValue"
2353
        )
Bogdan Timofte authored a month ago
2354
        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
2355
        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
2356
        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
2357
        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
2358
        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
2359
        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
2360

            
Bogdan Timofte authored a month ago
2361
        let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
2362
        let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
Bogdan Timofte authored a month ago
2363
        let preferredMinimumCurrent: Double?
2364
        let preferredCapacity: Double?
2365
        switch preferredChargingTransportMode {
2366
        case .wired:
Bogdan Timofte authored a month ago
2367
            preferredMinimumCurrent = configuredCompletionCurrents[
2368
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2369
            ] ?? learnedCompletionCurrents[
2370
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2371
            ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
Bogdan Timofte authored a month ago
2372
            preferredCapacity = wiredCapacity ?? wirelessCapacity
2373
        case .wireless:
Bogdan Timofte authored a month ago
2374
            preferredMinimumCurrent = configuredCompletionCurrents[
2375
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2376
            ] ?? learnedCompletionCurrents[
2377
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2378
            ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
Bogdan Timofte authored a month ago
2379
            preferredCapacity = wirelessCapacity ?? wiredCapacity
2380
        }
2381

            
Bogdan Timofte authored a month ago
2382
        setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
2383
        setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
2384
        setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
2385
        setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
2386
        setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
2387
        setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
2388
        setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
2389
        setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
2390
        setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
2391
        setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
2392
        setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
2393
        setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
2394
        setValue(Date(), on: chargedDevice, key: "updatedAt")
Bogdan Timofte authored a month ago
2395
    }
2396

            
2397
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
2398
        sessions
2399
            .filter { $0.status == .completed }
2400
            .compactMap { session in
2401
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
2402
                let timestamp = session.endedAt ?? session.lastObservedAt
2403
                return CapacityTrendPoint(
2404
                    sessionID: session.id,
2405
                    timestamp: timestamp,
2406
                    capacityWh: capacityEstimateWh,
2407
                    chargingTransportMode: session.chargingTransportMode
2408
                )
2409
            }
2410
            .sorted { $0.timestamp < $1.timestamp }
2411
    }
2412

            
2413
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
2414
        var groupedEnergyByBin: [Int: [Double]] = [:]
2415

            
2416
        for session in sessions where session.status == .completed {
Bogdan Timofte authored a month ago
2417
            let anchors = normalizedTypicalCurveAnchors(for: session)
2418
            guard anchors.count >= 2 else {
2419
                continue
Bogdan Timofte authored a month ago
2420
            }
2421

            
Bogdan Timofte authored a month ago
2422
            for percentBin in stride(from: 0, through: 100, by: 10) {
Bogdan Timofte authored a month ago
2423
                guard let energyWh = interpolatedTypicalCurvePoint(
Bogdan Timofte authored a month ago
2424
                    for: Double(percentBin),
2425
                    anchors: anchors
2426
                ) else {
2427
                    continue
2428
                }
Bogdan Timofte authored a month ago
2429

            
Bogdan Timofte authored a month ago
2430
                groupedEnergyByBin[percentBin, default: []].append(energyWh)
Bogdan Timofte authored a month ago
2431
            }
2432
        }
2433

            
Bogdan Timofte authored a month ago
2434
        let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
Bogdan Timofte authored a month ago
2435
            guard let energies = groupedEnergyByBin[percentBin], !energies.isEmpty else {
Bogdan Timofte authored a month ago
2436
                return nil
2437
            }
2438

            
2439
            return TypicalChargeCurvePoint(
2440
                percentBin: percentBin,
2441
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
Bogdan Timofte authored a month ago
2442
                sampleCount: energies.count
Bogdan Timofte authored a month ago
2443
            )
2444
        }
Bogdan Timofte authored a month ago
2445

            
2446
        var runningMaximumEnergyWh = 0.0
2447

            
2448
        return averagedPoints.map { point in
2449
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
2450
            return TypicalChargeCurvePoint(
2451
                percentBin: point.percentBin,
2452
                averageEnergyWh: runningMaximumEnergyWh,
2453
                sampleCount: point.sampleCount
2454
            )
2455
        }
2456
    }
2457

            
2458
    private func normalizedTypicalCurveAnchors(
2459
        for session: ChargeSessionSummary
Bogdan Timofte authored a month ago
2460
    ) -> [(percent: Double, energyWh: Double)] {
Bogdan Timofte authored a month ago
2461
        struct Anchor {
2462
            let percent: Double
2463
            let energyWh: Double
2464
            let timestamp: Date
2465
        }
2466

            
2467
        var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
2468
            guard checkpoint.batteryPercent.isFinite,
2469
                  checkpoint.measuredEnergyWh.isFinite,
2470
                  checkpoint.batteryPercent >= 0,
2471
                  checkpoint.batteryPercent <= 100,
Bogdan Timofte authored a month ago
2472
                  checkpoint.measuredEnergyWh >= 0 else {
Bogdan Timofte authored a month ago
2473
                return nil
2474
            }
2475

            
2476
            return Anchor(
2477
                percent: checkpoint.batteryPercent,
2478
                energyWh: checkpoint.measuredEnergyWh,
2479
                timestamp: checkpoint.timestamp
2480
            )
2481
        }
2482

            
2483
        if let startBatteryPercent = session.startBatteryPercent,
2484
           startBatteryPercent.isFinite,
2485
           startBatteryPercent >= 0,
2486
           startBatteryPercent <= 100 {
2487
            anchors.append(
2488
                Anchor(
2489
                    percent: startBatteryPercent,
2490
                    energyWh: 0,
2491
                    timestamp: session.startedAt
2492
                )
2493
            )
2494
        }
2495

            
2496
        if let endBatteryPercent = session.endBatteryPercent,
2497
           endBatteryPercent.isFinite,
2498
           endBatteryPercent >= 0,
2499
           endBatteryPercent <= 100 {
2500
            anchors.append(
2501
                Anchor(
2502
                    percent: endBatteryPercent,
2503
                    energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
2504
                    timestamp: session.endedAt ?? session.lastObservedAt
2505
                )
2506
            )
2507
        }
2508

            
2509
        let sortedAnchors = anchors.sorted { lhs, rhs in
2510
            if lhs.percent != rhs.percent {
2511
                return lhs.percent < rhs.percent
2512
            }
2513
            if lhs.energyWh != rhs.energyWh {
2514
                return lhs.energyWh < rhs.energyWh
2515
            }
2516
            return lhs.timestamp < rhs.timestamp
2517
        }
2518

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

            
2521
        for anchor in sortedAnchors {
2522
            if let lastIndex = collapsedAnchors.indices.last,
2523
               abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
2524
                collapsedAnchors[lastIndex] = (
2525
                    percent: collapsedAnchors[lastIndex].percent,
Bogdan Timofte authored a month ago
2526
                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh)
Bogdan Timofte authored a month ago
2527
                )
2528
            } else {
2529
                collapsedAnchors.append(
Bogdan Timofte authored a month ago
2530
                    (percent: anchor.percent, energyWh: anchor.energyWh)
Bogdan Timofte authored a month ago
2531
                )
2532
            }
2533
        }
2534

            
2535
        var runningMaximumEnergyWh = 0.0
2536

            
2537
        return collapsedAnchors.map { anchor in
2538
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
2539
            return (
2540
                percent: anchor.percent,
Bogdan Timofte authored a month ago
2541
                energyWh: runningMaximumEnergyWh
Bogdan Timofte authored a month ago
2542
            )
2543
        }
2544
    }
2545

            
2546
    private func interpolatedTypicalCurvePoint(
2547
        for percent: Double,
Bogdan Timofte authored a month ago
2548
        anchors: [(percent: Double, energyWh: Double)]
2549
    ) -> Double? {
Bogdan Timofte authored a month ago
2550
        guard
2551
            let firstAnchor = anchors.first,
2552
            let lastAnchor = anchors.last,
2553
            percent >= firstAnchor.percent,
2554
            percent <= lastAnchor.percent
2555
        else {
2556
            return nil
2557
        }
2558

            
2559
        if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
Bogdan Timofte authored a month ago
2560
            return exactAnchor.energyWh
Bogdan Timofte authored a month ago
2561
        }
2562

            
2563
        guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
2564
              upperIndex > 0 else {
2565
            return nil
2566
        }
2567

            
2568
        let lowerAnchor = anchors[upperIndex - 1]
2569
        let upperAnchor = anchors[upperIndex]
2570
        let span = upperAnchor.percent - lowerAnchor.percent
2571
        guard span > 0.000_1 else {
2572
            return nil
2573
        }
2574

            
2575
        let ratio = (percent - lowerAnchor.percent) / span
Bogdan Timofte authored a month ago
2576
        return lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
Bogdan Timofte authored a month ago
2577
    }
2578

            
2579
    private func makeSessionSummary(
2580
        from object: NSManagedObject,
2581
        checkpoints: [NSManagedObject],
2582
        samples: [NSManagedObject]
2583
    ) -> ChargeSessionSummary? {
2584
        let chargingTransportMode = chargingTransportMode(for: object)
2585

            
2586
        guard
2587
            let id = uuidValue(object, key: "id"),
2588
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2589
            let startedAt = dateValue(object, key: "startedAt"),
2590
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
2591
            let status = statusValue(object, key: "statusRawValue"),
2592
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
2593
        else {
2594
            return nil
2595
        }
2596

            
2597
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
2598
            .sorted { $0.timestamp < $1.timestamp }
2599
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
2600
            .sorted { lhs, rhs in
2601
                if lhs.bucketIndex != rhs.bucketIndex {
2602
                    return lhs.bucketIndex < rhs.bucketIndex
2603
                }
2604
                return lhs.timestamp < rhs.timestamp
2605
            }
2606

            
2607
        return ChargeSessionSummary(
2608
            id: id,
2609
            chargedDeviceID: chargedDeviceID,
2610
            chargerID: uuidValue(object, key: "chargerID"),
2611
            meterMACAddress: stringValue(object, key: "meterMACAddress"),
2612
            meterName: stringValue(object, key: "meterName"),
2613
            meterModel: stringValue(object, key: "meterModel"),
2614
            startedAt: startedAt,
2615
            endedAt: dateValue(object, key: "endedAt"),
2616
            lastObservedAt: lastObservedAt,
Bogdan Timofte authored a month ago
2617
            pausedAt: dateValue(object, key: "pausedAt"),
Bogdan Timofte authored a month ago
2618
            status: status,
2619
            sourceMode: sourceMode,
2620
            chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
2621
            chargingStateMode: chargingStateMode(for: object),
2622
            autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
Bogdan Timofte authored a month ago
2623
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2624
            effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
Bogdan Timofte authored a month ago
2625
            meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
Bogdan Timofte authored a month ago
2626
            meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"),
2627
            meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"),
Bogdan Timofte authored a month ago
2628
            minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
2629
            maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
2630
            maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
2631
            maximumObservedVoltageVolts: chargingTransportMode == .wired
2632
                ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
2633
                : nil,
Bogdan Timofte authored a month ago
2634
            hasObservedChargeFlow: boolValue(object, key: "hasObservedChargeFlow"),
Bogdan Timofte authored a month ago
2635
            selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
2636
            completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
2637
            stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
2638
            startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
2639
            endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
2640
            capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
2641
            wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
2642
            usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
2643
            shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
2644
            supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
2645
            usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
2646
            targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
2647
            targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
2648
            requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
2649
            completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
2650
            completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
2651
            selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
Bogdan Timofte authored a month ago
2652
            trimStart: dateValue(object, key: "trimStart"),
2653
            trimEnd: dateValue(object, key: "trimEnd"),
Bogdan Timofte authored a month ago
2654
            wasConflictHealed: boolValue(object, key: "wasConflictHealed"),
Bogdan Timofte authored a month ago
2655
            checkpoints: checkpointSummaries,
2656
            aggregatedSamples: sampleSummaries
2657
        )
2658
    }
2659

            
2660
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
2661
        guard
2662
            let id = uuidValue(object, key: "id"),
2663
            let sessionID = uuidValue(object, key: "sessionID"),
2664
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2665
            let timestamp = dateValue(object, key: "timestamp")
2666
        else {
2667
            return nil
2668
        }
2669

            
2670
        return ChargeCheckpointSummary(
2671
            id: id,
2672
            sessionID: sessionID,
2673
            chargedDeviceID: chargedDeviceID,
2674
            timestamp: timestamp,
2675
            batteryPercent: doubleValue(object, key: "batteryPercent"),
2676
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2677
            currentAmps: doubleValue(object, key: "currentAmps"),
2678
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
2679
            label: stringValue(object, key: "label")
2680
        )
2681
    }
2682

            
2683
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
2684
        guard
2685
            let sessionID = uuidValue(object, key: "sessionID"),
2686
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2687
            let timestamp = dateValue(object, key: "timestamp")
2688
        else {
2689
            return nil
2690
        }
2691

            
2692
        return ChargeSessionSampleSummary(
2693
            sessionID: sessionID,
2694
            chargedDeviceID: chargedDeviceID,
2695
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
2696
            timestamp: timestamp,
2697
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
2698
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
2699
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
2700
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2701
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
2702
        )
2703
    }
2704

            
Bogdan Timofte authored a month ago
2705
    private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2706
        fetchSessionObject(
2707
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2708
                format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
Bogdan Timofte authored a month ago
2709
                normalizedMACAddress(meterMACAddress),
Bogdan Timofte authored a month ago
2710
                ChargeSessionStatus.active.rawValue,
2711
                ChargeSessionStatus.paused.rawValue
Bogdan Timofte authored a month ago
2712
            )
2713
        )
2714
    }
2715

            
Bogdan Timofte authored a month ago
2716
    private func fetchOpenSessionObjects() -> [NSManagedObject] {
2717
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2718
        request.predicate = NSPredicate(
2719
            format: "statusRawValue == %@ OR statusRawValue == %@",
2720
            ChargeSessionStatus.active.rawValue,
2721
            ChargeSessionStatus.paused.rawValue
2722
        )
2723
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2724
        return (try? context.fetch(request)) ?? []
2725
    }
2726

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

            
2737
    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
2738
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2739
        request.predicate = predicate
2740
        request.fetchLimit = 1
2741
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
2742
        return (try? context.fetch(request))?.first
2743
    }
2744

            
2745
    private func fetchSessionObject(id: String) -> NSManagedObject? {
2746
        fetchSessionObject(
2747
            predicate: NSPredicate(format: "id == %@", id)
2748
        )
2749
    }
2750

            
2751
    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
2752
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2753
        request.predicate = NSPredicate(
2754
            format: "sessionID == %@ AND bucketIndex == %d",
2755
            sessionID,
2756
            bucketIndex
2757
        )
2758
        request.fetchLimit = 1
2759
        return (try? context.fetch(request))?.first
2760
    }
2761

            
2762
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2763
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2764
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2765
        return (try? context.fetch(request)) ?? []
2766
    }
2767

            
Bogdan Timofte authored a month ago
2768
    private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
2769
        guard !sessionIDs.isEmpty else {
2770
            return []
2771
        }
2772

            
2773
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2774
        request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs)
2775
        return (try? context.fetch(request)) ?? []
2776
    }
2777

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

            
Bogdan Timofte authored a month ago
2785
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2786
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2787
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2788
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
2789
        return (try? context.fetch(request)) ?? []
2790
    }
2791

            
2792
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
2793
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2794
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
2795
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2796
        return (try? context.fetch(request)) ?? []
2797
    }
2798

            
2799
    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
2800
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2801
        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
2802
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2803
        return (try? context.fetch(request)) ?? []
2804
    }
2805

            
Bogdan Timofte authored a month ago
2806
    private func sampleBackedSessionIDs(
2807
        devices: [NSManagedObject],
2808
        sessionsByDeviceID: [String: [NSManagedObject]],
2809
        sessionsByChargerID: [String: [NSManagedObject]]
2810
    ) -> Set<String> {
2811
        var sessionIDs: Set<String> = []
2812

            
2813
        for device in devices {
2814
            guard
2815
                let deviceID = stringValue(device, key: "id"),
2816
                let rawClass = stringValue(device, key: "deviceClassRawValue"),
2817
                let deviceClass = ChargedDeviceClass(rawValue: rawClass)
2818
            else {
2819
                continue
2820
            }
2821

            
2822
            let relevantSessions = relevantSessionObjects(
2823
                for: deviceID,
2824
                deviceClass: deviceClass,
2825
                sessionsByDeviceID: sessionsByDeviceID,
2826
                sessionsByChargerID: sessionsByChargerID
2827
            )
2828
            .sorted { lhs, rhs in
2829
                let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed
2830
                let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed
2831

            
2832
                if lhsStatus.isOpen && !rhsStatus.isOpen {
2833
                    return true
2834
                }
2835
                if !lhsStatus.isOpen && rhsStatus.isOpen {
2836
                    return false
2837
                }
2838

            
2839
                return (dateValue(lhs, key: "startedAt") ?? .distantPast)
2840
                    > (dateValue(rhs, key: "startedAt") ?? .distantPast)
2841
            }
2842

            
2843
            var recentCompletedSamplesIncluded = 0
2844

            
2845
            for session in relevantSessions {
2846
                guard let sessionID = stringValue(session, key: "id"),
2847
                      let status = statusValue(session, key: "statusRawValue") else {
2848
                    continue
2849
                }
2850

            
2851
                if status.isOpen {
2852
                    sessionIDs.insert(sessionID)
2853
                    continue
2854
                }
2855

            
2856
                guard recentCompletedSamplesIncluded < 2 else {
2857
                    continue
2858
                }
2859

            
2860
                sessionIDs.insert(sessionID)
2861
                recentCompletedSamplesIncluded += 1
2862
            }
2863
        }
2864

            
2865
        return sessionIDs
2866
    }
2867

            
Bogdan Timofte authored a month ago
2868
    private func relevantSessionObjects(
2869
        for chargedDeviceID: String,
2870
        deviceClass: ChargedDeviceClass,
2871
        sessionsByDeviceID: [String: [NSManagedObject]],
2872
        sessionsByChargerID: [String: [NSManagedObject]]
2873
    ) -> [NSManagedObject] {
2874
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
2875
        guard deviceClass == .charger else {
2876
            return directSessions
2877
        }
2878

            
2879
        var seenSessionIDs = Set<String>()
2880
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
2881
            .filter { session in
2882
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
2883
                return seenSessionIDs.insert(sessionID).inserted
2884
            }
2885
            .sorted {
2886
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
2887
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
2888
                return lhsDate < rhsDate
2889
            }
2890
    }
2891

            
2892
    private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
2893
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false)
2894
    }
2895

            
2896
    private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
2897
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true)
2898
    }
2899

            
2900
    private func resolvedAssignedObject(
2901
        for meterMACAddress: String,
2902
        expectsChargerClass: Bool
2903
    ) -> NSManagedObject? {
2904
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
2905
        guard !normalizedMAC.isEmpty else { return nil }
2906

            
2907
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2908
        request.predicate = NSPredicate(format: "lastAssociatedMeterMAC == %@", normalizedMAC)
2909
        request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
2910
        let matches = (try? context.fetch(request)) ?? []
2911
        return matches.first { object in
Bogdan Timofte authored a month ago
2912
            isChargerObject(object) == expectsChargerClass
Bogdan Timofte authored a month ago
2913
        }
2914
    }
2915

            
Bogdan Timofte authored a month ago
2916
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
2917
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2918
    }
2919

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

            
2927
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
2928
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
2929
        return (try? context.fetch(request)) ?? []
2930
    }
2931

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

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

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

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

            
Bogdan Timofte authored a month ago
2987
    private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
2988
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other
2989
    }
2990

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
3059
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
3060
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
3061
            return chargingStateMode
3062
        }
3063

            
3064
        return .on
3065
    }
3066

            
3067
    private func resolvedChargingStateMode(
3068
        _ chargingStateMode: ChargingStateMode,
3069
        availability: ChargingStateAvailability
3070
    ) -> ChargingStateMode {
3071
        if availability.supportedModes.contains(chargingStateMode) {
3072
            return chargingStateMode
3073
        }
3074
        return availability.supportedModes.first ?? .on
3075
    }
3076

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

            
3081
        // Primary: chargerTypeRawValue (set on v13+)
3082
        if let rawValue = stringValue(chargedDevice, key: "chargerTypeRawValue"),
3083
           let type = ChargerType(rawValue: rawValue) {
3084
            return type
3085
        }
3086

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

            
3094
        // Last resort: derive from wirelessChargingProfileRawValue
3095
        if let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
3096
           let profile = WirelessChargingProfile(rawValue: rawValue),
3097
           profile == .magsafe {
3098
            return .genericMagSafe
3099
        }
3100

            
3101
        return .genericQi
3102
    }
3103

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

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

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

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

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

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

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

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

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

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

            
3229
        guard let sessionID = stringValue(session, key: "id") else {
3230
            return false
3231
        }
3232

            
3233
        return fetchSessionSampleObjects(forSessionID: sessionID).contains { sample in
3234
            doubleValue(sample, key: "measuredEnergyWh") > 0
3235
                || doubleValue(sample, key: "measuredChargeAh") > 0
3236
                || (optionalDoubleValue(sample, key: "averageCurrentAmps") ?? 0) > 0
3237
                || (optionalDoubleValue(sample, key: "averagePowerWatts") ?? 0) > 0
3238
        }
3239
    }
3240

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

            
3249
        let sampleEnergyWh = doubleValue(latestSample, key: "measuredEnergyWh")
3250
        if doubleValue(session, key: "measuredEnergyWh") <= 0, sampleEnergyWh > 0 {
3251
            session.setValue(sampleEnergyWh, forKey: "measuredEnergyWh")
3252
        }
3253

            
3254
        let sampleChargeAh = doubleValue(latestSample, key: "measuredChargeAh")
3255
        if doubleValue(session, key: "measuredChargeAh") <= 0, sampleChargeAh > 0 {
3256
            session.setValue(sampleChargeAh, forKey: "measuredChargeAh")
3257
        }
Bogdan Timofte authored a month ago
3258
    }
3259

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

            
3278
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
3279
        guard !recentCompletionCurrents.isEmpty else { return nil }
3280
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
3281
    }
3282

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

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

            
3298
            let sessionKind = ChargeSessionKind(
3299
                chargingTransportMode: chargingTransportMode(for: session),
3300
                chargingStateMode: chargingStateMode(for: session)
3301
            )
3302
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
3303
        }
3304

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

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

            
3336
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
3337
        guard !recentCapacityCandidates.isEmpty else { return nil }
3338
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
3339
    }
3340

            
3341
    private func derivedWirelessEfficiency(
3342
        from sessions: [NSManagedObject],
3343
        chargingProfile: WirelessChargingProfile
3344
    ) -> Double? {
3345
        guard chargingProfile == .magsafe else {
3346
            return nil
3347
        }
3348

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

            
3359
        let recentCandidates = Array(candidates.suffix(6))
3360
        guard !recentCandidates.isEmpty else { return nil }
3361
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3362
    }
3363

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

            
3373
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
3374
        return counts.keys.sorted()
3375
    }
3376

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

            
3386
        let recentCandidates = Array(candidates.suffix(6))
3387
        guard !recentCandidates.isEmpty else { return nil }
3388
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3389
    }
3390

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

            
3400
        let recentCandidates = Array(candidates.suffix(6))
3401
        guard !recentCandidates.isEmpty else { return nil }
3402
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3403
    }
3404

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

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

            
3426
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
3427
            return persistedChargingTransportMode
Bogdan Timofte authored a month ago
3428
        }
3429

            
3430
        return .wired
3431
    }
3432

            
3433
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
3434
        if session.isInserted {
3435
            return .created
3436
        }
3437

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

            
3455
        if currentStatus == .completed, committedStatus != .completed {
3456
            return .completed
3457
        }
3458

            
Bogdan Timofte authored a month ago
3459
        if currentStatus != committedStatus {
3460
            return .event
3461
        }
3462

            
Bogdan Timofte authored a month ago
3463
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
3464
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
3465
            return .event
3466
        }
3467

            
3468
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3469
            ?? dateValue(session, key: "createdAt")
3470
            ?? observedAt
3471

            
3472
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
3473
            return .periodic
3474
        }
3475

            
3476
        return .none
3477
    }
3478

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

            
3487
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
3488
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3489
            ?? dateValue(sample, key: "createdAt")
3490
            ?? observedAt
3491

            
3492
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
3493
    }
3494

            
Bogdan Timofte authored a month ago
3495
    private func generateQRIdentifier() -> String {
3496
        "device:\(UUID().uuidString)"
3497
    }
3498

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

            
3512
    private func normalizedText(_ text: String) -> String {
3513
        text.trimmingCharacters(in: .whitespacesAndNewlines)
3514
    }
3515

            
3516
    private func normalizedOptionalText(_ text: String?) -> String? {
3517
        guard let text else { return nil }
3518
        let normalized = normalizedText(text)
3519
        return normalized.isEmpty ? nil : normalized
3520
    }
3521

            
3522
    private func normalizedMACAddress(_ macAddress: String) -> String {
3523
        normalizedText(macAddress).uppercased()
3524
    }
3525

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

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

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

            
3546
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
3547
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
3548
    }
3549

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

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

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

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

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

            
3598
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
3599
        guard let value = stringValue(object, key: key) else { return nil }
3600
        return UUID(uuidString: value)
3601
    }
3602

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

            
3608
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
3609
        guard let value = stringValue(object, key: key) else { return nil }
3610
        return ChargingTransportMode(rawValue: value)
3611
    }
3612

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

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

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

            
3642
private enum ObservationSaveReason {
3643
    case none
3644
    case created
3645
    case periodic
3646
    case completed
3647
    case event
3648
}