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

            
33
    private static let persistedSamplesPerHour = 300
34
    private static let aggregatedSampleBucketDuration = 3600.0 / Double(persistedSamplesPerHour)
35

            
36
    private let context: NSManagedObjectContext
37
    private let stopDetectionHoldDuration: TimeInterval = 20
Bogdan Timofte authored a month ago
38
    private let maximumLiveIntegrationGap: TimeInterval = 90
Bogdan Timofte authored a month ago
39
    private let activeSessionSaveInterval: TimeInterval = 60
40
    private let aggregatedSampleSaveInterval: TimeInterval = 30
Bogdan Timofte authored a month ago
41
    private let counterDecreaseTolerance = 0.002
42
    private let completionConfirmationCooldown: TimeInterval = 15 * 60
Bogdan Timofte authored a month ago
43
    private let pausedSessionTimeout: TimeInterval = 10 * 60
Bogdan Timofte authored a month ago
44
    private let defaultCompletionPercentThreshold = 95.0
45
    private let completionContradictionTolerancePercent = 2.0
46
    private let minimumWirelessEfficiencyFactor = 0.35
47
    private let maximumWirelessEfficiencyFactor = 0.95
48
    private let lowWirelessEfficiencyThreshold = 0.72
Bogdan Timofte authored a month ago
49
    private let unresolvedFlatBatteryPercent = -1.0
Bogdan Timofte authored a month ago
50

            
51
    init(context: NSManagedObjectContext) {
52
        self.context = context
53
    }
54

            
55
    func refreshContext() {
56
        context.performAndWait {
57
            context.processPendingChanges()
58
        }
59
    }
60

            
Bogdan Timofte authored a month ago
61
    func resetContext() {
62
        context.performAndWait {
63
            context.reset()
64
        }
65
    }
66

            
Bogdan Timofte authored a month ago
67
    @discardableResult
68
    func flushPendingChanges() -> Bool {
69
        var didSave = false
70
        context.performAndWait {
71
            context.processPendingChanges()
72
            didSave = saveContext()
73
        }
74
        return didSave
75
    }
76

            
77
    @discardableResult
Bogdan Timofte authored a month ago
78
    func createDevice(
Bogdan Timofte authored a month ago
79
        name: String,
80
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
81
        templateID: String?,
Bogdan Timofte authored a month ago
82
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
83
        supportsWiredCharging: Bool,
84
        supportsWirelessCharging: Bool,
85
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
86
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
87
        notes: String?,
88
        assignTo meterMACAddress: String?
89
    ) -> Bool {
Bogdan Timofte authored a month ago
90
        guard deviceClass.kind == .device else { return false }
Bogdan Timofte authored a month ago
91
        let normalizedName = normalizedText(name)
Bogdan Timofte authored a month ago
92
        let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability)
93
        let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
94
            supportsWiredCharging: supportsWiredCharging,
95
            supportsWirelessCharging: supportsWirelessCharging
96
        )
97
        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device)
Bogdan Timofte authored a month ago
98
        guard !normalizedName.isEmpty else { return false }
Bogdan Timofte authored a month ago
99
        guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
Bogdan Timofte authored a month ago
100

            
101
        var didSave = false
102
        context.performAndWait {
103
            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
104
                return
105
            }
106

            
107
            let object = NSManagedObject(entity: entity, insertInto: context)
108
            let now = Date()
109
            object.setValue(UUID().uuidString, forKey: "id")
110
            object.setValue(normalizedName, forKey: "name")
111
            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
112
            object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
113
            object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
114
            object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
115
            object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging")
116
            object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging")
Bogdan Timofte authored a month ago
117
            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
118
            object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
119
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
120
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
121
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
122
            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
123
            object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC")
124
            object.setValue(now, forKey: "createdAt")
125
            object.setValue(now, forKey: "updatedAt")
126
            didSave = saveContext()
127
        }
128
        return didSave
129
    }
130

            
131
    @discardableResult
Bogdan Timofte authored a month ago
132
    func createCharger(
133
        name: String,
Bogdan Timofte authored a month ago
134
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
135
        notes: String?,
136
        assignTo meterMACAddress: String?
137
    ) -> Bool {
138
        let normalizedName = normalizedText(name)
139
        guard !normalizedName.isEmpty else { return false }
140

            
141
        var didSave = false
142
        context.performAndWait {
143
            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
144
                return
145
            }
146

            
147
            let object = NSManagedObject(entity: entity, insertInto: context)
148
            let now = Date()
149
            object.setValue(UUID().uuidString, forKey: "id")
150
            object.setValue(normalizedName, forKey: "name")
151
            object.setValue(ChargedDeviceClass.charger.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
152
            object.setValue(nil, forKey: "deviceTemplateID")
153
            object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue")
154
            object.setValue(false, forKey: "supportsChargingWhileOff")
155
            object.setValue(false, forKey: "supportsWiredCharging")
156
            object.setValue(true, forKey: "supportsWirelessCharging")
157
            if object.entity.attributesByName["chargerTypeRawValue"] != nil {
158
                object.setValue(chargerType.rawValue, forKey: "chargerTypeRawValue")
159
            }
160
            object.setValue(chargerType.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
161
            object.setValue(encodedCompletionCurrents([:]), forKey: "configuredCompletionCurrentsRawValue")
162
            object.setValue(nil, forKey: "wiredChargeCompletionCurrentAmps")
163
            object.setValue(nil, forKey: "wirelessChargeCompletionCurrentAmps")
164
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
165
            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
166
            object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC")
167
            object.setValue(now, forKey: "createdAt")
168
            object.setValue(now, forKey: "updatedAt")
169
            didSave = saveContext()
170
        }
171
        return didSave
172
    }
173

            
174
    @discardableResult
175
    func updateDevice(
Bogdan Timofte authored a month ago
176
        id: UUID,
177
        name: String,
178
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
179
        templateID: String?,
Bogdan Timofte authored a month ago
180
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
181
        supportsWiredCharging: Bool,
182
        supportsWirelessCharging: Bool,
183
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
184
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
185
        notes: String?
186
    ) -> Bool {
Bogdan Timofte authored a month ago
187
        guard deviceClass.kind == .device else { return false }
Bogdan Timofte authored a month ago
188
        let normalizedName = normalizedText(name)
Bogdan Timofte authored a month ago
189
        let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability)
190
        let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
191
            supportsWiredCharging: supportsWiredCharging,
192
            supportsWirelessCharging: supportsWirelessCharging
193
        )
194
        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device)
Bogdan Timofte authored a month ago
195
        guard !normalizedName.isEmpty else { return false }
Bogdan Timofte authored a month ago
196
        guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
Bogdan Timofte authored a month ago
197

            
198
        var didSave = false
199
        context.performAndWait {
200
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
201
                return
202
            }
Bogdan Timofte authored a month ago
203
            guard isChargerObject(object) == false else {
204
                return
205
            }
Bogdan Timofte authored a month ago
206

            
207
            let previousSupportsChargingWhileOff = boolValue(object, key: "supportsChargingWhileOff")
Bogdan Timofte authored a month ago
208
            let previousChargingStateAvailability = self.chargingStateAvailability(for: object)
Bogdan Timofte authored a month ago
209
            let previousSupportsWiredCharging = self.supportsWiredCharging(for: object)
210
            let previousSupportsWirelessCharging = self.supportsWirelessCharging(for: object)
211
            let now = Date()
212

            
213
            object.setValue(normalizedName, forKey: "name")
214
            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
215
            object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
216
            object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
217
            object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
218
            object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging")
219
            object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging")
Bogdan Timofte authored a month ago
220
            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
221
            object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
222
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
223
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
224
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
225
            object.setValue(now, forKey: "updatedAt")
226

            
Bogdan Timofte authored a month ago
227
            let supportsChargingWhileOff = normalizedChargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
228
            let shouldRecalculateSessionCapacity = previousSupportsChargingWhileOff != supportsChargingWhileOff
229
            let shouldRefreshActiveSessions = shouldRecalculateSessionCapacity
Bogdan Timofte authored a month ago
230
                || previousChargingStateAvailability != normalizedChargingStateAvailability
231
                || previousSupportsWiredCharging != normalizedChargingSupport.wired
232
                || previousSupportsWirelessCharging != normalizedChargingSupport.wireless
Bogdan Timofte authored a month ago
233

            
234
            if shouldRecalculateSessionCapacity || shouldRefreshActiveSessions {
235
                let sessions = fetchSessions(forChargedDeviceID: id.uuidString)
236
                for session in sessions {
Bogdan Timofte authored a month ago
237
                    let isOpen = statusValue(session, key: "statusRawValue")?.isOpen == true
Bogdan Timofte authored a month ago
238

            
239
                    if shouldRecalculateSessionCapacity {
240
                        session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
241
                        updateCapacityEstimate(for: session)
242
                        session.setValue(now, forKey: "updatedAt")
243
                    }
244

            
Bogdan Timofte authored a month ago
245
                    guard isOpen, shouldRefreshActiveSessions else {
Bogdan Timofte authored a month ago
246
                        continue
247
                    }
248

            
249
                    let resolvedSessionChargingTransportMode = resolvedPreferredChargingTransportMode(
250
                        chargingTransportMode(for: session),
Bogdan Timofte authored a month ago
251
                        supportsWiredCharging: normalizedChargingSupport.wired,
252
                        supportsWirelessCharging: normalizedChargingSupport.wireless
Bogdan Timofte authored a month ago
253
                    )
Bogdan Timofte authored a month ago
254
                    let resolvedSessionChargingStateMode = resolvedChargingStateMode(
255
                        chargingStateMode(for: session),
Bogdan Timofte authored a month ago
256
                        availability: normalizedChargingStateAvailability
Bogdan Timofte authored a month ago
257
                    )
258
                    let charger = stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
259

            
260
                    session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
261
                    session.setValue(resolvedSessionChargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
262
                    session.setValue(resolvedSessionChargingStateMode.rawValue, forKey: "chargingStateRawValue")
Bogdan Timofte authored a month ago
263
                    session.setValue(
264
                        resolvedStopThreshold(
265
                            for: object,
266
                            chargingTransportMode: resolvedSessionChargingTransportMode,
Bogdan Timofte authored a month ago
267
                            chargingStateMode: resolvedSessionChargingStateMode,
268
                            charger: charger,
269
                            fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
270
                        ) ?? 0,
Bogdan Timofte authored a month ago
271
                        forKey: "stopThresholdAmps"
272
                    )
273
                    session.setValue(now, forKey: "updatedAt")
274
                    updateCapacityEstimate(for: session)
275
                }
276
            }
277

            
278
            refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
279
            didSave = saveContext()
280
        }
281
        return didSave
282
    }
283

            
Bogdan Timofte authored a month ago
284
    @discardableResult
285
    func updateCharger(
286
        id: UUID,
287
        name: String,
Bogdan Timofte authored a month ago
288
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
289
        notes: String?
290
    ) -> Bool {
291
        let normalizedName = normalizedText(name)
292
        guard !normalizedName.isEmpty else { return false }
293

            
294
        var didSave = false
295
        context.performAndWait {
296
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
297
                return
298
            }
299
            guard isChargerObject(object) else {
300
                return
301
            }
302

            
303
            object.setValue(normalizedName, forKey: "name")
Bogdan Timofte authored a month ago
304
            object.setValue(nil, forKey: "deviceTemplateID")
305
            object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue")
306
            object.setValue(false, forKey: "supportsChargingWhileOff")
307
            object.setValue(false, forKey: "supportsWiredCharging")
308
            object.setValue(true, forKey: "supportsWirelessCharging")
309
            if object.entity.attributesByName["chargerTypeRawValue"] != nil {
310
                object.setValue(chargerType.rawValue, forKey: "chargerTypeRawValue")
311
            }
312
            object.setValue(chargerType.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
313
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
314
            object.setValue(Date(), forKey: "updatedAt")
Bogdan Timofte authored a month ago
315
            refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
Bogdan Timofte authored a month ago
316
            didSave = saveContext()
317
        }
318

            
319
        return didSave
320
    }
321

            
Bogdan Timofte authored a month ago
322
    @discardableResult
323
    func assignChargedDevice(id: UUID, to meterMACAddress: String) -> Bool {
324
        assign(itemWithID: id, to: meterMACAddress, kind: .chargedDevice)
325
    }
326

            
327
    @discardableResult
328
    func assignCharger(id: UUID, to meterMACAddress: String) -> Bool {
329
        assign(itemWithID: id, to: meterMACAddress, kind: .charger)
330
    }
331

            
332
    @discardableResult
333
    private func assign(
334
        itemWithID id: UUID,
335
        to meterMACAddress: String,
336
        kind: MeterAssignmentKind
337
    ) -> Bool {
338
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
339
        guard !normalizedMAC.isEmpty else { return false }
340

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

            
347
            let isCharger = ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
348
            guard isCharger == kind.expectsChargerClass else {
349
                return
350
            }
351

            
352
            let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
353
            request.predicate = NSPredicate(
354
                format: "lastAssociatedMeterMAC == %@ AND id != %@",
355
                normalizedMAC,
356
                id.uuidString
357
            )
358
            let previouslyAssignedDevices = (try? context.fetch(request)) ?? []
359
            for previousDevice in previouslyAssignedDevices {
360
                let previousIsCharger = ChargedDeviceClass(rawValue: stringValue(previousDevice, key: "deviceClassRawValue") ?? "") == .charger
361
                guard previousIsCharger == kind.expectsChargerClass else {
362
                    continue
363
                }
364
                previousDevice.setValue(nil, forKey: "lastAssociatedMeterMAC")
365
                previousDevice.setValue(Date(), forKey: "updatedAt")
366
            }
367

            
368
            object.setValue(normalizedMAC, forKey: "lastAssociatedMeterMAC")
369
            object.setValue(Date(), forKey: "updatedAt")
370

            
371
            if kind == .charger,
Bogdan Timofte authored a month ago
372
               let openSession = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
373
               chargingTransportMode(for: openSession) == .wireless {
374
                openSession.setValue(id.uuidString, forKey: "chargerID")
375
                openSession.setValue(Date(), forKey: "updatedAt")
Bogdan Timofte authored a month ago
376
            }
377

            
378
            didSave = saveContext()
379
        }
380
        return didSave
381
    }
382

            
383
    @discardableResult
Bogdan Timofte authored a month ago
384
    func startSession(
385
        for snapshot: ChargingMonitorSnapshot,
386
        chargedDeviceID: UUID,
387
        chargerID: UUID?,
388
        chargingTransportMode: ChargingTransportMode,
389
        chargingStateMode: ChargingStateMode,
390
        autoStopEnabled: Bool,
Bogdan Timofte authored a month ago
391
        initialBatteryPercent: Double?,
392
        startsFromFlatBattery: Bool
Bogdan Timofte authored a month ago
393
    ) -> Bool {
Bogdan Timofte authored a month ago
394
        if let initialBatteryPercent,
395
           (initialBatteryPercent.isFinite == false || initialBatteryPercent < 0 || initialBatteryPercent > 100) {
Bogdan Timofte authored a month ago
396
            return false
397
        }
398

            
Bogdan Timofte authored a month ago
399
        var didSave = false
400
        context.performAndWait {
Bogdan Timofte authored a month ago
401
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
Bogdan Timofte authored a month ago
402
                return
403
            }
Bogdan Timofte authored a month ago
404
            guard isChargerObject(chargedDevice) == false else {
405
                return
406
            }
Bogdan Timofte authored a month ago
407

            
Bogdan Timofte authored a month ago
408
            guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
Bogdan Timofte authored a month ago
409
                return
410
            }
411

            
Bogdan Timofte authored a month ago
412
            let resolvedChargingTransportMode = resolvedPreferredChargingTransportMode(
413
                chargingTransportMode,
414
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
415
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
Bogdan Timofte authored a month ago
416
            )
Bogdan Timofte authored a month ago
417
            let resolvedChargingStateMode = resolvedChargingStateMode(
418
                chargingStateMode,
419
                availability: chargingStateAvailability(for: chargedDevice)
420
            )
421
            let charger = resolvedChargingTransportMode == .wireless
422
                ? chargerID.flatMap { fetchChargedDeviceObject(id: $0.uuidString) }
423
                : nil
Bogdan Timofte authored a month ago
424
            if let charger, isChargerObject(charger) == false {
425
                return
426
            }
Bogdan Timofte authored a month ago
427
            guard resolvedChargingTransportMode == .wired || charger != nil else {
Bogdan Timofte authored a month ago
428
                return
429
            }
Bogdan Timofte authored a month ago
430
            let stopThreshold = resolvedStopThreshold(
Bogdan Timofte authored a month ago
431
                for: chargedDevice,
432
                chargingTransportMode: resolvedChargingTransportMode,
433
                chargingStateMode: resolvedChargingStateMode,
434
                charger: charger,
Bogdan Timofte authored a month ago
435
                fallback: snapshot.fallbackStopThresholdAmps > 0 ? snapshot.fallbackStopThresholdAmps : nil
436
            )
Bogdan Timofte authored a month ago
437
            guard let session = createSessionObject(
438
                for: chargedDevice,
Bogdan Timofte authored a month ago
439
                charger: charger,
440
                snapshot: snapshot,
441
                stopThreshold: stopThreshold,
Bogdan Timofte authored a month ago
442
                chargingTransportMode: resolvedChargingTransportMode,
443
                chargingStateMode: resolvedChargingStateMode,
444
                autoStopEnabled: autoStopEnabled
445
            ) else {
446
                return
447
            }
448

            
Bogdan Timofte authored a month ago
449
            if startsFromFlatBattery {
450
                session.setValue(unresolvedFlatBatteryPercent, forKey: "startBatteryPercent")
451
                session.setValue(nil, forKey: "endBatteryPercent")
452
            } else if let initialBatteryPercent {
453
                guard insertBatteryCheckpoint(
454
                    percent: initialBatteryPercent,
Bogdan Timofte authored a month ago
455
                    flag: .initial,
Bogdan Timofte authored a month ago
456
                    timestamp: snapshot.observedAt,
457
                    to: session
458
                ) != nil else {
459
                    return
460
                }
Bogdan Timofte authored a month ago
461
            }
Bogdan Timofte authored a month ago
462
            didSave = saveContext()
463
        }
464
        return didSave
465
    }
466

            
Bogdan Timofte authored a month ago
467
    @discardableResult
468
    func pauseSession(id sessionID: UUID, observedAt: Date) -> Bool {
469
        var didSave = false
470
        context.performAndWait {
471
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
472
                return
473
            }
474

            
475
            guard statusValue(session, key: "statusRawValue") == .active else {
476
                return
477
            }
478

            
479
            session.setValue(ChargeSessionStatus.paused.rawValue, forKey: "statusRawValue")
480
            session.setValue(observedAt, forKey: "pausedAt")
481
            session.setValue(nil, forKey: "belowThresholdSince")
482
            clearCompletionConfirmationState(for: session)
483
            session.setValue(observedAt, forKey: "updatedAt")
484
            didSave = saveContext()
485
        }
486
        return didSave
487
    }
488

            
489
    @discardableResult
490
    func resumeSession(id sessionID: UUID, snapshot: ChargingMonitorSnapshot?) -> Bool {
491
        var didSave = false
492
        context.performAndWait {
493
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
494
                return
495
            }
496

            
497
            guard statusValue(session, key: "statusRawValue") == .paused else {
498
                return
499
            }
500

            
501
            let pausedAt = dateValue(session, key: "pausedAt") ?? Date()
502
            let resumedAt = snapshot?.observedAt ?? Date()
503
            if resumedAt.timeIntervalSince(pausedAt) >= pausedSessionTimeout {
504
                finishSession(
505
                    session,
506
                    observedAt: pausedAt.addingTimeInterval(pausedSessionTimeout),
507
                    finalBatteryPercent: nil,
508
                    status: .completed
509
                )
510
                guard saveContext() else {
511
                    return
512
                }
513
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
514
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
515
                    didSave = saveContext()
516
                } else {
517
                    didSave = true
518
                }
519
                return
520
            }
521

            
522
            session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
523
            session.setValue(nil, forKey: "pausedAt")
524
            session.setValue(nil, forKey: "belowThresholdSince")
525
            clearCompletionConfirmationState(for: session)
526
            session.setValue(resumedAt, forKey: "lastObservedAt")
527
            if let snapshot {
528
                session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
529
                session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
530
                session.setValue(
531
                    chargingTransportMode(for: session) == .wired ? snapshot.voltageVolts : nil,
532
                    forKey: "lastObservedVoltageVolts"
533
                )
534
            } else {
535
                session.setValue(0, forKey: "lastObservedCurrentAmps")
536
                session.setValue(0, forKey: "lastObservedPowerWatts")
537
                session.setValue(nil, forKey: "lastObservedVoltageVolts")
538
            }
539
            session.setValue(resumedAt, forKey: "updatedAt")
540
            didSave = saveContext()
541
        }
542
        return didSave
543
    }
544

            
545
    @discardableResult
546
    func stopSession(
547
        id sessionID: UUID,
Bogdan Timofte authored a month ago
548
        finalBatteryPercent: Double? = nil
Bogdan Timofte authored a month ago
549
    ) -> Bool {
Bogdan Timofte authored a month ago
550
        if let finalBatteryPercent {
551
            guard finalBatteryPercent.isFinite, finalBatteryPercent >= 0, finalBatteryPercent <= 100 else {
552
                return false
553
            }
Bogdan Timofte authored a month ago
554
        }
555

            
556
        var didSave = false
557
        context.performAndWait {
558
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
559
                return
560
            }
561

            
562
            guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
563
                return
564
            }
565

            
566
            let observedAt = snapshotDateForManualStop(session)
567
            finishSession(
568
                session,
569
                observedAt: observedAt,
570
                finalBatteryPercent: finalBatteryPercent,
571
                status: .completed
572
            )
573

            
574
            guard saveContext() else {
575
                return
576
            }
577

            
578
            if let deviceID = stringValue(session, key: "chargedDeviceID") {
579
                refreshDerivedMetrics(forChargedDeviceID: deviceID)
580
                didSave = saveContext()
581
            } else {
582
                didSave = true
583
            }
584
        }
585
        return didSave
586
    }
587

            
Bogdan Timofte authored a month ago
588
    @discardableResult
589
    func addBatteryCheckpoint(
590
        percent: Double,
Bogdan Timofte authored a month ago
591
        for meterMACAddress: String,
592
        measuredEnergyWh: Double? = nil,
593
        measuredChargeAh: Double? = nil
Bogdan Timofte authored a month ago
594
    ) -> Bool {
595
        guard percent.isFinite, percent >= 0, percent <= 100 else {
596
            return false
597
        }
598

            
599
        var didSave = false
600
        context.performAndWait {
Bogdan Timofte authored a month ago
601
            guard let session = fetchOpenSessionObject(forMeterMACAddress: meterMACAddress) else {
Bogdan Timofte authored a month ago
602
                return
603
            }
604

            
Bogdan Timofte authored a month ago
605
            didSave = addBatteryCheckpoint(
606
                percent: percent,
607
                measuredEnergyWh: measuredEnergyWh,
608
                measuredChargeAh: measuredChargeAh,
Bogdan Timofte authored a month ago
609
                flag: .intermediate,
Bogdan Timofte authored a month ago
610
                to: session
611
            )
Bogdan Timofte authored a month ago
612
        }
613
        return didSave
614
    }
615

            
616
    @discardableResult
617
    func addBatteryCheckpoint(
618
        percent: Double,
Bogdan Timofte authored a month ago
619
        for sessionID: UUID,
620
        measuredEnergyWh: Double? = nil,
621
        measuredChargeAh: Double? = nil
Bogdan Timofte authored a month ago
622
    ) -> Bool {
623
        guard percent.isFinite, percent >= 0, percent <= 100 else {
624
            return false
625
        }
626

            
627
        var didSave = false
628
        context.performAndWait {
629
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
630
                return
631
            }
632

            
Bogdan Timofte authored a month ago
633
            didSave = addBatteryCheckpoint(
634
                percent: percent,
635
                measuredEnergyWh: measuredEnergyWh,
636
                measuredChargeAh: measuredChargeAh,
Bogdan Timofte authored a month ago
637
                flag: .intermediate,
Bogdan Timofte authored a month ago
638
                to: session
639
            )
Bogdan Timofte authored a month ago
640
        }
641
        return didSave
642
    }
643

            
Bogdan Timofte authored a month ago
644
    @discardableResult
645
    func deleteBatteryCheckpoint(
646
        id checkpointID: UUID,
647
        from sessionID: UUID
648
    ) -> Bool {
649
        var didSave = false
650
        context.performAndWait {
651
            guard let session = fetchSessionObject(id: sessionID.uuidString),
652
                  let checkpoint = fetchCheckpointObject(
653
                    id: checkpointID.uuidString,
654
                    sessionID: sessionID.uuidString
655
                  ) else {
656
                return
657
            }
658

            
659
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
660
            context.delete(checkpoint)
661
            refreshCheckpointDerivedValues(for: session)
662

            
663
            if let chargedDeviceID {
664
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
665
            }
Bogdan Timofte authored a month ago
666

            
667
            didSave = saveContext()
Bogdan Timofte authored a month ago
668
        }
669
        return didSave
670
    }
671

            
Bogdan Timofte authored a month ago
672
    @discardableResult
673
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
674
        if let percent, (!percent.isFinite || percent <= 0 || percent > 100) {
675
            return false
676
        }
677

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

            
684
            session.setValue(percent, forKey: "targetBatteryPercent")
685
            session.setValue(nil, forKey: "targetBatteryAlertTriggeredAt")
686
            session.setValue(Date(), forKey: "updatedAt")
687
            didSave = saveContext()
688
        }
689
        return didSave
690
    }
691

            
692
    @discardableResult
693
    func confirmCompletion(for sessionID: UUID) -> Bool {
694
        var didSave = false
695
        context.performAndWait {
696
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
697
                return
698
            }
699

            
700
            guard statusValue(session, key: "statusRawValue") == .active else {
701
                return
702
            }
703

            
Bogdan Timofte authored a month ago
704
            finishSession(
705
                session,
706
                observedAt: dateValue(session, key: "lastObservedAt") ?? Date(),
707
                finalBatteryPercent: nil,
708
                status: .completed
709
            )
Bogdan Timofte authored a month ago
710

            
711
            if saveContext() {
712
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
713
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
714
                    didSave = saveContext()
715
                } else {
716
                    didSave = true
717
                }
718
            }
719
        }
720
        return didSave
721
    }
722

            
723
    @discardableResult
724
    func continueMonitoringDespiteCompletionContradiction(for sessionID: UUID) -> Bool {
725
        var didSave = false
726
        context.performAndWait {
727
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
728
                return
729
            }
730

            
731
            guard statusValue(session, key: "statusRawValue") == .active else {
732
                return
733
            }
734

            
735
            clearCompletionConfirmationState(for: session)
Bogdan Timofte authored a month ago
736
            session.setValue(nil, forKey: "belowThresholdSince")
Bogdan Timofte authored a month ago
737
            session.setValue(Date().addingTimeInterval(completionConfirmationCooldown), forKey: "completionConfirmationCooldownUntil")
738
            session.setValue(Date(), forKey: "updatedAt")
739
            didSave = saveContext()
740
        }
741
        return didSave
742
    }
743

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

            
752
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
753

            
754
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
755
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
756
            context.delete(session)
757

            
758
            guard saveContext() else {
759
                return
760
            }
761

            
762
            if let chargedDeviceID {
763
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
764
                didSave = saveContext()
765
            } else {
766
                didSave = true
767
            }
768
        }
769
        return didSave
770
    }
771

            
772
    @discardableResult
773
    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
774
        var didSave = false
775

            
776
        context.performAndWait {
777
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
778
                return
779
            }
780

            
781
            let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
782
            let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
783
            let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
784

            
785
            var impactedChargedDeviceIDs = Set<String>()
786

            
787
            for session in deviceSessions {
788
                if let impactedID = stringValue(session, key: "chargedDeviceID") {
789
                    impactedChargedDeviceIDs.insert(impactedID)
790
                }
791
                if let impactedChargerID = stringValue(session, key: "chargerID") {
792
                    impactedChargedDeviceIDs.insert(impactedChargerID)
793
                }
794
                if let sessionID = stringValue(session, key: "id") {
795
                    fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete)
796
                    fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete)
797
                }
798
                context.delete(session)
799
            }
800

            
801
            if deviceClass == .charger {
802
                for session in linkedWirelessSessions {
803
                    guard stringValue(session, key: "chargedDeviceID") != chargedDeviceID.uuidString else {
804
                        continue
805
                    }
806
                    if let impactedID = stringValue(session, key: "chargedDeviceID") {
807
                        impactedChargedDeviceIDs.insert(impactedID)
808
                    }
809
                    session.setValue(nil, forKey: "chargerID")
810
                    session.setValue(Date(), forKey: "updatedAt")
811
                }
812
            }
813

            
814
            context.delete(chargedDevice)
815

            
816
            guard saveContext() else {
817
                return
818
            }
819

            
820
            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
821
            for impactedID in impactedChargedDeviceIDs {
822
                refreshDerivedMetrics(forChargedDeviceID: impactedID)
823
            }
824
            didSave = saveContext()
825
        }
826

            
827
        return didSave
828
    }
829

            
830
    @discardableResult
831
    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
832
        var didSave = false
833

            
834
        context.performAndWait {
Bogdan Timofte authored a month ago
835
            guard let session = fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress),
836
                  let resolvedDevice = stringValue(session, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:)) else {
837
                return
838
            }
Bogdan Timofte authored a month ago
839

            
Bogdan Timofte authored a month ago
840
            if statusValue(session, key: "statusRawValue") == .paused {
841
                if maybeCompletePausedSession(session, observedAt: snapshot.observedAt) {
842
                    didSave = true
843
                }
Bogdan Timofte authored a month ago
844
                return
845
            }
846

            
Bogdan Timofte authored a month ago
847
            let chargingTransportMode = self.chargingTransportMode(for: session)
848
            let chargingStateMode = self.chargingStateMode(for: session)
Bogdan Timofte authored a month ago
849
            let charger = chargingTransportMode == .wireless
Bogdan Timofte authored a month ago
850
                ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
851
                : nil
852
            guard chargingTransportMode == .wired || charger != nil else {
853
                return
854
            }
855
            let stopThreshold = resolvedStopThreshold(
856
                for: resolvedDevice,
857
                chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
858
                chargingStateMode: chargingStateMode,
859
                charger: charger,
860
                fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
Bogdan Timofte authored a month ago
861
            )
862

            
Bogdan Timofte authored a month ago
863
            update(session: session, with: snapshot, stopThreshold: stopThreshold, charger: charger)
Bogdan Timofte authored a month ago
864
            let aggregatedSample = updateAggregatedSample(session: session, with: snapshot)
Bogdan Timofte authored a month ago
865

            
866
            let saveReason = saveReason(for: session, observedAt: snapshot.observedAt)
Bogdan Timofte authored a month ago
867
            let shouldPersistAggregatedCurve = aggregatedSample.map {
868
                shouldPersistAggregatedSample($0, observedAt: snapshot.observedAt)
869
            } ?? false
870

            
871
            guard saveReason != .none || shouldPersistAggregatedCurve else {
Bogdan Timofte authored a month ago
872
                return
873
            }
874

            
875
            session.setValue(snapshot.observedAt, forKey: "updatedAt")
876

            
877
            if saveContext() {
878
                if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
879
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
880
                    didSave = saveContext()
881
                } else {
882
                    didSave = true
883
                }
884
            }
885
        }
886

            
887
        return didSave
888
    }
889

            
890
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
891
        var summaries: [ChargedDeviceSummary] = []
892

            
893
        context.performAndWait {
894
            let devices = fetchObjects(entityName: EntityName.chargedDevice)
895
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
896
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
897

            
898
            let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
899
            let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
900
            let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
Bogdan Timofte authored a month ago
901
            let sampleBackedSessionIDs = sampleBackedSessionIDs(
902
                devices: devices,
903
                sessionsByDeviceID: sessionsByDeviceID,
904
                sessionsByChargerID: sessionsByChargerID
905
            )
906
            let samplesBySessionID = Dictionary(
907
                grouping: fetchSessionSampleObjects(forSessionIDs: Array(sampleBackedSessionIDs))
908
            ) { stringValue($0, key: "sessionID") ?? "" }
Bogdan Timofte authored a month ago
909

            
910
            summaries = devices.compactMap { device in
911
                guard
912
                    let id = uuidValue(device, key: "id"),
913
                    let name = stringValue(device, key: "name"),
914
                    let qrIdentifier = stringValue(device, key: "qrIdentifier"),
915
                    let rawClass = stringValue(device, key: "deviceClassRawValue"),
916
                    let deviceClass = ChargedDeviceClass(rawValue: rawClass)
917
                else {
918
                    return nil
919
                }
920

            
Bogdan Timofte authored a month ago
921
                let chargingStateAvailability = chargingStateAvailability(for: device)
922
                let supportsWiredCharging = supportsWiredCharging(for: device)
923
                let supportsWirelessCharging = supportsWirelessCharging(for: device)
924
                let templateDefinition = templateDefinition(for: device)
925

            
Bogdan Timofte authored a month ago
926
                let sessionObjects = relevantSessionObjects(
927
                    for: id.uuidString,
928
                    deviceClass: deviceClass,
929
                    sessionsByDeviceID: sessionsByDeviceID,
930
                    sessionsByChargerID: sessionsByChargerID
931
                )
932
                let sessionSummaries = sessionObjects
933
                    .compactMap { session in
934
                        makeSessionSummary(
935
                            from: session,
936
                            checkpoints: checkpointsBySessionID[stringValue(session, key: "id") ?? ""] ?? [],
937
                            samples: samplesBySessionID[stringValue(session, key: "id") ?? ""] ?? []
938
                        )
939
                    }
940
                    .sorted { lhs, rhs in
Bogdan Timofte authored a month ago
941
                        if lhs.status.isOpen && !rhs.status.isOpen {
Bogdan Timofte authored a month ago
942
                            return true
943
                        }
Bogdan Timofte authored a month ago
944
                        if !lhs.status.isOpen && rhs.status.isOpen {
945
                            return false
946
                        }
947
                        if lhs.status == .active && rhs.status == .paused {
948
                            return true
949
                        }
950
                        if lhs.status == .paused && rhs.status == .active {
Bogdan Timofte authored a month ago
951
                            return false
952
                        }
953
                        return lhs.startedAt > rhs.startedAt
954
                    }
955

            
956
                return ChargedDeviceSummary(
957
                    id: id,
958
                    qrIdentifier: qrIdentifier,
959
                    name: name,
960
                    deviceClass: deviceClass,
Bogdan Timofte authored a month ago
961
                    deviceTemplateID: stringValue(device, key: "deviceTemplateID"),
962
                    templateDefinition: templateDefinition,
963
                    supportsChargingWhileOff: chargingStateAvailability.supportsChargingWhileOff,
964
                    chargingStateAvailability: chargingStateAvailability,
965
                    supportsWiredCharging: supportsWiredCharging,
966
                    supportsWirelessCharging: supportsWirelessCharging,
Bogdan Timofte authored a month ago
967
                    chargerType: chargerType(for: device),
Bogdan Timofte authored a month ago
968
                    wirelessChargingProfile: wirelessChargingProfile(for: device),
Bogdan Timofte authored a month ago
969
                    configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"),
970
                    learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"),
Bogdan Timofte authored a month ago
971
                    wirelessChargerEfficiencyFactor: optionalDoubleValue(device, key: "wirelessChargerEfficiencyFactor"),
972
                    wiredChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wiredChargeCompletionCurrentAmps"),
973
                    wirelessChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wirelessChargeCompletionCurrentAmps"),
974
                    chargerObservedVoltageSelections: decodedObservedVoltageSelections(from: device),
975
                    chargerIdleCurrentAmps: optionalDoubleValue(device, key: "chargerIdleCurrentAmps"),
976
                    chargerEfficiencyFactor: optionalDoubleValue(device, key: "chargerEfficiencyFactor"),
977
                    chargerMaximumPowerWatts: optionalDoubleValue(device, key: "chargerMaximumPowerWatts"),
978
                    notes: stringValue(device, key: "notes"),
979
                    minimumCurrentAmps: optionalDoubleValue(device, key: "minimumCurrentAmps"),
980
                    estimatedBatteryCapacityWh: optionalDoubleValue(device, key: "estimatedBatteryCapacityWh"),
981
                    wiredMinimumCurrentAmps: optionalDoubleValue(device, key: "wiredMinimumCurrentAmps"),
982
                    wirelessMinimumCurrentAmps: optionalDoubleValue(device, key: "wirelessMinimumCurrentAmps"),
983
                    wiredEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wiredEstimatedBatteryCapacityWh"),
984
                    wirelessEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wirelessEstimatedBatteryCapacityWh"),
985
                    lastAssociatedMeterMAC: stringValue(device, key: "lastAssociatedMeterMAC"),
986
                    createdAt: dateValue(device, key: "createdAt") ?? .distantPast,
987
                    updatedAt: dateValue(device, key: "updatedAt") ?? .distantPast,
988
                    sessions: sessionSummaries,
989
                    capacityHistory: buildCapacityHistory(from: sessionSummaries),
Bogdan Timofte authored a month ago
990
                    typicalCurve: buildTypicalCurve(from: sessionSummaries),
991
                    standbyPowerMeasurements: []
Bogdan Timofte authored a month ago
992
                )
993
            }
994
            .sorted { lhs, rhs in
995
                if lhs.activeSession != nil && rhs.activeSession == nil {
996
                    return true
997
                }
998
                if lhs.activeSession == nil && rhs.activeSession != nil {
999
                    return false
1000
                }
1001
                if lhs.updatedAt != rhs.updatedAt {
1002
                    return lhs.updatedAt > rhs.updatedAt
1003
                }
1004
                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
1005
            }
1006
        }
1007

            
1008
        return summaries
1009
    }
1010

            
1011
    func resolvedChargedDeviceSummary(forMeterMACAddress meterMACAddress: String) -> ChargedDeviceSummary? {
1012
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1013
        guard !normalizedMAC.isEmpty else { return nil }
1014

            
1015
        let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
1016

            
1017
        if let activeMatch = summaries.first(where: { summary in
1018
            summary.activeSession?.meterMACAddress == normalizedMAC
1019
        }) {
1020
            return activeMatch
1021
        }
1022

            
1023
        return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
1024
    }
1025

            
1026
    func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
1027
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1028
        guard !normalizedMAC.isEmpty else { return nil }
1029

            
Bogdan Timofte authored a month ago
1030
        var summary: ChargeSessionSummary?
1031

            
1032
        context.performAndWait {
Bogdan Timofte authored a month ago
1033
            guard let session = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
Bogdan Timofte authored a month ago
1034
                  let sessionID = stringValue(session, key: "id") else {
1035
                return
1036
            }
1037

            
1038
            summary = makeSessionSummary(
1039
                from: session,
1040
                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
1041
                samples: fetchSessionSampleObjects(forSessionID: sessionID)
1042
            )
1043
        }
1044

            
1045
        return summary
Bogdan Timofte authored a month ago
1046
    }
1047

            
1048
    private func createSessionObject(
1049
        for chargedDevice: NSManagedObject,
1050
        charger: NSManagedObject?,
1051
        snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1052
        stopThreshold: Double?,
1053
        chargingTransportMode: ChargingTransportMode,
1054
        chargingStateMode: ChargingStateMode,
1055
        autoStopEnabled: Bool
Bogdan Timofte authored a month ago
1056
    ) -> NSManagedObject? {
1057
        guard
1058
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
1059
            let chargedDeviceID = stringValue(chargedDevice, key: "id")
1060
        else {
1061
            return nil
1062
        }
1063

            
1064
        let session = NSManagedObject(entity: entity, insertInto: context)
1065
        let now = snapshot.observedAt
1066
        session.setValue(UUID().uuidString, forKey: "id")
1067
        session.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1068
        session.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
1069
        session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress")
1070
        session.setValue(snapshot.meterName, forKey: "meterName")
1071
        session.setValue(snapshot.meterModel, forKey: "meterModel")
1072
        session.setValue(now, forKey: "startedAt")
1073
        session.setValue(now, forKey: "lastObservedAt")
1074
        session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
Bogdan Timofte authored a month ago
1075
        let usesMeterCounters = snapshot.meterEnergyCounterWh != nil || snapshot.meterChargeCounterAh != nil
1076
        session.setValue(
1077
            (usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue,
1078
            forKey: "sourceModeRawValue"
1079
        )
Bogdan Timofte authored a month ago
1080
        session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
1081
        session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue")
1082
        session.setValue(autoStopEnabled, forKey: "autoStopEnabled")
1083
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1084
        session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
1085
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1086
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1087
        session.setValue(
1088
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1089
            forKey: "lastObservedVoltageVolts"
1090
        )
Bogdan Timofte authored a month ago
1091
        session.setValue(
1092
            hasObservedChargeFlow(
1093
                currentAmps: snapshot.currentAmps,
1094
                chargingTransportMode: chargingTransportMode,
1095
                charger: charger,
1096
                stopThreshold: stopThreshold
1097
            ),
1098
            forKey: "hasObservedChargeFlow"
1099
        )
Bogdan Timofte authored a month ago
1100
        session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
1101
        session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
1102
        session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
1103
        session.setValue(
1104
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1105
            forKey: "maximumObservedVoltageVolts"
1106
        )
1107
        session.setValue(boolValue(chargedDevice, key: "supportsChargingWhileOff"), forKey: "supportsChargingWhileOff")
1108
        if let selectedDataGroup = snapshot.selectedDataGroup {
1109
            session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup")
1110
        }
1111
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1112
            session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1113
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1114
        }
1115
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1116
            session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1117
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1118
        }
Bogdan Timofte authored a month ago
1119
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1120
            setValue(meterRecordingDurationSeconds, on: session, key: "meterDurationBaselineSeconds")
1121
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1122
        }
Bogdan Timofte authored a month ago
1123
        session.setValue(now, forKey: "createdAt")
1124
        session.setValue(now, forKey: "updatedAt")
1125

            
1126
        chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
1127
        chargedDevice.setValue(now, forKey: "updatedAt")
1128
        return session
1129
    }
1130

            
1131
    private func update(
1132
        session: NSManagedObject,
1133
        with snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1134
        stopThreshold: Double?,
1135
        charger: NSManagedObject?
Bogdan Timofte authored a month ago
1136
    ) {
1137
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1138
        let lastObservedAt = dateValue(session, key: "lastObservedAt")
1139
        let previousPower = doubleValue(session, key: "lastObservedPowerWatts")
1140
        let previousCurrent = doubleValue(session, key: "lastObservedCurrentAmps")
1141
        var measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1142
        var measuredChargeAh = doubleValue(session, key: "measuredChargeAh")
1143
        var sourceMode = ChargeSessionSourceMode(rawValue: stringValue(session, key: "sourceModeRawValue") ?? "") ?? .live
1144
        var usedOfflineMeterCounters = boolValue(session, key: "usedOfflineMeterCounters")
1145

            
1146
        if let lastObservedAt {
1147
            let deltaSeconds = max(snapshot.observedAt.timeIntervalSince(lastObservedAt), 0)
1148
            if deltaSeconds > 0, deltaSeconds <= maximumLiveIntegrationGap {
1149
                measuredEnergyWh += max(previousPower, 0) * deltaSeconds / 3600
1150
                measuredChargeAh += max(previousCurrent, 0) * deltaSeconds / 3600
1151
                if sourceMode == .offline {
1152
                    sourceMode = .blended
1153
                }
1154
            }
1155
        }
1156

            
1157
        if let counterGroup = snapshot.selectedDataGroup,
1158
           let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
1159
           UInt8(storedGroup) != counterGroup {
1160
            session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
1161
            session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1162
            session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1163
        }
1164

            
1165
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1166
            let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
1167
            let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
1168
            if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
1169
                session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
1170
            }
1171

            
1172
            if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
1173
                let offlineEnergy = meterEnergyCounterWh - baselineEnergy
Bogdan Timofte authored a month ago
1174
                measuredEnergyWh = max(offlineEnergy, 0)
Bogdan Timofte authored a month ago
1175
                usedOfflineMeterCounters = true
Bogdan Timofte authored a month ago
1176
                sourceMode = .offline
Bogdan Timofte authored a month ago
1177
            } else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
1178
                let delta = meterEnergyCounterWh - lastEnergy
1179
                if delta > 0 {
1180
                    measuredEnergyWh += delta
1181
                    usedOfflineMeterCounters = true
1182
                    sourceMode = .blended
1183
                }
1184
            }
1185
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1186
        }
1187

            
1188
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1189
            let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
1190
            let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
1191
            if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
1192
                session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
1193
            }
1194

            
1195
            if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
1196
                let offlineCharge = meterChargeCounterAh - baselineCharge
Bogdan Timofte authored a month ago
1197
                measuredChargeAh = max(offlineCharge, 0)
Bogdan Timofte authored a month ago
1198
                usedOfflineMeterCounters = true
1199
            } else if let lastCharge, meterChargeCounterAh > lastCharge {
1200
                let delta = meterChargeCounterAh - lastCharge
1201
                if delta > 0 {
1202
                    measuredChargeAh += delta
1203
                    usedOfflineMeterCounters = true
1204
                }
1205
            }
1206
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1207
        }
1208

            
Bogdan Timofte authored a month ago
1209
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1210
            let baselineDuration = optionalDoubleValue(session, key: "meterDurationBaselineSeconds") ?? meterRecordingDurationSeconds
1211
            if optionalDoubleValue(session, key: "meterDurationBaselineSeconds") == nil {
1212
                setValue(baselineDuration, on: session, key: "meterDurationBaselineSeconds")
1213
            }
1214
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1215
        }
1216

            
Bogdan Timofte authored a month ago
1217
        let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps")
1218
        let updatedMinimum: Double
1219
        if snapshot.currentAmps > 0 {
1220
            updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps)
1221
        } else {
1222
            updatedMinimum = existingMinimum ?? 0
1223
        }
1224

            
Bogdan Timofte authored a month ago
1225
        let effectiveCurrent = effectiveCurrentAmps(
1226
            fromMeasuredCurrent: snapshot.currentAmps,
1227
            chargingTransportMode: sessionChargingTransportMode,
1228
            charger: charger
1229
        )
1230
        let observedChargeFlow = boolValue(session, key: "hasObservedChargeFlow")
1231
            || hasObservedChargeFlow(
1232
                currentAmps: snapshot.currentAmps,
1233
                chargingTransportMode: sessionChargingTransportMode,
1234
                charger: charger,
1235
                stopThreshold: stopThreshold
1236
            )
1237

            
Bogdan Timofte authored a month ago
1238
        session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh")
1239
        session.setValue(measuredChargeAh, forKey: "measuredChargeAh")
1240
        session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps")
Bogdan Timofte authored a month ago
1241
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1242
        session.setValue(snapshot.observedAt, forKey: "lastObservedAt")
1243
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1244
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1245
        session.setValue(
1246
            sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1247
            forKey: "lastObservedVoltageVolts"
1248
        )
Bogdan Timofte authored a month ago
1249
        session.setValue(observedChargeFlow, forKey: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
1250
        session.setValue(
1251
            max(optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? snapshot.currentAmps, snapshot.currentAmps),
1252
            forKey: "maximumObservedCurrentAmps"
1253
        )
1254
        session.setValue(
1255
            max(optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? snapshot.powerWatts, snapshot.powerWatts),
1256
            forKey: "maximumObservedPowerWatts"
1257
        )
1258
        session.setValue(
1259
            sessionChargingTransportMode == .wired
1260
                ? max(optionalDoubleValue(session, key: "maximumObservedVoltageVolts") ?? snapshot.voltageVolts, snapshot.voltageVolts)
1261
                : nil,
1262
            forKey: "maximumObservedVoltageVolts"
1263
        )
1264
        session.setValue(usedOfflineMeterCounters, forKey: "usedOfflineMeterCounters")
1265
        session.setValue(sourceMode.rawValue, forKey: "sourceModeRawValue")
1266
        maybeTriggerTargetBatteryAlert(for: session, observedAt: snapshot.observedAt)
1267

            
Bogdan Timofte authored a month ago
1268
        guard boolValue(session, key: "autoStopEnabled"), let stopThreshold, stopThreshold > 0, observedChargeFlow else {
1269
            session.setValue(nil, forKey: "belowThresholdSince")
1270
            clearCompletionConfirmationState(for: session)
1271
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1272
            return
1273
        }
1274

            
1275
        if effectiveCurrent <= stopThreshold {
Bogdan Timofte authored a month ago
1276
            let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt
1277
            session.setValue(belowThresholdSince, forKey: "belowThresholdSince")
1278
            if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
1279
                if boolValue(session, key: "requiresCompletionConfirmation") {
1280
                    // Leave the session active until the user explicitly confirms or charging resumes.
1281
                    return
1282
                }
1283

            
1284
                if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
1285
                    requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt)
1286
                } else {
Bogdan Timofte authored a month ago
1287
                    finishSession(
1288
                        session,
1289
                        observedAt: snapshot.observedAt,
1290
                        finalBatteryPercent: nil,
1291
                        status: .completed
1292
                    )
Bogdan Timofte authored a month ago
1293
                }
1294
            }
1295
        } else {
1296
            session.setValue(nil, forKey: "belowThresholdSince")
1297
            clearCompletionConfirmationState(for: session)
1298
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1299
        }
1300
    }
1301

            
1302
    private func updateAggregatedSample(
1303
        session: NSManagedObject,
1304
        with snapshot: ChargingMonitorSnapshot
Bogdan Timofte authored a month ago
1305
    ) -> NSManagedObject? {
Bogdan Timofte authored a month ago
1306
        guard
1307
            let sessionID = stringValue(session, key: "id"),
1308
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1309
            let startedAt = dateValue(session, key: "startedAt"),
1310
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context)
1311
        else {
Bogdan Timofte authored a month ago
1312
            return nil
Bogdan Timofte authored a month ago
1313
        }
1314

            
1315
        let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0)
1316
        let bucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
1317
        let bucketStart = startedAt.addingTimeInterval(Double(bucketIndex) * Self.aggregatedSampleBucketDuration)
1318
        let bucketIdentifier = "\(sessionID)-\(bucketIndex)"
1319
        let sample = fetchSessionSampleObject(sessionID: sessionID, bucketIndex: bucketIndex)
1320
            ?? NSManagedObject(entity: entity, insertInto: context)
1321
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1322
        let sampleVoltage = sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil
1323

            
1324
        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
1325
        let updatedCount = existingCount + 1
1326

            
1327
        sample.setValue(bucketIdentifier, forKey: "id")
1328
        sample.setValue(sessionID, forKey: "sessionID")
1329
        sample.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1330
        sample.setValue(bucketIndex, forKey: "bucketIndex")
1331
        sample.setValue(max(snapshot.observedAt, bucketStart), forKey: "timestamp")
1332
        sample.setValue(
1333
            runningAverage(
1334
                currentAverage: optionalDoubleValue(sample, key: "averageCurrentAmps") ?? snapshot.currentAmps,
1335
                currentCount: Int(existingCount),
1336
                newValue: snapshot.currentAmps
1337
            ),
1338
            forKey: "averageCurrentAmps"
1339
        )
1340
        sample.setValue(
1341
            sampleVoltage.flatMap { voltage in
1342
                runningAverage(
1343
                    currentAverage: optionalDoubleValue(sample, key: "averageVoltageVolts") ?? voltage,
1344
                    currentCount: Int(existingCount),
1345
                    newValue: voltage
1346
                )
1347
            },
1348
            forKey: "averageVoltageVolts"
1349
        )
1350
        sample.setValue(
1351
            runningAverage(
1352
                currentAverage: optionalDoubleValue(sample, key: "averagePowerWatts") ?? snapshot.powerWatts,
1353
                currentCount: Int(existingCount),
1354
                newValue: snapshot.powerWatts
1355
            ),
1356
            forKey: "averagePowerWatts"
1357
        )
1358
        sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh")
1359
        sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
1360
        sample.setValue(Int16(updatedCount), forKey: "sampleCount")
1361
        sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
1362
        sample.setValue(snapshot.observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1363
        return sample
Bogdan Timofte authored a month ago
1364
    }
1365

            
Bogdan Timofte authored a month ago
1366
    private func maybeTriggerTargetBatteryAlert(
1367
        for session: NSManagedObject,
1368
        observedAt: Date,
1369
        completionFallbackPercent: Double? = nil
1370
    ) {
Bogdan Timofte authored a month ago
1371
        guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
1372
            return
1373
        }
1374

            
1375
        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
1376
            return
1377
        }
1378

            
1379
        let predictedBatteryPercent = predictedBatteryPercent(for: session)
1380
            ?? optionalDoubleValue(session, key: "endBatteryPercent")
Bogdan Timofte authored a month ago
1381
            ?? completionFallbackPercent
Bogdan Timofte authored a month ago
1382

            
1383
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
1384
            return
1385
        }
1386

            
1387
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
1388
    }
1389

            
1390
    private func shouldRequireCompletionConfirmation(
1391
        for session: NSManagedObject,
1392
        observedAt: Date
1393
    ) -> Bool {
1394
        if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"),
1395
           cooldownUntil > observedAt {
1396
            return false
1397
        }
1398

            
1399
        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
1400
            return false
1401
        }
1402

            
1403
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
1404
            ?? defaultCompletionPercentThreshold
1405

            
1406
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1407
    }
1408

            
1409
    private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
1410
        guard !boolValue(session, key: "requiresCompletionConfirmation") else {
1411
            return
1412
        }
1413

            
1414
        session.setValue(true, forKey: "requiresCompletionConfirmation")
1415
        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
1416
        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
1417
    }
1418

            
1419
    private func clearCompletionConfirmationState(for session: NSManagedObject) {
1420
        session.setValue(false, forKey: "requiresCompletionConfirmation")
1421
        session.setValue(nil, forKey: "completionConfirmationRequestedAt")
1422
        session.setValue(nil, forKey: "completionContradictionPercent")
1423
    }
1424

            
Bogdan Timofte authored a month ago
1425
    private func snapshotDateForManualStop(_ session: NSManagedObject) -> Date {
1426
        if statusValue(session, key: "statusRawValue") == .paused {
1427
            return dateValue(session, key: "pausedAt")
1428
                ?? dateValue(session, key: "lastObservedAt")
1429
                ?? Date()
1430
        }
1431
        return dateValue(session, key: "lastObservedAt") ?? Date()
1432
    }
1433

            
1434
    @discardableResult
1435
    private func maybeCompletePausedSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
1436
        guard statusValue(session, key: "statusRawValue") == .paused else {
1437
            return false
1438
        }
1439

            
1440
        guard let pausedAt = dateValue(session, key: "pausedAt"),
1441
              observedAt.timeIntervalSince(pausedAt) >= pausedSessionTimeout else {
1442
            return false
1443
        }
1444

            
1445
        finishSession(
1446
            session,
1447
            observedAt: pausedAt.addingTimeInterval(pausedSessionTimeout),
1448
            finalBatteryPercent: nil,
1449
            status: .completed
1450
        )
1451

            
1452
        guard saveContext() else {
1453
            return false
1454
        }
1455

            
1456
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1457
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1458
            return saveContext()
1459
        }
1460

            
1461
        return true
1462
    }
1463

            
1464
    private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
1465
        let chargingTransportMode = chargingTransportMode(for: session)
1466
        let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps")
1467
            ?? doubleValue(session, key: "lastObservedCurrentAmps")
1468

            
1469
        guard measuredCurrent > 0 else {
1470
            return nil
1471
        }
1472

            
1473
        let charger = chargingTransportMode == .wireless
1474
            ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
1475
            : nil
1476

            
1477
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
1478
            return nil
1479
        }
1480

            
1481
        let effectiveCurrent = effectiveCurrentAmps(
1482
            fromMeasuredCurrent: measuredCurrent,
1483
            chargingTransportMode: chargingTransportMode,
1484
            charger: charger
1485
        )
1486
        guard effectiveCurrent > 0 else {
1487
            return nil
1488
        }
1489
        return effectiveCurrent
1490
    }
1491

            
1492
    private func finishSession(
1493
        _ session: NSManagedObject,
1494
        observedAt: Date,
1495
        finalBatteryPercent: Double?,
1496
        status: ChargeSessionStatus
1497
    ) {
1498
        if let finalBatteryPercent {
1499
            _ = insertBatteryCheckpoint(
1500
                percent: finalBatteryPercent,
Bogdan Timofte authored a month ago
1501
                flag: .final,
Bogdan Timofte authored a month ago
1502
                timestamp: observedAt,
1503
                to: session
1504
            )
1505
        }
1506

            
1507
        session.setValue(status.rawValue, forKey: "statusRawValue")
1508
        session.setValue(nil, forKey: "pausedAt")
1509
        session.setValue(nil, forKey: "belowThresholdSince")
1510
        session.setValue(observedAt, forKey: "endedAt")
1511
        session.setValue(observedAt, forKey: "lastObservedAt")
1512
        session.setValue(completionCurrentForSessionEnd(session), forKey: "completionCurrentAmps")
1513
        clearCompletionConfirmationState(for: session)
1514
        session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1515
        updateCapacityEstimate(for: session)
1516
        session.setValue(observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1517

            
1518
        if status == .completed {
1519
            maybeTriggerTargetBatteryAlert(
1520
                for: session,
1521
                observedAt: observedAt,
1522
                completionFallbackPercent: defaultCompletionPercentThreshold
1523
            )
1524
        }
Bogdan Timofte authored a month ago
1525
    }
1526

            
Bogdan Timofte authored a month ago
1527
    private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
1528
        guard
1529
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1530
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
1531
            let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice),
1532
            estimatedCapacityWh > 0
1533
        else {
1534
            return nil
1535
        }
1536

            
Bogdan Timofte authored a month ago
1537
        // Compute effective battery energy dynamically so the prediction uses the
1538
        // most current measuredEnergyWh rather than the stale effectiveBatteryEnergyWh
1539
        // (which is only refreshed at session start, checkpoint insertion, and finish).
1540
        let rawMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1541
        let measuredEnergyWh: Double
1542
        switch chargingTransportMode(for: session) {
1543
        case .wired:
1544
            measuredEnergyWh = rawMeasuredEnergyWh
1545
        case .wireless:
1546
            if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 {
1547
                measuredEnergyWh = rawMeasuredEnergyWh * factor
1548
            } else {
1549
                measuredEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1550
                    ?? rawMeasuredEnergyWh
1551
            }
1552
        }
Bogdan Timofte authored a month ago
1553
        let sessionID = stringValue(session, key: "id") ?? ""
1554

            
1555
        struct Anchor {
1556
            let percent: Double
1557
            let energyWh: Double
Bogdan Timofte authored a month ago
1558
            let timestamp: Date
1559
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1560
        }
1561

            
1562
        var anchors: [Anchor] = []
Bogdan Timofte authored a month ago
1563
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1564
           startBatteryPercent >= 0 {
1565
            anchors.append(
1566
                Anchor(
1567
                    percent: startBatteryPercent,
1568
                    energyWh: 0,
1569
                    timestamp: dateValue(session, key: "startedAt") ?? Date.distantPast,
1570
                    isCheckpoint: false
1571
                )
1572
            )
Bogdan Timofte authored a month ago
1573
        }
1574

            
1575
        let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
1576
            .compactMap(makeCheckpointSummary(from:))
1577
            .sorted { lhs, rhs in
1578
                if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1579
                    return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1580
                }
1581
                return lhs.timestamp < rhs.timestamp
1582
            }
Bogdan Timofte authored a month ago
1583
            .filter { $0.batteryPercent >= 0 }
1584
            .map {
1585
                Anchor(
1586
                    percent: $0.batteryPercent,
1587
                    energyWh: $0.measuredEnergyWh,
1588
                    timestamp: $0.timestamp,
1589
                    isCheckpoint: true
1590
                )
1591
            }
Bogdan Timofte authored a month ago
1592
        anchors.append(contentsOf: checkpointAnchors)
1593

            
1594
        guard !anchors.isEmpty else {
1595
            return optionalDoubleValue(session, key: "endBatteryPercent")
1596
        }
1597

            
1598
        let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
Bogdan Timofte authored a month ago
1599
        return BatteryLevelPredictionTuning.predictedPercent(
1600
            anchorPercent: anchor.percent,
1601
            anchorEnergyWh: anchor.energyWh,
1602
            anchorTimestamp: anchor.timestamp,
1603
            anchorIsCheckpoint: anchor.isCheckpoint,
1604
            effectiveEnergyWh: measuredEnergyWh,
1605
            referenceTimestamp: dateValue(session, key: "lastObservedAt") ?? anchor.timestamp,
1606
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1607
        )
1608
    }
1609

            
1610
    private func resolvedEstimatedBatteryCapacityWh(
1611
        for session: NSManagedObject,
1612
        chargedDevice: NSManagedObject
1613
    ) -> Double? {
1614
        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
1615
           sessionCapacityEstimate > 0 {
1616
            return sessionCapacityEstimate
1617
        }
1618

            
1619
        switch chargingTransportMode(for: session) {
1620
        case .wired:
1621
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1622
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1623
        case .wireless:
1624
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1625
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1626
        }
1627
    }
1628

            
1629
    private func updateCapacityEstimate(for session: NSManagedObject) {
1630
        guard
1631
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1632
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
1633
        else {
1634
            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
1635
            session.setValue(nil, forKey: "capacityEstimateWh")
1636
            return
1637
        }
1638

            
1639
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1640
        let chargingMode = chargingTransportMode(for: session)
1641
        let wirelessResolution = chargingMode == .wireless
1642
            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
1643
            : nil
1644
        let effectiveBatteryEnergyWh = chargingMode == .wired
1645
            ? measuredEnergyWh
1646
            : wirelessResolution.map { measuredEnergyWh * $0.factor }
1647

            
1648
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
1649
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
1650
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
1651
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
1652

            
1653
        guard
1654
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1655
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1656
        else {
1657
            session.setValue(nil, forKey: "capacityEstimateWh")
1658
            return
1659
        }
1660

            
Bogdan Timofte authored a month ago
1661
        guard startBatteryPercent >= 0, endBatteryPercent >= 0 else {
1662
            session.setValue(nil, forKey: "capacityEstimateWh")
1663
            return
1664
        }
1665

            
Bogdan Timofte authored a month ago
1666
        let percentDelta = endBatteryPercent - startBatteryPercent
1667
        let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
1668

            
1669
        guard percentDelta >= 20, let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
1670
            session.setValue(nil, forKey: "capacityEstimateWh")
1671
            return
1672
        }
1673

            
1674
        if !supportsChargingWhileOff && endBatteryPercent >= 99.5 {
1675
            session.setValue(nil, forKey: "capacityEstimateWh")
1676
            return
1677
        }
1678

            
1679
        let capacityEstimateWh = effectiveBatteryEnergyWh / (percentDelta / 100)
1680
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
1681
    }
1682

            
1683
    @discardableResult
Bogdan Timofte authored a month ago
1684
    private func insertBatteryCheckpoint(
Bogdan Timofte authored a month ago
1685
        percent: Double,
Bogdan Timofte authored a month ago
1686
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
1687
        timestamp: Date = Date(),
Bogdan Timofte authored a month ago
1688
        measuredEnergyWhOverride: Double? = nil,
1689
        measuredChargeAhOverride: Double? = nil,
Bogdan Timofte authored a month ago
1690
        to session: NSManagedObject
Bogdan Timofte authored a month ago
1691
    ) -> String? {
Bogdan Timofte authored a month ago
1692
        guard
1693
            let sessionID = stringValue(session, key: "id"),
1694
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1695
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
1696
        else {
Bogdan Timofte authored a month ago
1697
            return nil
Bogdan Timofte authored a month ago
1698
        }
1699

            
1700
        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
Bogdan Timofte authored a month ago
1701
        let checkpointEnergyWh = measuredEnergyWhOverride
1702
            ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
Bogdan Timofte authored a month ago
1703
            ?? doubleValue(session, key: "measuredEnergyWh")
Bogdan Timofte authored a month ago
1704
        let checkpointChargeAh = measuredChargeAhOverride
1705
            ?? doubleValue(session, key: "measuredChargeAh")
Bogdan Timofte authored a month ago
1706
        checkpoint.setValue(UUID().uuidString, forKey: "id")
1707
        checkpoint.setValue(sessionID, forKey: "sessionID")
1708
        checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
Bogdan Timofte authored a month ago
1709
        checkpoint.setValue(timestamp, forKey: "timestamp")
Bogdan Timofte authored a month ago
1710
        checkpoint.setValue(percent, forKey: "batteryPercent")
1711
        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
Bogdan Timofte authored a month ago
1712
        checkpoint.setValue(checkpointChargeAh, forKey: "measuredChargeAh")
Bogdan Timofte authored a month ago
1713
        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
1714
        checkpoint.setValue(
1715
            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
1716
            forKey: "voltageVolts"
1717
        )
Bogdan Timofte authored a month ago
1718
        checkpoint.setValue(flag.rawValue, forKey: "label")
Bogdan Timofte authored a month ago
1719
        checkpoint.setValue(timestamp, forKey: "createdAt")
Bogdan Timofte authored a month ago
1720

            
Bogdan Timofte authored a month ago
1721
        let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
1722
        if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
Bogdan Timofte authored a month ago
1723
            session.setValue(percent, forKey: "startBatteryPercent")
1724
        }
Bogdan Timofte authored a month ago
1725
        if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
1726
            session.setValue(percent, forKey: "endBatteryPercent")
1727
        }
Bogdan Timofte authored a month ago
1728
        session.setValue(timestamp, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1729
        updateCapacityEstimate(for: session)
1730

            
Bogdan Timofte authored a month ago
1731
        return chargedDeviceID
1732
    }
1733

            
Bogdan Timofte authored a month ago
1734
    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
1735
        guard let sessionID = stringValue(session, key: "id") else {
1736
            return
1737
        }
1738

            
1739
        let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
1740
        if let latestCheckpoint = remainingCheckpoints.last {
1741
            session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
1742
        } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1743
                  startBatteryPercent >= 0 {
1744
            session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
1745
        } else {
1746
            session.setValue(nil, forKey: "endBatteryPercent")
1747
        }
1748

            
1749
        session.setValue(Date(), forKey: "updatedAt")
1750
        updateCapacityEstimate(for: session)
1751
    }
1752

            
Bogdan Timofte authored a month ago
1753
    @discardableResult
1754
    private func addBatteryCheckpoint(
1755
        percent: Double,
Bogdan Timofte authored a month ago
1756
        measuredEnergyWh: Double? = nil,
1757
        measuredChargeAh: Double? = nil,
Bogdan Timofte authored a month ago
1758
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
1759
        to session: NSManagedObject,
1760
        timestamp: Date = Date()
1761
    ) -> Bool {
Bogdan Timofte authored a month ago
1762
        if let measuredEnergyWh, measuredEnergyWh.isFinite {
1763
            session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
1764
        }
1765
        if let measuredChargeAh, measuredChargeAh.isFinite {
1766
            session.setValue(max(measuredChargeAh, 0), forKey: "measuredChargeAh")
1767
        }
1768

            
Bogdan Timofte authored a month ago
1769
        guard let chargedDeviceID = insertBatteryCheckpoint(
1770
            percent: percent,
Bogdan Timofte authored a month ago
1771
            flag: flag,
Bogdan Timofte authored a month ago
1772
            timestamp: timestamp,
Bogdan Timofte authored a month ago
1773
            measuredEnergyWhOverride: measuredEnergyWh,
1774
            measuredChargeAhOverride: measuredChargeAh,
Bogdan Timofte authored a month ago
1775
            to: session
1776
        ) else {
1777
            return false
1778
        }
1779

            
Bogdan Timofte authored a month ago
1780
        guard saveContext() else {
1781
            return false
1782
        }
1783

            
1784
        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1785
        return saveContext()
1786
    }
1787

            
1788
    private func resolvedWirelessEfficiency(
1789
        for session: NSManagedObject,
1790
        chargedDevice: NSManagedObject
1791
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
1792
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
1793
           storedFactor > 0 {
1794
            return (
1795
                factor: storedFactor,
1796
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
1797
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
1798
            )
1799
        }
1800

            
1801
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
1802
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1803
        guard measuredEnergyWh > 0 else {
1804
            return nil
1805
        }
1806

            
1807
        if chargingProfile == .magsafe,
1808
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
1809
           calibratedFactor > 0 {
1810
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
1811
        }
1812

            
1813
        guard
1814
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1815
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1816
        else {
1817
            return nil
1818
        }
1819

            
1820
        let percentDelta = endBatteryPercent - startBatteryPercent
1821
        guard percentDelta >= 20 else {
1822
            return nil
1823
        }
1824

            
1825
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
Bogdan Timofte authored a month ago
1826
            ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
Bogdan Timofte authored a month ago
1827
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1828
                : nil),
1829
              wiredCapacityWh > 0
1830
        else {
1831
            return nil
1832
        }
1833

            
1834
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
1835
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
1836
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
1837
        let usesEstimated = chargingProfile != .magsafe
1838
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
1839

            
1840
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
1841
    }
1842

            
1843
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
1844
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
1845
            return
1846
        }
1847

            
Bogdan Timofte authored a month ago
1848
        let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
Bogdan Timofte authored a month ago
1849
        let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
1850
        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
1851
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
1852
        let sessions = relevantSessionObjects(
1853
            for: chargedDeviceID,
1854
            deviceClass: deviceClass,
1855
            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
1856
            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
1857
        )
Bogdan Timofte authored a month ago
1858
        let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
Bogdan Timofte authored a month ago
1859
        let wiredMinimumCurrent = derivedMinimumCurrent(
1860
            from: sessions,
1861
            chargingTransportMode: .wired
1862
        )
1863
        let wirelessMinimumCurrent = derivedMinimumCurrent(
1864
            from: sessions,
1865
            chargingTransportMode: .wireless
1866
        )
1867

            
1868
        let wiredCapacity = derivedCapacity(
1869
            from: sessions,
1870
            chargingTransportMode: .wired,
1871
            supportsChargingWhileOff: supportsChargingWhileOff
1872
        )
1873
        let wirelessCapacity = derivedCapacity(
1874
            from: sessions,
1875
            chargingTransportMode: .wireless,
1876
            supportsChargingWhileOff: supportsChargingWhileOff
1877
        )
1878
        let wirelessEfficiency = derivedWirelessEfficiency(
1879
            from: sessions,
1880
            chargingProfile: wirelessProfile
1881
        )
Bogdan Timofte authored a month ago
1882
        let configuredCompletionCurrents = decodedCompletionCurrents(
1883
            from: chargedDevice,
1884
            key: "configuredCompletionCurrentsRawValue"
1885
        )
Bogdan Timofte authored a month ago
1886
        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
1887
        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
1888
        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
1889
        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
1890
        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
1891
        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
1892

            
Bogdan Timofte authored a month ago
1893
        let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
1894
        let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
Bogdan Timofte authored a month ago
1895
        let preferredMinimumCurrent: Double?
1896
        let preferredCapacity: Double?
1897
        switch preferredChargingTransportMode {
1898
        case .wired:
Bogdan Timofte authored a month ago
1899
            preferredMinimumCurrent = configuredCompletionCurrents[
1900
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
1901
            ] ?? learnedCompletionCurrents[
1902
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
1903
            ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
Bogdan Timofte authored a month ago
1904
            preferredCapacity = wiredCapacity ?? wirelessCapacity
1905
        case .wireless:
Bogdan Timofte authored a month ago
1906
            preferredMinimumCurrent = configuredCompletionCurrents[
1907
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
1908
            ] ?? learnedCompletionCurrents[
1909
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
1910
            ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
Bogdan Timofte authored a month ago
1911
            preferredCapacity = wirelessCapacity ?? wiredCapacity
1912
        }
1913

            
Bogdan Timofte authored a month ago
1914
        setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
1915
        setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
1916
        setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
1917
        setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1918
        setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1919
        setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
1920
        setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
1921
        setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
1922
        setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
1923
        setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
1924
        setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
1925
        setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
1926
        setValue(Date(), on: chargedDevice, key: "updatedAt")
Bogdan Timofte authored a month ago
1927
    }
1928

            
1929
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
1930
        sessions
1931
            .filter { $0.status == .completed }
1932
            .compactMap { session in
1933
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
1934
                let timestamp = session.endedAt ?? session.lastObservedAt
1935
                return CapacityTrendPoint(
1936
                    sessionID: session.id,
1937
                    timestamp: timestamp,
1938
                    capacityWh: capacityEstimateWh,
1939
                    chargingTransportMode: session.chargingTransportMode
1940
                )
1941
            }
1942
            .sorted { $0.timestamp < $1.timestamp }
1943
    }
1944

            
1945
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
1946
        var groupedEnergyByBin: [Int: [Double]] = [:]
1947
        var groupedChargeByBin: [Int: [Double]] = [:]
1948

            
1949
        for session in sessions where session.status == .completed {
Bogdan Timofte authored a month ago
1950
            let anchors = normalizedTypicalCurveAnchors(for: session)
1951
            guard anchors.count >= 2 else {
1952
                continue
Bogdan Timofte authored a month ago
1953
            }
1954

            
Bogdan Timofte authored a month ago
1955
            for percentBin in stride(from: 0, through: 100, by: 10) {
1956
                guard let interpolatedPoint = interpolatedTypicalCurvePoint(
1957
                    for: Double(percentBin),
1958
                    anchors: anchors
1959
                ) else {
1960
                    continue
1961
                }
Bogdan Timofte authored a month ago
1962

            
Bogdan Timofte authored a month ago
1963
                groupedEnergyByBin[percentBin, default: []].append(interpolatedPoint.energyWh)
1964
                groupedChargeByBin[percentBin, default: []].append(interpolatedPoint.chargeAh)
Bogdan Timofte authored a month ago
1965
            }
1966
        }
1967

            
Bogdan Timofte authored a month ago
1968
        let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
Bogdan Timofte authored a month ago
1969
            guard
1970
                let energies = groupedEnergyByBin[percentBin],
1971
                let charges = groupedChargeByBin[percentBin],
1972
                !energies.isEmpty,
1973
                !charges.isEmpty
1974
            else {
1975
                return nil
1976
            }
1977

            
1978
            return TypicalChargeCurvePoint(
1979
                percentBin: percentBin,
1980
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
1981
                averageChargeAh: charges.reduce(0, +) / Double(charges.count),
1982
                sampleCount: min(energies.count, charges.count)
1983
            )
1984
        }
Bogdan Timofte authored a month ago
1985

            
1986
        var runningMaximumEnergyWh = 0.0
1987
        var runningMaximumChargeAh = 0.0
1988

            
1989
        return averagedPoints.map { point in
1990
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
1991
            runningMaximumChargeAh = max(runningMaximumChargeAh, point.averageChargeAh)
1992
            return TypicalChargeCurvePoint(
1993
                percentBin: point.percentBin,
1994
                averageEnergyWh: runningMaximumEnergyWh,
1995
                averageChargeAh: runningMaximumChargeAh,
1996
                sampleCount: point.sampleCount
1997
            )
1998
        }
1999
    }
2000

            
2001
    private func normalizedTypicalCurveAnchors(
2002
        for session: ChargeSessionSummary
2003
    ) -> [(percent: Double, energyWh: Double, chargeAh: Double)] {
2004
        struct Anchor {
2005
            let percent: Double
2006
            let energyWh: Double
2007
            let chargeAh: Double
2008
            let timestamp: Date
2009
        }
2010

            
2011
        var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
2012
            guard checkpoint.batteryPercent.isFinite,
2013
                  checkpoint.measuredEnergyWh.isFinite,
2014
                  checkpoint.measuredChargeAh.isFinite,
2015
                  checkpoint.batteryPercent >= 0,
2016
                  checkpoint.batteryPercent <= 100,
2017
                  checkpoint.measuredEnergyWh >= 0,
2018
                  checkpoint.measuredChargeAh >= 0 else {
2019
                return nil
2020
            }
2021

            
2022
            return Anchor(
2023
                percent: checkpoint.batteryPercent,
2024
                energyWh: checkpoint.measuredEnergyWh,
2025
                chargeAh: checkpoint.measuredChargeAh,
2026
                timestamp: checkpoint.timestamp
2027
            )
2028
        }
2029

            
2030
        if let startBatteryPercent = session.startBatteryPercent,
2031
           startBatteryPercent.isFinite,
2032
           startBatteryPercent >= 0,
2033
           startBatteryPercent <= 100 {
2034
            anchors.append(
2035
                Anchor(
2036
                    percent: startBatteryPercent,
2037
                    energyWh: 0,
2038
                    chargeAh: 0,
2039
                    timestamp: session.startedAt
2040
                )
2041
            )
2042
        }
2043

            
2044
        if let endBatteryPercent = session.endBatteryPercent,
2045
           endBatteryPercent.isFinite,
2046
           endBatteryPercent >= 0,
2047
           endBatteryPercent <= 100 {
2048
            anchors.append(
2049
                Anchor(
2050
                    percent: endBatteryPercent,
2051
                    energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
2052
                    chargeAh: session.measuredChargeAh,
2053
                    timestamp: session.endedAt ?? session.lastObservedAt
2054
                )
2055
            )
2056
        }
2057

            
2058
        let sortedAnchors = anchors.sorted { lhs, rhs in
2059
            if lhs.percent != rhs.percent {
2060
                return lhs.percent < rhs.percent
2061
            }
2062
            if lhs.energyWh != rhs.energyWh {
2063
                return lhs.energyWh < rhs.energyWh
2064
            }
2065
            return lhs.timestamp < rhs.timestamp
2066
        }
2067

            
2068
        var collapsedAnchors: [(percent: Double, energyWh: Double, chargeAh: Double)] = []
2069

            
2070
        for anchor in sortedAnchors {
2071
            if let lastIndex = collapsedAnchors.indices.last,
2072
               abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
2073
                collapsedAnchors[lastIndex] = (
2074
                    percent: collapsedAnchors[lastIndex].percent,
2075
                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh),
2076
                    chargeAh: max(collapsedAnchors[lastIndex].chargeAh, anchor.chargeAh)
2077
                )
2078
            } else {
2079
                collapsedAnchors.append(
2080
                    (percent: anchor.percent, energyWh: anchor.energyWh, chargeAh: anchor.chargeAh)
2081
                )
2082
            }
2083
        }
2084

            
2085
        var runningMaximumEnergyWh = 0.0
2086
        var runningMaximumChargeAh = 0.0
2087

            
2088
        return collapsedAnchors.map { anchor in
2089
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
2090
            runningMaximumChargeAh = max(runningMaximumChargeAh, anchor.chargeAh)
2091
            return (
2092
                percent: anchor.percent,
2093
                energyWh: runningMaximumEnergyWh,
2094
                chargeAh: runningMaximumChargeAh
2095
            )
2096
        }
2097
    }
2098

            
2099
    private func interpolatedTypicalCurvePoint(
2100
        for percent: Double,
2101
        anchors: [(percent: Double, energyWh: Double, chargeAh: Double)]
2102
    ) -> (energyWh: Double, chargeAh: Double)? {
2103
        guard
2104
            let firstAnchor = anchors.first,
2105
            let lastAnchor = anchors.last,
2106
            percent >= firstAnchor.percent,
2107
            percent <= lastAnchor.percent
2108
        else {
2109
            return nil
2110
        }
2111

            
2112
        if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
2113
            return (energyWh: exactAnchor.energyWh, chargeAh: exactAnchor.chargeAh)
2114
        }
2115

            
2116
        guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
2117
              upperIndex > 0 else {
2118
            return nil
2119
        }
2120

            
2121
        let lowerAnchor = anchors[upperIndex - 1]
2122
        let upperAnchor = anchors[upperIndex]
2123
        let span = upperAnchor.percent - lowerAnchor.percent
2124
        guard span > 0.000_1 else {
2125
            return nil
2126
        }
2127

            
2128
        let ratio = (percent - lowerAnchor.percent) / span
2129
        let energyWh = lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
2130
        let chargeAh = lowerAnchor.chargeAh + ((upperAnchor.chargeAh - lowerAnchor.chargeAh) * ratio)
2131
        return (energyWh: energyWh, chargeAh: chargeAh)
Bogdan Timofte authored a month ago
2132
    }
2133

            
2134
    private func makeSessionSummary(
2135
        from object: NSManagedObject,
2136
        checkpoints: [NSManagedObject],
2137
        samples: [NSManagedObject]
2138
    ) -> ChargeSessionSummary? {
2139
        let chargingTransportMode = chargingTransportMode(for: object)
2140

            
2141
        guard
2142
            let id = uuidValue(object, key: "id"),
2143
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2144
            let startedAt = dateValue(object, key: "startedAt"),
2145
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
2146
            let status = statusValue(object, key: "statusRawValue"),
2147
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
2148
        else {
2149
            return nil
2150
        }
2151

            
2152
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
2153
            .sorted { $0.timestamp < $1.timestamp }
2154
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
2155
            .sorted { lhs, rhs in
2156
                if lhs.bucketIndex != rhs.bucketIndex {
2157
                    return lhs.bucketIndex < rhs.bucketIndex
2158
                }
2159
                return lhs.timestamp < rhs.timestamp
2160
            }
2161

            
2162
        return ChargeSessionSummary(
2163
            id: id,
2164
            chargedDeviceID: chargedDeviceID,
2165
            chargerID: uuidValue(object, key: "chargerID"),
2166
            meterMACAddress: stringValue(object, key: "meterMACAddress"),
2167
            meterName: stringValue(object, key: "meterName"),
2168
            meterModel: stringValue(object, key: "meterModel"),
2169
            startedAt: startedAt,
2170
            endedAt: dateValue(object, key: "endedAt"),
2171
            lastObservedAt: lastObservedAt,
Bogdan Timofte authored a month ago
2172
            pausedAt: dateValue(object, key: "pausedAt"),
Bogdan Timofte authored a month ago
2173
            status: status,
2174
            sourceMode: sourceMode,
2175
            chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
2176
            chargingStateMode: chargingStateMode(for: object),
2177
            autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
Bogdan Timofte authored a month ago
2178
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2179
            effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
2180
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
Bogdan Timofte authored a month ago
2181
            meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
2182
            meterChargeBaselineAh: optionalDoubleValue(object, key: "meterChargeBaselineAh"),
Bogdan Timofte authored a month ago
2183
            meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"),
2184
            meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"),
Bogdan Timofte authored a month ago
2185
            minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
2186
            maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
2187
            maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
2188
            maximumObservedVoltageVolts: chargingTransportMode == .wired
2189
                ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
2190
                : nil,
2191
            selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
2192
            completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
2193
            stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
2194
            startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
2195
            endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
2196
            capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
2197
            wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
2198
            usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
2199
            shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
2200
            supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
2201
            usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
2202
            targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
2203
            targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
2204
            requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
2205
            completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
2206
            completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
2207
            selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
2208
            checkpoints: checkpointSummaries,
2209
            aggregatedSamples: sampleSummaries
2210
        )
2211
    }
2212

            
2213
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
2214
        guard
2215
            let id = uuidValue(object, key: "id"),
2216
            let sessionID = uuidValue(object, key: "sessionID"),
2217
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2218
            let timestamp = dateValue(object, key: "timestamp")
2219
        else {
2220
            return nil
2221
        }
2222

            
2223
        return ChargeCheckpointSummary(
2224
            id: id,
2225
            sessionID: sessionID,
2226
            chargedDeviceID: chargedDeviceID,
2227
            timestamp: timestamp,
2228
            batteryPercent: doubleValue(object, key: "batteryPercent"),
2229
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2230
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2231
            currentAmps: doubleValue(object, key: "currentAmps"),
2232
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
2233
            label: stringValue(object, key: "label")
2234
        )
2235
    }
2236

            
2237
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
2238
        guard
2239
            let sessionID = uuidValue(object, key: "sessionID"),
2240
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2241
            let timestamp = dateValue(object, key: "timestamp")
2242
        else {
2243
            return nil
2244
        }
2245

            
2246
        return ChargeSessionSampleSummary(
2247
            sessionID: sessionID,
2248
            chargedDeviceID: chargedDeviceID,
2249
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
2250
            timestamp: timestamp,
2251
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
2252
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
2253
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
2254
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2255
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2256
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
2257
        )
2258
    }
2259

            
Bogdan Timofte authored a month ago
2260
    private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2261
        fetchSessionObject(
2262
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2263
                format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
Bogdan Timofte authored a month ago
2264
                normalizedMACAddress(meterMACAddress),
Bogdan Timofte authored a month ago
2265
                ChargeSessionStatus.active.rawValue,
2266
                ChargeSessionStatus.paused.rawValue
Bogdan Timofte authored a month ago
2267
            )
2268
        )
2269
    }
2270

            
Bogdan Timofte authored a month ago
2271
    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2272
        fetchSessionObject(
2273
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2274
                format: "meterMACAddress == %@ AND statusRawValue == %@",
2275
                normalizedMACAddress(meterMACAddress),
2276
                ChargeSessionStatus.active.rawValue
Bogdan Timofte authored a month ago
2277
            )
2278
        )
2279
    }
2280

            
2281
    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
2282
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2283
        request.predicate = predicate
2284
        request.fetchLimit = 1
2285
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
2286
        return (try? context.fetch(request))?.first
2287
    }
2288

            
2289
    private func fetchSessionObject(id: String) -> NSManagedObject? {
2290
        fetchSessionObject(
2291
            predicate: NSPredicate(format: "id == %@", id)
2292
        )
2293
    }
2294

            
2295
    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
2296
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2297
        request.predicate = NSPredicate(
2298
            format: "sessionID == %@ AND bucketIndex == %d",
2299
            sessionID,
2300
            bucketIndex
2301
        )
2302
        request.fetchLimit = 1
2303
        return (try? context.fetch(request))?.first
2304
    }
2305

            
2306
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2307
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2308
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2309
        return (try? context.fetch(request)) ?? []
2310
    }
2311

            
Bogdan Timofte authored a month ago
2312
    private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
2313
        guard !sessionIDs.isEmpty else {
2314
            return []
2315
        }
2316

            
2317
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2318
        request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs)
2319
        return (try? context.fetch(request)) ?? []
2320
    }
2321

            
Bogdan Timofte authored a month ago
2322
    private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
2323
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2324
        request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
2325
        request.fetchLimit = 1
2326
        return (try? context.fetch(request))?.first
2327
    }
2328

            
Bogdan Timofte authored a month ago
2329
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2330
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2331
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2332
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
2333
        return (try? context.fetch(request)) ?? []
2334
    }
2335

            
2336
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
2337
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2338
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
2339
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2340
        return (try? context.fetch(request)) ?? []
2341
    }
2342

            
2343
    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
2344
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2345
        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
2346
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2347
        return (try? context.fetch(request)) ?? []
2348
    }
2349

            
Bogdan Timofte authored a month ago
2350
    private func sampleBackedSessionIDs(
2351
        devices: [NSManagedObject],
2352
        sessionsByDeviceID: [String: [NSManagedObject]],
2353
        sessionsByChargerID: [String: [NSManagedObject]]
2354
    ) -> Set<String> {
2355
        var sessionIDs: Set<String> = []
2356

            
2357
        for device in devices {
2358
            guard
2359
                let deviceID = stringValue(device, key: "id"),
2360
                let rawClass = stringValue(device, key: "deviceClassRawValue"),
2361
                let deviceClass = ChargedDeviceClass(rawValue: rawClass)
2362
            else {
2363
                continue
2364
            }
2365

            
2366
            let relevantSessions = relevantSessionObjects(
2367
                for: deviceID,
2368
                deviceClass: deviceClass,
2369
                sessionsByDeviceID: sessionsByDeviceID,
2370
                sessionsByChargerID: sessionsByChargerID
2371
            )
2372
            .sorted { lhs, rhs in
2373
                let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed
2374
                let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed
2375

            
2376
                if lhsStatus.isOpen && !rhsStatus.isOpen {
2377
                    return true
2378
                }
2379
                if !lhsStatus.isOpen && rhsStatus.isOpen {
2380
                    return false
2381
                }
2382

            
2383
                return (dateValue(lhs, key: "startedAt") ?? .distantPast)
2384
                    > (dateValue(rhs, key: "startedAt") ?? .distantPast)
2385
            }
2386

            
2387
            var recentCompletedSamplesIncluded = 0
2388

            
2389
            for session in relevantSessions {
2390
                guard let sessionID = stringValue(session, key: "id"),
2391
                      let status = statusValue(session, key: "statusRawValue") else {
2392
                    continue
2393
                }
2394

            
2395
                if status.isOpen {
2396
                    sessionIDs.insert(sessionID)
2397
                    continue
2398
                }
2399

            
2400
                guard recentCompletedSamplesIncluded < 2 else {
2401
                    continue
2402
                }
2403

            
2404
                sessionIDs.insert(sessionID)
2405
                recentCompletedSamplesIncluded += 1
2406
            }
2407
        }
2408

            
2409
        return sessionIDs
2410
    }
2411

            
Bogdan Timofte authored a month ago
2412
    private func relevantSessionObjects(
2413
        for chargedDeviceID: String,
2414
        deviceClass: ChargedDeviceClass,
2415
        sessionsByDeviceID: [String: [NSManagedObject]],
2416
        sessionsByChargerID: [String: [NSManagedObject]]
2417
    ) -> [NSManagedObject] {
2418
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
2419
        guard deviceClass == .charger else {
2420
            return directSessions
2421
        }
2422

            
2423
        var seenSessionIDs = Set<String>()
2424
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
2425
            .filter { session in
2426
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
2427
                return seenSessionIDs.insert(sessionID).inserted
2428
            }
2429
            .sorted {
2430
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
2431
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
2432
                return lhsDate < rhsDate
2433
            }
2434
    }
2435

            
2436
    private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
2437
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false)
2438
    }
2439

            
2440
    private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
2441
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true)
2442
    }
2443

            
2444
    private func resolvedAssignedObject(
2445
        for meterMACAddress: String,
2446
        expectsChargerClass: Bool
2447
    ) -> NSManagedObject? {
2448
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
2449
        guard !normalizedMAC.isEmpty else { return nil }
2450

            
2451
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2452
        request.predicate = NSPredicate(format: "lastAssociatedMeterMAC == %@", normalizedMAC)
2453
        request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
2454
        let matches = (try? context.fetch(request)) ?? []
2455
        return matches.first { object in
Bogdan Timofte authored a month ago
2456
            isChargerObject(object) == expectsChargerClass
Bogdan Timofte authored a month ago
2457
        }
2458
    }
2459

            
Bogdan Timofte authored a month ago
2460
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
2461
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2462
    }
2463

            
Bogdan Timofte authored a month ago
2464
    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
2465
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2466
        request.predicate = NSPredicate(format: "id == %@", id)
2467
        request.fetchLimit = 1
2468
        return (try? context.fetch(request))?.first
2469
    }
2470

            
2471
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
2472
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
2473
        return (try? context.fetch(request)) ?? []
2474
    }
2475

            
2476
    private func resolvedStopThreshold(
2477
        for chargedDevice: NSManagedObject,
2478
        chargingTransportMode: ChargingTransportMode,
Bogdan Timofte authored a month ago
2479
        chargingStateMode: ChargingStateMode,
2480
        charger: NSManagedObject?,
2481
        fallback: Double?
2482
    ) -> Double? {
2483
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
2484
            return nil
2485
        }
2486

            
2487
        let sessionKind = ChargeSessionKind(
2488
            chargingTransportMode: chargingTransportMode,
2489
            chargingStateMode: chargingStateMode
2490
        )
2491
        let configuredCurrents = decodedCompletionCurrents(
2492
            from: chargedDevice,
2493
            key: "configuredCompletionCurrentsRawValue"
2494
        )
2495
        let learnedCurrents = decodedCompletionCurrents(
2496
            from: chargedDevice,
2497
            key: "learnedCompletionCurrentsRawValue"
2498
        )
2499
        let legacyCurrent: Double?
Bogdan Timofte authored a month ago
2500
        switch chargingTransportMode {
2501
        case .wired:
Bogdan Timofte authored a month ago
2502
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2503
                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
2504
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2505
        case .wireless:
Bogdan Timofte authored a month ago
2506
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2507
                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
2508
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2509
        }
Bogdan Timofte authored a month ago
2510

            
2511
        let resolvedCurrent = configuredCurrents[sessionKind]
2512
            ?? learnedCurrents[sessionKind]
2513
            ?? legacyCurrent
2514
            ?? fallback
2515
        guard let resolvedCurrent, resolvedCurrent > 0 else {
2516
            return nil
2517
        }
2518
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
2519
    }
2520

            
Bogdan Timofte authored a month ago
2521
    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
Bogdan Timofte authored a month ago
2522
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
2523
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
2524
        return resolvedPreferredChargingTransportMode(
Bogdan Timofte authored a month ago
2525
            .wired,
Bogdan Timofte authored a month ago
2526
            supportsWiredCharging: supportsWiredCharging,
2527
            supportsWirelessCharging: supportsWirelessCharging
2528
        )
2529
    }
2530

            
Bogdan Timofte authored a month ago
2531
    private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
2532
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other
2533
    }
2534

            
2535
    private func normalizedTemplateID(
2536
        _ templateID: String?,
2537
        kind: ChargedDeviceKind
2538
    ) -> String? {
2539
        guard let templateID,
2540
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2541
              templateDefinition.kind == kind else {
2542
            return nil
Bogdan Timofte authored a month ago
2543
        }
Bogdan Timofte authored a month ago
2544
        return templateDefinition.id
Bogdan Timofte authored a month ago
2545
    }
2546

            
Bogdan Timofte authored a month ago
2547
    private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
2548
        guard let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
2549
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2550
              templateDefinition.kind == deviceClass(for: chargedDevice).kind else {
2551
            return nil
Bogdan Timofte authored a month ago
2552
        }
Bogdan Timofte authored a month ago
2553
        return templateDefinition
2554
    }
2555

            
2556
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
2557
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2558
            ? true
2559
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2560
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2561
            ? false
2562
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2563
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2564
            supportsWiredCharging: persistedWiredCharging,
2565
            supportsWirelessCharging: persistedWirelessCharging
2566
        ).wired
2567
    }
2568

            
2569
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
2570
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2571
            ? true
2572
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2573
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2574
            ? false
2575
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2576
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2577
            supportsWiredCharging: persistedWiredCharging,
2578
            supportsWirelessCharging: persistedWirelessCharging
2579
        ).wireless
2580
    }
2581

            
2582
    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
2583
        let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue")
2584
            .flatMap(ChargingStateAvailability.init(rawValue:))
2585
            ?? ChargingStateAvailability.fallback(
Bogdan Timofte authored a month ago
2586
            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
2587
        )
Bogdan Timofte authored a month ago
2588
        return deviceClass(for: chargedDevice).normalizedChargingStateAvailability(persistedAvailability)
Bogdan Timofte authored a month ago
2589
    }
2590

            
2591
    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
Bogdan Timofte authored a month ago
2592
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2593
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2594
            let persistedChargingStateMode = stringValue(session, key: "chargingStateRawValue")
2595
                .flatMap(ChargingStateMode.init(rawValue:))
2596
                ?? .on
2597
            return resolvedChargingStateMode(
2598
                persistedChargingStateMode,
2599
                availability: chargingStateAvailability(for: chargedDevice)
2600
            )
2601
        }
2602

            
Bogdan Timofte authored a month ago
2603
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
2604
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
2605
            return chargingStateMode
2606
        }
2607

            
2608
        return .on
2609
    }
2610

            
2611
    private func resolvedChargingStateMode(
2612
        _ chargingStateMode: ChargingStateMode,
2613
        availability: ChargingStateAvailability
2614
    ) -> ChargingStateMode {
2615
        if availability.supportedModes.contains(chargingStateMode) {
2616
            return chargingStateMode
2617
        }
2618
        return availability.supportedModes.first ?? .on
2619
    }
2620

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

            
2625
        // Primary: chargerTypeRawValue (set on v13+)
2626
        if let rawValue = stringValue(chargedDevice, key: "chargerTypeRawValue"),
2627
           let type = ChargerType(rawValue: rawValue) {
2628
            return type
2629
        }
2630

            
2631
        // Migration fallback: derive from old deviceTemplateID
2632
        switch stringValue(chargedDevice, key: "deviceTemplateID") {
2633
        case "apple-magsafe-charger": return .appleMagSafe
2634
        case "apple-watch-charger": return .appleWatch
2635
        default: break
2636
        }
2637

            
2638
        // Last resort: derive from wirelessChargingProfileRawValue
2639
        if let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2640
           let profile = WirelessChargingProfile(rawValue: rawValue),
2641
           profile == .magsafe {
2642
            return .genericMagSafe
2643
        }
2644

            
2645
        return .genericQi
2646
    }
2647

            
Bogdan Timofte authored a month ago
2648
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
Bogdan Timofte authored a month ago
2649
        if let type = chargerType(for: chargedDevice) {
2650
            return type.wirelessChargingProfile
2651
        }
Bogdan Timofte authored a month ago
2652
        guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2653
              let profile = WirelessChargingProfile(rawValue: rawValue) else {
2654
            return .genericQi
2655
        }
2656
        return profile
2657
    }
2658

            
2659
    private func resolvedPreferredChargingTransportMode(
2660
        _ preferredChargingTransportMode: ChargingTransportMode,
2661
        supportsWiredCharging: Bool,
2662
        supportsWirelessCharging: Bool
2663
    ) -> ChargingTransportMode {
2664
        switch preferredChargingTransportMode {
2665
        case .wired where supportsWiredCharging:
2666
            return .wired
2667
        case .wireless where supportsWirelessCharging:
2668
            return .wireless
2669
        default:
2670
            if supportsWiredCharging {
2671
                return .wired
2672
            }
2673
            if supportsWirelessCharging {
2674
                return .wireless
2675
            }
2676
            return .wired
2677
        }
2678
    }
2679

            
Bogdan Timofte authored a month ago
2680
    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
2681
        let payload = Dictionary(
2682
            uniqueKeysWithValues: currents.map { key, value in
2683
                (key.rawValue, value)
2684
            }
2685
        )
2686
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
2687
            return nil
2688
        }
2689
        return String(data: data, encoding: .utf8)
2690
    }
2691

            
2692
    private func decodedCompletionCurrents(
2693
        from object: NSManagedObject,
2694
        key: String
2695
    ) -> [ChargeSessionKind: Double] {
2696
        guard let rawValue = stringValue(object, key: key),
2697
              let data = rawValue.data(using: .utf8),
2698
              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
2699
            return [:]
2700
        }
2701

            
2702
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2703
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
2704
                return
2705
            }
2706
            result[sessionKind] = entry.value
2707
        }
2708
    }
2709

            
2710
    private func legacyConfiguredCompletionCurrent(
2711
        for currents: [ChargeSessionKind: Double],
2712
        chargingTransportMode: ChargingTransportMode
2713
    ) -> Double? {
2714
        let candidates = currents
2715
            .filter { $0.key.chargingTransportMode == chargingTransportMode }
2716
            .sorted { lhs, rhs in
2717
                lhs.key.rawValue < rhs.key.rawValue
2718
            }
2719
            .map(\.value)
2720
        return candidates.first
2721
    }
2722

            
2723
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
2724
        guard let charger else {
2725
            return nil
2726
        }
2727
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
2728
        guard let idleCurrent, idleCurrent >= 0 else {
2729
            return nil
2730
        }
2731
        return idleCurrent
2732
    }
2733

            
2734
    private func effectiveCurrentAmps(
2735
        fromMeasuredCurrent currentAmps: Double,
2736
        chargingTransportMode: ChargingTransportMode,
2737
        charger: NSManagedObject?
2738
    ) -> Double {
2739
        switch chargingTransportMode {
2740
        case .wired:
2741
            return max(currentAmps, 0)
2742
        case .wireless:
2743
            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
2744
                return max(currentAmps, 0)
2745
            }
2746
            return max(currentAmps - idleCurrent, 0)
2747
        }
2748
    }
2749

            
2750
    private func hasObservedChargeFlow(
2751
        currentAmps: Double,
2752
        chargingTransportMode: ChargingTransportMode,
2753
        charger: NSManagedObject?,
2754
        stopThreshold: Double?
2755
    ) -> Bool {
2756
        let effectiveCurrent = effectiveCurrentAmps(
2757
            fromMeasuredCurrent: currentAmps,
2758
            chargingTransportMode: chargingTransportMode,
2759
            charger: charger
2760
        )
2761
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
2762
    }
2763

            
Bogdan Timofte authored a month ago
2764
    private func derivedMinimumCurrent(
2765
        from sessions: [NSManagedObject],
2766
        chargingTransportMode: ChargingTransportMode
2767
    ) -> Double? {
2768
        let completionCurrents = sessions.compactMap { session -> Double? in
2769
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2770
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
2771
                return nil
2772
            }
Bogdan Timofte authored a month ago
2773
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2774
                return nil
2775
            }
Bogdan Timofte authored a month ago
2776
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
2777
                return nil
2778
            }
2779
            return completionCurrent
2780
        }
2781

            
2782
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
2783
        guard !recentCompletionCurrents.isEmpty else { return nil }
2784
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
2785
    }
2786

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

            
2790
        for session in sessions {
2791
            guard statusValue(session, key: "statusRawValue") == .completed else {
2792
                continue
2793
            }
2794
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2795
                continue
2796
            }
2797
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
2798
                  completionCurrent > 0 else {
2799
                continue
2800
            }
2801

            
2802
            let sessionKind = ChargeSessionKind(
2803
                chargingTransportMode: chargingTransportMode(for: session),
2804
                chargingStateMode: chargingStateMode(for: session)
2805
            )
2806
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
2807
        }
2808

            
2809
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2810
            let recentCurrents = Array(entry.value.suffix(5))
2811
            guard !recentCurrents.isEmpty else {
2812
                return
2813
            }
2814
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
2815
        }
2816
    }
2817

            
Bogdan Timofte authored a month ago
2818
    private func derivedCapacity(
2819
        from sessions: [NSManagedObject],
2820
        chargingTransportMode: ChargingTransportMode,
2821
        supportsChargingWhileOff: Bool
2822
    ) -> Double? {
2823
        let capacityCandidates = sessions.compactMap { session -> Double? in
2824
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2825
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
2826
                return nil
2827
            }
2828
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
2829
                return nil
2830
            }
2831
            if supportsChargingWhileOff {
2832
                return capacityEstimate
2833
            }
2834
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
2835
                return nil
2836
            }
2837
            return capacityEstimate
2838
        }
2839

            
2840
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
2841
        guard !recentCapacityCandidates.isEmpty else { return nil }
2842
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
2843
    }
2844

            
2845
    private func derivedWirelessEfficiency(
2846
        from sessions: [NSManagedObject],
2847
        chargingProfile: WirelessChargingProfile
2848
    ) -> Double? {
2849
        guard chargingProfile == .magsafe else {
2850
            return nil
2851
        }
2852

            
2853
        let candidates = sessions.compactMap { session -> Double? in
2854
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2855
            guard chargingTransportMode(for: session) == .wireless else { return nil }
2856
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
2857
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
2858
                return nil
2859
            }
2860
            return factor
2861
        }
2862

            
2863
        let recentCandidates = Array(candidates.suffix(6))
2864
        guard !recentCandidates.isEmpty else { return nil }
2865
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2866
    }
2867

            
2868
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
2869
        let candidates = sessions.compactMap { session -> Double? in
2870
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2871
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
2872
                return nil
2873
            }
2874
            return (sourceVoltage * 10).rounded() / 10
2875
        }
2876

            
2877
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
2878
        return counts.keys.sorted()
2879
    }
2880

            
2881
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
2882
        let candidates = sessions.compactMap { session -> Double? in
2883
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2884
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
2885
                return nil
2886
            }
2887
            return minimumObservedCurrent
2888
        }
2889

            
2890
        let recentCandidates = Array(candidates.suffix(6))
2891
        guard !recentCandidates.isEmpty else { return nil }
2892
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2893
    }
2894

            
2895
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
2896
        let candidates = sessions.compactMap { session -> Double? in
2897
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2898
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
2899
                return nil
2900
            }
2901
            return factor
2902
        }
2903

            
2904
        let recentCandidates = Array(candidates.suffix(6))
2905
        guard !recentCandidates.isEmpty else { return nil }
2906
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2907
    }
2908

            
2909
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
2910
        sessions.compactMap { session -> Double? in
2911
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2912
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
2913
                return nil
2914
            }
2915
            return maximumObservedPower
2916
        }
2917
        .max()
2918
    }
2919

            
2920
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
2921
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2922
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
2923
            return resolvedPreferredChargingTransportMode(
2924
                chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired,
2925
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
2926
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
2927
            )
2928
        }
2929

            
2930
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
2931
            return persistedChargingTransportMode
Bogdan Timofte authored a month ago
2932
        }
2933

            
2934
        return .wired
2935
    }
2936

            
2937
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
2938
        if session.isInserted {
2939
            return .created
2940
        }
2941

            
2942
        let committedValues = session.committedValues(
2943
            forKeys: [
2944
                "statusRawValue",
2945
                "updatedAt",
2946
                "targetBatteryAlertTriggeredAt",
2947
                "requiresCompletionConfirmation"
2948
            ]
2949
        )
2950
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
2951
        let currentStatus = statusValue(session, key: "statusRawValue")
2952
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
2953
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
2954
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
2955
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
2956
            ?? false
2957
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
2958

            
2959
        if currentStatus == .completed, committedStatus != .completed {
2960
            return .completed
2961
        }
2962

            
Bogdan Timofte authored a month ago
2963
        if currentStatus != committedStatus {
2964
            return .event
2965
        }
2966

            
Bogdan Timofte authored a month ago
2967
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
2968
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
2969
            return .event
2970
        }
2971

            
2972
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
2973
            ?? dateValue(session, key: "createdAt")
2974
            ?? observedAt
2975

            
2976
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
2977
            return .periodic
2978
        }
2979

            
2980
        return .none
2981
    }
2982

            
Bogdan Timofte authored a month ago
2983
    private func shouldPersistAggregatedSample(
2984
        _ sample: NSManagedObject,
2985
        observedAt: Date
2986
    ) -> Bool {
2987
        if sample.isInserted {
2988
            return true
2989
        }
2990

            
2991
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
2992
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
2993
            ?? dateValue(sample, key: "createdAt")
2994
            ?? observedAt
2995

            
2996
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
2997
    }
2998

            
Bogdan Timofte authored a month ago
2999
    private func generateQRIdentifier() -> String {
3000
        "device:\(UUID().uuidString)"
3001
    }
3002

            
3003
    @discardableResult
3004
    private func saveContext() -> Bool {
3005
        guard context.hasChanges else { return true }
3006
        do {
3007
            try context.save()
3008
            return true
3009
        } catch {
3010
            track("Failed saving charge insights context: \(error)")
3011
            context.rollback()
3012
            return false
3013
        }
3014
    }
3015

            
3016
    private func normalizedText(_ text: String) -> String {
3017
        text.trimmingCharacters(in: .whitespacesAndNewlines)
3018
    }
3019

            
3020
    private func normalizedOptionalText(_ text: String?) -> String? {
3021
        guard let text else { return nil }
3022
        let normalized = normalizedText(text)
3023
        return normalized.isEmpty ? nil : normalized
3024
    }
3025

            
3026
    private func normalizedMACAddress(_ macAddress: String) -> String {
3027
        normalizedText(macAddress).uppercased()
3028
    }
3029

            
Bogdan Timofte authored a month ago
3030
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
3031
        guard object.entity.propertiesByName[key] != nil else {
3032
            return nil
3033
        }
3034
        return object.value(forKey: key)
3035
    }
3036

            
3037
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
3038
        guard object.entity.propertiesByName[key] != nil else {
3039
            return
3040
        }
3041
        object.setValue(value, forKey: key)
3042
    }
3043

            
Bogdan Timofte authored a month ago
3044
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
3045
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
3046
        let normalized = normalizedOptionalText(value)
3047
        return normalized
3048
    }
3049

            
3050
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
3051
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
3052
    }
3053

            
3054
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
3055
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
3056
            return value
3057
        }
Bogdan Timofte authored a month ago
3058
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3059
            return value.doubleValue
3060
        }
3061
        return 0
3062
    }
3063

            
3064
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
3065
        let value = rawValue(object, key: key)
3066
        if value == nil {
Bogdan Timofte authored a month ago
3067
            return nil
3068
        }
3069
        return doubleValue(object, key: key)
3070
    }
3071

            
3072
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
Bogdan Timofte authored a month ago
3073
        if let value = rawValue(object, key: key) as? Int16 {
Bogdan Timofte authored a month ago
3074
            return value
3075
        }
Bogdan Timofte authored a month ago
3076
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3077
            return value.int16Value
3078
        }
3079
        return nil
3080
    }
3081

            
3082
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
3083
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
3084
            return value
3085
        }
Bogdan Timofte authored a month ago
3086
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3087
            return value.int32Value
3088
        }
3089
        return nil
3090
    }
3091

            
3092
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
Bogdan Timofte authored a month ago
3093
        if let value = rawValue(object, key: key) as? Bool {
Bogdan Timofte authored a month ago
3094
            return value
3095
        }
Bogdan Timofte authored a month ago
3096
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3097
            return value.boolValue
3098
        }
3099
        return false
3100
    }
3101

            
3102
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
3103
        guard let value = stringValue(object, key: key) else { return nil }
3104
        return UUID(uuidString: value)
3105
    }
3106

            
3107
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
3108
        guard let value = stringValue(object, key: key) else { return nil }
3109
        return ChargeSessionStatus(rawValue: value)
3110
    }
3111

            
3112
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
3113
        guard let value = stringValue(object, key: key) else { return nil }
3114
        return ChargingTransportMode(rawValue: value)
3115
    }
3116

            
3117
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
3118
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
3119
            return []
3120
        }
3121
        return rawValue
3122
            .split(separator: ",")
3123
            .compactMap { Double($0) }
3124
            .sorted()
3125
    }
3126

            
3127
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
3128
        let uniqueVoltages = Array(Set(voltages)).sorted()
3129
        guard !uniqueVoltages.isEmpty else {
3130
            return nil
3131
        }
3132
        return uniqueVoltages
3133
            .map { String(format: "%.1f", $0) }
3134
            .joined(separator: ",")
3135
    }
3136

            
3137
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
3138
        guard currentCount > 0 else {
3139
            return newValue
3140
        }
3141
        let total = (currentAverage * Double(currentCount)) + newValue
3142
        return total / Double(currentCount + 1)
3143
    }
3144
}
3145

            
3146
private enum ObservationSaveReason {
3147
    case none
3148
    case created
3149
    case periodic
3150
    case completed
3151
    case event
3152
}