USB-Meter / USB Meter / Model / ChargeInsightsStore.swift
Newer Older
3439 lines | 149.619kb
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
    @discardableResult
Bogdan Timofte authored a month ago
123
    func createDevice(
Bogdan Timofte authored a month ago
124
        name: String,
125
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
126
        templateID: String?,
Bogdan Timofte authored a month ago
127
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
128
        supportsWiredCharging: Bool,
129
        supportsWirelessCharging: Bool,
130
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
131
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
132
        notes: String?,
133
        assignTo meterMACAddress: String?
134
    ) -> Bool {
Bogdan Timofte authored a month ago
135
        guard deviceClass.kind == .device else { return false }
Bogdan Timofte authored a month ago
136
        let normalizedName = normalizedText(name)
Bogdan Timofte authored a month ago
137
        let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability)
138
        let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
139
            supportsWiredCharging: supportsWiredCharging,
140
            supportsWirelessCharging: supportsWirelessCharging
141
        )
142
        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device)
Bogdan Timofte authored a month ago
143
        guard !normalizedName.isEmpty else { return false }
Bogdan Timofte authored a month ago
144
        guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
Bogdan Timofte authored a month ago
145

            
146
        var didSave = false
147
        context.performAndWait {
148
            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
149
                return
150
            }
151

            
152
            let object = NSManagedObject(entity: entity, insertInto: context)
153
            let now = Date()
154
            object.setValue(UUID().uuidString, forKey: "id")
155
            object.setValue(normalizedName, forKey: "name")
156
            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
157
            object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
158
            object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
159
            object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
160
            object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging")
161
            object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging")
Bogdan Timofte authored a month ago
162
            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
163
            object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
164
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
165
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
166
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
167
            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
168
            object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC")
169
            object.setValue(now, forKey: "createdAt")
170
            object.setValue(now, forKey: "updatedAt")
171
            didSave = saveContext()
172
        }
173
        return didSave
174
    }
175

            
176
    @discardableResult
Bogdan Timofte authored a month ago
177
    func createCharger(
178
        name: String,
Bogdan Timofte authored a month ago
179
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
180
        notes: String?,
181
        assignTo meterMACAddress: String?
182
    ) -> Bool {
183
        let normalizedName = normalizedText(name)
184
        guard !normalizedName.isEmpty else { return false }
185

            
186
        var didSave = false
187
        context.performAndWait {
188
            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
189
                return
190
            }
191

            
192
            let object = NSManagedObject(entity: entity, insertInto: context)
193
            let now = Date()
194
            object.setValue(UUID().uuidString, forKey: "id")
195
            object.setValue(normalizedName, forKey: "name")
196
            object.setValue(ChargedDeviceClass.charger.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
197
            object.setValue(nil, forKey: "deviceTemplateID")
198
            object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue")
199
            object.setValue(false, forKey: "supportsChargingWhileOff")
200
            object.setValue(false, forKey: "supportsWiredCharging")
201
            object.setValue(true, forKey: "supportsWirelessCharging")
202
            if object.entity.attributesByName["chargerTypeRawValue"] != nil {
203
                object.setValue(chargerType.rawValue, forKey: "chargerTypeRawValue")
204
            }
205
            object.setValue(chargerType.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
206
            object.setValue(encodedCompletionCurrents([:]), forKey: "configuredCompletionCurrentsRawValue")
207
            object.setValue(nil, forKey: "wiredChargeCompletionCurrentAmps")
208
            object.setValue(nil, forKey: "wirelessChargeCompletionCurrentAmps")
209
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
210
            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
211
            object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC")
212
            object.setValue(now, forKey: "createdAt")
213
            object.setValue(now, forKey: "updatedAt")
214
            didSave = saveContext()
215
        }
216
        return didSave
217
    }
218

            
219
    @discardableResult
220
    func updateDevice(
Bogdan Timofte authored a month ago
221
        id: UUID,
222
        name: String,
223
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
224
        templateID: String?,
Bogdan Timofte authored a month ago
225
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
226
        supportsWiredCharging: Bool,
227
        supportsWirelessCharging: Bool,
228
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
229
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
230
        notes: String?
231
    ) -> Bool {
Bogdan Timofte authored a month ago
232
        guard deviceClass.kind == .device else { return false }
Bogdan Timofte authored a month ago
233
        let normalizedName = normalizedText(name)
Bogdan Timofte authored a month ago
234
        let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability)
235
        let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
236
            supportsWiredCharging: supportsWiredCharging,
237
            supportsWirelessCharging: supportsWirelessCharging
238
        )
239
        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device)
Bogdan Timofte authored a month ago
240
        guard !normalizedName.isEmpty else { return false }
Bogdan Timofte authored a month ago
241
        guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
Bogdan Timofte authored a month ago
242

            
243
        var didSave = false
244
        context.performAndWait {
245
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
246
                return
247
            }
Bogdan Timofte authored a month ago
248
            guard isChargerObject(object) == false else {
249
                return
250
            }
Bogdan Timofte authored a month ago
251

            
252
            let previousSupportsChargingWhileOff = boolValue(object, key: "supportsChargingWhileOff")
Bogdan Timofte authored a month ago
253
            let previousChargingStateAvailability = self.chargingStateAvailability(for: object)
Bogdan Timofte authored a month ago
254
            let previousSupportsWiredCharging = self.supportsWiredCharging(for: object)
255
            let previousSupportsWirelessCharging = self.supportsWirelessCharging(for: object)
256
            let now = Date()
257

            
258
            object.setValue(normalizedName, forKey: "name")
259
            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
260
            object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
261
            object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
262
            object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
263
            object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging")
264
            object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging")
Bogdan Timofte authored a month ago
265
            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
266
            object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
267
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
268
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
269
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
270
            object.setValue(now, forKey: "updatedAt")
271

            
Bogdan Timofte authored a month ago
272
            let supportsChargingWhileOff = normalizedChargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
273
            let shouldRecalculateSessionCapacity = previousSupportsChargingWhileOff != supportsChargingWhileOff
274
            let shouldRefreshActiveSessions = shouldRecalculateSessionCapacity
Bogdan Timofte authored a month ago
275
                || previousChargingStateAvailability != normalizedChargingStateAvailability
276
                || previousSupportsWiredCharging != normalizedChargingSupport.wired
277
                || previousSupportsWirelessCharging != normalizedChargingSupport.wireless
Bogdan Timofte authored a month ago
278

            
279
            if shouldRecalculateSessionCapacity || shouldRefreshActiveSessions {
280
                let sessions = fetchSessions(forChargedDeviceID: id.uuidString)
281
                for session in sessions {
Bogdan Timofte authored a month ago
282
                    let isOpen = statusValue(session, key: "statusRawValue")?.isOpen == true
Bogdan Timofte authored a month ago
283

            
284
                    if shouldRecalculateSessionCapacity {
285
                        session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
286
                        updateCapacityEstimate(for: session)
287
                        session.setValue(now, forKey: "updatedAt")
288
                    }
289

            
Bogdan Timofte authored a month ago
290
                    guard isOpen, shouldRefreshActiveSessions else {
Bogdan Timofte authored a month ago
291
                        continue
292
                    }
293

            
294
                    let resolvedSessionChargingTransportMode = resolvedPreferredChargingTransportMode(
295
                        chargingTransportMode(for: session),
Bogdan Timofte authored a month ago
296
                        supportsWiredCharging: normalizedChargingSupport.wired,
297
                        supportsWirelessCharging: normalizedChargingSupport.wireless
Bogdan Timofte authored a month ago
298
                    )
Bogdan Timofte authored a month ago
299
                    let resolvedSessionChargingStateMode = resolvedChargingStateMode(
300
                        chargingStateMode(for: session),
Bogdan Timofte authored a month ago
301
                        availability: normalizedChargingStateAvailability
Bogdan Timofte authored a month ago
302
                    )
303
                    let charger = stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
304

            
305
                    session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
306
                    session.setValue(resolvedSessionChargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
307
                    session.setValue(resolvedSessionChargingStateMode.rawValue, forKey: "chargingStateRawValue")
Bogdan Timofte authored a month ago
308
                    session.setValue(
309
                        resolvedStopThreshold(
310
                            for: object,
311
                            chargingTransportMode: resolvedSessionChargingTransportMode,
Bogdan Timofte authored a month ago
312
                            chargingStateMode: resolvedSessionChargingStateMode,
313
                            charger: charger,
314
                            fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
315
                        ) ?? 0,
Bogdan Timofte authored a month ago
316
                        forKey: "stopThresholdAmps"
317
                    )
318
                    session.setValue(now, forKey: "updatedAt")
319
                    updateCapacityEstimate(for: session)
320
                }
321
            }
322

            
323
            refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
324
            didSave = saveContext()
325
        }
326
        return didSave
327
    }
328

            
Bogdan Timofte authored a month ago
329
    @discardableResult
330
    func updateCharger(
331
        id: UUID,
332
        name: String,
Bogdan Timofte authored a month ago
333
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
334
        notes: String?
335
    ) -> Bool {
336
        let normalizedName = normalizedText(name)
337
        guard !normalizedName.isEmpty else { return false }
338

            
339
        var didSave = false
340
        context.performAndWait {
341
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
342
                return
343
            }
344
            guard isChargerObject(object) else {
345
                return
346
            }
347

            
348
            object.setValue(normalizedName, forKey: "name")
Bogdan Timofte authored a month ago
349
            object.setValue(nil, forKey: "deviceTemplateID")
350
            object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue")
351
            object.setValue(false, forKey: "supportsChargingWhileOff")
352
            object.setValue(false, forKey: "supportsWiredCharging")
353
            object.setValue(true, forKey: "supportsWirelessCharging")
354
            if object.entity.attributesByName["chargerTypeRawValue"] != nil {
355
                object.setValue(chargerType.rawValue, forKey: "chargerTypeRawValue")
356
            }
357
            object.setValue(chargerType.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
358
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
359
            object.setValue(Date(), forKey: "updatedAt")
Bogdan Timofte authored a month ago
360
            refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
Bogdan Timofte authored a month ago
361
            didSave = saveContext()
362
        }
363

            
364
        return didSave
365
    }
366

            
Bogdan Timofte authored a month ago
367
    @discardableResult
368
    func assignChargedDevice(id: UUID, to meterMACAddress: String) -> Bool {
369
        assign(itemWithID: id, to: meterMACAddress, kind: .chargedDevice)
370
    }
371

            
372
    @discardableResult
373
    func assignCharger(id: UUID, to meterMACAddress: String) -> Bool {
374
        assign(itemWithID: id, to: meterMACAddress, kind: .charger)
375
    }
376

            
377
    @discardableResult
378
    private func assign(
379
        itemWithID id: UUID,
380
        to meterMACAddress: String,
381
        kind: MeterAssignmentKind
382
    ) -> Bool {
383
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
384
        guard !normalizedMAC.isEmpty else { return false }
385

            
386
        var didSave = false
387
        context.performAndWait {
388
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
389
                return
390
            }
391

            
392
            let isCharger = ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
393
            guard isCharger == kind.expectsChargerClass else {
394
                return
395
            }
396

            
397
            let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
398
            request.predicate = NSPredicate(
399
                format: "lastAssociatedMeterMAC == %@ AND id != %@",
400
                normalizedMAC,
401
                id.uuidString
402
            )
403
            let previouslyAssignedDevices = (try? context.fetch(request)) ?? []
404
            for previousDevice in previouslyAssignedDevices {
405
                let previousIsCharger = ChargedDeviceClass(rawValue: stringValue(previousDevice, key: "deviceClassRawValue") ?? "") == .charger
406
                guard previousIsCharger == kind.expectsChargerClass else {
407
                    continue
408
                }
409
                previousDevice.setValue(nil, forKey: "lastAssociatedMeterMAC")
410
                previousDevice.setValue(Date(), forKey: "updatedAt")
411
            }
412

            
413
            object.setValue(normalizedMAC, forKey: "lastAssociatedMeterMAC")
414
            object.setValue(Date(), forKey: "updatedAt")
415

            
416
            if kind == .charger,
Bogdan Timofte authored a month ago
417
               let openSession = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
418
               chargingTransportMode(for: openSession) == .wireless {
419
                openSession.setValue(id.uuidString, forKey: "chargerID")
420
                openSession.setValue(Date(), forKey: "updatedAt")
Bogdan Timofte authored a month ago
421
            }
422

            
423
            didSave = saveContext()
424
        }
425
        return didSave
426
    }
427

            
428
    @discardableResult
Bogdan Timofte authored a month ago
429
    func startSession(
430
        for snapshot: ChargingMonitorSnapshot,
431
        chargedDeviceID: UUID,
432
        chargerID: UUID?,
433
        chargingTransportMode: ChargingTransportMode,
434
        chargingStateMode: ChargingStateMode,
435
        autoStopEnabled: Bool,
Bogdan Timofte authored a month ago
436
        initialBatteryPercent: Double?,
437
        startsFromFlatBattery: Bool
Bogdan Timofte authored a month ago
438
    ) -> Bool {
Bogdan Timofte authored a month ago
439
        if let initialBatteryPercent,
440
           (initialBatteryPercent.isFinite == false || initialBatteryPercent < 0 || initialBatteryPercent > 100) {
Bogdan Timofte authored a month ago
441
            return false
442
        }
443

            
Bogdan Timofte authored a month ago
444
        var didSave = false
445
        context.performAndWait {
Bogdan Timofte authored a month ago
446
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
Bogdan Timofte authored a month ago
447
                return
448
            }
Bogdan Timofte authored a month ago
449
            guard isChargerObject(chargedDevice) == false else {
450
                return
451
            }
Bogdan Timofte authored a month ago
452

            
Bogdan Timofte authored a month ago
453
            guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
Bogdan Timofte authored a month ago
454
                return
455
            }
456

            
Bogdan Timofte authored a month ago
457
            let resolvedChargingTransportMode = resolvedPreferredChargingTransportMode(
458
                chargingTransportMode,
459
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
460
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
Bogdan Timofte authored a month ago
461
            )
Bogdan Timofte authored a month ago
462
            let resolvedChargingStateMode = resolvedChargingStateMode(
463
                chargingStateMode,
464
                availability: chargingStateAvailability(for: chargedDevice)
465
            )
466
            let charger = resolvedChargingTransportMode == .wireless
467
                ? chargerID.flatMap { fetchChargedDeviceObject(id: $0.uuidString) }
468
                : nil
Bogdan Timofte authored a month ago
469
            if let charger, isChargerObject(charger) == false {
470
                return
471
            }
Bogdan Timofte authored a month ago
472
            guard resolvedChargingTransportMode == .wired || charger != nil else {
Bogdan Timofte authored a month ago
473
                return
474
            }
Bogdan Timofte authored a month ago
475
            let stopThreshold = resolvedStopThreshold(
Bogdan Timofte authored a month ago
476
                for: chargedDevice,
477
                chargingTransportMode: resolvedChargingTransportMode,
478
                chargingStateMode: resolvedChargingStateMode,
479
                charger: charger,
Bogdan Timofte authored a month ago
480
                fallback: snapshot.fallbackStopThresholdAmps > 0 ? snapshot.fallbackStopThresholdAmps : nil
481
            )
Bogdan Timofte authored a month ago
482
            guard let session = createSessionObject(
483
                for: chargedDevice,
Bogdan Timofte authored a month ago
484
                charger: charger,
485
                snapshot: snapshot,
486
                stopThreshold: stopThreshold,
Bogdan Timofte authored a month ago
487
                chargingTransportMode: resolvedChargingTransportMode,
488
                chargingStateMode: resolvedChargingStateMode,
489
                autoStopEnabled: autoStopEnabled
490
            ) else {
491
                return
492
            }
493

            
Bogdan Timofte authored a month ago
494
            if startsFromFlatBattery {
495
                session.setValue(unresolvedFlatBatteryPercent, forKey: "startBatteryPercent")
496
                session.setValue(nil, forKey: "endBatteryPercent")
497
            } else if let initialBatteryPercent {
498
                guard insertBatteryCheckpoint(
499
                    percent: initialBatteryPercent,
Bogdan Timofte authored a month ago
500
                    flag: .initial,
Bogdan Timofte authored a month ago
501
                    timestamp: snapshot.observedAt,
502
                    to: session
503
                ) != nil else {
504
                    return
505
                }
Bogdan Timofte authored a month ago
506
            }
Bogdan Timofte authored a month ago
507
            didSave = saveContext()
508
        }
509
        return didSave
510
    }
511

            
Bogdan Timofte authored a month ago
512
    @discardableResult
513
    func pauseSession(id sessionID: UUID, observedAt: Date) -> Bool {
514
        var didSave = false
515
        context.performAndWait {
516
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
517
                return
518
            }
519

            
520
            guard statusValue(session, key: "statusRawValue") == .active else {
521
                return
522
            }
523

            
524
            session.setValue(ChargeSessionStatus.paused.rawValue, forKey: "statusRawValue")
525
            session.setValue(observedAt, forKey: "pausedAt")
526
            session.setValue(nil, forKey: "belowThresholdSince")
527
            clearCompletionConfirmationState(for: session)
528
            session.setValue(observedAt, forKey: "updatedAt")
529
            didSave = saveContext()
530
        }
531
        return didSave
532
    }
533

            
534
    @discardableResult
535
    func resumeSession(id sessionID: UUID, snapshot: ChargingMonitorSnapshot?) -> Bool {
536
        var didSave = false
537
        context.performAndWait {
538
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
539
                return
540
            }
541

            
542
            guard statusValue(session, key: "statusRawValue") == .paused else {
543
                return
544
            }
545

            
546
            let resumedAt = snapshot?.observedAt ?? Date()
Bogdan Timofte authored a month ago
547
            if let completionDate = automaticCompletionDate(for: session, referenceDate: resumedAt) {
Bogdan Timofte authored a month ago
548
                finishSession(
549
                    session,
Bogdan Timofte authored a month ago
550
                    observedAt: completionDate,
Bogdan Timofte authored a month ago
551
                    finalBatteryPercent: nil,
552
                    status: .completed
553
                )
554
                guard saveContext() else {
555
                    return
556
                }
557
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
558
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
559
                    didSave = saveContext()
560
                } else {
561
                    didSave = true
562
                }
563
                return
564
            }
565

            
566
            session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
567
            session.setValue(nil, forKey: "pausedAt")
568
            session.setValue(nil, forKey: "belowThresholdSince")
569
            clearCompletionConfirmationState(for: session)
570
            session.setValue(resumedAt, forKey: "lastObservedAt")
571
            if let snapshot {
572
                session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
573
                session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
574
                session.setValue(
575
                    chargingTransportMode(for: session) == .wired ? snapshot.voltageVolts : nil,
576
                    forKey: "lastObservedVoltageVolts"
577
                )
578
            } else {
579
                session.setValue(0, forKey: "lastObservedCurrentAmps")
580
                session.setValue(0, forKey: "lastObservedPowerWatts")
581
                session.setValue(nil, forKey: "lastObservedVoltageVolts")
582
            }
583
            session.setValue(resumedAt, forKey: "updatedAt")
584
            didSave = saveContext()
585
        }
586
        return didSave
587
    }
588

            
589
    @discardableResult
590
    func stopSession(
591
        id sessionID: UUID,
Bogdan Timofte authored a month ago
592
        finalBatteryPercent: Double? = nil
Bogdan Timofte authored a month ago
593
    ) -> Bool {
Bogdan Timofte authored a month ago
594
        if let finalBatteryPercent {
595
            guard finalBatteryPercent.isFinite, finalBatteryPercent >= 0, finalBatteryPercent <= 100 else {
596
                return false
597
            }
Bogdan Timofte authored a month ago
598
        }
599

            
600
        var didSave = false
Bogdan Timofte authored a month ago
601
        var deviceIDToRefresh: String?
602

            
Bogdan Timofte authored a month ago
603
        context.performAndWait {
604
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
605
                return
606
            }
607

            
608
            guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
609
                return
610
            }
611

            
612
            let observedAt = snapshotDateForManualStop(session)
613
            finishSession(
614
                session,
615
                observedAt: observedAt,
616
                finalBatteryPercent: finalBatteryPercent,
617
                status: .completed
618
            )
619

            
620
            guard saveContext() else {
621
                return
622
            }
623

            
Bogdan Timofte authored a month ago
624
            didSave = true
625
            deviceIDToRefresh = stringValue(session, key: "chargedDeviceID")
626
        }
627

            
628
        if let deviceID = deviceIDToRefresh {
629
            context.perform { [weak self] in
630
                guard let self else { return }
631
                self.refreshDerivedMetrics(forChargedDeviceID: deviceID)
632
                self.saveContext()
Bogdan Timofte authored a month ago
633
            }
634
        }
Bogdan Timofte authored a month ago
635

            
Bogdan Timofte authored a month ago
636
        return didSave
637
    }
638

            
Bogdan Timofte authored a month ago
639
    @discardableResult
640
    func addBatteryCheckpoint(
641
        percent: Double,
Bogdan Timofte authored a month ago
642
        for meterMACAddress: String,
643
        measuredEnergyWh: Double? = nil,
644
        measuredChargeAh: Double? = nil
Bogdan Timofte authored a month ago
645
    ) -> Bool {
646
        guard percent.isFinite, percent >= 0, percent <= 100 else {
647
            return false
648
        }
649

            
650
        var didSave = false
651
        context.performAndWait {
Bogdan Timofte authored a month ago
652
            guard let session = fetchOpenSessionObject(forMeterMACAddress: meterMACAddress) else {
Bogdan Timofte authored a month ago
653
                return
654
            }
655

            
Bogdan Timofte authored a month ago
656
            didSave = addBatteryCheckpoint(
657
                percent: percent,
658
                measuredEnergyWh: measuredEnergyWh,
659
                measuredChargeAh: measuredChargeAh,
Bogdan Timofte authored a month ago
660
                flag: .intermediate,
Bogdan Timofte authored a month ago
661
                to: session
662
            )
Bogdan Timofte authored a month ago
663
        }
664
        return didSave
665
    }
666

            
667
    @discardableResult
668
    func addBatteryCheckpoint(
669
        percent: Double,
Bogdan Timofte authored a month ago
670
        for sessionID: UUID,
671
        measuredEnergyWh: Double? = nil,
672
        measuredChargeAh: Double? = nil
Bogdan Timofte authored a month ago
673
    ) -> Bool {
674
        guard percent.isFinite, percent >= 0, percent <= 100 else {
675
            return false
676
        }
677

            
678
        var didSave = false
679
        context.performAndWait {
680
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
681
                return
682
            }
683

            
Bogdan Timofte authored a month ago
684
            didSave = addBatteryCheckpoint(
685
                percent: percent,
686
                measuredEnergyWh: measuredEnergyWh,
687
                measuredChargeAh: measuredChargeAh,
Bogdan Timofte authored a month ago
688
                flag: .intermediate,
Bogdan Timofte authored a month ago
689
                to: session
690
            )
Bogdan Timofte authored a month ago
691
        }
692
        return didSave
693
    }
694

            
Bogdan Timofte authored a month ago
695
    @discardableResult
696
    func deleteBatteryCheckpoint(
697
        id checkpointID: UUID,
698
        from sessionID: UUID
699
    ) -> Bool {
700
        var didSave = false
701
        context.performAndWait {
702
            guard let session = fetchSessionObject(id: sessionID.uuidString),
703
                  let checkpoint = fetchCheckpointObject(
704
                    id: checkpointID.uuidString,
705
                    sessionID: sessionID.uuidString
706
                  ) else {
707
                return
708
            }
709

            
710
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
711
            context.delete(checkpoint)
712
            refreshCheckpointDerivedValues(for: session)
713

            
714
            if let chargedDeviceID {
715
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
716
            }
Bogdan Timofte authored a month ago
717

            
718
            didSave = saveContext()
Bogdan Timofte authored a month ago
719
        }
720
        return didSave
721
    }
722

            
Bogdan Timofte authored a month ago
723
    @discardableResult
724
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
725
        if let percent, (!percent.isFinite || percent <= 0 || percent > 100) {
726
            return false
727
        }
728

            
729
        var didSave = false
730
        context.performAndWait {
731
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
732
                return
733
            }
734

            
735
            session.setValue(percent, forKey: "targetBatteryPercent")
736
            session.setValue(nil, forKey: "targetBatteryAlertTriggeredAt")
737
            session.setValue(Date(), forKey: "updatedAt")
738
            didSave = saveContext()
739
        }
740
        return didSave
741
    }
742

            
743
    @discardableResult
744
    func confirmCompletion(for sessionID: UUID) -> Bool {
745
        var didSave = false
746
        context.performAndWait {
747
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
748
                return
749
            }
750

            
751
            guard statusValue(session, key: "statusRawValue") == .active else {
752
                return
753
            }
754

            
Bogdan Timofte authored a month ago
755
            finishSession(
756
                session,
757
                observedAt: dateValue(session, key: "lastObservedAt") ?? Date(),
758
                finalBatteryPercent: nil,
759
                status: .completed
760
            )
Bogdan Timofte authored a month ago
761

            
762
            if saveContext() {
763
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
764
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
765
                    didSave = saveContext()
766
                } else {
767
                    didSave = true
768
                }
769
            }
770
        }
771
        return didSave
772
    }
773

            
774
    @discardableResult
775
    func continueMonitoringDespiteCompletionContradiction(for sessionID: UUID) -> Bool {
776
        var didSave = false
777
        context.performAndWait {
778
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
779
                return
780
            }
781

            
782
            guard statusValue(session, key: "statusRawValue") == .active else {
783
                return
784
            }
785

            
786
            clearCompletionConfirmationState(for: session)
Bogdan Timofte authored a month ago
787
            session.setValue(nil, forKey: "belowThresholdSince")
Bogdan Timofte authored a month ago
788
            session.setValue(Date().addingTimeInterval(completionConfirmationCooldown), forKey: "completionConfirmationCooldownUntil")
789
            session.setValue(Date(), forKey: "updatedAt")
790
            didSave = saveContext()
791
        }
792
        return didSave
793
    }
794

            
Bogdan Timofte authored a month ago
795
    @discardableResult
796
    func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
797
        var didSave = false
798
        context.performAndWait {
799
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
800
                return
801
            }
802

            
803
            let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast
804
            let sessionEnd   = dateValue(session, key: "endedAt")
805
                ?? dateValue(session, key: "lastObservedAt")
806
                ?? Date.distantFuture
807

            
808
            let effectiveStart = min(max(start ?? sessionStart, sessionStart), sessionEnd)
809
            let effectiveEnd   = max(min(end ?? sessionEnd, sessionEnd), effectiveStart)
810
            let persistedStart = effectiveStart == sessionStart ? nil : effectiveStart
811
            let persistedEnd   = effectiveEnd == sessionEnd ? nil : effectiveEnd
812

            
813
            let allSamples = fetchSessionSampleObjects(forSessionID: sessionID.uuidString)
814
                .compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in
815
                    guard let ts = dateValue(obj, key: "timestamp") else { return nil }
816
                    return (
817
                        timestamp: ts,
818
                        energy: doubleValue(obj, key: "measuredEnergyWh"),
819
                        charge: doubleValue(obj, key: "measuredChargeAh")
820
                    )
821
                }
822
                .sorted { $0.timestamp < $1.timestamp }
823

            
824
            // Each sample stores cumulative energy since session start.
825
            // Trimmed energy = value at trimEnd  -  value just before trimStart.
826
            let baselineSample = allSamples.last { $0.timestamp <= effectiveStart }
827
            let endSample      = allSamples.last { $0.timestamp <= effectiveEnd }
828
            let baselineEnergy = baselineSample?.energy ?? 0
829
            let baselineCharge = baselineSample?.charge ?? 0
830

            
831
            if let endSample {
832
                let trimmedEnergy  = max(endSample.energy - baselineEnergy, 0)
833
                let trimmedCharge  = max(endSample.charge - baselineCharge, 0)
834
                session.setValue(trimmedEnergy, forKey: "measuredEnergyWh")
835
                session.setValue(trimmedCharge, forKey: "measuredChargeAh")
836
            } else {
837
                session.setValue(0, forKey: "measuredEnergyWh")
838
                session.setValue(0, forKey: "measuredChargeAh")
839
            }
840

            
841
            session.setValue(persistedStart, forKey: "trimStart")
842
            session.setValue(persistedEnd,   forKey: "trimEnd")
843
            session.setValue(Date(), forKey: "updatedAt")
844

            
845
            let checkpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
846
            for checkpoint in checkpoints {
847
                guard let timestamp = dateValue(checkpoint, key: "timestamp") else { continue }
848

            
849
                if timestamp < effectiveStart || timestamp > effectiveEnd {
850
                    context.delete(checkpoint)
851
                    continue
852
                }
853

            
854
                let checkpointSample = allSamples.last { $0.timestamp <= timestamp }
855
                let rebasedEnergy = max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0)
856
                let rebasedCharge = max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0)
857
                checkpoint.setValue(rebasedEnergy, forKey: "measuredEnergyWh")
858
                checkpoint.setValue(rebasedCharge, forKey: "measuredChargeAh")
859
            }
860

            
861
            let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
862
                .sorted {
863
                    (dateValue($0, key: "timestamp") ?? .distantPast)
864
                        < (dateValue($1, key: "timestamp") ?? .distantPast)
865
                }
866
            let restoredInitialCheckpoint = remainingCheckpoints.first { checkpoint in
867
                let label = stringValue(checkpoint, key: "label")
868
                let timestamp = dateValue(checkpoint, key: "timestamp") ?? .distantFuture
869
                return ChargeCheckpointFlag.fromStoredLabel(label) == .initial || timestamp <= effectiveStart
870
            }
871

            
872
            if persistedStart == nil {
873
                if let restoredInitialCheckpoint,
874
                   let percent = optionalDoubleValue(restoredInitialCheckpoint, key: "batteryPercent"),
875
                   percent >= 0 {
876
                    session.setValue(percent, forKey: "startBatteryPercent")
877
                }
878
            } else {
879
                session.setValue(nil, forKey: "startBatteryPercent")
880
            }
881

            
882
            refreshCheckpointDerivedValues(for: session)
883

            
884
            if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
885
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
886
            }
887

            
888
            didSave = saveContext()
889
        }
890
        return didSave
891
    }
892

            
Bogdan Timofte authored a month ago
893
    @discardableResult
894
    func deleteChargeSession(id sessionID: UUID) -> Bool {
895
        var didSave = false
896
        context.performAndWait {
897
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
898
                return
899
            }
900

            
901
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
902

            
903
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
904
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
905
            context.delete(session)
906

            
907
            guard saveContext() else {
908
                return
909
            }
910

            
911
            if let chargedDeviceID {
912
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
913
                didSave = saveContext()
914
            } else {
915
                didSave = true
916
            }
917
        }
918
        return didSave
919
    }
920

            
921
    @discardableResult
922
    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
923
        var didSave = false
924

            
925
        context.performAndWait {
926
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
927
                return
928
            }
929

            
930
            let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
931
            let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
932
            let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
933

            
934
            var impactedChargedDeviceIDs = Set<String>()
935

            
936
            for session in deviceSessions {
937
                if let impactedID = stringValue(session, key: "chargedDeviceID") {
938
                    impactedChargedDeviceIDs.insert(impactedID)
939
                }
940
                if let impactedChargerID = stringValue(session, key: "chargerID") {
941
                    impactedChargedDeviceIDs.insert(impactedChargerID)
942
                }
943
                if let sessionID = stringValue(session, key: "id") {
944
                    fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete)
945
                    fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete)
946
                }
947
                context.delete(session)
948
            }
949

            
950
            if deviceClass == .charger {
951
                for session in linkedWirelessSessions {
952
                    guard stringValue(session, key: "chargedDeviceID") != chargedDeviceID.uuidString else {
953
                        continue
954
                    }
955
                    if let impactedID = stringValue(session, key: "chargedDeviceID") {
956
                        impactedChargedDeviceIDs.insert(impactedID)
957
                    }
958
                    session.setValue(nil, forKey: "chargerID")
959
                    session.setValue(Date(), forKey: "updatedAt")
960
                }
961
            }
962

            
963
            context.delete(chargedDevice)
964

            
965
            guard saveContext() else {
966
                return
967
            }
968

            
969
            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
970
            for impactedID in impactedChargedDeviceIDs {
971
                refreshDerivedMetrics(forChargedDeviceID: impactedID)
972
            }
973
            didSave = saveContext()
974
        }
975

            
976
        return didSave
977
    }
978

            
979
    @discardableResult
980
    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
981
        var didSave = false
982

            
983
        context.performAndWait {
Bogdan Timofte authored a month ago
984
            guard let session = fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress),
985
                  let resolvedDevice = stringValue(session, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:)) else {
986
                return
987
            }
Bogdan Timofte authored a month ago
988

            
Bogdan Timofte authored a month ago
989
            if statusValue(session, key: "statusRawValue") == .paused {
Bogdan Timofte authored a month ago
990
                if maybeCompleteOpenSession(session, observedAt: snapshot.observedAt) {
Bogdan Timofte authored a month ago
991
                    didSave = true
992
                }
Bogdan Timofte authored a month ago
993
                return
994
            }
995

            
Bogdan Timofte authored a month ago
996
            let chargingTransportMode = self.chargingTransportMode(for: session)
997
            let chargingStateMode = self.chargingStateMode(for: session)
Bogdan Timofte authored a month ago
998
            let charger = chargingTransportMode == .wireless
Bogdan Timofte authored a month ago
999
                ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
1000
                : nil
1001
            guard chargingTransportMode == .wired || charger != nil else {
1002
                return
1003
            }
1004
            let stopThreshold = resolvedStopThreshold(
1005
                for: resolvedDevice,
1006
                chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
1007
                chargingStateMode: chargingStateMode,
1008
                charger: charger,
1009
                fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1010
            )
1011

            
Bogdan Timofte authored a month ago
1012
            let sessionSnapshot = snapshotClampedToMaximumDuration(snapshot, for: session)
1013
            update(session: session, with: sessionSnapshot, stopThreshold: stopThreshold, charger: charger)
1014
            let aggregatedSample = updateAggregatedSample(session: session, with: sessionSnapshot)
1015
            if let completionDate = automaticCompletionDate(for: session, referenceDate: snapshot.observedAt),
1016
               statusValue(session, key: "statusRawValue")?.isOpen == true {
1017
                finishSession(
1018
                    session,
1019
                    observedAt: completionDate,
1020
                    finalBatteryPercent: nil,
1021
                    status: .completed
1022
                )
1023
            }
Bogdan Timofte authored a month ago
1024

            
Bogdan Timofte authored a month ago
1025
            let saveReason = saveReason(for: session, observedAt: sessionSnapshot.observedAt)
Bogdan Timofte authored a month ago
1026
            let shouldPersistAggregatedCurve = aggregatedSample.map {
Bogdan Timofte authored a month ago
1027
                shouldPersistAggregatedSample($0, observedAt: sessionSnapshot.observedAt)
Bogdan Timofte authored a month ago
1028
            } ?? false
1029

            
1030
            guard saveReason != .none || shouldPersistAggregatedCurve else {
Bogdan Timofte authored a month ago
1031
                return
1032
            }
1033

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

            
1036
            if saveContext() {
1037
                if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
1038
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
1039
                    didSave = saveContext()
1040
                } else {
1041
                    didSave = true
1042
                }
1043
            }
1044
        }
1045

            
1046
        return didSave
1047
    }
1048

            
1049
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
1050
        var summaries: [ChargedDeviceSummary] = []
1051

            
1052
        context.performAndWait {
1053
            let devices = fetchObjects(entityName: EntityName.chargedDevice)
1054
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
1055
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
1056

            
1057
            let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
1058
            let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
1059
            let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
Bogdan Timofte authored a month ago
1060
            let sampleBackedSessionIDs = sampleBackedSessionIDs(
1061
                devices: devices,
1062
                sessionsByDeviceID: sessionsByDeviceID,
1063
                sessionsByChargerID: sessionsByChargerID
1064
            )
1065
            let samplesBySessionID = Dictionary(
1066
                grouping: fetchSessionSampleObjects(forSessionIDs: Array(sampleBackedSessionIDs))
1067
            ) { stringValue($0, key: "sessionID") ?? "" }
Bogdan Timofte authored a month ago
1068

            
1069
            summaries = devices.compactMap { device in
1070
                guard
1071
                    let id = uuidValue(device, key: "id"),
1072
                    let name = stringValue(device, key: "name"),
1073
                    let qrIdentifier = stringValue(device, key: "qrIdentifier"),
1074
                    let rawClass = stringValue(device, key: "deviceClassRawValue"),
1075
                    let deviceClass = ChargedDeviceClass(rawValue: rawClass)
1076
                else {
1077
                    return nil
1078
                }
1079

            
Bogdan Timofte authored a month ago
1080
                let chargingStateAvailability = chargingStateAvailability(for: device)
1081
                let supportsWiredCharging = supportsWiredCharging(for: device)
1082
                let supportsWirelessCharging = supportsWirelessCharging(for: device)
1083
                let templateDefinition = templateDefinition(for: device)
1084

            
Bogdan Timofte authored a month ago
1085
                let sessionObjects = relevantSessionObjects(
1086
                    for: id.uuidString,
1087
                    deviceClass: deviceClass,
1088
                    sessionsByDeviceID: sessionsByDeviceID,
1089
                    sessionsByChargerID: sessionsByChargerID
1090
                )
1091
                let sessionSummaries = sessionObjects
1092
                    .compactMap { session in
1093
                        makeSessionSummary(
1094
                            from: session,
1095
                            checkpoints: checkpointsBySessionID[stringValue(session, key: "id") ?? ""] ?? [],
1096
                            samples: samplesBySessionID[stringValue(session, key: "id") ?? ""] ?? []
1097
                        )
1098
                    }
1099
                    .sorted { lhs, rhs in
Bogdan Timofte authored a month ago
1100
                        if lhs.status.isOpen && !rhs.status.isOpen {
Bogdan Timofte authored a month ago
1101
                            return true
1102
                        }
Bogdan Timofte authored a month ago
1103
                        if !lhs.status.isOpen && rhs.status.isOpen {
1104
                            return false
1105
                        }
1106
                        if lhs.status == .active && rhs.status == .paused {
1107
                            return true
1108
                        }
1109
                        if lhs.status == .paused && rhs.status == .active {
Bogdan Timofte authored a month ago
1110
                            return false
1111
                        }
1112
                        return lhs.startedAt > rhs.startedAt
1113
                    }
1114

            
1115
                return ChargedDeviceSummary(
1116
                    id: id,
1117
                    qrIdentifier: qrIdentifier,
1118
                    name: name,
1119
                    deviceClass: deviceClass,
Bogdan Timofte authored a month ago
1120
                    deviceTemplateID: stringValue(device, key: "deviceTemplateID"),
1121
                    templateDefinition: templateDefinition,
1122
                    supportsChargingWhileOff: chargingStateAvailability.supportsChargingWhileOff,
1123
                    chargingStateAvailability: chargingStateAvailability,
1124
                    supportsWiredCharging: supportsWiredCharging,
1125
                    supportsWirelessCharging: supportsWirelessCharging,
Bogdan Timofte authored a month ago
1126
                    chargerType: chargerType(for: device),
Bogdan Timofte authored a month ago
1127
                    wirelessChargingProfile: wirelessChargingProfile(for: device),
Bogdan Timofte authored a month ago
1128
                    configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"),
1129
                    learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"),
Bogdan Timofte authored a month ago
1130
                    wirelessChargerEfficiencyFactor: optionalDoubleValue(device, key: "wirelessChargerEfficiencyFactor"),
1131
                    wiredChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wiredChargeCompletionCurrentAmps"),
1132
                    wirelessChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wirelessChargeCompletionCurrentAmps"),
1133
                    chargerObservedVoltageSelections: decodedObservedVoltageSelections(from: device),
1134
                    chargerIdleCurrentAmps: optionalDoubleValue(device, key: "chargerIdleCurrentAmps"),
1135
                    chargerEfficiencyFactor: optionalDoubleValue(device, key: "chargerEfficiencyFactor"),
1136
                    chargerMaximumPowerWatts: optionalDoubleValue(device, key: "chargerMaximumPowerWatts"),
1137
                    notes: stringValue(device, key: "notes"),
1138
                    minimumCurrentAmps: optionalDoubleValue(device, key: "minimumCurrentAmps"),
1139
                    estimatedBatteryCapacityWh: optionalDoubleValue(device, key: "estimatedBatteryCapacityWh"),
1140
                    wiredMinimumCurrentAmps: optionalDoubleValue(device, key: "wiredMinimumCurrentAmps"),
1141
                    wirelessMinimumCurrentAmps: optionalDoubleValue(device, key: "wirelessMinimumCurrentAmps"),
1142
                    wiredEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wiredEstimatedBatteryCapacityWh"),
1143
                    wirelessEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wirelessEstimatedBatteryCapacityWh"),
1144
                    lastAssociatedMeterMAC: stringValue(device, key: "lastAssociatedMeterMAC"),
1145
                    createdAt: dateValue(device, key: "createdAt") ?? .distantPast,
1146
                    updatedAt: dateValue(device, key: "updatedAt") ?? .distantPast,
1147
                    sessions: sessionSummaries,
1148
                    capacityHistory: buildCapacityHistory(from: sessionSummaries),
Bogdan Timofte authored a month ago
1149
                    typicalCurve: buildTypicalCurve(from: sessionSummaries),
1150
                    standbyPowerMeasurements: []
Bogdan Timofte authored a month ago
1151
                )
1152
            }
1153
            .sorted { lhs, rhs in
1154
                if lhs.activeSession != nil && rhs.activeSession == nil {
1155
                    return true
1156
                }
1157
                if lhs.activeSession == nil && rhs.activeSession != nil {
1158
                    return false
1159
                }
1160
                if lhs.updatedAt != rhs.updatedAt {
1161
                    return lhs.updatedAt > rhs.updatedAt
1162
                }
1163
                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
1164
            }
1165
        }
1166

            
1167
        return summaries
1168
    }
1169

            
1170
    func resolvedChargedDeviceSummary(forMeterMACAddress meterMACAddress: String) -> ChargedDeviceSummary? {
1171
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1172
        guard !normalizedMAC.isEmpty else { return nil }
1173

            
1174
        let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
1175

            
1176
        if let activeMatch = summaries.first(where: { summary in
1177
            summary.activeSession?.meterMACAddress == normalizedMAC
1178
        }) {
1179
            return activeMatch
1180
        }
1181

            
1182
        return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
1183
    }
1184

            
1185
    func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
1186
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1187
        guard !normalizedMAC.isEmpty else { return nil }
1188

            
Bogdan Timofte authored a month ago
1189
        var summary: ChargeSessionSummary?
1190

            
1191
        context.performAndWait {
Bogdan Timofte authored a month ago
1192
            guard let session = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
Bogdan Timofte authored a month ago
1193
                  let sessionID = stringValue(session, key: "id") else {
1194
                return
1195
            }
1196

            
1197
            summary = makeSessionSummary(
1198
                from: session,
1199
                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
1200
                samples: fetchSessionSampleObjects(forSessionID: sessionID)
1201
            )
1202
        }
1203

            
1204
        return summary
Bogdan Timofte authored a month ago
1205
    }
1206

            
1207
    private func createSessionObject(
1208
        for chargedDevice: NSManagedObject,
1209
        charger: NSManagedObject?,
1210
        snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1211
        stopThreshold: Double?,
1212
        chargingTransportMode: ChargingTransportMode,
1213
        chargingStateMode: ChargingStateMode,
1214
        autoStopEnabled: Bool
Bogdan Timofte authored a month ago
1215
    ) -> NSManagedObject? {
1216
        guard
1217
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
1218
            let chargedDeviceID = stringValue(chargedDevice, key: "id")
1219
        else {
1220
            return nil
1221
        }
1222

            
1223
        let session = NSManagedObject(entity: entity, insertInto: context)
1224
        let now = snapshot.observedAt
1225
        session.setValue(UUID().uuidString, forKey: "id")
1226
        session.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1227
        session.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
1228
        session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress")
1229
        session.setValue(snapshot.meterName, forKey: "meterName")
1230
        session.setValue(snapshot.meterModel, forKey: "meterModel")
1231
        session.setValue(now, forKey: "startedAt")
1232
        session.setValue(now, forKey: "lastObservedAt")
1233
        session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
Bogdan Timofte authored a month ago
1234
        let usesMeterCounters = snapshot.meterEnergyCounterWh != nil || snapshot.meterChargeCounterAh != nil
1235
        session.setValue(
1236
            (usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue,
1237
            forKey: "sourceModeRawValue"
1238
        )
Bogdan Timofte authored a month ago
1239
        session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
1240
        session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue")
1241
        session.setValue(autoStopEnabled, forKey: "autoStopEnabled")
1242
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1243
        session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
1244
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1245
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1246
        session.setValue(
1247
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1248
            forKey: "lastObservedVoltageVolts"
1249
        )
Bogdan Timofte authored a month ago
1250
        session.setValue(
1251
            hasObservedChargeFlow(
1252
                currentAmps: snapshot.currentAmps,
1253
                chargingTransportMode: chargingTransportMode,
1254
                charger: charger,
1255
                stopThreshold: stopThreshold
1256
            ),
1257
            forKey: "hasObservedChargeFlow"
1258
        )
Bogdan Timofte authored a month ago
1259
        session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
1260
        session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
1261
        session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
1262
        session.setValue(
1263
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1264
            forKey: "maximumObservedVoltageVolts"
1265
        )
1266
        session.setValue(boolValue(chargedDevice, key: "supportsChargingWhileOff"), forKey: "supportsChargingWhileOff")
1267
        if let selectedDataGroup = snapshot.selectedDataGroup {
1268
            session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup")
1269
        }
1270
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1271
            session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1272
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1273
        }
1274
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1275
            session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1276
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1277
        }
Bogdan Timofte authored a month ago
1278
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1279
            setValue(meterRecordingDurationSeconds, on: session, key: "meterDurationBaselineSeconds")
1280
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1281
        }
Bogdan Timofte authored a month ago
1282
        session.setValue(now, forKey: "createdAt")
1283
        session.setValue(now, forKey: "updatedAt")
1284

            
1285
        chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
1286
        chargedDevice.setValue(now, forKey: "updatedAt")
1287
        return session
1288
    }
1289

            
1290
    private func update(
1291
        session: NSManagedObject,
1292
        with snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1293
        stopThreshold: Double?,
1294
        charger: NSManagedObject?
Bogdan Timofte authored a month ago
1295
    ) {
1296
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1297
        let lastObservedAt = dateValue(session, key: "lastObservedAt")
1298
        let previousPower = doubleValue(session, key: "lastObservedPowerWatts")
1299
        let previousCurrent = doubleValue(session, key: "lastObservedCurrentAmps")
1300
        var measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1301
        var measuredChargeAh = doubleValue(session, key: "measuredChargeAh")
1302
        var sourceMode = ChargeSessionSourceMode(rawValue: stringValue(session, key: "sourceModeRawValue") ?? "") ?? .live
1303
        var usedOfflineMeterCounters = boolValue(session, key: "usedOfflineMeterCounters")
1304

            
1305
        if let lastObservedAt {
1306
            let deltaSeconds = max(snapshot.observedAt.timeIntervalSince(lastObservedAt), 0)
1307
            if deltaSeconds > 0, deltaSeconds <= maximumLiveIntegrationGap {
1308
                measuredEnergyWh += max(previousPower, 0) * deltaSeconds / 3600
1309
                measuredChargeAh += max(previousCurrent, 0) * deltaSeconds / 3600
1310
                if sourceMode == .offline {
1311
                    sourceMode = .blended
1312
                }
1313
            }
1314
        }
1315

            
1316
        if let counterGroup = snapshot.selectedDataGroup,
1317
           let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
1318
           UInt8(storedGroup) != counterGroup {
1319
            session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
1320
            session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1321
            session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1322
        }
1323

            
1324
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1325
            let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
1326
            let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
1327
            if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
1328
                session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
1329
            }
1330

            
1331
            if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
1332
                let offlineEnergy = meterEnergyCounterWh - baselineEnergy
Bogdan Timofte authored a month ago
1333
                measuredEnergyWh = max(offlineEnergy, 0)
Bogdan Timofte authored a month ago
1334
                usedOfflineMeterCounters = true
Bogdan Timofte authored a month ago
1335
                sourceMode = .offline
Bogdan Timofte authored a month ago
1336
            } else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
1337
                let delta = meterEnergyCounterWh - lastEnergy
1338
                if delta > 0 {
1339
                    measuredEnergyWh += delta
1340
                    usedOfflineMeterCounters = true
1341
                    sourceMode = .blended
1342
                }
1343
            }
1344
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1345
        }
1346

            
1347
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1348
            let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
1349
            let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
1350
            if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
1351
                session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
1352
            }
1353

            
1354
            if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
1355
                let offlineCharge = meterChargeCounterAh - baselineCharge
Bogdan Timofte authored a month ago
1356
                measuredChargeAh = max(offlineCharge, 0)
Bogdan Timofte authored a month ago
1357
                usedOfflineMeterCounters = true
1358
            } else if let lastCharge, meterChargeCounterAh > lastCharge {
1359
                let delta = meterChargeCounterAh - lastCharge
1360
                if delta > 0 {
1361
                    measuredChargeAh += delta
1362
                    usedOfflineMeterCounters = true
1363
                }
1364
            }
1365
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1366
        }
1367

            
Bogdan Timofte authored a month ago
1368
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1369
            let baselineDuration = optionalDoubleValue(session, key: "meterDurationBaselineSeconds") ?? meterRecordingDurationSeconds
1370
            if optionalDoubleValue(session, key: "meterDurationBaselineSeconds") == nil {
1371
                setValue(baselineDuration, on: session, key: "meterDurationBaselineSeconds")
1372
            }
1373
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1374
        }
1375

            
Bogdan Timofte authored a month ago
1376
        let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps")
1377
        let updatedMinimum: Double
1378
        if snapshot.currentAmps > 0 {
1379
            updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps)
1380
        } else {
1381
            updatedMinimum = existingMinimum ?? 0
1382
        }
1383

            
Bogdan Timofte authored a month ago
1384
        let effectiveCurrent = effectiveCurrentAmps(
1385
            fromMeasuredCurrent: snapshot.currentAmps,
1386
            chargingTransportMode: sessionChargingTransportMode,
1387
            charger: charger
1388
        )
1389
        let observedChargeFlow = boolValue(session, key: "hasObservedChargeFlow")
1390
            || hasObservedChargeFlow(
1391
                currentAmps: snapshot.currentAmps,
1392
                chargingTransportMode: sessionChargingTransportMode,
1393
                charger: charger,
1394
                stopThreshold: stopThreshold
1395
            )
1396

            
Bogdan Timofte authored a month ago
1397
        session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh")
1398
        session.setValue(measuredChargeAh, forKey: "measuredChargeAh")
1399
        session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps")
Bogdan Timofte authored a month ago
1400
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1401
        session.setValue(snapshot.observedAt, forKey: "lastObservedAt")
1402
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1403
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1404
        session.setValue(
1405
            sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1406
            forKey: "lastObservedVoltageVolts"
1407
        )
Bogdan Timofte authored a month ago
1408
        session.setValue(observedChargeFlow, forKey: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
1409
        session.setValue(
1410
            max(optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? snapshot.currentAmps, snapshot.currentAmps),
1411
            forKey: "maximumObservedCurrentAmps"
1412
        )
1413
        session.setValue(
1414
            max(optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? snapshot.powerWatts, snapshot.powerWatts),
1415
            forKey: "maximumObservedPowerWatts"
1416
        )
1417
        session.setValue(
1418
            sessionChargingTransportMode == .wired
1419
                ? max(optionalDoubleValue(session, key: "maximumObservedVoltageVolts") ?? snapshot.voltageVolts, snapshot.voltageVolts)
1420
                : nil,
1421
            forKey: "maximumObservedVoltageVolts"
1422
        )
1423
        session.setValue(usedOfflineMeterCounters, forKey: "usedOfflineMeterCounters")
1424
        session.setValue(sourceMode.rawValue, forKey: "sourceModeRawValue")
1425
        maybeTriggerTargetBatteryAlert(for: session, observedAt: snapshot.observedAt)
1426

            
Bogdan Timofte authored a month ago
1427
        guard boolValue(session, key: "autoStopEnabled"), let stopThreshold, stopThreshold > 0, observedChargeFlow else {
1428
            session.setValue(nil, forKey: "belowThresholdSince")
1429
            clearCompletionConfirmationState(for: session)
1430
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1431
            return
1432
        }
1433

            
1434
        if effectiveCurrent <= stopThreshold {
Bogdan Timofte authored a month ago
1435
            let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt
1436
            session.setValue(belowThresholdSince, forKey: "belowThresholdSince")
1437
            if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
1438
                if boolValue(session, key: "requiresCompletionConfirmation") {
1439
                    // Leave the session active until the user explicitly confirms or charging resumes.
1440
                    return
1441
                }
1442

            
1443
                if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
1444
                    requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt)
1445
                } else {
Bogdan Timofte authored a month ago
1446
                    finishSession(
1447
                        session,
1448
                        observedAt: snapshot.observedAt,
1449
                        finalBatteryPercent: nil,
1450
                        status: .completed
1451
                    )
Bogdan Timofte authored a month ago
1452
                }
1453
            }
1454
        } else {
1455
            session.setValue(nil, forKey: "belowThresholdSince")
1456
            clearCompletionConfirmationState(for: session)
1457
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1458
        }
1459
    }
1460

            
1461
    private func updateAggregatedSample(
1462
        session: NSManagedObject,
1463
        with snapshot: ChargingMonitorSnapshot
Bogdan Timofte authored a month ago
1464
    ) -> NSManagedObject? {
Bogdan Timofte authored a month ago
1465
        guard
1466
            let sessionID = stringValue(session, key: "id"),
1467
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1468
            let startedAt = dateValue(session, key: "startedAt"),
1469
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context)
1470
        else {
Bogdan Timofte authored a month ago
1471
            return nil
Bogdan Timofte authored a month ago
1472
        }
1473

            
1474
        let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0)
1475
        let bucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
1476
        let bucketStart = startedAt.addingTimeInterval(Double(bucketIndex) * Self.aggregatedSampleBucketDuration)
1477
        let bucketIdentifier = "\(sessionID)-\(bucketIndex)"
1478
        let sample = fetchSessionSampleObject(sessionID: sessionID, bucketIndex: bucketIndex)
1479
            ?? NSManagedObject(entity: entity, insertInto: context)
1480
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1481
        let sampleVoltage = sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil
1482

            
1483
        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
1484
        let updatedCount = existingCount + 1
1485

            
1486
        sample.setValue(bucketIdentifier, forKey: "id")
1487
        sample.setValue(sessionID, forKey: "sessionID")
1488
        sample.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1489
        sample.setValue(bucketIndex, forKey: "bucketIndex")
1490
        sample.setValue(max(snapshot.observedAt, bucketStart), forKey: "timestamp")
1491
        sample.setValue(
1492
            runningAverage(
1493
                currentAverage: optionalDoubleValue(sample, key: "averageCurrentAmps") ?? snapshot.currentAmps,
1494
                currentCount: Int(existingCount),
1495
                newValue: snapshot.currentAmps
1496
            ),
1497
            forKey: "averageCurrentAmps"
1498
        )
1499
        sample.setValue(
1500
            sampleVoltage.flatMap { voltage in
1501
                runningAverage(
1502
                    currentAverage: optionalDoubleValue(sample, key: "averageVoltageVolts") ?? voltage,
1503
                    currentCount: Int(existingCount),
1504
                    newValue: voltage
1505
                )
1506
            },
1507
            forKey: "averageVoltageVolts"
1508
        )
1509
        sample.setValue(
1510
            runningAverage(
1511
                currentAverage: optionalDoubleValue(sample, key: "averagePowerWatts") ?? snapshot.powerWatts,
1512
                currentCount: Int(existingCount),
1513
                newValue: snapshot.powerWatts
1514
            ),
1515
            forKey: "averagePowerWatts"
1516
        )
1517
        sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh")
1518
        sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
1519
        sample.setValue(Int16(updatedCount), forKey: "sampleCount")
1520
        sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
1521
        sample.setValue(snapshot.observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1522
        return sample
Bogdan Timofte authored a month ago
1523
    }
1524

            
Bogdan Timofte authored a month ago
1525
    private func maybeTriggerTargetBatteryAlert(
1526
        for session: NSManagedObject,
1527
        observedAt: Date,
1528
        completionFallbackPercent: Double? = nil
1529
    ) {
Bogdan Timofte authored a month ago
1530
        guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
1531
            return
1532
        }
1533

            
1534
        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
1535
            return
1536
        }
1537

            
1538
        let predictedBatteryPercent = predictedBatteryPercent(for: session)
1539
            ?? optionalDoubleValue(session, key: "endBatteryPercent")
Bogdan Timofte authored a month ago
1540
            ?? completionFallbackPercent
Bogdan Timofte authored a month ago
1541

            
1542
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
1543
            return
1544
        }
1545

            
1546
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
1547
    }
1548

            
1549
    private func shouldRequireCompletionConfirmation(
1550
        for session: NSManagedObject,
1551
        observedAt: Date
1552
    ) -> Bool {
1553
        if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"),
1554
           cooldownUntil > observedAt {
1555
            return false
1556
        }
1557

            
1558
        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
1559
            return false
1560
        }
1561

            
1562
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
1563
            ?? defaultCompletionPercentThreshold
1564

            
1565
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1566
    }
1567

            
1568
    private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
1569
        guard !boolValue(session, key: "requiresCompletionConfirmation") else {
1570
            return
1571
        }
1572

            
1573
        session.setValue(true, forKey: "requiresCompletionConfirmation")
1574
        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
1575
        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
1576
    }
1577

            
1578
    private func clearCompletionConfirmationState(for session: NSManagedObject) {
1579
        session.setValue(false, forKey: "requiresCompletionConfirmation")
1580
        session.setValue(nil, forKey: "completionConfirmationRequestedAt")
1581
        session.setValue(nil, forKey: "completionContradictionPercent")
1582
    }
1583

            
Bogdan Timofte authored a month ago
1584
    private func snapshotDateForManualStop(_ session: NSManagedObject) -> Date {
1585
        if statusValue(session, key: "statusRawValue") == .paused {
1586
            return dateValue(session, key: "pausedAt")
1587
                ?? dateValue(session, key: "lastObservedAt")
1588
                ?? Date()
1589
        }
1590
        return dateValue(session, key: "lastObservedAt") ?? Date()
1591
    }
1592

            
Bogdan Timofte authored a month ago
1593
    private func snapshotClampedToMaximumDuration(
1594
        _ snapshot: ChargingMonitorSnapshot,
1595
        for session: NSManagedObject
1596
    ) -> ChargingMonitorSnapshot {
1597
        guard let maximumEndDate = maximumEndDate(for: session),
1598
              snapshot.observedAt > maximumEndDate else {
1599
            return snapshot
1600
        }
1601

            
1602
        return ChargingMonitorSnapshot(
1603
            meterMACAddress: snapshot.meterMACAddress,
1604
            meterName: snapshot.meterName,
1605
            meterModel: snapshot.meterModel,
1606
            observedAt: maximumEndDate,
1607
            voltageVolts: snapshot.voltageVolts,
1608
            currentAmps: snapshot.currentAmps,
1609
            powerWatts: snapshot.powerWatts,
1610
            selectedDataGroup: snapshot.selectedDataGroup,
1611
            meterChargeCounterAh: snapshot.meterChargeCounterAh,
1612
            meterEnergyCounterWh: snapshot.meterEnergyCounterWh,
1613
            meterRecordingDurationSeconds: snapshot.meterRecordingDurationSeconds,
1614
            fallbackStopThresholdAmps: snapshot.fallbackStopThresholdAmps
1615
        )
1616
    }
1617

            
1618
    private func automaticCompletionDate(
1619
        for session: NSManagedObject,
1620
        referenceDate: Date
1621
    ) -> Date? {
1622
        guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
1623
            return nil
Bogdan Timofte authored a month ago
1624
        }
1625

            
Bogdan Timofte authored a month ago
1626
        var completionDates: [Date] = []
1627

            
1628
        if let maximumEndDate = maximumEndDate(for: session) {
1629
            completionDates.append(maximumEndDate)
1630
        }
1631

            
1632
        if statusValue(session, key: "statusRawValue") == .paused,
1633
           let pausedAt = dateValue(session, key: "pausedAt") {
1634
            completionDates.append(pausedAt.addingTimeInterval(pausedSessionTimeout))
1635
        }
1636

            
1637
        guard let completionDate = completionDates.min(),
1638
              referenceDate >= completionDate else {
1639
            return nil
1640
        }
1641

            
1642
        return completionDate
1643
    }
1644

            
1645
    private func maximumEndDate(for session: NSManagedObject) -> Date? {
1646
        dateValue(session, key: "startedAt")?.addingTimeInterval(Self.maximumChargeSessionDuration)
1647
    }
1648

            
1649
    @discardableResult
1650
    private func maybeCompleteOpenSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
1651
        guard statusValue(session, key: "statusRawValue")?.isOpen == true,
1652
              let completionDate = automaticCompletionDate(for: session, referenceDate: observedAt) else {
Bogdan Timofte authored a month ago
1653
            return false
1654
        }
1655

            
1656
        finishSession(
1657
            session,
Bogdan Timofte authored a month ago
1658
            observedAt: completionDate,
Bogdan Timofte authored a month ago
1659
            finalBatteryPercent: nil,
1660
            status: .completed
1661
        )
1662

            
1663
        guard saveContext() else {
1664
            return false
1665
        }
1666

            
1667
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1668
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1669
            return saveContext()
1670
        }
1671

            
1672
        return true
1673
    }
1674

            
1675
    private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
1676
        let chargingTransportMode = chargingTransportMode(for: session)
1677
        let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps")
1678
            ?? doubleValue(session, key: "lastObservedCurrentAmps")
1679

            
1680
        guard measuredCurrent > 0 else {
1681
            return nil
1682
        }
1683

            
1684
        let charger = chargingTransportMode == .wireless
1685
            ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
1686
            : nil
1687

            
1688
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
1689
            return nil
1690
        }
1691

            
1692
        let effectiveCurrent = effectiveCurrentAmps(
1693
            fromMeasuredCurrent: measuredCurrent,
1694
            chargingTransportMode: chargingTransportMode,
1695
            charger: charger
1696
        )
1697
        guard effectiveCurrent > 0 else {
1698
            return nil
1699
        }
1700
        return effectiveCurrent
1701
    }
1702

            
1703
    private func finishSession(
1704
        _ session: NSManagedObject,
1705
        observedAt: Date,
1706
        finalBatteryPercent: Double?,
1707
        status: ChargeSessionStatus
1708
    ) {
1709
        if let finalBatteryPercent {
1710
            _ = insertBatteryCheckpoint(
1711
                percent: finalBatteryPercent,
Bogdan Timofte authored a month ago
1712
                flag: .final,
Bogdan Timofte authored a month ago
1713
                timestamp: observedAt,
1714
                to: session
1715
            )
1716
        }
1717

            
1718
        session.setValue(status.rawValue, forKey: "statusRawValue")
1719
        session.setValue(nil, forKey: "pausedAt")
1720
        session.setValue(nil, forKey: "belowThresholdSince")
1721
        session.setValue(observedAt, forKey: "endedAt")
1722
        session.setValue(observedAt, forKey: "lastObservedAt")
1723
        session.setValue(completionCurrentForSessionEnd(session), forKey: "completionCurrentAmps")
1724
        clearCompletionConfirmationState(for: session)
1725
        session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1726
        updateCapacityEstimate(for: session)
1727
        session.setValue(observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1728

            
1729
        if status == .completed {
1730
            maybeTriggerTargetBatteryAlert(
1731
                for: session,
1732
                observedAt: observedAt,
1733
                completionFallbackPercent: defaultCompletionPercentThreshold
1734
            )
1735
        }
Bogdan Timofte authored a month ago
1736
    }
1737

            
Bogdan Timofte authored a month ago
1738
    private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
1739
        guard
1740
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1741
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
1742
            let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice),
1743
            estimatedCapacityWh > 0
1744
        else {
1745
            return nil
1746
        }
1747

            
Bogdan Timofte authored a month ago
1748
        // Compute effective battery energy dynamically so the prediction uses the
1749
        // most current measuredEnergyWh rather than the stale effectiveBatteryEnergyWh
1750
        // (which is only refreshed at session start, checkpoint insertion, and finish).
1751
        let rawMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1752
        let measuredEnergyWh: Double
1753
        switch chargingTransportMode(for: session) {
1754
        case .wired:
1755
            measuredEnergyWh = rawMeasuredEnergyWh
1756
        case .wireless:
1757
            if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 {
1758
                measuredEnergyWh = rawMeasuredEnergyWh * factor
1759
            } else {
1760
                measuredEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1761
                    ?? rawMeasuredEnergyWh
1762
            }
1763
        }
Bogdan Timofte authored a month ago
1764
        let sessionID = stringValue(session, key: "id") ?? ""
1765

            
1766
        struct Anchor {
1767
            let percent: Double
1768
            let energyWh: Double
Bogdan Timofte authored a month ago
1769
            let timestamp: Date
1770
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1771
        }
1772

            
1773
        var anchors: [Anchor] = []
Bogdan Timofte authored a month ago
1774
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1775
           startBatteryPercent >= 0 {
1776
            anchors.append(
1777
                Anchor(
1778
                    percent: startBatteryPercent,
1779
                    energyWh: 0,
Bogdan Timofte authored a month ago
1780
                    timestamp: dateValue(session, key: "trimStart")
1781
                        ?? dateValue(session, key: "startedAt")
1782
                        ?? Date.distantPast,
Bogdan Timofte authored a month ago
1783
                    isCheckpoint: false
1784
                )
1785
            )
Bogdan Timofte authored a month ago
1786
        }
1787

            
1788
        let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
1789
            .compactMap(makeCheckpointSummary(from:))
1790
            .sorted { lhs, rhs in
1791
                if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1792
                    return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1793
                }
1794
                return lhs.timestamp < rhs.timestamp
1795
            }
Bogdan Timofte authored a month ago
1796
            .filter { $0.batteryPercent >= 0 }
1797
            .map {
1798
                Anchor(
1799
                    percent: $0.batteryPercent,
1800
                    energyWh: $0.measuredEnergyWh,
1801
                    timestamp: $0.timestamp,
1802
                    isCheckpoint: true
1803
                )
1804
            }
Bogdan Timofte authored a month ago
1805
        anchors.append(contentsOf: checkpointAnchors)
1806

            
1807
        guard !anchors.isEmpty else {
1808
            return optionalDoubleValue(session, key: "endBatteryPercent")
1809
        }
1810

            
1811
        let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
Bogdan Timofte authored a month ago
1812
        return BatteryLevelPredictionTuning.predictedPercent(
1813
            anchorPercent: anchor.percent,
1814
            anchorEnergyWh: anchor.energyWh,
1815
            anchorTimestamp: anchor.timestamp,
1816
            anchorIsCheckpoint: anchor.isCheckpoint,
1817
            effectiveEnergyWh: measuredEnergyWh,
1818
            referenceTimestamp: dateValue(session, key: "lastObservedAt") ?? anchor.timestamp,
1819
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1820
        )
1821
    }
1822

            
1823
    private func resolvedEstimatedBatteryCapacityWh(
1824
        for session: NSManagedObject,
1825
        chargedDevice: NSManagedObject
1826
    ) -> Double? {
1827
        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
1828
           sessionCapacityEstimate > 0 {
1829
            return sessionCapacityEstimate
1830
        }
1831

            
1832
        switch chargingTransportMode(for: session) {
1833
        case .wired:
1834
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1835
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1836
        case .wireless:
1837
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1838
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1839
        }
1840
    }
1841

            
1842
    private func updateCapacityEstimate(for session: NSManagedObject) {
1843
        guard
1844
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1845
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
1846
        else {
1847
            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
1848
            session.setValue(nil, forKey: "capacityEstimateWh")
1849
            return
1850
        }
1851

            
1852
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1853
        let chargingMode = chargingTransportMode(for: session)
1854
        let wirelessResolution = chargingMode == .wireless
1855
            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
1856
            : nil
1857
        let effectiveBatteryEnergyWh = chargingMode == .wired
1858
            ? measuredEnergyWh
1859
            : wirelessResolution.map { measuredEnergyWh * $0.factor }
1860

            
1861
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
1862
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
1863
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
1864
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
1865

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

            
1868
        guard let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
Bogdan Timofte authored a month ago
1869
            session.setValue(nil, forKey: "capacityEstimateWh")
1870
            return
1871
        }
1872

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

            
1879
        var anchors: [CapacityAnchor] = []
1880

            
1881
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1882
           startBatteryPercent >= 0 {
1883
            anchors.append(
1884
                CapacityAnchor(
1885
                    percent: startBatteryPercent,
1886
                    energyWh: 0,
1887
                    timestamp: dateValue(session, key: "trimStart")
1888
                        ?? dateValue(session, key: "startedAt")
1889
                        ?? Date.distantPast
1890
                )
1891
            )
1892
        }
1893

            
1894
        if let sessionID = stringValue(session, key: "id") {
1895
            anchors.append(
1896
                contentsOf: fetchCheckpointObjects(forSessionID: sessionID).compactMap { checkpoint in
1897
                    guard
1898
                        let percent = optionalDoubleValue(checkpoint, key: "batteryPercent"),
1899
                        percent >= 0,
1900
                        let timestamp = dateValue(checkpoint, key: "timestamp")
1901
                    else {
1902
                        return nil
1903
                    }
1904

            
1905
                    return CapacityAnchor(
1906
                        percent: percent,
1907
                        energyWh: doubleValue(checkpoint, key: "measuredEnergyWh"),
1908
                        timestamp: timestamp
1909
                    )
1910
                }
1911
            )
1912
        }
1913

            
1914
        if let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"),
1915
           endBatteryPercent >= 0 {
1916
            anchors.append(
1917
                CapacityAnchor(
1918
                    percent: endBatteryPercent,
1919
                    energyWh: effectiveBatteryEnergyWh,
1920
                    timestamp: dateValue(session, key: "endedAt")
1921
                        ?? dateValue(session, key: "lastObservedAt")
1922
                        ?? Date.distantPast
1923
                )
1924
            )
1925
        }
1926

            
1927
        let sortedAnchors = anchors.sorted { lhs, rhs in
1928
            if lhs.energyWh != rhs.energyWh {
1929
                return lhs.energyWh < rhs.energyWh
1930
            }
1931
            return lhs.timestamp < rhs.timestamp
1932
        }
1933

            
1934
        guard let firstAnchor = sortedAnchors.first,
1935
              let lastAnchor = sortedAnchors.last else {
Bogdan Timofte authored a month ago
1936
            session.setValue(nil, forKey: "capacityEstimateWh")
1937
            return
1938
        }
1939

            
Bogdan Timofte authored a month ago
1940
        let percentDelta = lastAnchor.percent - firstAnchor.percent
1941
        let energyDelta = lastAnchor.energyWh - firstAnchor.energyWh
Bogdan Timofte authored a month ago
1942

            
Bogdan Timofte authored a month ago
1943
        guard percentDelta >= 20, energyDelta > 0 else {
Bogdan Timofte authored a month ago
1944
            session.setValue(nil, forKey: "capacityEstimateWh")
1945
            return
1946
        }
1947

            
Bogdan Timofte authored a month ago
1948
        if !supportsChargingWhileOff && lastAnchor.percent >= 99.5 {
Bogdan Timofte authored a month ago
1949
            session.setValue(nil, forKey: "capacityEstimateWh")
1950
            return
1951
        }
1952

            
Bogdan Timofte authored a month ago
1953
        let capacityEstimateWh = energyDelta / (percentDelta / 100)
Bogdan Timofte authored a month ago
1954
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
1955
    }
1956

            
1957
    @discardableResult
Bogdan Timofte authored a month ago
1958
    private func insertBatteryCheckpoint(
Bogdan Timofte authored a month ago
1959
        percent: Double,
Bogdan Timofte authored a month ago
1960
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
1961
        timestamp: Date = Date(),
Bogdan Timofte authored a month ago
1962
        measuredEnergyWhOverride: Double? = nil,
1963
        measuredChargeAhOverride: Double? = nil,
Bogdan Timofte authored a month ago
1964
        to session: NSManagedObject
Bogdan Timofte authored a month ago
1965
    ) -> String? {
Bogdan Timofte authored a month ago
1966
        guard
1967
            let sessionID = stringValue(session, key: "id"),
1968
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1969
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
1970
        else {
Bogdan Timofte authored a month ago
1971
            return nil
Bogdan Timofte authored a month ago
1972
        }
1973

            
1974
        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
Bogdan Timofte authored a month ago
1975
        let checkpointEnergyWh = measuredEnergyWhOverride
1976
            ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
Bogdan Timofte authored a month ago
1977
            ?? doubleValue(session, key: "measuredEnergyWh")
Bogdan Timofte authored a month ago
1978
        let checkpointChargeAh = measuredChargeAhOverride
1979
            ?? doubleValue(session, key: "measuredChargeAh")
Bogdan Timofte authored a month ago
1980
        checkpoint.setValue(UUID().uuidString, forKey: "id")
1981
        checkpoint.setValue(sessionID, forKey: "sessionID")
1982
        checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
Bogdan Timofte authored a month ago
1983
        checkpoint.setValue(timestamp, forKey: "timestamp")
Bogdan Timofte authored a month ago
1984
        checkpoint.setValue(percent, forKey: "batteryPercent")
1985
        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
Bogdan Timofte authored a month ago
1986
        checkpoint.setValue(checkpointChargeAh, forKey: "measuredChargeAh")
Bogdan Timofte authored a month ago
1987
        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
1988
        checkpoint.setValue(
1989
            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
1990
            forKey: "voltageVolts"
1991
        )
Bogdan Timofte authored a month ago
1992
        checkpoint.setValue(flag.rawValue, forKey: "label")
Bogdan Timofte authored a month ago
1993
        checkpoint.setValue(timestamp, forKey: "createdAt")
Bogdan Timofte authored a month ago
1994

            
Bogdan Timofte authored a month ago
1995
        let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
1996
        if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
Bogdan Timofte authored a month ago
1997
            session.setValue(percent, forKey: "startBatteryPercent")
1998
        }
Bogdan Timofte authored a month ago
1999
        if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
2000
            session.setValue(percent, forKey: "endBatteryPercent")
2001
        }
Bogdan Timofte authored a month ago
2002
        session.setValue(timestamp, forKey: "updatedAt")
Bogdan Timofte authored a month ago
2003
        updateCapacityEstimate(for: session)
2004

            
Bogdan Timofte authored a month ago
2005
        return chargedDeviceID
2006
    }
2007

            
Bogdan Timofte authored a month ago
2008
    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
2009
        guard let sessionID = stringValue(session, key: "id") else {
2010
            return
2011
        }
2012

            
2013
        let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
2014
        if let latestCheckpoint = remainingCheckpoints.last {
2015
            session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
2016
        } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2017
                  startBatteryPercent >= 0 {
2018
            session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
2019
        } else {
2020
            session.setValue(nil, forKey: "endBatteryPercent")
2021
        }
2022

            
2023
        session.setValue(Date(), forKey: "updatedAt")
2024
        updateCapacityEstimate(for: session)
2025
    }
2026

            
Bogdan Timofte authored a month ago
2027
    @discardableResult
2028
    private func addBatteryCheckpoint(
2029
        percent: Double,
Bogdan Timofte authored a month ago
2030
        measuredEnergyWh: Double? = nil,
2031
        measuredChargeAh: Double? = nil,
Bogdan Timofte authored a month ago
2032
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
2033
        to session: NSManagedObject,
2034
        timestamp: Date = Date()
2035
    ) -> Bool {
Bogdan Timofte authored a month ago
2036
        if let measuredEnergyWh, measuredEnergyWh.isFinite {
2037
            session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
2038
        }
2039
        if let measuredChargeAh, measuredChargeAh.isFinite {
2040
            session.setValue(max(measuredChargeAh, 0), forKey: "measuredChargeAh")
2041
        }
2042

            
Bogdan Timofte authored a month ago
2043
        guard let chargedDeviceID = insertBatteryCheckpoint(
2044
            percent: percent,
Bogdan Timofte authored a month ago
2045
            flag: flag,
Bogdan Timofte authored a month ago
2046
            timestamp: timestamp,
Bogdan Timofte authored a month ago
2047
            measuredEnergyWhOverride: measuredEnergyWh,
2048
            measuredChargeAhOverride: measuredChargeAh,
Bogdan Timofte authored a month ago
2049
            to: session
2050
        ) else {
2051
            return false
2052
        }
2053

            
Bogdan Timofte authored a month ago
2054
        guard saveContext() else {
2055
            return false
2056
        }
2057

            
2058
        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
2059
        return saveContext()
2060
    }
2061

            
2062
    private func resolvedWirelessEfficiency(
2063
        for session: NSManagedObject,
2064
        chargedDevice: NSManagedObject
2065
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
2066
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
2067
           storedFactor > 0 {
2068
            return (
2069
                factor: storedFactor,
2070
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
2071
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
2072
            )
2073
        }
2074

            
2075
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
2076
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2077
        guard measuredEnergyWh > 0 else {
2078
            return nil
2079
        }
2080

            
2081
        if chargingProfile == .magsafe,
2082
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
2083
           calibratedFactor > 0 {
2084
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
2085
        }
2086

            
2087
        guard
2088
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2089
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
2090
        else {
2091
            return nil
2092
        }
2093

            
2094
        let percentDelta = endBatteryPercent - startBatteryPercent
2095
        guard percentDelta >= 20 else {
2096
            return nil
2097
        }
2098

            
2099
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
Bogdan Timofte authored a month ago
2100
            ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
Bogdan Timofte authored a month ago
2101
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2102
                : nil),
2103
              wiredCapacityWh > 0
2104
        else {
2105
            return nil
2106
        }
2107

            
2108
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
2109
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
2110
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
2111
        let usesEstimated = chargingProfile != .magsafe
2112
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
2113

            
2114
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
2115
    }
2116

            
2117
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
2118
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
2119
            return
2120
        }
2121

            
Bogdan Timofte authored a month ago
2122
        let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
Bogdan Timofte authored a month ago
2123
        let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
2124
        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
2125
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
2126
        let sessions = relevantSessionObjects(
2127
            for: chargedDeviceID,
2128
            deviceClass: deviceClass,
2129
            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
2130
            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
2131
        )
Bogdan Timofte authored a month ago
2132
        let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
Bogdan Timofte authored a month ago
2133
        let wiredMinimumCurrent = derivedMinimumCurrent(
2134
            from: sessions,
2135
            chargingTransportMode: .wired
2136
        )
2137
        let wirelessMinimumCurrent = derivedMinimumCurrent(
2138
            from: sessions,
2139
            chargingTransportMode: .wireless
2140
        )
2141

            
2142
        let wiredCapacity = derivedCapacity(
2143
            from: sessions,
2144
            chargingTransportMode: .wired,
2145
            supportsChargingWhileOff: supportsChargingWhileOff
2146
        )
2147
        let wirelessCapacity = derivedCapacity(
2148
            from: sessions,
2149
            chargingTransportMode: .wireless,
2150
            supportsChargingWhileOff: supportsChargingWhileOff
2151
        )
2152
        let wirelessEfficiency = derivedWirelessEfficiency(
2153
            from: sessions,
2154
            chargingProfile: wirelessProfile
2155
        )
Bogdan Timofte authored a month ago
2156
        let configuredCompletionCurrents = decodedCompletionCurrents(
2157
            from: chargedDevice,
2158
            key: "configuredCompletionCurrentsRawValue"
2159
        )
Bogdan Timofte authored a month ago
2160
        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
2161
        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
2162
        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
2163
        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
2164
        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
2165
        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
2166

            
Bogdan Timofte authored a month ago
2167
        let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
2168
        let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
Bogdan Timofte authored a month ago
2169
        let preferredMinimumCurrent: Double?
2170
        let preferredCapacity: Double?
2171
        switch preferredChargingTransportMode {
2172
        case .wired:
Bogdan Timofte authored a month ago
2173
            preferredMinimumCurrent = configuredCompletionCurrents[
2174
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2175
            ] ?? learnedCompletionCurrents[
2176
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2177
            ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
Bogdan Timofte authored a month ago
2178
            preferredCapacity = wiredCapacity ?? wirelessCapacity
2179
        case .wireless:
Bogdan Timofte authored a month ago
2180
            preferredMinimumCurrent = configuredCompletionCurrents[
2181
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2182
            ] ?? learnedCompletionCurrents[
2183
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2184
            ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
Bogdan Timofte authored a month ago
2185
            preferredCapacity = wirelessCapacity ?? wiredCapacity
2186
        }
2187

            
Bogdan Timofte authored a month ago
2188
        setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
2189
        setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
2190
        setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
2191
        setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
2192
        setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
2193
        setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
2194
        setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
2195
        setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
2196
        setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
2197
        setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
2198
        setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
2199
        setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
2200
        setValue(Date(), on: chargedDevice, key: "updatedAt")
Bogdan Timofte authored a month ago
2201
    }
2202

            
2203
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
2204
        sessions
2205
            .filter { $0.status == .completed }
2206
            .compactMap { session in
2207
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
2208
                let timestamp = session.endedAt ?? session.lastObservedAt
2209
                return CapacityTrendPoint(
2210
                    sessionID: session.id,
2211
                    timestamp: timestamp,
2212
                    capacityWh: capacityEstimateWh,
2213
                    chargingTransportMode: session.chargingTransportMode
2214
                )
2215
            }
2216
            .sorted { $0.timestamp < $1.timestamp }
2217
    }
2218

            
2219
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
2220
        var groupedEnergyByBin: [Int: [Double]] = [:]
2221
        var groupedChargeByBin: [Int: [Double]] = [:]
2222

            
2223
        for session in sessions where session.status == .completed {
Bogdan Timofte authored a month ago
2224
            let anchors = normalizedTypicalCurveAnchors(for: session)
2225
            guard anchors.count >= 2 else {
2226
                continue
Bogdan Timofte authored a month ago
2227
            }
2228

            
Bogdan Timofte authored a month ago
2229
            for percentBin in stride(from: 0, through: 100, by: 10) {
2230
                guard let interpolatedPoint = interpolatedTypicalCurvePoint(
2231
                    for: Double(percentBin),
2232
                    anchors: anchors
2233
                ) else {
2234
                    continue
2235
                }
Bogdan Timofte authored a month ago
2236

            
Bogdan Timofte authored a month ago
2237
                groupedEnergyByBin[percentBin, default: []].append(interpolatedPoint.energyWh)
2238
                groupedChargeByBin[percentBin, default: []].append(interpolatedPoint.chargeAh)
Bogdan Timofte authored a month ago
2239
            }
2240
        }
2241

            
Bogdan Timofte authored a month ago
2242
        let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
Bogdan Timofte authored a month ago
2243
            guard
2244
                let energies = groupedEnergyByBin[percentBin],
2245
                let charges = groupedChargeByBin[percentBin],
2246
                !energies.isEmpty,
2247
                !charges.isEmpty
2248
            else {
2249
                return nil
2250
            }
2251

            
2252
            return TypicalChargeCurvePoint(
2253
                percentBin: percentBin,
2254
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
2255
                averageChargeAh: charges.reduce(0, +) / Double(charges.count),
2256
                sampleCount: min(energies.count, charges.count)
2257
            )
2258
        }
Bogdan Timofte authored a month ago
2259

            
2260
        var runningMaximumEnergyWh = 0.0
2261
        var runningMaximumChargeAh = 0.0
2262

            
2263
        return averagedPoints.map { point in
2264
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
2265
            runningMaximumChargeAh = max(runningMaximumChargeAh, point.averageChargeAh)
2266
            return TypicalChargeCurvePoint(
2267
                percentBin: point.percentBin,
2268
                averageEnergyWh: runningMaximumEnergyWh,
2269
                averageChargeAh: runningMaximumChargeAh,
2270
                sampleCount: point.sampleCount
2271
            )
2272
        }
2273
    }
2274

            
2275
    private func normalizedTypicalCurveAnchors(
2276
        for session: ChargeSessionSummary
2277
    ) -> [(percent: Double, energyWh: Double, chargeAh: Double)] {
2278
        struct Anchor {
2279
            let percent: Double
2280
            let energyWh: Double
2281
            let chargeAh: Double
2282
            let timestamp: Date
2283
        }
2284

            
2285
        var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
2286
            guard checkpoint.batteryPercent.isFinite,
2287
                  checkpoint.measuredEnergyWh.isFinite,
2288
                  checkpoint.measuredChargeAh.isFinite,
2289
                  checkpoint.batteryPercent >= 0,
2290
                  checkpoint.batteryPercent <= 100,
2291
                  checkpoint.measuredEnergyWh >= 0,
2292
                  checkpoint.measuredChargeAh >= 0 else {
2293
                return nil
2294
            }
2295

            
2296
            return Anchor(
2297
                percent: checkpoint.batteryPercent,
2298
                energyWh: checkpoint.measuredEnergyWh,
2299
                chargeAh: checkpoint.measuredChargeAh,
2300
                timestamp: checkpoint.timestamp
2301
            )
2302
        }
2303

            
2304
        if let startBatteryPercent = session.startBatteryPercent,
2305
           startBatteryPercent.isFinite,
2306
           startBatteryPercent >= 0,
2307
           startBatteryPercent <= 100 {
2308
            anchors.append(
2309
                Anchor(
2310
                    percent: startBatteryPercent,
2311
                    energyWh: 0,
2312
                    chargeAh: 0,
2313
                    timestamp: session.startedAt
2314
                )
2315
            )
2316
        }
2317

            
2318
        if let endBatteryPercent = session.endBatteryPercent,
2319
           endBatteryPercent.isFinite,
2320
           endBatteryPercent >= 0,
2321
           endBatteryPercent <= 100 {
2322
            anchors.append(
2323
                Anchor(
2324
                    percent: endBatteryPercent,
2325
                    energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
2326
                    chargeAh: session.measuredChargeAh,
2327
                    timestamp: session.endedAt ?? session.lastObservedAt
2328
                )
2329
            )
2330
        }
2331

            
2332
        let sortedAnchors = anchors.sorted { lhs, rhs in
2333
            if lhs.percent != rhs.percent {
2334
                return lhs.percent < rhs.percent
2335
            }
2336
            if lhs.energyWh != rhs.energyWh {
2337
                return lhs.energyWh < rhs.energyWh
2338
            }
2339
            return lhs.timestamp < rhs.timestamp
2340
        }
2341

            
2342
        var collapsedAnchors: [(percent: Double, energyWh: Double, chargeAh: Double)] = []
2343

            
2344
        for anchor in sortedAnchors {
2345
            if let lastIndex = collapsedAnchors.indices.last,
2346
               abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
2347
                collapsedAnchors[lastIndex] = (
2348
                    percent: collapsedAnchors[lastIndex].percent,
2349
                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh),
2350
                    chargeAh: max(collapsedAnchors[lastIndex].chargeAh, anchor.chargeAh)
2351
                )
2352
            } else {
2353
                collapsedAnchors.append(
2354
                    (percent: anchor.percent, energyWh: anchor.energyWh, chargeAh: anchor.chargeAh)
2355
                )
2356
            }
2357
        }
2358

            
2359
        var runningMaximumEnergyWh = 0.0
2360
        var runningMaximumChargeAh = 0.0
2361

            
2362
        return collapsedAnchors.map { anchor in
2363
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
2364
            runningMaximumChargeAh = max(runningMaximumChargeAh, anchor.chargeAh)
2365
            return (
2366
                percent: anchor.percent,
2367
                energyWh: runningMaximumEnergyWh,
2368
                chargeAh: runningMaximumChargeAh
2369
            )
2370
        }
2371
    }
2372

            
2373
    private func interpolatedTypicalCurvePoint(
2374
        for percent: Double,
2375
        anchors: [(percent: Double, energyWh: Double, chargeAh: Double)]
2376
    ) -> (energyWh: Double, chargeAh: Double)? {
2377
        guard
2378
            let firstAnchor = anchors.first,
2379
            let lastAnchor = anchors.last,
2380
            percent >= firstAnchor.percent,
2381
            percent <= lastAnchor.percent
2382
        else {
2383
            return nil
2384
        }
2385

            
2386
        if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
2387
            return (energyWh: exactAnchor.energyWh, chargeAh: exactAnchor.chargeAh)
2388
        }
2389

            
2390
        guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
2391
              upperIndex > 0 else {
2392
            return nil
2393
        }
2394

            
2395
        let lowerAnchor = anchors[upperIndex - 1]
2396
        let upperAnchor = anchors[upperIndex]
2397
        let span = upperAnchor.percent - lowerAnchor.percent
2398
        guard span > 0.000_1 else {
2399
            return nil
2400
        }
2401

            
2402
        let ratio = (percent - lowerAnchor.percent) / span
2403
        let energyWh = lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
2404
        let chargeAh = lowerAnchor.chargeAh + ((upperAnchor.chargeAh - lowerAnchor.chargeAh) * ratio)
2405
        return (energyWh: energyWh, chargeAh: chargeAh)
Bogdan Timofte authored a month ago
2406
    }
2407

            
2408
    private func makeSessionSummary(
2409
        from object: NSManagedObject,
2410
        checkpoints: [NSManagedObject],
2411
        samples: [NSManagedObject]
2412
    ) -> ChargeSessionSummary? {
2413
        let chargingTransportMode = chargingTransportMode(for: object)
2414

            
2415
        guard
2416
            let id = uuidValue(object, key: "id"),
2417
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2418
            let startedAt = dateValue(object, key: "startedAt"),
2419
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
2420
            let status = statusValue(object, key: "statusRawValue"),
2421
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
2422
        else {
2423
            return nil
2424
        }
2425

            
2426
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
2427
            .sorted { $0.timestamp < $1.timestamp }
2428
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
2429
            .sorted { lhs, rhs in
2430
                if lhs.bucketIndex != rhs.bucketIndex {
2431
                    return lhs.bucketIndex < rhs.bucketIndex
2432
                }
2433
                return lhs.timestamp < rhs.timestamp
2434
            }
2435

            
2436
        return ChargeSessionSummary(
2437
            id: id,
2438
            chargedDeviceID: chargedDeviceID,
2439
            chargerID: uuidValue(object, key: "chargerID"),
2440
            meterMACAddress: stringValue(object, key: "meterMACAddress"),
2441
            meterName: stringValue(object, key: "meterName"),
2442
            meterModel: stringValue(object, key: "meterModel"),
2443
            startedAt: startedAt,
2444
            endedAt: dateValue(object, key: "endedAt"),
2445
            lastObservedAt: lastObservedAt,
Bogdan Timofte authored a month ago
2446
            pausedAt: dateValue(object, key: "pausedAt"),
Bogdan Timofte authored a month ago
2447
            status: status,
2448
            sourceMode: sourceMode,
2449
            chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
2450
            chargingStateMode: chargingStateMode(for: object),
2451
            autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
Bogdan Timofte authored a month ago
2452
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2453
            effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
2454
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
Bogdan Timofte authored a month ago
2455
            meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
2456
            meterChargeBaselineAh: optionalDoubleValue(object, key: "meterChargeBaselineAh"),
Bogdan Timofte authored a month ago
2457
            meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"),
2458
            meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"),
Bogdan Timofte authored a month ago
2459
            minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
2460
            maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
2461
            maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
2462
            maximumObservedVoltageVolts: chargingTransportMode == .wired
2463
                ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
2464
                : nil,
2465
            selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
2466
            completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
2467
            stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
2468
            startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
2469
            endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
2470
            capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
2471
            wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
2472
            usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
2473
            shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
2474
            supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
2475
            usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
2476
            targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
2477
            targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
2478
            requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
2479
            completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
2480
            completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
2481
            selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
Bogdan Timofte authored a month ago
2482
            trimStart: dateValue(object, key: "trimStart"),
2483
            trimEnd: dateValue(object, key: "trimEnd"),
Bogdan Timofte authored a month ago
2484
            checkpoints: checkpointSummaries,
2485
            aggregatedSamples: sampleSummaries
2486
        )
2487
    }
2488

            
2489
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
2490
        guard
2491
            let id = uuidValue(object, key: "id"),
2492
            let sessionID = uuidValue(object, key: "sessionID"),
2493
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2494
            let timestamp = dateValue(object, key: "timestamp")
2495
        else {
2496
            return nil
2497
        }
2498

            
2499
        return ChargeCheckpointSummary(
2500
            id: id,
2501
            sessionID: sessionID,
2502
            chargedDeviceID: chargedDeviceID,
2503
            timestamp: timestamp,
2504
            batteryPercent: doubleValue(object, key: "batteryPercent"),
2505
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2506
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2507
            currentAmps: doubleValue(object, key: "currentAmps"),
2508
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
2509
            label: stringValue(object, key: "label")
2510
        )
2511
    }
2512

            
2513
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
2514
        guard
2515
            let sessionID = uuidValue(object, key: "sessionID"),
2516
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2517
            let timestamp = dateValue(object, key: "timestamp")
2518
        else {
2519
            return nil
2520
        }
2521

            
2522
        return ChargeSessionSampleSummary(
2523
            sessionID: sessionID,
2524
            chargedDeviceID: chargedDeviceID,
2525
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
2526
            timestamp: timestamp,
2527
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
2528
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
2529
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
2530
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2531
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2532
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
2533
        )
2534
    }
2535

            
Bogdan Timofte authored a month ago
2536
    private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2537
        fetchSessionObject(
2538
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2539
                format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
Bogdan Timofte authored a month ago
2540
                normalizedMACAddress(meterMACAddress),
Bogdan Timofte authored a month ago
2541
                ChargeSessionStatus.active.rawValue,
2542
                ChargeSessionStatus.paused.rawValue
Bogdan Timofte authored a month ago
2543
            )
2544
        )
2545
    }
2546

            
Bogdan Timofte authored a month ago
2547
    private func fetchOpenSessionObjects() -> [NSManagedObject] {
2548
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2549
        request.predicate = NSPredicate(
2550
            format: "statusRawValue == %@ OR statusRawValue == %@",
2551
            ChargeSessionStatus.active.rawValue,
2552
            ChargeSessionStatus.paused.rawValue
2553
        )
2554
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2555
        return (try? context.fetch(request)) ?? []
2556
    }
2557

            
Bogdan Timofte authored a month ago
2558
    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2559
        fetchSessionObject(
2560
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2561
                format: "meterMACAddress == %@ AND statusRawValue == %@",
2562
                normalizedMACAddress(meterMACAddress),
2563
                ChargeSessionStatus.active.rawValue
Bogdan Timofte authored a month ago
2564
            )
2565
        )
2566
    }
2567

            
2568
    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
2569
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2570
        request.predicate = predicate
2571
        request.fetchLimit = 1
2572
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
2573
        return (try? context.fetch(request))?.first
2574
    }
2575

            
2576
    private func fetchSessionObject(id: String) -> NSManagedObject? {
2577
        fetchSessionObject(
2578
            predicate: NSPredicate(format: "id == %@", id)
2579
        )
2580
    }
2581

            
2582
    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
2583
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2584
        request.predicate = NSPredicate(
2585
            format: "sessionID == %@ AND bucketIndex == %d",
2586
            sessionID,
2587
            bucketIndex
2588
        )
2589
        request.fetchLimit = 1
2590
        return (try? context.fetch(request))?.first
2591
    }
2592

            
2593
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2594
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2595
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2596
        return (try? context.fetch(request)) ?? []
2597
    }
2598

            
Bogdan Timofte authored a month ago
2599
    private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
2600
        guard !sessionIDs.isEmpty else {
2601
            return []
2602
        }
2603

            
2604
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2605
        request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs)
2606
        return (try? context.fetch(request)) ?? []
2607
    }
2608

            
Bogdan Timofte authored a month ago
2609
    private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
2610
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2611
        request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
2612
        request.fetchLimit = 1
2613
        return (try? context.fetch(request))?.first
2614
    }
2615

            
Bogdan Timofte authored a month ago
2616
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2617
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2618
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2619
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
2620
        return (try? context.fetch(request)) ?? []
2621
    }
2622

            
2623
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
2624
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2625
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
2626
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2627
        return (try? context.fetch(request)) ?? []
2628
    }
2629

            
2630
    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
2631
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2632
        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
2633
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2634
        return (try? context.fetch(request)) ?? []
2635
    }
2636

            
Bogdan Timofte authored a month ago
2637
    private func sampleBackedSessionIDs(
2638
        devices: [NSManagedObject],
2639
        sessionsByDeviceID: [String: [NSManagedObject]],
2640
        sessionsByChargerID: [String: [NSManagedObject]]
2641
    ) -> Set<String> {
2642
        var sessionIDs: Set<String> = []
2643

            
2644
        for device in devices {
2645
            guard
2646
                let deviceID = stringValue(device, key: "id"),
2647
                let rawClass = stringValue(device, key: "deviceClassRawValue"),
2648
                let deviceClass = ChargedDeviceClass(rawValue: rawClass)
2649
            else {
2650
                continue
2651
            }
2652

            
2653
            let relevantSessions = relevantSessionObjects(
2654
                for: deviceID,
2655
                deviceClass: deviceClass,
2656
                sessionsByDeviceID: sessionsByDeviceID,
2657
                sessionsByChargerID: sessionsByChargerID
2658
            )
2659
            .sorted { lhs, rhs in
2660
                let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed
2661
                let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed
2662

            
2663
                if lhsStatus.isOpen && !rhsStatus.isOpen {
2664
                    return true
2665
                }
2666
                if !lhsStatus.isOpen && rhsStatus.isOpen {
2667
                    return false
2668
                }
2669

            
2670
                return (dateValue(lhs, key: "startedAt") ?? .distantPast)
2671
                    > (dateValue(rhs, key: "startedAt") ?? .distantPast)
2672
            }
2673

            
2674
            var recentCompletedSamplesIncluded = 0
2675

            
2676
            for session in relevantSessions {
2677
                guard let sessionID = stringValue(session, key: "id"),
2678
                      let status = statusValue(session, key: "statusRawValue") else {
2679
                    continue
2680
                }
2681

            
2682
                if status.isOpen {
2683
                    sessionIDs.insert(sessionID)
2684
                    continue
2685
                }
2686

            
2687
                guard recentCompletedSamplesIncluded < 2 else {
2688
                    continue
2689
                }
2690

            
2691
                sessionIDs.insert(sessionID)
2692
                recentCompletedSamplesIncluded += 1
2693
            }
2694
        }
2695

            
2696
        return sessionIDs
2697
    }
2698

            
Bogdan Timofte authored a month ago
2699
    private func relevantSessionObjects(
2700
        for chargedDeviceID: String,
2701
        deviceClass: ChargedDeviceClass,
2702
        sessionsByDeviceID: [String: [NSManagedObject]],
2703
        sessionsByChargerID: [String: [NSManagedObject]]
2704
    ) -> [NSManagedObject] {
2705
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
2706
        guard deviceClass == .charger else {
2707
            return directSessions
2708
        }
2709

            
2710
        var seenSessionIDs = Set<String>()
2711
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
2712
            .filter { session in
2713
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
2714
                return seenSessionIDs.insert(sessionID).inserted
2715
            }
2716
            .sorted {
2717
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
2718
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
2719
                return lhsDate < rhsDate
2720
            }
2721
    }
2722

            
2723
    private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
2724
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false)
2725
    }
2726

            
2727
    private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
2728
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true)
2729
    }
2730

            
2731
    private func resolvedAssignedObject(
2732
        for meterMACAddress: String,
2733
        expectsChargerClass: Bool
2734
    ) -> NSManagedObject? {
2735
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
2736
        guard !normalizedMAC.isEmpty else { return nil }
2737

            
2738
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2739
        request.predicate = NSPredicate(format: "lastAssociatedMeterMAC == %@", normalizedMAC)
2740
        request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
2741
        let matches = (try? context.fetch(request)) ?? []
2742
        return matches.first { object in
Bogdan Timofte authored a month ago
2743
            isChargerObject(object) == expectsChargerClass
Bogdan Timofte authored a month ago
2744
        }
2745
    }
2746

            
Bogdan Timofte authored a month ago
2747
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
2748
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2749
    }
2750

            
Bogdan Timofte authored a month ago
2751
    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
2752
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2753
        request.predicate = NSPredicate(format: "id == %@", id)
2754
        request.fetchLimit = 1
2755
        return (try? context.fetch(request))?.first
2756
    }
2757

            
2758
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
2759
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
2760
        return (try? context.fetch(request)) ?? []
2761
    }
2762

            
2763
    private func resolvedStopThreshold(
2764
        for chargedDevice: NSManagedObject,
2765
        chargingTransportMode: ChargingTransportMode,
Bogdan Timofte authored a month ago
2766
        chargingStateMode: ChargingStateMode,
2767
        charger: NSManagedObject?,
2768
        fallback: Double?
2769
    ) -> Double? {
2770
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
2771
            return nil
2772
        }
2773

            
2774
        let sessionKind = ChargeSessionKind(
2775
            chargingTransportMode: chargingTransportMode,
2776
            chargingStateMode: chargingStateMode
2777
        )
2778
        let configuredCurrents = decodedCompletionCurrents(
2779
            from: chargedDevice,
2780
            key: "configuredCompletionCurrentsRawValue"
2781
        )
2782
        let learnedCurrents = decodedCompletionCurrents(
2783
            from: chargedDevice,
2784
            key: "learnedCompletionCurrentsRawValue"
2785
        )
2786
        let legacyCurrent: Double?
Bogdan Timofte authored a month ago
2787
        switch chargingTransportMode {
2788
        case .wired:
Bogdan Timofte authored a month ago
2789
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2790
                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
2791
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2792
        case .wireless:
Bogdan Timofte authored a month ago
2793
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2794
                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
2795
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2796
        }
Bogdan Timofte authored a month ago
2797

            
2798
        let resolvedCurrent = configuredCurrents[sessionKind]
2799
            ?? learnedCurrents[sessionKind]
2800
            ?? legacyCurrent
2801
            ?? fallback
2802
        guard let resolvedCurrent, resolvedCurrent > 0 else {
2803
            return nil
2804
        }
2805
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
2806
    }
2807

            
Bogdan Timofte authored a month ago
2808
    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
Bogdan Timofte authored a month ago
2809
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
2810
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
2811
        return resolvedPreferredChargingTransportMode(
Bogdan Timofte authored a month ago
2812
            .wired,
Bogdan Timofte authored a month ago
2813
            supportsWiredCharging: supportsWiredCharging,
2814
            supportsWirelessCharging: supportsWirelessCharging
2815
        )
2816
    }
2817

            
Bogdan Timofte authored a month ago
2818
    private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
2819
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other
2820
    }
2821

            
2822
    private func normalizedTemplateID(
2823
        _ templateID: String?,
2824
        kind: ChargedDeviceKind
2825
    ) -> String? {
2826
        guard let templateID,
2827
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2828
              templateDefinition.kind == kind else {
2829
            return nil
Bogdan Timofte authored a month ago
2830
        }
Bogdan Timofte authored a month ago
2831
        return templateDefinition.id
Bogdan Timofte authored a month ago
2832
    }
2833

            
Bogdan Timofte authored a month ago
2834
    private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
2835
        guard let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
2836
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2837
              templateDefinition.kind == deviceClass(for: chargedDevice).kind else {
2838
            return nil
Bogdan Timofte authored a month ago
2839
        }
Bogdan Timofte authored a month ago
2840
        return templateDefinition
2841
    }
2842

            
2843
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
2844
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2845
            ? true
2846
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2847
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2848
            ? false
2849
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2850
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2851
            supportsWiredCharging: persistedWiredCharging,
2852
            supportsWirelessCharging: persistedWirelessCharging
2853
        ).wired
2854
    }
2855

            
2856
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
2857
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2858
            ? true
2859
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2860
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2861
            ? false
2862
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2863
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2864
            supportsWiredCharging: persistedWiredCharging,
2865
            supportsWirelessCharging: persistedWirelessCharging
2866
        ).wireless
2867
    }
2868

            
2869
    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
2870
        let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue")
2871
            .flatMap(ChargingStateAvailability.init(rawValue:))
2872
            ?? ChargingStateAvailability.fallback(
Bogdan Timofte authored a month ago
2873
            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
2874
        )
Bogdan Timofte authored a month ago
2875
        return deviceClass(for: chargedDevice).normalizedChargingStateAvailability(persistedAvailability)
Bogdan Timofte authored a month ago
2876
    }
2877

            
2878
    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
Bogdan Timofte authored a month ago
2879
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2880
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2881
            let persistedChargingStateMode = stringValue(session, key: "chargingStateRawValue")
2882
                .flatMap(ChargingStateMode.init(rawValue:))
2883
                ?? .on
2884
            return resolvedChargingStateMode(
2885
                persistedChargingStateMode,
2886
                availability: chargingStateAvailability(for: chargedDevice)
2887
            )
2888
        }
2889

            
Bogdan Timofte authored a month ago
2890
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
2891
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
2892
            return chargingStateMode
2893
        }
2894

            
2895
        return .on
2896
    }
2897

            
2898
    private func resolvedChargingStateMode(
2899
        _ chargingStateMode: ChargingStateMode,
2900
        availability: ChargingStateAvailability
2901
    ) -> ChargingStateMode {
2902
        if availability.supportedModes.contains(chargingStateMode) {
2903
            return chargingStateMode
2904
        }
2905
        return availability.supportedModes.first ?? .on
2906
    }
2907

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

            
2912
        // Primary: chargerTypeRawValue (set on v13+)
2913
        if let rawValue = stringValue(chargedDevice, key: "chargerTypeRawValue"),
2914
           let type = ChargerType(rawValue: rawValue) {
2915
            return type
2916
        }
2917

            
2918
        // Migration fallback: derive from old deviceTemplateID
2919
        switch stringValue(chargedDevice, key: "deviceTemplateID") {
2920
        case "apple-magsafe-charger": return .appleMagSafe
2921
        case "apple-watch-charger": return .appleWatch
2922
        default: break
2923
        }
2924

            
2925
        // Last resort: derive from wirelessChargingProfileRawValue
2926
        if let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2927
           let profile = WirelessChargingProfile(rawValue: rawValue),
2928
           profile == .magsafe {
2929
            return .genericMagSafe
2930
        }
2931

            
2932
        return .genericQi
2933
    }
2934

            
Bogdan Timofte authored a month ago
2935
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
Bogdan Timofte authored a month ago
2936
        if let type = chargerType(for: chargedDevice) {
2937
            return type.wirelessChargingProfile
2938
        }
Bogdan Timofte authored a month ago
2939
        guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2940
              let profile = WirelessChargingProfile(rawValue: rawValue) else {
2941
            return .genericQi
2942
        }
2943
        return profile
2944
    }
2945

            
2946
    private func resolvedPreferredChargingTransportMode(
2947
        _ preferredChargingTransportMode: ChargingTransportMode,
2948
        supportsWiredCharging: Bool,
2949
        supportsWirelessCharging: Bool
2950
    ) -> ChargingTransportMode {
2951
        switch preferredChargingTransportMode {
2952
        case .wired where supportsWiredCharging:
2953
            return .wired
2954
        case .wireless where supportsWirelessCharging:
2955
            return .wireless
2956
        default:
2957
            if supportsWiredCharging {
2958
                return .wired
2959
            }
2960
            if supportsWirelessCharging {
2961
                return .wireless
2962
            }
2963
            return .wired
2964
        }
2965
    }
2966

            
Bogdan Timofte authored a month ago
2967
    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
2968
        let payload = Dictionary(
2969
            uniqueKeysWithValues: currents.map { key, value in
2970
                (key.rawValue, value)
2971
            }
2972
        )
2973
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
2974
            return nil
2975
        }
2976
        return String(data: data, encoding: .utf8)
2977
    }
2978

            
2979
    private func decodedCompletionCurrents(
2980
        from object: NSManagedObject,
2981
        key: String
2982
    ) -> [ChargeSessionKind: Double] {
2983
        guard let rawValue = stringValue(object, key: key),
2984
              let data = rawValue.data(using: .utf8),
2985
              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
2986
            return [:]
2987
        }
2988

            
2989
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2990
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
2991
                return
2992
            }
2993
            result[sessionKind] = entry.value
2994
        }
2995
    }
2996

            
2997
    private func legacyConfiguredCompletionCurrent(
2998
        for currents: [ChargeSessionKind: Double],
2999
        chargingTransportMode: ChargingTransportMode
3000
    ) -> Double? {
3001
        let candidates = currents
3002
            .filter { $0.key.chargingTransportMode == chargingTransportMode }
3003
            .sorted { lhs, rhs in
3004
                lhs.key.rawValue < rhs.key.rawValue
3005
            }
3006
            .map(\.value)
3007
        return candidates.first
3008
    }
3009

            
3010
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
3011
        guard let charger else {
3012
            return nil
3013
        }
3014
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
3015
        guard let idleCurrent, idleCurrent >= 0 else {
3016
            return nil
3017
        }
3018
        return idleCurrent
3019
    }
3020

            
3021
    private func effectiveCurrentAmps(
3022
        fromMeasuredCurrent currentAmps: Double,
3023
        chargingTransportMode: ChargingTransportMode,
3024
        charger: NSManagedObject?
3025
    ) -> Double {
3026
        switch chargingTransportMode {
3027
        case .wired:
3028
            return max(currentAmps, 0)
3029
        case .wireless:
3030
            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
3031
                return max(currentAmps, 0)
3032
            }
3033
            return max(currentAmps - idleCurrent, 0)
3034
        }
3035
    }
3036

            
3037
    private func hasObservedChargeFlow(
3038
        currentAmps: Double,
3039
        chargingTransportMode: ChargingTransportMode,
3040
        charger: NSManagedObject?,
3041
        stopThreshold: Double?
3042
    ) -> Bool {
3043
        let effectiveCurrent = effectiveCurrentAmps(
3044
            fromMeasuredCurrent: currentAmps,
3045
            chargingTransportMode: chargingTransportMode,
3046
            charger: charger
3047
        )
3048
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
3049
    }
3050

            
Bogdan Timofte authored a month ago
3051
    private func derivedMinimumCurrent(
3052
        from sessions: [NSManagedObject],
3053
        chargingTransportMode: ChargingTransportMode
3054
    ) -> Double? {
3055
        let completionCurrents = sessions.compactMap { session -> Double? in
3056
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3057
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
3058
                return nil
3059
            }
Bogdan Timofte authored a month ago
3060
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3061
                return nil
3062
            }
Bogdan Timofte authored a month ago
3063
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
3064
                return nil
3065
            }
3066
            return completionCurrent
3067
        }
3068

            
3069
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
3070
        guard !recentCompletionCurrents.isEmpty else { return nil }
3071
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
3072
    }
3073

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

            
3077
        for session in sessions {
3078
            guard statusValue(session, key: "statusRawValue") == .completed else {
3079
                continue
3080
            }
3081
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3082
                continue
3083
            }
3084
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
3085
                  completionCurrent > 0 else {
3086
                continue
3087
            }
3088

            
3089
            let sessionKind = ChargeSessionKind(
3090
                chargingTransportMode: chargingTransportMode(for: session),
3091
                chargingStateMode: chargingStateMode(for: session)
3092
            )
3093
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
3094
        }
3095

            
3096
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
3097
            let recentCurrents = Array(entry.value.suffix(5))
3098
            guard !recentCurrents.isEmpty else {
3099
                return
3100
            }
3101
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
3102
        }
3103
    }
3104

            
Bogdan Timofte authored a month ago
3105
    private func derivedCapacity(
3106
        from sessions: [NSManagedObject],
3107
        chargingTransportMode: ChargingTransportMode,
3108
        supportsChargingWhileOff: Bool
3109
    ) -> Double? {
3110
        let capacityCandidates = sessions.compactMap { session -> Double? in
3111
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3112
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
3113
                return nil
3114
            }
3115
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
3116
                return nil
3117
            }
3118
            if supportsChargingWhileOff {
3119
                return capacityEstimate
3120
            }
3121
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
3122
                return nil
3123
            }
3124
            return capacityEstimate
3125
        }
3126

            
3127
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
3128
        guard !recentCapacityCandidates.isEmpty else { return nil }
3129
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
3130
    }
3131

            
3132
    private func derivedWirelessEfficiency(
3133
        from sessions: [NSManagedObject],
3134
        chargingProfile: WirelessChargingProfile
3135
    ) -> Double? {
3136
        guard chargingProfile == .magsafe else {
3137
            return nil
3138
        }
3139

            
3140
        let candidates = sessions.compactMap { session -> Double? in
3141
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3142
            guard chargingTransportMode(for: session) == .wireless else { return nil }
3143
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
3144
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3145
                return nil
3146
            }
3147
            return factor
3148
        }
3149

            
3150
        let recentCandidates = Array(candidates.suffix(6))
3151
        guard !recentCandidates.isEmpty else { return nil }
3152
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3153
    }
3154

            
3155
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
3156
        let candidates = sessions.compactMap { session -> Double? in
3157
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3158
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
3159
                return nil
3160
            }
3161
            return (sourceVoltage * 10).rounded() / 10
3162
        }
3163

            
3164
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
3165
        return counts.keys.sorted()
3166
    }
3167

            
3168
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
3169
        let candidates = sessions.compactMap { session -> Double? in
3170
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3171
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
3172
                return nil
3173
            }
3174
            return minimumObservedCurrent
3175
        }
3176

            
3177
        let recentCandidates = Array(candidates.suffix(6))
3178
        guard !recentCandidates.isEmpty else { return nil }
3179
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3180
    }
3181

            
3182
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
3183
        let candidates = sessions.compactMap { session -> Double? in
3184
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3185
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3186
                return nil
3187
            }
3188
            return factor
3189
        }
3190

            
3191
        let recentCandidates = Array(candidates.suffix(6))
3192
        guard !recentCandidates.isEmpty else { return nil }
3193
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3194
    }
3195

            
3196
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
3197
        sessions.compactMap { session -> Double? in
3198
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3199
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
3200
                return nil
3201
            }
3202
            return maximumObservedPower
3203
        }
3204
        .max()
3205
    }
3206

            
3207
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
3208
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
3209
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
3210
            return resolvedPreferredChargingTransportMode(
3211
                chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired,
3212
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
3213
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
3214
            )
3215
        }
3216

            
3217
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
3218
            return persistedChargingTransportMode
Bogdan Timofte authored a month ago
3219
        }
3220

            
3221
        return .wired
3222
    }
3223

            
3224
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
3225
        if session.isInserted {
3226
            return .created
3227
        }
3228

            
3229
        let committedValues = session.committedValues(
3230
            forKeys: [
3231
                "statusRawValue",
3232
                "updatedAt",
3233
                "targetBatteryAlertTriggeredAt",
3234
                "requiresCompletionConfirmation"
3235
            ]
3236
        )
3237
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
3238
        let currentStatus = statusValue(session, key: "statusRawValue")
3239
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
3240
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
3241
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
3242
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
3243
            ?? false
3244
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
3245

            
3246
        if currentStatus == .completed, committedStatus != .completed {
3247
            return .completed
3248
        }
3249

            
Bogdan Timofte authored a month ago
3250
        if currentStatus != committedStatus {
3251
            return .event
3252
        }
3253

            
Bogdan Timofte authored a month ago
3254
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
3255
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
3256
            return .event
3257
        }
3258

            
3259
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3260
            ?? dateValue(session, key: "createdAt")
3261
            ?? observedAt
3262

            
3263
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
3264
            return .periodic
3265
        }
3266

            
3267
        return .none
3268
    }
3269

            
Bogdan Timofte authored a month ago
3270
    private func shouldPersistAggregatedSample(
3271
        _ sample: NSManagedObject,
3272
        observedAt: Date
3273
    ) -> Bool {
3274
        if sample.isInserted {
3275
            return true
3276
        }
3277

            
3278
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
3279
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3280
            ?? dateValue(sample, key: "createdAt")
3281
            ?? observedAt
3282

            
3283
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
3284
    }
3285

            
Bogdan Timofte authored a month ago
3286
    private func generateQRIdentifier() -> String {
3287
        "device:\(UUID().uuidString)"
3288
    }
3289

            
3290
    @discardableResult
3291
    private func saveContext() -> Bool {
3292
        guard context.hasChanges else { return true }
3293
        do {
3294
            try context.save()
3295
            return true
3296
        } catch {
3297
            track("Failed saving charge insights context: \(error)")
3298
            context.rollback()
3299
            return false
3300
        }
3301
    }
3302

            
3303
    private func normalizedText(_ text: String) -> String {
3304
        text.trimmingCharacters(in: .whitespacesAndNewlines)
3305
    }
3306

            
3307
    private func normalizedOptionalText(_ text: String?) -> String? {
3308
        guard let text else { return nil }
3309
        let normalized = normalizedText(text)
3310
        return normalized.isEmpty ? nil : normalized
3311
    }
3312

            
3313
    private func normalizedMACAddress(_ macAddress: String) -> String {
3314
        normalizedText(macAddress).uppercased()
3315
    }
3316

            
Bogdan Timofte authored a month ago
3317
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
3318
        guard object.entity.propertiesByName[key] != nil else {
3319
            return nil
3320
        }
3321
        return object.value(forKey: key)
3322
    }
3323

            
3324
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
3325
        guard object.entity.propertiesByName[key] != nil else {
3326
            return
3327
        }
3328
        object.setValue(value, forKey: key)
3329
    }
3330

            
Bogdan Timofte authored a month ago
3331
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
3332
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
3333
        let normalized = normalizedOptionalText(value)
3334
        return normalized
3335
    }
3336

            
3337
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
3338
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
3339
    }
3340

            
3341
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
3342
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
3343
            return value
3344
        }
Bogdan Timofte authored a month ago
3345
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3346
            return value.doubleValue
3347
        }
3348
        return 0
3349
    }
3350

            
3351
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
3352
        let value = rawValue(object, key: key)
3353
        if value == nil {
Bogdan Timofte authored a month ago
3354
            return nil
3355
        }
3356
        return doubleValue(object, key: key)
3357
    }
3358

            
3359
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
Bogdan Timofte authored a month ago
3360
        if let value = rawValue(object, key: key) as? Int16 {
Bogdan Timofte authored a month ago
3361
            return value
3362
        }
Bogdan Timofte authored a month ago
3363
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3364
            return value.int16Value
3365
        }
3366
        return nil
3367
    }
3368

            
3369
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
3370
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
3371
            return value
3372
        }
Bogdan Timofte authored a month ago
3373
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3374
            return value.int32Value
3375
        }
3376
        return nil
3377
    }
3378

            
3379
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
Bogdan Timofte authored a month ago
3380
        if let value = rawValue(object, key: key) as? Bool {
Bogdan Timofte authored a month ago
3381
            return value
3382
        }
Bogdan Timofte authored a month ago
3383
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3384
            return value.boolValue
3385
        }
3386
        return false
3387
    }
3388

            
3389
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
3390
        guard let value = stringValue(object, key: key) else { return nil }
3391
        return UUID(uuidString: value)
3392
    }
3393

            
3394
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
3395
        guard let value = stringValue(object, key: key) else { return nil }
3396
        return ChargeSessionStatus(rawValue: value)
3397
    }
3398

            
3399
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
3400
        guard let value = stringValue(object, key: key) else { return nil }
3401
        return ChargingTransportMode(rawValue: value)
3402
    }
3403

            
3404
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
3405
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
3406
            return []
3407
        }
3408
        return rawValue
3409
            .split(separator: ",")
3410
            .compactMap { Double($0) }
3411
            .sorted()
3412
    }
3413

            
3414
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
3415
        let uniqueVoltages = Array(Set(voltages)).sorted()
3416
        guard !uniqueVoltages.isEmpty else {
3417
            return nil
3418
        }
3419
        return uniqueVoltages
3420
            .map { String(format: "%.1f", $0) }
3421
            .joined(separator: ",")
3422
    }
3423

            
3424
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
3425
        guard currentCount > 0 else {
3426
            return newValue
3427
        }
3428
        let total = (currentAverage * Double(currentCount)) + newValue
3429
        return total / Double(currentCount + 1)
3430
    }
3431
}
3432

            
3433
private enum ObservationSaveReason {
3434
    case none
3435
    case created
3436
    case periodic
3437
    case completed
3438
    case event
3439
}