USB-Meter / USB Meter / Model / ChargeInsightsStore.swift
Newer Older
3486 lines | 151.779kb
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
            restoreMeasuredTotalsFromLatestSampleIfNeeded(session)
613

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

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

            
626
            guard saveContext() else {
627
                return
628
            }
629

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

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

            
Bogdan Timofte authored a month ago
642
        return didSave
643
    }
644

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
888
            refreshCheckpointDerivedValues(for: session)
889

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

            
894
            didSave = saveContext()
895
        }
896
        return didSave
897
    }
898

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

            
907
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
908

            
909
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
910
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
911
            context.delete(session)
912

            
913
            guard saveContext() else {
914
                return
915
            }
916

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

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

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

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

            
940
            var impactedChargedDeviceIDs = Set<String>()
941

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

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

            
969
            context.delete(chargedDevice)
970

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

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

            
982
        return didSave
983
    }
984

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

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

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

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

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

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

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

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

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

            
1052
        return didSave
1053
    }
1054

            
1055
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
1056
        var summaries: [ChargedDeviceSummary] = []
1057

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

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

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

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

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

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

            
1173
        return summaries
1174
    }
1175

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

            
1180
        let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
1181

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

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

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

            
Bogdan Timofte authored a month ago
1195
        var summary: ChargeSessionSummary?
1196

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

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

            
1210
        return summary
Bogdan Timofte authored a month ago
1211
    }
1212

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1548
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
1549
            return
1550
        }
1551

            
1552
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
1553
    }
1554

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

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

            
1568
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
1569
            ?? defaultCompletionPercentThreshold
1570

            
1571
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1572
    }
1573

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
1632
        var completionDates: [Date] = []
1633

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

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

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

            
1648
        return completionDate
1649
    }
1650

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

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

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

            
1669
        guard saveContext() else {
1670
            return false
1671
        }
1672

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

            
1678
        return true
1679
    }
1680

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

            
1686
        guard measuredCurrent > 0 else {
1687
            return nil
1688
        }
1689

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1885
        var anchors: [CapacityAnchor] = []
1886

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
2011
        return chargedDeviceID
2012
    }
2013

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

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

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

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

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

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

            
2064
        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
2065
        return saveContext()
2066
    }
2067

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

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

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

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

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

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

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

            
2120
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
2121
    }
2122

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

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

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

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

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

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

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

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

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

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

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

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

            
2266
        var runningMaximumEnergyWh = 0.0
2267
        var runningMaximumChargeAh = 0.0
2268

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

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

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

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

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

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

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

            
2348
        var collapsedAnchors: [(percent: Double, energyWh: Double, chargeAh: Double)] = []
2349

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

            
2365
        var runningMaximumEnergyWh = 0.0
2366
        var runningMaximumChargeAh = 0.0
2367

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
2681
            var recentCompletedSamplesIncluded = 0
2682

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

            
2689
                if status.isOpen {
2690
                    sessionIDs.insert(sessionID)
2691
                    continue
2692
                }
2693

            
2694
                guard recentCompletedSamplesIncluded < 2 else {
2695
                    continue
2696
                }
2697

            
2698
                sessionIDs.insert(sessionID)
2699
                recentCompletedSamplesIncluded += 1
2700
            }
2701
        }
2702

            
2703
        return sessionIDs
2704
    }
2705

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
2902
        return .on
2903
    }
2904

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

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

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

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

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

            
2939
        return .genericQi
2940
    }
2941

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

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

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

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

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

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

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

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

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

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

            
3067
        guard let sessionID = stringValue(session, key: "id") else {
3068
            return false
3069
        }
3070

            
3071
        return fetchSessionSampleObjects(forSessionID: sessionID).contains { sample in
3072
            doubleValue(sample, key: "measuredEnergyWh") > 0
3073
                || doubleValue(sample, key: "measuredChargeAh") > 0
3074
                || (optionalDoubleValue(sample, key: "averageCurrentAmps") ?? 0) > 0
3075
                || (optionalDoubleValue(sample, key: "averagePowerWatts") ?? 0) > 0
3076
        }
3077
    }
3078

            
3079
    private func restoreMeasuredTotalsFromLatestSampleIfNeeded(_ session: NSManagedObject) {
3080
        guard let sessionID = stringValue(session, key: "id"),
3081
              let latestSample = fetchSessionSampleObjects(forSessionID: sessionID).max(by: {
3082
                  (dateValue($0, key: "timestamp") ?? .distantPast) < (dateValue($1, key: "timestamp") ?? .distantPast)
3083
              }) else {
3084
            return
3085
        }
3086

            
3087
        let sampleEnergyWh = doubleValue(latestSample, key: "measuredEnergyWh")
3088
        if doubleValue(session, key: "measuredEnergyWh") <= 0, sampleEnergyWh > 0 {
3089
            session.setValue(sampleEnergyWh, forKey: "measuredEnergyWh")
3090
        }
3091

            
3092
        let sampleChargeAh = doubleValue(latestSample, key: "measuredChargeAh")
3093
        if doubleValue(session, key: "measuredChargeAh") <= 0, sampleChargeAh > 0 {
3094
            session.setValue(sampleChargeAh, forKey: "measuredChargeAh")
3095
        }
Bogdan Timofte authored a month ago
3096
    }
3097

            
Bogdan Timofte authored a month ago
3098
    private func derivedMinimumCurrent(
3099
        from sessions: [NSManagedObject],
3100
        chargingTransportMode: ChargingTransportMode
3101
    ) -> Double? {
3102
        let completionCurrents = sessions.compactMap { session -> Double? in
3103
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3104
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
3105
                return nil
3106
            }
Bogdan Timofte authored a month ago
3107
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3108
                return nil
3109
            }
Bogdan Timofte authored a month ago
3110
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
3111
                return nil
3112
            }
3113
            return completionCurrent
3114
        }
3115

            
3116
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
3117
        guard !recentCompletionCurrents.isEmpty else { return nil }
3118
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
3119
    }
3120

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

            
3124
        for session in sessions {
3125
            guard statusValue(session, key: "statusRawValue") == .completed else {
3126
                continue
3127
            }
3128
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3129
                continue
3130
            }
3131
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
3132
                  completionCurrent > 0 else {
3133
                continue
3134
            }
3135

            
3136
            let sessionKind = ChargeSessionKind(
3137
                chargingTransportMode: chargingTransportMode(for: session),
3138
                chargingStateMode: chargingStateMode(for: session)
3139
            )
3140
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
3141
        }
3142

            
3143
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
3144
            let recentCurrents = Array(entry.value.suffix(5))
3145
            guard !recentCurrents.isEmpty else {
3146
                return
3147
            }
3148
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
3149
        }
3150
    }
3151

            
Bogdan Timofte authored a month ago
3152
    private func derivedCapacity(
3153
        from sessions: [NSManagedObject],
3154
        chargingTransportMode: ChargingTransportMode,
3155
        supportsChargingWhileOff: Bool
3156
    ) -> Double? {
3157
        let capacityCandidates = sessions.compactMap { session -> Double? in
3158
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3159
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
3160
                return nil
3161
            }
3162
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
3163
                return nil
3164
            }
3165
            if supportsChargingWhileOff {
3166
                return capacityEstimate
3167
            }
3168
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
3169
                return nil
3170
            }
3171
            return capacityEstimate
3172
        }
3173

            
3174
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
3175
        guard !recentCapacityCandidates.isEmpty else { return nil }
3176
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
3177
    }
3178

            
3179
    private func derivedWirelessEfficiency(
3180
        from sessions: [NSManagedObject],
3181
        chargingProfile: WirelessChargingProfile
3182
    ) -> Double? {
3183
        guard chargingProfile == .magsafe else {
3184
            return nil
3185
        }
3186

            
3187
        let candidates = sessions.compactMap { session -> Double? in
3188
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3189
            guard chargingTransportMode(for: session) == .wireless else { return nil }
3190
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
3191
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3192
                return nil
3193
            }
3194
            return factor
3195
        }
3196

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

            
3202
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
3203
        let candidates = sessions.compactMap { session -> Double? in
3204
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3205
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
3206
                return nil
3207
            }
3208
            return (sourceVoltage * 10).rounded() / 10
3209
        }
3210

            
3211
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
3212
        return counts.keys.sorted()
3213
    }
3214

            
3215
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
3216
        let candidates = sessions.compactMap { session -> Double? in
3217
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3218
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
3219
                return nil
3220
            }
3221
            return minimumObservedCurrent
3222
        }
3223

            
3224
        let recentCandidates = Array(candidates.suffix(6))
3225
        guard !recentCandidates.isEmpty else { return nil }
3226
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3227
    }
3228

            
3229
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
3230
        let candidates = sessions.compactMap { session -> Double? in
3231
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3232
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3233
                return nil
3234
            }
3235
            return factor
3236
        }
3237

            
3238
        let recentCandidates = Array(candidates.suffix(6))
3239
        guard !recentCandidates.isEmpty else { return nil }
3240
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3241
    }
3242

            
3243
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
3244
        sessions.compactMap { session -> Double? in
3245
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3246
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
3247
                return nil
3248
            }
3249
            return maximumObservedPower
3250
        }
3251
        .max()
3252
    }
3253

            
3254
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
3255
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
3256
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
3257
            return resolvedPreferredChargingTransportMode(
3258
                chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired,
3259
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
3260
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
3261
            )
3262
        }
3263

            
3264
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
3265
            return persistedChargingTransportMode
Bogdan Timofte authored a month ago
3266
        }
3267

            
3268
        return .wired
3269
    }
3270

            
3271
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
3272
        if session.isInserted {
3273
            return .created
3274
        }
3275

            
3276
        let committedValues = session.committedValues(
3277
            forKeys: [
3278
                "statusRawValue",
3279
                "updatedAt",
3280
                "targetBatteryAlertTriggeredAt",
3281
                "requiresCompletionConfirmation"
3282
            ]
3283
        )
3284
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
3285
        let currentStatus = statusValue(session, key: "statusRawValue")
3286
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
3287
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
3288
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
3289
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
3290
            ?? false
3291
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
3292

            
3293
        if currentStatus == .completed, committedStatus != .completed {
3294
            return .completed
3295
        }
3296

            
Bogdan Timofte authored a month ago
3297
        if currentStatus != committedStatus {
3298
            return .event
3299
        }
3300

            
Bogdan Timofte authored a month ago
3301
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
3302
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
3303
            return .event
3304
        }
3305

            
3306
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3307
            ?? dateValue(session, key: "createdAt")
3308
            ?? observedAt
3309

            
3310
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
3311
            return .periodic
3312
        }
3313

            
3314
        return .none
3315
    }
3316

            
Bogdan Timofte authored a month ago
3317
    private func shouldPersistAggregatedSample(
3318
        _ sample: NSManagedObject,
3319
        observedAt: Date
3320
    ) -> Bool {
3321
        if sample.isInserted {
3322
            return true
3323
        }
3324

            
3325
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
3326
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3327
            ?? dateValue(sample, key: "createdAt")
3328
            ?? observedAt
3329

            
3330
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
3331
    }
3332

            
Bogdan Timofte authored a month ago
3333
    private func generateQRIdentifier() -> String {
3334
        "device:\(UUID().uuidString)"
3335
    }
3336

            
3337
    @discardableResult
3338
    private func saveContext() -> Bool {
3339
        guard context.hasChanges else { return true }
3340
        do {
3341
            try context.save()
3342
            return true
3343
        } catch {
3344
            track("Failed saving charge insights context: \(error)")
3345
            context.rollback()
3346
            return false
3347
        }
3348
    }
3349

            
3350
    private func normalizedText(_ text: String) -> String {
3351
        text.trimmingCharacters(in: .whitespacesAndNewlines)
3352
    }
3353

            
3354
    private func normalizedOptionalText(_ text: String?) -> String? {
3355
        guard let text else { return nil }
3356
        let normalized = normalizedText(text)
3357
        return normalized.isEmpty ? nil : normalized
3358
    }
3359

            
3360
    private func normalizedMACAddress(_ macAddress: String) -> String {
3361
        normalizedText(macAddress).uppercased()
3362
    }
3363

            
Bogdan Timofte authored a month ago
3364
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
3365
        guard object.entity.propertiesByName[key] != nil else {
3366
            return nil
3367
        }
3368
        return object.value(forKey: key)
3369
    }
3370

            
3371
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
3372
        guard object.entity.propertiesByName[key] != nil else {
3373
            return
3374
        }
3375
        object.setValue(value, forKey: key)
3376
    }
3377

            
Bogdan Timofte authored a month ago
3378
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
3379
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
3380
        let normalized = normalizedOptionalText(value)
3381
        return normalized
3382
    }
3383

            
3384
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
3385
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
3386
    }
3387

            
3388
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
3389
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
3390
            return value
3391
        }
Bogdan Timofte authored a month ago
3392
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3393
            return value.doubleValue
3394
        }
3395
        return 0
3396
    }
3397

            
3398
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
3399
        let value = rawValue(object, key: key)
3400
        if value == nil {
Bogdan Timofte authored a month ago
3401
            return nil
3402
        }
3403
        return doubleValue(object, key: key)
3404
    }
3405

            
3406
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
Bogdan Timofte authored a month ago
3407
        if let value = rawValue(object, key: key) as? Int16 {
Bogdan Timofte authored a month ago
3408
            return value
3409
        }
Bogdan Timofte authored a month ago
3410
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3411
            return value.int16Value
3412
        }
3413
        return nil
3414
    }
3415

            
3416
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
3417
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
3418
            return value
3419
        }
Bogdan Timofte authored a month ago
3420
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3421
            return value.int32Value
3422
        }
3423
        return nil
3424
    }
3425

            
3426
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
Bogdan Timofte authored a month ago
3427
        if let value = rawValue(object, key: key) as? Bool {
Bogdan Timofte authored a month ago
3428
            return value
3429
        }
Bogdan Timofte authored a month ago
3430
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3431
            return value.boolValue
3432
        }
3433
        return false
3434
    }
3435

            
3436
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
3437
        guard let value = stringValue(object, key: key) else { return nil }
3438
        return UUID(uuidString: value)
3439
    }
3440

            
3441
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
3442
        guard let value = stringValue(object, key: key) else { return nil }
3443
        return ChargeSessionStatus(rawValue: value)
3444
    }
3445

            
3446
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
3447
        guard let value = stringValue(object, key: key) else { return nil }
3448
        return ChargingTransportMode(rawValue: value)
3449
    }
3450

            
3451
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
3452
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
3453
            return []
3454
        }
3455
        return rawValue
3456
            .split(separator: ",")
3457
            .compactMap { Double($0) }
3458
            .sorted()
3459
    }
3460

            
3461
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
3462
        let uniqueVoltages = Array(Set(voltages)).sorted()
3463
        guard !uniqueVoltages.isEmpty else {
3464
            return nil
3465
        }
3466
        return uniqueVoltages
3467
            .map { String(format: "%.1f", $0) }
3468
            .joined(separator: ",")
3469
    }
3470

            
3471
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
3472
        guard currentCount > 0 else {
3473
            return newValue
3474
        }
3475
        let total = (currentAverage * Double(currentCount)) + newValue
3476
        return total / Double(currentCount + 1)
3477
    }
3478
}
3479

            
3480
private enum ObservationSaveReason {
3481
    case none
3482
    case created
3483
    case periodic
3484
    case completed
3485
    case event
3486
}