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

            
Bogdan Timofte authored a month ago
612
            guard hasSavableChargeData(session) else {
613
                return
614
            }
615

            
Bogdan Timofte authored a month ago
616
            let observedAt = snapshotDateForManualStop(session)
617
            finishSession(
618
                session,
619
                observedAt: observedAt,
620
                finalBatteryPercent: finalBatteryPercent,
621
                status: .completed
622
            )
623

            
624
            guard saveContext() else {
625
                return
626
            }
627

            
Bogdan Timofte authored a month ago
628
            didSave = true
629
            deviceIDToRefresh = stringValue(session, key: "chargedDeviceID")
630
        }
631

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

            
Bogdan Timofte authored a month ago
640
        return didSave
641
    }
642

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

            
654
        var didSave = false
655
        context.performAndWait {
Bogdan Timofte authored a month ago
656
            guard let session = fetchOpenSessionObject(forMeterMACAddress: meterMACAddress) else {
Bogdan Timofte authored a month ago
657
                return
658
            }
659

            
Bogdan Timofte authored a month ago
660
            didSave = addBatteryCheckpoint(
661
                percent: percent,
662
                measuredEnergyWh: measuredEnergyWh,
663
                measuredChargeAh: measuredChargeAh,
Bogdan Timofte authored a month ago
664
                flag: .intermediate,
Bogdan Timofte authored a month ago
665
                to: session
666
            )
Bogdan Timofte authored a month ago
667
        }
668
        return didSave
669
    }
670

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

            
682
        var didSave = false
683
        context.performAndWait {
684
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
685
                return
686
            }
687

            
Bogdan Timofte authored a month ago
688
            didSave = addBatteryCheckpoint(
689
                percent: percent,
690
                measuredEnergyWh: measuredEnergyWh,
691
                measuredChargeAh: measuredChargeAh,
Bogdan Timofte authored a month ago
692
                flag: .intermediate,
Bogdan Timofte authored a month ago
693
                to: session
694
            )
Bogdan Timofte authored a month ago
695
        }
696
        return didSave
697
    }
698

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

            
714
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
715
            context.delete(checkpoint)
716
            refreshCheckpointDerivedValues(for: session)
717

            
718
            if let chargedDeviceID {
719
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
720
            }
Bogdan Timofte authored a month ago
721

            
722
            didSave = saveContext()
Bogdan Timofte authored a month ago
723
        }
724
        return didSave
725
    }
726

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

            
733
        var didSave = false
734
        context.performAndWait {
735
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
736
                return
737
            }
738

            
739
            session.setValue(percent, forKey: "targetBatteryPercent")
740
            session.setValue(nil, forKey: "targetBatteryAlertTriggeredAt")
741
            session.setValue(Date(), forKey: "updatedAt")
742
            didSave = saveContext()
743
        }
744
        return didSave
745
    }
746

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

            
755
            guard statusValue(session, key: "statusRawValue") == .active else {
756
                return
757
            }
758

            
Bogdan Timofte authored a month ago
759
            finishSession(
760
                session,
761
                observedAt: dateValue(session, key: "lastObservedAt") ?? Date(),
762
                finalBatteryPercent: nil,
763
                status: .completed
764
            )
Bogdan Timofte authored a month ago
765

            
766
            if saveContext() {
767
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
768
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
769
                    didSave = saveContext()
770
                } else {
771
                    didSave = true
772
                }
773
            }
774
        }
775
        return didSave
776
    }
777

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

            
786
            guard statusValue(session, key: "statusRawValue") == .active else {
787
                return
788
            }
789

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

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

            
807
            let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast
808
            let sessionEnd   = dateValue(session, key: "endedAt")
809
                ?? dateValue(session, key: "lastObservedAt")
810
                ?? Date.distantFuture
811

            
812
            let effectiveStart = min(max(start ?? sessionStart, sessionStart), sessionEnd)
813
            let effectiveEnd   = max(min(end ?? sessionEnd, sessionEnd), effectiveStart)
814
            let persistedStart = effectiveStart == sessionStart ? nil : effectiveStart
815
            let persistedEnd   = effectiveEnd == sessionEnd ? nil : effectiveEnd
816

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

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

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

            
845
            session.setValue(persistedStart, forKey: "trimStart")
846
            session.setValue(persistedEnd,   forKey: "trimEnd")
847
            session.setValue(Date(), forKey: "updatedAt")
848

            
849
            let checkpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
850
            for checkpoint in checkpoints {
851
                guard let timestamp = dateValue(checkpoint, key: "timestamp") else { continue }
852

            
853
                if timestamp < effectiveStart || timestamp > effectiveEnd {
854
                    context.delete(checkpoint)
855
                    continue
856
                }
857

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

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

            
876
            if persistedStart == nil {
877
                if let restoredInitialCheckpoint,
878
                   let percent = optionalDoubleValue(restoredInitialCheckpoint, key: "batteryPercent"),
879
                   percent >= 0 {
880
                    session.setValue(percent, forKey: "startBatteryPercent")
881
                }
882
            } else {
883
                session.setValue(nil, forKey: "startBatteryPercent")
884
            }
885

            
886
            refreshCheckpointDerivedValues(for: session)
887

            
888
            if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
889
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
890
            }
891

            
892
            didSave = saveContext()
893
        }
894
        return didSave
895
    }
896

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

            
905
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
906

            
907
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
908
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
909
            context.delete(session)
910

            
911
            guard saveContext() else {
912
                return
913
            }
914

            
915
            if let chargedDeviceID {
916
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
917
                didSave = saveContext()
918
            } else {
919
                didSave = true
920
            }
921
        }
922
        return didSave
923
    }
924

            
925
    @discardableResult
926
    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
927
        var didSave = false
928

            
929
        context.performAndWait {
930
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
931
                return
932
            }
933

            
934
            let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
935
            let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
936
            let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
937

            
938
            var impactedChargedDeviceIDs = Set<String>()
939

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

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

            
967
            context.delete(chargedDevice)
968

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

            
973
            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
974
            for impactedID in impactedChargedDeviceIDs {
975
                refreshDerivedMetrics(forChargedDeviceID: impactedID)
976
            }
977
            didSave = saveContext()
978
        }
979

            
980
        return didSave
981
    }
982

            
983
    @discardableResult
984
    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
985
        var didSave = false
986

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

            
Bogdan Timofte authored a month ago
993
            if statusValue(session, key: "statusRawValue") == .paused {
Bogdan Timofte authored a month ago
994
                if maybeCompleteOpenSession(session, observedAt: snapshot.observedAt) {
Bogdan Timofte authored a month ago
995
                    didSave = true
996
                }
Bogdan Timofte authored a month ago
997
                return
998
            }
999

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

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

            
Bogdan Timofte authored a month ago
1029
            let saveReason = saveReason(for: session, observedAt: sessionSnapshot.observedAt)
Bogdan Timofte authored a month ago
1030
            let shouldPersistAggregatedCurve = aggregatedSample.map {
Bogdan Timofte authored a month ago
1031
                shouldPersistAggregatedSample($0, observedAt: sessionSnapshot.observedAt)
Bogdan Timofte authored a month ago
1032
            } ?? false
1033

            
1034
            guard saveReason != .none || shouldPersistAggregatedCurve else {
Bogdan Timofte authored a month ago
1035
                return
1036
            }
1037

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

            
1040
            if saveContext() {
1041
                if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
1042
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
1043
                    didSave = saveContext()
1044
                } else {
1045
                    didSave = true
1046
                }
1047
            }
1048
        }
1049

            
1050
        return didSave
1051
    }
1052

            
1053
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
1054
        var summaries: [ChargedDeviceSummary] = []
1055

            
1056
        context.performAndWait {
1057
            let devices = fetchObjects(entityName: EntityName.chargedDevice)
1058
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
1059
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
1060

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

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

            
Bogdan Timofte authored a month ago
1084
                let chargingStateAvailability = chargingStateAvailability(for: device)
1085
                let supportsWiredCharging = supportsWiredCharging(for: device)
1086
                let supportsWirelessCharging = supportsWirelessCharging(for: device)
1087
                let templateDefinition = templateDefinition(for: device)
1088

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

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

            
1171
        return summaries
1172
    }
1173

            
1174
    func resolvedChargedDeviceSummary(forMeterMACAddress meterMACAddress: String) -> ChargedDeviceSummary? {
1175
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1176
        guard !normalizedMAC.isEmpty else { return nil }
1177

            
1178
        let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
1179

            
1180
        if let activeMatch = summaries.first(where: { summary in
1181
            summary.activeSession?.meterMACAddress == normalizedMAC
1182
        }) {
1183
            return activeMatch
1184
        }
1185

            
1186
        return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
1187
    }
1188

            
1189
    func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
1190
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1191
        guard !normalizedMAC.isEmpty else { return nil }
1192

            
Bogdan Timofte authored a month ago
1193
        var summary: ChargeSessionSummary?
1194

            
1195
        context.performAndWait {
Bogdan Timofte authored a month ago
1196
            guard let session = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
Bogdan Timofte authored a month ago
1197
                  let sessionID = stringValue(session, key: "id") else {
1198
                return
1199
            }
1200

            
1201
            summary = makeSessionSummary(
1202
                from: session,
1203
                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
1204
                samples: fetchSessionSampleObjects(forSessionID: sessionID)
1205
            )
1206
        }
1207

            
1208
        return summary
Bogdan Timofte authored a month ago
1209
    }
1210

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

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

            
1289
        chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
1290
        chargedDevice.setValue(now, forKey: "updatedAt")
1291
        return session
1292
    }
1293

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

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

            
1320
        if let counterGroup = snapshot.selectedDataGroup,
1321
           let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
1322
           UInt8(storedGroup) != counterGroup {
1323
            session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
1324
            session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1325
            session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1326
        }
1327

            
1328
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1329
            let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
1330
            let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
1331
            if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
1332
                session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
1333
            }
1334

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

            
1351
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1352
            let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
1353
            let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
1354
            if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
1355
                session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
1356
            }
1357

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

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

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

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

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

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

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

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

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

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

            
1487
        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
1488
        let updatedCount = existingCount + 1
1489

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

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

            
1538
        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
1539
            return
1540
        }
1541

            
1542
        let predictedBatteryPercent = predictedBatteryPercent(for: session)
1543
            ?? optionalDoubleValue(session, key: "endBatteryPercent")
Bogdan Timofte authored a month ago
1544
            ?? completionFallbackPercent
Bogdan Timofte authored a month ago
1545

            
1546
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
1547
            return
1548
        }
1549

            
1550
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
1551
    }
1552

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

            
1562
        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
1563
            return false
1564
        }
1565

            
1566
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
1567
            ?? defaultCompletionPercentThreshold
1568

            
1569
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1570
    }
1571

            
1572
    private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
1573
        guard !boolValue(session, key: "requiresCompletionConfirmation") else {
1574
            return
1575
        }
1576

            
1577
        session.setValue(true, forKey: "requiresCompletionConfirmation")
1578
        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
1579
        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
1580
    }
1581

            
1582
    private func clearCompletionConfirmationState(for session: NSManagedObject) {
1583
        session.setValue(false, forKey: "requiresCompletionConfirmation")
1584
        session.setValue(nil, forKey: "completionConfirmationRequestedAt")
1585
        session.setValue(nil, forKey: "completionContradictionPercent")
1586
    }
1587

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

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

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

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

            
Bogdan Timofte authored a month ago
1630
        var completionDates: [Date] = []
1631

            
1632
        if let maximumEndDate = maximumEndDate(for: session) {
1633
            completionDates.append(maximumEndDate)
1634
        }
1635

            
1636
        if statusValue(session, key: "statusRawValue") == .paused,
1637
           let pausedAt = dateValue(session, key: "pausedAt") {
1638
            completionDates.append(pausedAt.addingTimeInterval(pausedSessionTimeout))
1639
        }
1640

            
1641
        guard let completionDate = completionDates.min(),
1642
              referenceDate >= completionDate else {
1643
            return nil
1644
        }
1645

            
1646
        return completionDate
1647
    }
1648

            
1649
    private func maximumEndDate(for session: NSManagedObject) -> Date? {
1650
        dateValue(session, key: "startedAt")?.addingTimeInterval(Self.maximumChargeSessionDuration)
1651
    }
1652

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

            
1660
        finishSession(
1661
            session,
Bogdan Timofte authored a month ago
1662
            observedAt: completionDate,
Bogdan Timofte authored a month ago
1663
            finalBatteryPercent: nil,
1664
            status: .completed
1665
        )
1666

            
1667
        guard saveContext() else {
1668
            return false
1669
        }
1670

            
1671
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1672
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1673
            return saveContext()
1674
        }
1675

            
1676
        return true
1677
    }
1678

            
1679
    private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
1680
        let chargingTransportMode = chargingTransportMode(for: session)
1681
        let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps")
1682
            ?? doubleValue(session, key: "lastObservedCurrentAmps")
1683

            
1684
        guard measuredCurrent > 0 else {
1685
            return nil
1686
        }
1687

            
1688
        let charger = chargingTransportMode == .wireless
1689
            ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
1690
            : nil
1691

            
1692
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
1693
            return nil
1694
        }
1695

            
1696
        let effectiveCurrent = effectiveCurrentAmps(
1697
            fromMeasuredCurrent: measuredCurrent,
1698
            chargingTransportMode: chargingTransportMode,
1699
            charger: charger
1700
        )
1701
        guard effectiveCurrent > 0 else {
1702
            return nil
1703
        }
1704
        return effectiveCurrent
1705
    }
1706

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

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

            
1733
        if status == .completed {
1734
            maybeTriggerTargetBatteryAlert(
1735
                for: session,
1736
                observedAt: observedAt,
1737
                completionFallbackPercent: defaultCompletionPercentThreshold
1738
            )
1739
        }
Bogdan Timofte authored a month ago
1740
    }
1741

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

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

            
1770
        struct Anchor {
1771
            let percent: Double
1772
            let energyWh: Double
Bogdan Timofte authored a month ago
1773
            let timestamp: Date
1774
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1775
        }
1776

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

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

            
1811
        guard !anchors.isEmpty else {
1812
            return optionalDoubleValue(session, key: "endBatteryPercent")
1813
        }
1814

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

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

            
1836
        switch chargingTransportMode(for: session) {
1837
        case .wired:
1838
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1839
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1840
        case .wireless:
1841
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1842
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1843
        }
1844
    }
1845

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

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

            
1865
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
1866
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
1867
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
1868
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
1869

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

            
1872
        guard let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
Bogdan Timofte authored a month ago
1873
            session.setValue(nil, forKey: "capacityEstimateWh")
1874
            return
1875
        }
1876

            
Bogdan Timofte authored a month ago
1877
        struct CapacityAnchor {
1878
            let percent: Double
1879
            let energyWh: Double
1880
            let timestamp: Date
1881
        }
1882

            
1883
        var anchors: [CapacityAnchor] = []
1884

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

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

            
1909
                    return CapacityAnchor(
1910
                        percent: percent,
1911
                        energyWh: doubleValue(checkpoint, key: "measuredEnergyWh"),
1912
                        timestamp: timestamp
1913
                    )
1914
                }
1915
            )
1916
        }
1917

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

            
1931
        let sortedAnchors = anchors.sorted { lhs, rhs in
1932
            if lhs.energyWh != rhs.energyWh {
1933
                return lhs.energyWh < rhs.energyWh
1934
            }
1935
            return lhs.timestamp < rhs.timestamp
1936
        }
1937

            
1938
        guard let firstAnchor = sortedAnchors.first,
1939
              let lastAnchor = sortedAnchors.last else {
Bogdan Timofte authored a month ago
1940
            session.setValue(nil, forKey: "capacityEstimateWh")
1941
            return
1942
        }
1943

            
Bogdan Timofte authored a month ago
1944
        let percentDelta = lastAnchor.percent - firstAnchor.percent
1945
        let energyDelta = lastAnchor.energyWh - firstAnchor.energyWh
Bogdan Timofte authored a month ago
1946

            
Bogdan Timofte authored a month ago
1947
        guard percentDelta >= 20, energyDelta > 0 else {
Bogdan Timofte authored a month ago
1948
            session.setValue(nil, forKey: "capacityEstimateWh")
1949
            return
1950
        }
1951

            
Bogdan Timofte authored a month ago
1952
        if !supportsChargingWhileOff && lastAnchor.percent >= 99.5 {
Bogdan Timofte authored a month ago
1953
            session.setValue(nil, forKey: "capacityEstimateWh")
1954
            return
1955
        }
1956

            
Bogdan Timofte authored a month ago
1957
        let capacityEstimateWh = energyDelta / (percentDelta / 100)
Bogdan Timofte authored a month ago
1958
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
1959
    }
1960

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

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

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

            
Bogdan Timofte authored a month ago
2009
        return chargedDeviceID
2010
    }
2011

            
Bogdan Timofte authored a month ago
2012
    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
2013
        guard let sessionID = stringValue(session, key: "id") else {
2014
            return
2015
        }
2016

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

            
2027
        session.setValue(Date(), forKey: "updatedAt")
2028
        updateCapacityEstimate(for: session)
2029
    }
2030

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

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

            
Bogdan Timofte authored a month ago
2058
        guard saveContext() else {
2059
            return false
2060
        }
2061

            
2062
        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
2063
        return saveContext()
2064
    }
2065

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

            
2079
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
2080
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2081
        guard measuredEnergyWh > 0 else {
2082
            return nil
2083
        }
2084

            
2085
        if chargingProfile == .magsafe,
2086
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
2087
           calibratedFactor > 0 {
2088
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
2089
        }
2090

            
2091
        guard
2092
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2093
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
2094
        else {
2095
            return nil
2096
        }
2097

            
2098
        let percentDelta = endBatteryPercent - startBatteryPercent
2099
        guard percentDelta >= 20 else {
2100
            return nil
2101
        }
2102

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

            
2112
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
2113
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
2114
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
2115
        let usesEstimated = chargingProfile != .magsafe
2116
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
2117

            
2118
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
2119
    }
2120

            
2121
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
2122
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
2123
            return
2124
        }
2125

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

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

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

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

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

            
2223
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
2224
        var groupedEnergyByBin: [Int: [Double]] = [:]
2225
        var groupedChargeByBin: [Int: [Double]] = [:]
2226

            
2227
        for session in sessions where session.status == .completed {
Bogdan Timofte authored a month ago
2228
            let anchors = normalizedTypicalCurveAnchors(for: session)
2229
            guard anchors.count >= 2 else {
2230
                continue
Bogdan Timofte authored a month ago
2231
            }
2232

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

            
Bogdan Timofte authored a month ago
2241
                groupedEnergyByBin[percentBin, default: []].append(interpolatedPoint.energyWh)
2242
                groupedChargeByBin[percentBin, default: []].append(interpolatedPoint.chargeAh)
Bogdan Timofte authored a month ago
2243
            }
2244
        }
2245

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

            
2256
            return TypicalChargeCurvePoint(
2257
                percentBin: percentBin,
2258
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
2259
                averageChargeAh: charges.reduce(0, +) / Double(charges.count),
2260
                sampleCount: min(energies.count, charges.count)
2261
            )
2262
        }
Bogdan Timofte authored a month ago
2263

            
2264
        var runningMaximumEnergyWh = 0.0
2265
        var runningMaximumChargeAh = 0.0
2266

            
2267
        return averagedPoints.map { point in
2268
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
2269
            runningMaximumChargeAh = max(runningMaximumChargeAh, point.averageChargeAh)
2270
            return TypicalChargeCurvePoint(
2271
                percentBin: point.percentBin,
2272
                averageEnergyWh: runningMaximumEnergyWh,
2273
                averageChargeAh: runningMaximumChargeAh,
2274
                sampleCount: point.sampleCount
2275
            )
2276
        }
2277
    }
2278

            
2279
    private func normalizedTypicalCurveAnchors(
2280
        for session: ChargeSessionSummary
2281
    ) -> [(percent: Double, energyWh: Double, chargeAh: Double)] {
2282
        struct Anchor {
2283
            let percent: Double
2284
            let energyWh: Double
2285
            let chargeAh: Double
2286
            let timestamp: Date
2287
        }
2288

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

            
2300
            return Anchor(
2301
                percent: checkpoint.batteryPercent,
2302
                energyWh: checkpoint.measuredEnergyWh,
2303
                chargeAh: checkpoint.measuredChargeAh,
2304
                timestamp: checkpoint.timestamp
2305
            )
2306
        }
2307

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

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

            
2336
        let sortedAnchors = anchors.sorted { lhs, rhs in
2337
            if lhs.percent != rhs.percent {
2338
                return lhs.percent < rhs.percent
2339
            }
2340
            if lhs.energyWh != rhs.energyWh {
2341
                return lhs.energyWh < rhs.energyWh
2342
            }
2343
            return lhs.timestamp < rhs.timestamp
2344
        }
2345

            
2346
        var collapsedAnchors: [(percent: Double, energyWh: Double, chargeAh: Double)] = []
2347

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

            
2363
        var runningMaximumEnergyWh = 0.0
2364
        var runningMaximumChargeAh = 0.0
2365

            
2366
        return collapsedAnchors.map { anchor in
2367
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
2368
            runningMaximumChargeAh = max(runningMaximumChargeAh, anchor.chargeAh)
2369
            return (
2370
                percent: anchor.percent,
2371
                energyWh: runningMaximumEnergyWh,
2372
                chargeAh: runningMaximumChargeAh
2373
            )
2374
        }
2375
    }
2376

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

            
2390
        if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
2391
            return (energyWh: exactAnchor.energyWh, chargeAh: exactAnchor.chargeAh)
2392
        }
2393

            
2394
        guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
2395
              upperIndex > 0 else {
2396
            return nil
2397
        }
2398

            
2399
        let lowerAnchor = anchors[upperIndex - 1]
2400
        let upperAnchor = anchors[upperIndex]
2401
        let span = upperAnchor.percent - lowerAnchor.percent
2402
        guard span > 0.000_1 else {
2403
            return nil
2404
        }
2405

            
2406
        let ratio = (percent - lowerAnchor.percent) / span
2407
        let energyWh = lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
2408
        let chargeAh = lowerAnchor.chargeAh + ((upperAnchor.chargeAh - lowerAnchor.chargeAh) * ratio)
2409
        return (energyWh: energyWh, chargeAh: chargeAh)
Bogdan Timofte authored a month ago
2410
    }
2411

            
2412
    private func makeSessionSummary(
2413
        from object: NSManagedObject,
2414
        checkpoints: [NSManagedObject],
2415
        samples: [NSManagedObject]
2416
    ) -> ChargeSessionSummary? {
2417
        let chargingTransportMode = chargingTransportMode(for: object)
2418

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

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

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

            
2494
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
2495
        guard
2496
            let id = uuidValue(object, key: "id"),
2497
            let sessionID = uuidValue(object, key: "sessionID"),
2498
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2499
            let timestamp = dateValue(object, key: "timestamp")
2500
        else {
2501
            return nil
2502
        }
2503

            
2504
        return ChargeCheckpointSummary(
2505
            id: id,
2506
            sessionID: sessionID,
2507
            chargedDeviceID: chargedDeviceID,
2508
            timestamp: timestamp,
2509
            batteryPercent: doubleValue(object, key: "batteryPercent"),
2510
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2511
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2512
            currentAmps: doubleValue(object, key: "currentAmps"),
2513
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
2514
            label: stringValue(object, key: "label")
2515
        )
2516
    }
2517

            
2518
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
2519
        guard
2520
            let sessionID = uuidValue(object, key: "sessionID"),
2521
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2522
            let timestamp = dateValue(object, key: "timestamp")
2523
        else {
2524
            return nil
2525
        }
2526

            
2527
        return ChargeSessionSampleSummary(
2528
            sessionID: sessionID,
2529
            chargedDeviceID: chargedDeviceID,
2530
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
2531
            timestamp: timestamp,
2532
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
2533
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
2534
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
2535
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2536
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2537
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
2538
        )
2539
    }
2540

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

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

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

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

            
2581
    private func fetchSessionObject(id: String) -> NSManagedObject? {
2582
        fetchSessionObject(
2583
            predicate: NSPredicate(format: "id == %@", id)
2584
        )
2585
    }
2586

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

            
2598
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2599
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2600
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2601
        return (try? context.fetch(request)) ?? []
2602
    }
2603

            
Bogdan Timofte authored a month ago
2604
    private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
2605
        guard !sessionIDs.isEmpty else {
2606
            return []
2607
        }
2608

            
2609
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2610
        request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs)
2611
        return (try? context.fetch(request)) ?? []
2612
    }
2613

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

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

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

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

            
Bogdan Timofte authored a month ago
2642
    private func sampleBackedSessionIDs(
2643
        devices: [NSManagedObject],
2644
        sessionsByDeviceID: [String: [NSManagedObject]],
2645
        sessionsByChargerID: [String: [NSManagedObject]]
2646
    ) -> Set<String> {
2647
        var sessionIDs: Set<String> = []
2648

            
2649
        for device in devices {
2650
            guard
2651
                let deviceID = stringValue(device, key: "id"),
2652
                let rawClass = stringValue(device, key: "deviceClassRawValue"),
2653
                let deviceClass = ChargedDeviceClass(rawValue: rawClass)
2654
            else {
2655
                continue
2656
            }
2657

            
2658
            let relevantSessions = relevantSessionObjects(
2659
                for: deviceID,
2660
                deviceClass: deviceClass,
2661
                sessionsByDeviceID: sessionsByDeviceID,
2662
                sessionsByChargerID: sessionsByChargerID
2663
            )
2664
            .sorted { lhs, rhs in
2665
                let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed
2666
                let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed
2667

            
2668
                if lhsStatus.isOpen && !rhsStatus.isOpen {
2669
                    return true
2670
                }
2671
                if !lhsStatus.isOpen && rhsStatus.isOpen {
2672
                    return false
2673
                }
2674

            
2675
                return (dateValue(lhs, key: "startedAt") ?? .distantPast)
2676
                    > (dateValue(rhs, key: "startedAt") ?? .distantPast)
2677
            }
2678

            
2679
            var recentCompletedSamplesIncluded = 0
2680

            
2681
            for session in relevantSessions {
2682
                guard let sessionID = stringValue(session, key: "id"),
2683
                      let status = statusValue(session, key: "statusRawValue") else {
2684
                    continue
2685
                }
2686

            
2687
                if status.isOpen {
2688
                    sessionIDs.insert(sessionID)
2689
                    continue
2690
                }
2691

            
2692
                guard recentCompletedSamplesIncluded < 2 else {
2693
                    continue
2694
                }
2695

            
2696
                sessionIDs.insert(sessionID)
2697
                recentCompletedSamplesIncluded += 1
2698
            }
2699
        }
2700

            
2701
        return sessionIDs
2702
    }
2703

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

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

            
2728
    private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
2729
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false)
2730
    }
2731

            
2732
    private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
2733
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true)
2734
    }
2735

            
2736
    private func resolvedAssignedObject(
2737
        for meterMACAddress: String,
2738
        expectsChargerClass: Bool
2739
    ) -> NSManagedObject? {
2740
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
2741
        guard !normalizedMAC.isEmpty else { return nil }
2742

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

            
Bogdan Timofte authored a month ago
2752
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
2753
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2754
    }
2755

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

            
2763
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
2764
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
2765
        return (try? context.fetch(request)) ?? []
2766
    }
2767

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

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

            
2803
        let resolvedCurrent = configuredCurrents[sessionKind]
2804
            ?? learnedCurrents[sessionKind]
2805
            ?? legacyCurrent
2806
            ?? fallback
2807
        guard let resolvedCurrent, resolvedCurrent > 0 else {
2808
            return nil
2809
        }
2810
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
2811
    }
2812

            
Bogdan Timofte authored a month ago
2813
    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
Bogdan Timofte authored a month ago
2814
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
2815
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
2816
        return resolvedPreferredChargingTransportMode(
Bogdan Timofte authored a month ago
2817
            .wired,
Bogdan Timofte authored a month ago
2818
            supportsWiredCharging: supportsWiredCharging,
2819
            supportsWirelessCharging: supportsWirelessCharging
2820
        )
2821
    }
2822

            
Bogdan Timofte authored a month ago
2823
    private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
2824
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other
2825
    }
2826

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
2895
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
2896
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
2897
            return chargingStateMode
2898
        }
2899

            
2900
        return .on
2901
    }
2902

            
2903
    private func resolvedChargingStateMode(
2904
        _ chargingStateMode: ChargingStateMode,
2905
        availability: ChargingStateAvailability
2906
    ) -> ChargingStateMode {
2907
        if availability.supportedModes.contains(chargingStateMode) {
2908
            return chargingStateMode
2909
        }
2910
        return availability.supportedModes.first ?? .on
2911
    }
2912

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

            
2917
        // Primary: chargerTypeRawValue (set on v13+)
2918
        if let rawValue = stringValue(chargedDevice, key: "chargerTypeRawValue"),
2919
           let type = ChargerType(rawValue: rawValue) {
2920
            return type
2921
        }
2922

            
2923
        // Migration fallback: derive from old deviceTemplateID
2924
        switch stringValue(chargedDevice, key: "deviceTemplateID") {
2925
        case "apple-magsafe-charger": return .appleMagSafe
2926
        case "apple-watch-charger": return .appleWatch
2927
        default: break
2928
        }
2929

            
2930
        // Last resort: derive from wirelessChargingProfileRawValue
2931
        if let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2932
           let profile = WirelessChargingProfile(rawValue: rawValue),
2933
           profile == .magsafe {
2934
            return .genericMagSafe
2935
        }
2936

            
2937
        return .genericQi
2938
    }
2939

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

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

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

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

            
2994
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2995
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
2996
                return
2997
            }
2998
            result[sessionKind] = entry.value
2999
        }
3000
    }
3001

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

            
3015
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
3016
        guard let charger else {
3017
            return nil
3018
        }
3019
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
3020
        guard let idleCurrent, idleCurrent >= 0 else {
3021
            return nil
3022
        }
3023
        return idleCurrent
3024
    }
3025

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

            
3042
    private func hasObservedChargeFlow(
3043
        currentAmps: Double,
3044
        chargingTransportMode: ChargingTransportMode,
3045
        charger: NSManagedObject?,
3046
        stopThreshold: Double?
3047
    ) -> Bool {
3048
        let effectiveCurrent = effectiveCurrentAmps(
3049
            fromMeasuredCurrent: currentAmps,
3050
            chargingTransportMode: chargingTransportMode,
3051
            charger: charger
3052
        )
3053
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
3054
    }
3055

            
Bogdan Timofte authored a month ago
3056
    private func hasSavableChargeData(_ session: NSManagedObject) -> Bool {
3057
        boolValue(session, key: "hasObservedChargeFlow")
3058
            || doubleValue(session, key: "measuredEnergyWh") > 0
3059
            || doubleValue(session, key: "measuredChargeAh") > 0
3060
            || (optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? 0) > 0
3061
            || (optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? 0) > 0
3062
    }
3063

            
Bogdan Timofte authored a month ago
3064
    private func derivedMinimumCurrent(
3065
        from sessions: [NSManagedObject],
3066
        chargingTransportMode: ChargingTransportMode
3067
    ) -> Double? {
3068
        let completionCurrents = sessions.compactMap { session -> Double? in
3069
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3070
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
3071
                return nil
3072
            }
Bogdan Timofte authored a month ago
3073
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3074
                return nil
3075
            }
Bogdan Timofte authored a month ago
3076
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
3077
                return nil
3078
            }
3079
            return completionCurrent
3080
        }
3081

            
3082
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
3083
        guard !recentCompletionCurrents.isEmpty else { return nil }
3084
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
3085
    }
3086

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

            
3090
        for session in sessions {
3091
            guard statusValue(session, key: "statusRawValue") == .completed else {
3092
                continue
3093
            }
3094
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3095
                continue
3096
            }
3097
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
3098
                  completionCurrent > 0 else {
3099
                continue
3100
            }
3101

            
3102
            let sessionKind = ChargeSessionKind(
3103
                chargingTransportMode: chargingTransportMode(for: session),
3104
                chargingStateMode: chargingStateMode(for: session)
3105
            )
3106
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
3107
        }
3108

            
3109
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
3110
            let recentCurrents = Array(entry.value.suffix(5))
3111
            guard !recentCurrents.isEmpty else {
3112
                return
3113
            }
3114
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
3115
        }
3116
    }
3117

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

            
3140
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
3141
        guard !recentCapacityCandidates.isEmpty else { return nil }
3142
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
3143
    }
3144

            
3145
    private func derivedWirelessEfficiency(
3146
        from sessions: [NSManagedObject],
3147
        chargingProfile: WirelessChargingProfile
3148
    ) -> Double? {
3149
        guard chargingProfile == .magsafe else {
3150
            return nil
3151
        }
3152

            
3153
        let candidates = sessions.compactMap { session -> Double? in
3154
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3155
            guard chargingTransportMode(for: session) == .wireless else { return nil }
3156
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
3157
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3158
                return nil
3159
            }
3160
            return factor
3161
        }
3162

            
3163
        let recentCandidates = Array(candidates.suffix(6))
3164
        guard !recentCandidates.isEmpty else { return nil }
3165
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3166
    }
3167

            
3168
    private func derivedObservedVoltageSelections(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 sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
3172
                return nil
3173
            }
3174
            return (sourceVoltage * 10).rounded() / 10
3175
        }
3176

            
3177
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
3178
        return counts.keys.sorted()
3179
    }
3180

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

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

            
3195
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
3196
        let candidates = sessions.compactMap { session -> Double? in
3197
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3198
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3199
                return nil
3200
            }
3201
            return factor
3202
        }
3203

            
3204
        let recentCandidates = Array(candidates.suffix(6))
3205
        guard !recentCandidates.isEmpty else { return nil }
3206
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3207
    }
3208

            
3209
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
3210
        sessions.compactMap { session -> Double? in
3211
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3212
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
3213
                return nil
3214
            }
3215
            return maximumObservedPower
3216
        }
3217
        .max()
3218
    }
3219

            
3220
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
3221
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
3222
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
3223
            return resolvedPreferredChargingTransportMode(
3224
                chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired,
3225
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
3226
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
3227
            )
3228
        }
3229

            
3230
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
3231
            return persistedChargingTransportMode
Bogdan Timofte authored a month ago
3232
        }
3233

            
3234
        return .wired
3235
    }
3236

            
3237
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
3238
        if session.isInserted {
3239
            return .created
3240
        }
3241

            
3242
        let committedValues = session.committedValues(
3243
            forKeys: [
3244
                "statusRawValue",
3245
                "updatedAt",
3246
                "targetBatteryAlertTriggeredAt",
3247
                "requiresCompletionConfirmation"
3248
            ]
3249
        )
3250
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
3251
        let currentStatus = statusValue(session, key: "statusRawValue")
3252
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
3253
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
3254
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
3255
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
3256
            ?? false
3257
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
3258

            
3259
        if currentStatus == .completed, committedStatus != .completed {
3260
            return .completed
3261
        }
3262

            
Bogdan Timofte authored a month ago
3263
        if currentStatus != committedStatus {
3264
            return .event
3265
        }
3266

            
Bogdan Timofte authored a month ago
3267
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
3268
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
3269
            return .event
3270
        }
3271

            
3272
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3273
            ?? dateValue(session, key: "createdAt")
3274
            ?? observedAt
3275

            
3276
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
3277
            return .periodic
3278
        }
3279

            
3280
        return .none
3281
    }
3282

            
Bogdan Timofte authored a month ago
3283
    private func shouldPersistAggregatedSample(
3284
        _ sample: NSManagedObject,
3285
        observedAt: Date
3286
    ) -> Bool {
3287
        if sample.isInserted {
3288
            return true
3289
        }
3290

            
3291
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
3292
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3293
            ?? dateValue(sample, key: "createdAt")
3294
            ?? observedAt
3295

            
3296
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
3297
    }
3298

            
Bogdan Timofte authored a month ago
3299
    private func generateQRIdentifier() -> String {
3300
        "device:\(UUID().uuidString)"
3301
    }
3302

            
3303
    @discardableResult
3304
    private func saveContext() -> Bool {
3305
        guard context.hasChanges else { return true }
3306
        do {
3307
            try context.save()
3308
            return true
3309
        } catch {
3310
            track("Failed saving charge insights context: \(error)")
3311
            context.rollback()
3312
            return false
3313
        }
3314
    }
3315

            
3316
    private func normalizedText(_ text: String) -> String {
3317
        text.trimmingCharacters(in: .whitespacesAndNewlines)
3318
    }
3319

            
3320
    private func normalizedOptionalText(_ text: String?) -> String? {
3321
        guard let text else { return nil }
3322
        let normalized = normalizedText(text)
3323
        return normalized.isEmpty ? nil : normalized
3324
    }
3325

            
3326
    private func normalizedMACAddress(_ macAddress: String) -> String {
3327
        normalizedText(macAddress).uppercased()
3328
    }
3329

            
Bogdan Timofte authored a month ago
3330
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
3331
        guard object.entity.propertiesByName[key] != nil else {
3332
            return nil
3333
        }
3334
        return object.value(forKey: key)
3335
    }
3336

            
3337
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
3338
        guard object.entity.propertiesByName[key] != nil else {
3339
            return
3340
        }
3341
        object.setValue(value, forKey: key)
3342
    }
3343

            
Bogdan Timofte authored a month ago
3344
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
3345
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
3346
        let normalized = normalizedOptionalText(value)
3347
        return normalized
3348
    }
3349

            
3350
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
3351
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
3352
    }
3353

            
3354
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
3355
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
3356
            return value
3357
        }
Bogdan Timofte authored a month ago
3358
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3359
            return value.doubleValue
3360
        }
3361
        return 0
3362
    }
3363

            
3364
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
3365
        let value = rawValue(object, key: key)
3366
        if value == nil {
Bogdan Timofte authored a month ago
3367
            return nil
3368
        }
3369
        return doubleValue(object, key: key)
3370
    }
3371

            
3372
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
Bogdan Timofte authored a month ago
3373
        if let value = rawValue(object, key: key) as? Int16 {
Bogdan Timofte authored a month ago
3374
            return value
3375
        }
Bogdan Timofte authored a month ago
3376
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3377
            return value.int16Value
3378
        }
3379
        return nil
3380
    }
3381

            
3382
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
3383
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
3384
            return value
3385
        }
Bogdan Timofte authored a month ago
3386
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3387
            return value.int32Value
3388
        }
3389
        return nil
3390
    }
3391

            
3392
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
Bogdan Timofte authored a month ago
3393
        if let value = rawValue(object, key: key) as? Bool {
Bogdan Timofte authored a month ago
3394
            return value
3395
        }
Bogdan Timofte authored a month ago
3396
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3397
            return value.boolValue
3398
        }
3399
        return false
3400
    }
3401

            
3402
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
3403
        guard let value = stringValue(object, key: key) else { return nil }
3404
        return UUID(uuidString: value)
3405
    }
3406

            
3407
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
3408
        guard let value = stringValue(object, key: key) else { return nil }
3409
        return ChargeSessionStatus(rawValue: value)
3410
    }
3411

            
3412
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
3413
        guard let value = stringValue(object, key: key) else { return nil }
3414
        return ChargingTransportMode(rawValue: value)
3415
    }
3416

            
3417
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
3418
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
3419
            return []
3420
        }
3421
        return rawValue
3422
            .split(separator: ",")
3423
            .compactMap { Double($0) }
3424
            .sorted()
3425
    }
3426

            
3427
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
3428
        let uniqueVoltages = Array(Set(voltages)).sorted()
3429
        guard !uniqueVoltages.isEmpty else {
3430
            return nil
3431
        }
3432
        return uniqueVoltages
3433
            .map { String(format: "%.1f", $0) }
3434
            .joined(separator: ",")
3435
    }
3436

            
3437
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
3438
        guard currentCount > 0 else {
3439
            return newValue
3440
        }
3441
        let total = (currentAverage * Double(currentCount)) + newValue
3442
        return total / Double(currentCount + 1)
3443
    }
3444
}
3445

            
3446
private enum ObservationSaveReason {
3447
    case none
3448
    case created
3449
    case periodic
3450
    case completed
3451
    case event
3452
}