USB-Meter / USB Meter / Model / ChargeInsightsStore.swift
Newer Older
3322 lines | 145.093kb
Bogdan Timofte authored a month ago
1
//
2
//  ChargeInsightsStore.swift
3
//  USB Meter
4
//
5
//  Created by Codex on 10/04/2026.
6
//
7

            
8
import CoreData
9
import Foundation
10

            
11
final class ChargeInsightsStore {
12
    private enum EntityName {
13
        static let chargedDevice = "ChargedDevice"
14
        static let chargeSession = "ChargeSession"
15
        static let chargeCheckpoint = "ChargeCheckpoint"
16
        static let chargeSessionSample = "ChargeSessionSample"
17
    }
18

            
19
    private enum MeterAssignmentKind {
20
        case chargedDevice
21
        case charger
22

            
23
        var expectsChargerClass: Bool {
24
            switch self {
25
            case .chargedDevice:
26
                return false
27
            case .charger:
28
                return true
29
            }
30
        }
31
    }
32

            
Bogdan Timofte authored a month ago
33
    private static let persistedSamplesPerHour = 360
Bogdan Timofte authored a month ago
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
Bogdan Timofte authored a month ago
557
        var deviceIDToRefresh: String?
558

            
Bogdan Timofte authored a month ago
559
        context.performAndWait {
560
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
561
                return
562
            }
563

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

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

            
576
            guard saveContext() else {
577
                return
578
            }
579

            
Bogdan Timofte authored a month ago
580
            didSave = true
581
            deviceIDToRefresh = stringValue(session, key: "chargedDeviceID")
582
        }
583

            
584
        if let deviceID = deviceIDToRefresh {
585
            context.perform { [weak self] in
586
                guard let self else { return }
587
                self.refreshDerivedMetrics(forChargedDeviceID: deviceID)
588
                self.saveContext()
Bogdan Timofte authored a month ago
589
            }
590
        }
Bogdan Timofte authored a month ago
591

            
Bogdan Timofte authored a month ago
592
        return didSave
593
    }
594

            
Bogdan Timofte authored a month ago
595
    @discardableResult
596
    func addBatteryCheckpoint(
597
        percent: Double,
Bogdan Timofte authored a month ago
598
        for meterMACAddress: String,
599
        measuredEnergyWh: Double? = nil,
600
        measuredChargeAh: Double? = nil
Bogdan Timofte authored a month ago
601
    ) -> Bool {
602
        guard percent.isFinite, percent >= 0, percent <= 100 else {
603
            return false
604
        }
605

            
606
        var didSave = false
607
        context.performAndWait {
Bogdan Timofte authored a month ago
608
            guard let session = fetchOpenSessionObject(forMeterMACAddress: meterMACAddress) else {
Bogdan Timofte authored a month ago
609
                return
610
            }
611

            
Bogdan Timofte authored a month ago
612
            didSave = addBatteryCheckpoint(
613
                percent: percent,
614
                measuredEnergyWh: measuredEnergyWh,
615
                measuredChargeAh: measuredChargeAh,
Bogdan Timofte authored a month ago
616
                flag: .intermediate,
Bogdan Timofte authored a month ago
617
                to: session
618
            )
Bogdan Timofte authored a month ago
619
        }
620
        return didSave
621
    }
622

            
623
    @discardableResult
624
    func addBatteryCheckpoint(
625
        percent: Double,
Bogdan Timofte authored a month ago
626
        for sessionID: UUID,
627
        measuredEnergyWh: Double? = nil,
628
        measuredChargeAh: Double? = nil
Bogdan Timofte authored a month ago
629
    ) -> Bool {
630
        guard percent.isFinite, percent >= 0, percent <= 100 else {
631
            return false
632
        }
633

            
634
        var didSave = false
635
        context.performAndWait {
636
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
637
                return
638
            }
639

            
Bogdan Timofte authored a month ago
640
            didSave = addBatteryCheckpoint(
641
                percent: percent,
642
                measuredEnergyWh: measuredEnergyWh,
643
                measuredChargeAh: measuredChargeAh,
Bogdan Timofte authored a month ago
644
                flag: .intermediate,
Bogdan Timofte authored a month ago
645
                to: session
646
            )
Bogdan Timofte authored a month ago
647
        }
648
        return didSave
649
    }
650

            
Bogdan Timofte authored a month ago
651
    @discardableResult
652
    func deleteBatteryCheckpoint(
653
        id checkpointID: UUID,
654
        from sessionID: UUID
655
    ) -> Bool {
656
        var didSave = false
657
        context.performAndWait {
658
            guard let session = fetchSessionObject(id: sessionID.uuidString),
659
                  let checkpoint = fetchCheckpointObject(
660
                    id: checkpointID.uuidString,
661
                    sessionID: sessionID.uuidString
662
                  ) else {
663
                return
664
            }
665

            
666
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
667
            context.delete(checkpoint)
668
            refreshCheckpointDerivedValues(for: session)
669

            
670
            if let chargedDeviceID {
671
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
672
            }
Bogdan Timofte authored a month ago
673

            
674
            didSave = saveContext()
Bogdan Timofte authored a month ago
675
        }
676
        return didSave
677
    }
678

            
Bogdan Timofte authored a month ago
679
    @discardableResult
680
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
681
        if let percent, (!percent.isFinite || percent <= 0 || percent > 100) {
682
            return false
683
        }
684

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

            
691
            session.setValue(percent, forKey: "targetBatteryPercent")
692
            session.setValue(nil, forKey: "targetBatteryAlertTriggeredAt")
693
            session.setValue(Date(), forKey: "updatedAt")
694
            didSave = saveContext()
695
        }
696
        return didSave
697
    }
698

            
699
    @discardableResult
700
    func confirmCompletion(for sessionID: UUID) -> Bool {
701
        var didSave = false
702
        context.performAndWait {
703
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
704
                return
705
            }
706

            
707
            guard statusValue(session, key: "statusRawValue") == .active else {
708
                return
709
            }
710

            
Bogdan Timofte authored a month ago
711
            finishSession(
712
                session,
713
                observedAt: dateValue(session, key: "lastObservedAt") ?? Date(),
714
                finalBatteryPercent: nil,
715
                status: .completed
716
            )
Bogdan Timofte authored a month ago
717

            
718
            if saveContext() {
719
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
720
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
721
                    didSave = saveContext()
722
                } else {
723
                    didSave = true
724
                }
725
            }
726
        }
727
        return didSave
728
    }
729

            
730
    @discardableResult
731
    func continueMonitoringDespiteCompletionContradiction(for sessionID: UUID) -> Bool {
732
        var didSave = false
733
        context.performAndWait {
734
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
735
                return
736
            }
737

            
738
            guard statusValue(session, key: "statusRawValue") == .active else {
739
                return
740
            }
741

            
742
            clearCompletionConfirmationState(for: session)
Bogdan Timofte authored a month ago
743
            session.setValue(nil, forKey: "belowThresholdSince")
Bogdan Timofte authored a month ago
744
            session.setValue(Date().addingTimeInterval(completionConfirmationCooldown), forKey: "completionConfirmationCooldownUntil")
745
            session.setValue(Date(), forKey: "updatedAt")
746
            didSave = saveContext()
747
        }
748
        return didSave
749
    }
750

            
Bogdan Timofte authored a month ago
751
    @discardableResult
752
    func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
753
        var didSave = false
754
        context.performAndWait {
755
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
756
                return
757
            }
758

            
759
            let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast
760
            let sessionEnd   = dateValue(session, key: "endedAt")
761
                ?? dateValue(session, key: "lastObservedAt")
762
                ?? Date.distantFuture
763

            
764
            let effectiveStart = min(max(start ?? sessionStart, sessionStart), sessionEnd)
765
            let effectiveEnd   = max(min(end ?? sessionEnd, sessionEnd), effectiveStart)
766
            let persistedStart = effectiveStart == sessionStart ? nil : effectiveStart
767
            let persistedEnd   = effectiveEnd == sessionEnd ? nil : effectiveEnd
768

            
769
            let allSamples = fetchSessionSampleObjects(forSessionID: sessionID.uuidString)
770
                .compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in
771
                    guard let ts = dateValue(obj, key: "timestamp") else { return nil }
772
                    return (
773
                        timestamp: ts,
774
                        energy: doubleValue(obj, key: "measuredEnergyWh"),
775
                        charge: doubleValue(obj, key: "measuredChargeAh")
776
                    )
777
                }
778
                .sorted { $0.timestamp < $1.timestamp }
779

            
780
            // Each sample stores cumulative energy since session start.
781
            // Trimmed energy = value at trimEnd  -  value just before trimStart.
782
            let baselineSample = allSamples.last { $0.timestamp <= effectiveStart }
783
            let endSample      = allSamples.last { $0.timestamp <= effectiveEnd }
784
            let baselineEnergy = baselineSample?.energy ?? 0
785
            let baselineCharge = baselineSample?.charge ?? 0
786

            
787
            if let endSample {
788
                let trimmedEnergy  = max(endSample.energy - baselineEnergy, 0)
789
                let trimmedCharge  = max(endSample.charge - baselineCharge, 0)
790
                session.setValue(trimmedEnergy, forKey: "measuredEnergyWh")
791
                session.setValue(trimmedCharge, forKey: "measuredChargeAh")
792
            } else {
793
                session.setValue(0, forKey: "measuredEnergyWh")
794
                session.setValue(0, forKey: "measuredChargeAh")
795
            }
796

            
797
            session.setValue(persistedStart, forKey: "trimStart")
798
            session.setValue(persistedEnd,   forKey: "trimEnd")
799
            session.setValue(Date(), forKey: "updatedAt")
800

            
801
            let checkpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
802
            for checkpoint in checkpoints {
803
                guard let timestamp = dateValue(checkpoint, key: "timestamp") else { continue }
804

            
805
                if timestamp < effectiveStart || timestamp > effectiveEnd {
806
                    context.delete(checkpoint)
807
                    continue
808
                }
809

            
810
                let checkpointSample = allSamples.last { $0.timestamp <= timestamp }
811
                let rebasedEnergy = max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0)
812
                let rebasedCharge = max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0)
813
                checkpoint.setValue(rebasedEnergy, forKey: "measuredEnergyWh")
814
                checkpoint.setValue(rebasedCharge, forKey: "measuredChargeAh")
815
            }
816

            
817
            let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
818
                .sorted {
819
                    (dateValue($0, key: "timestamp") ?? .distantPast)
820
                        < (dateValue($1, key: "timestamp") ?? .distantPast)
821
                }
822
            let restoredInitialCheckpoint = remainingCheckpoints.first { checkpoint in
823
                let label = stringValue(checkpoint, key: "label")
824
                let timestamp = dateValue(checkpoint, key: "timestamp") ?? .distantFuture
825
                return ChargeCheckpointFlag.fromStoredLabel(label) == .initial || timestamp <= effectiveStart
826
            }
827

            
828
            if persistedStart == nil {
829
                if let restoredInitialCheckpoint,
830
                   let percent = optionalDoubleValue(restoredInitialCheckpoint, key: "batteryPercent"),
831
                   percent >= 0 {
832
                    session.setValue(percent, forKey: "startBatteryPercent")
833
                }
834
            } else {
835
                session.setValue(nil, forKey: "startBatteryPercent")
836
            }
837

            
838
            refreshCheckpointDerivedValues(for: session)
839

            
840
            if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
841
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
842
            }
843

            
844
            didSave = saveContext()
845
        }
846
        return didSave
847
    }
848

            
Bogdan Timofte authored a month ago
849
    @discardableResult
850
    func deleteChargeSession(id sessionID: UUID) -> Bool {
851
        var didSave = false
852
        context.performAndWait {
853
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
854
                return
855
            }
856

            
857
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
858

            
859
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
860
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
861
            context.delete(session)
862

            
863
            guard saveContext() else {
864
                return
865
            }
866

            
867
            if let chargedDeviceID {
868
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
869
                didSave = saveContext()
870
            } else {
871
                didSave = true
872
            }
873
        }
874
        return didSave
875
    }
876

            
877
    @discardableResult
878
    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
879
        var didSave = false
880

            
881
        context.performAndWait {
882
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
883
                return
884
            }
885

            
886
            let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
887
            let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
888
            let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
889

            
890
            var impactedChargedDeviceIDs = Set<String>()
891

            
892
            for session in deviceSessions {
893
                if let impactedID = stringValue(session, key: "chargedDeviceID") {
894
                    impactedChargedDeviceIDs.insert(impactedID)
895
                }
896
                if let impactedChargerID = stringValue(session, key: "chargerID") {
897
                    impactedChargedDeviceIDs.insert(impactedChargerID)
898
                }
899
                if let sessionID = stringValue(session, key: "id") {
900
                    fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete)
901
                    fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete)
902
                }
903
                context.delete(session)
904
            }
905

            
906
            if deviceClass == .charger {
907
                for session in linkedWirelessSessions {
908
                    guard stringValue(session, key: "chargedDeviceID") != chargedDeviceID.uuidString else {
909
                        continue
910
                    }
911
                    if let impactedID = stringValue(session, key: "chargedDeviceID") {
912
                        impactedChargedDeviceIDs.insert(impactedID)
913
                    }
914
                    session.setValue(nil, forKey: "chargerID")
915
                    session.setValue(Date(), forKey: "updatedAt")
916
                }
917
            }
918

            
919
            context.delete(chargedDevice)
920

            
921
            guard saveContext() else {
922
                return
923
            }
924

            
925
            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
926
            for impactedID in impactedChargedDeviceIDs {
927
                refreshDerivedMetrics(forChargedDeviceID: impactedID)
928
            }
929
            didSave = saveContext()
930
        }
931

            
932
        return didSave
933
    }
934

            
935
    @discardableResult
936
    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
937
        var didSave = false
938

            
939
        context.performAndWait {
Bogdan Timofte authored a month ago
940
            guard let session = fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress),
941
                  let resolvedDevice = stringValue(session, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:)) else {
942
                return
943
            }
Bogdan Timofte authored a month ago
944

            
Bogdan Timofte authored a month ago
945
            if statusValue(session, key: "statusRawValue") == .paused {
946
                if maybeCompletePausedSession(session, observedAt: snapshot.observedAt) {
947
                    didSave = true
948
                }
Bogdan Timofte authored a month ago
949
                return
950
            }
951

            
Bogdan Timofte authored a month ago
952
            let chargingTransportMode = self.chargingTransportMode(for: session)
953
            let chargingStateMode = self.chargingStateMode(for: session)
Bogdan Timofte authored a month ago
954
            let charger = chargingTransportMode == .wireless
Bogdan Timofte authored a month ago
955
                ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
956
                : nil
957
            guard chargingTransportMode == .wired || charger != nil else {
958
                return
959
            }
960
            let stopThreshold = resolvedStopThreshold(
961
                for: resolvedDevice,
962
                chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
963
                chargingStateMode: chargingStateMode,
964
                charger: charger,
965
                fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
Bogdan Timofte authored a month ago
966
            )
967

            
Bogdan Timofte authored a month ago
968
            update(session: session, with: snapshot, stopThreshold: stopThreshold, charger: charger)
Bogdan Timofte authored a month ago
969
            let aggregatedSample = updateAggregatedSample(session: session, with: snapshot)
Bogdan Timofte authored a month ago
970

            
971
            let saveReason = saveReason(for: session, observedAt: snapshot.observedAt)
Bogdan Timofte authored a month ago
972
            let shouldPersistAggregatedCurve = aggregatedSample.map {
973
                shouldPersistAggregatedSample($0, observedAt: snapshot.observedAt)
974
            } ?? false
975

            
976
            guard saveReason != .none || shouldPersistAggregatedCurve else {
Bogdan Timofte authored a month ago
977
                return
978
            }
979

            
980
            session.setValue(snapshot.observedAt, forKey: "updatedAt")
981

            
982
            if saveContext() {
983
                if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
984
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
985
                    didSave = saveContext()
986
                } else {
987
                    didSave = true
988
                }
989
            }
990
        }
991

            
992
        return didSave
993
    }
994

            
995
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
996
        var summaries: [ChargedDeviceSummary] = []
997

            
998
        context.performAndWait {
999
            let devices = fetchObjects(entityName: EntityName.chargedDevice)
1000
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
1001
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
1002

            
1003
            let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
1004
            let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
1005
            let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
Bogdan Timofte authored a month ago
1006
            let sampleBackedSessionIDs = sampleBackedSessionIDs(
1007
                devices: devices,
1008
                sessionsByDeviceID: sessionsByDeviceID,
1009
                sessionsByChargerID: sessionsByChargerID
1010
            )
1011
            let samplesBySessionID = Dictionary(
1012
                grouping: fetchSessionSampleObjects(forSessionIDs: Array(sampleBackedSessionIDs))
1013
            ) { stringValue($0, key: "sessionID") ?? "" }
Bogdan Timofte authored a month ago
1014

            
1015
            summaries = devices.compactMap { device in
1016
                guard
1017
                    let id = uuidValue(device, key: "id"),
1018
                    let name = stringValue(device, key: "name"),
1019
                    let qrIdentifier = stringValue(device, key: "qrIdentifier"),
1020
                    let rawClass = stringValue(device, key: "deviceClassRawValue"),
1021
                    let deviceClass = ChargedDeviceClass(rawValue: rawClass)
1022
                else {
1023
                    return nil
1024
                }
1025

            
Bogdan Timofte authored a month ago
1026
                let chargingStateAvailability = chargingStateAvailability(for: device)
1027
                let supportsWiredCharging = supportsWiredCharging(for: device)
1028
                let supportsWirelessCharging = supportsWirelessCharging(for: device)
1029
                let templateDefinition = templateDefinition(for: device)
1030

            
Bogdan Timofte authored a month ago
1031
                let sessionObjects = relevantSessionObjects(
1032
                    for: id.uuidString,
1033
                    deviceClass: deviceClass,
1034
                    sessionsByDeviceID: sessionsByDeviceID,
1035
                    sessionsByChargerID: sessionsByChargerID
1036
                )
1037
                let sessionSummaries = sessionObjects
1038
                    .compactMap { session in
1039
                        makeSessionSummary(
1040
                            from: session,
1041
                            checkpoints: checkpointsBySessionID[stringValue(session, key: "id") ?? ""] ?? [],
1042
                            samples: samplesBySessionID[stringValue(session, key: "id") ?? ""] ?? []
1043
                        )
1044
                    }
1045
                    .sorted { lhs, rhs in
Bogdan Timofte authored a month ago
1046
                        if lhs.status.isOpen && !rhs.status.isOpen {
Bogdan Timofte authored a month ago
1047
                            return true
1048
                        }
Bogdan Timofte authored a month ago
1049
                        if !lhs.status.isOpen && rhs.status.isOpen {
1050
                            return false
1051
                        }
1052
                        if lhs.status == .active && rhs.status == .paused {
1053
                            return true
1054
                        }
1055
                        if lhs.status == .paused && rhs.status == .active {
Bogdan Timofte authored a month ago
1056
                            return false
1057
                        }
1058
                        return lhs.startedAt > rhs.startedAt
1059
                    }
1060

            
1061
                return ChargedDeviceSummary(
1062
                    id: id,
1063
                    qrIdentifier: qrIdentifier,
1064
                    name: name,
1065
                    deviceClass: deviceClass,
Bogdan Timofte authored a month ago
1066
                    deviceTemplateID: stringValue(device, key: "deviceTemplateID"),
1067
                    templateDefinition: templateDefinition,
1068
                    supportsChargingWhileOff: chargingStateAvailability.supportsChargingWhileOff,
1069
                    chargingStateAvailability: chargingStateAvailability,
1070
                    supportsWiredCharging: supportsWiredCharging,
1071
                    supportsWirelessCharging: supportsWirelessCharging,
Bogdan Timofte authored a month ago
1072
                    chargerType: chargerType(for: device),
Bogdan Timofte authored a month ago
1073
                    wirelessChargingProfile: wirelessChargingProfile(for: device),
Bogdan Timofte authored a month ago
1074
                    configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"),
1075
                    learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"),
Bogdan Timofte authored a month ago
1076
                    wirelessChargerEfficiencyFactor: optionalDoubleValue(device, key: "wirelessChargerEfficiencyFactor"),
1077
                    wiredChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wiredChargeCompletionCurrentAmps"),
1078
                    wirelessChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wirelessChargeCompletionCurrentAmps"),
1079
                    chargerObservedVoltageSelections: decodedObservedVoltageSelections(from: device),
1080
                    chargerIdleCurrentAmps: optionalDoubleValue(device, key: "chargerIdleCurrentAmps"),
1081
                    chargerEfficiencyFactor: optionalDoubleValue(device, key: "chargerEfficiencyFactor"),
1082
                    chargerMaximumPowerWatts: optionalDoubleValue(device, key: "chargerMaximumPowerWatts"),
1083
                    notes: stringValue(device, key: "notes"),
1084
                    minimumCurrentAmps: optionalDoubleValue(device, key: "minimumCurrentAmps"),
1085
                    estimatedBatteryCapacityWh: optionalDoubleValue(device, key: "estimatedBatteryCapacityWh"),
1086
                    wiredMinimumCurrentAmps: optionalDoubleValue(device, key: "wiredMinimumCurrentAmps"),
1087
                    wirelessMinimumCurrentAmps: optionalDoubleValue(device, key: "wirelessMinimumCurrentAmps"),
1088
                    wiredEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wiredEstimatedBatteryCapacityWh"),
1089
                    wirelessEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wirelessEstimatedBatteryCapacityWh"),
1090
                    lastAssociatedMeterMAC: stringValue(device, key: "lastAssociatedMeterMAC"),
1091
                    createdAt: dateValue(device, key: "createdAt") ?? .distantPast,
1092
                    updatedAt: dateValue(device, key: "updatedAt") ?? .distantPast,
1093
                    sessions: sessionSummaries,
1094
                    capacityHistory: buildCapacityHistory(from: sessionSummaries),
Bogdan Timofte authored a month ago
1095
                    typicalCurve: buildTypicalCurve(from: sessionSummaries),
1096
                    standbyPowerMeasurements: []
Bogdan Timofte authored a month ago
1097
                )
1098
            }
1099
            .sorted { lhs, rhs in
1100
                if lhs.activeSession != nil && rhs.activeSession == nil {
1101
                    return true
1102
                }
1103
                if lhs.activeSession == nil && rhs.activeSession != nil {
1104
                    return false
1105
                }
1106
                if lhs.updatedAt != rhs.updatedAt {
1107
                    return lhs.updatedAt > rhs.updatedAt
1108
                }
1109
                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
1110
            }
1111
        }
1112

            
1113
        return summaries
1114
    }
1115

            
1116
    func resolvedChargedDeviceSummary(forMeterMACAddress meterMACAddress: String) -> ChargedDeviceSummary? {
1117
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1118
        guard !normalizedMAC.isEmpty else { return nil }
1119

            
1120
        let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
1121

            
1122
        if let activeMatch = summaries.first(where: { summary in
1123
            summary.activeSession?.meterMACAddress == normalizedMAC
1124
        }) {
1125
            return activeMatch
1126
        }
1127

            
1128
        return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
1129
    }
1130

            
1131
    func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
1132
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1133
        guard !normalizedMAC.isEmpty else { return nil }
1134

            
Bogdan Timofte authored a month ago
1135
        var summary: ChargeSessionSummary?
1136

            
1137
        context.performAndWait {
Bogdan Timofte authored a month ago
1138
            guard let session = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
Bogdan Timofte authored a month ago
1139
                  let sessionID = stringValue(session, key: "id") else {
1140
                return
1141
            }
1142

            
1143
            summary = makeSessionSummary(
1144
                from: session,
1145
                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
1146
                samples: fetchSessionSampleObjects(forSessionID: sessionID)
1147
            )
1148
        }
1149

            
1150
        return summary
Bogdan Timofte authored a month ago
1151
    }
1152

            
1153
    private func createSessionObject(
1154
        for chargedDevice: NSManagedObject,
1155
        charger: NSManagedObject?,
1156
        snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1157
        stopThreshold: Double?,
1158
        chargingTransportMode: ChargingTransportMode,
1159
        chargingStateMode: ChargingStateMode,
1160
        autoStopEnabled: Bool
Bogdan Timofte authored a month ago
1161
    ) -> NSManagedObject? {
1162
        guard
1163
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
1164
            let chargedDeviceID = stringValue(chargedDevice, key: "id")
1165
        else {
1166
            return nil
1167
        }
1168

            
1169
        let session = NSManagedObject(entity: entity, insertInto: context)
1170
        let now = snapshot.observedAt
1171
        session.setValue(UUID().uuidString, forKey: "id")
1172
        session.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1173
        session.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
1174
        session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress")
1175
        session.setValue(snapshot.meterName, forKey: "meterName")
1176
        session.setValue(snapshot.meterModel, forKey: "meterModel")
1177
        session.setValue(now, forKey: "startedAt")
1178
        session.setValue(now, forKey: "lastObservedAt")
1179
        session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
Bogdan Timofte authored a month ago
1180
        let usesMeterCounters = snapshot.meterEnergyCounterWh != nil || snapshot.meterChargeCounterAh != nil
1181
        session.setValue(
1182
            (usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue,
1183
            forKey: "sourceModeRawValue"
1184
        )
Bogdan Timofte authored a month ago
1185
        session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
1186
        session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue")
1187
        session.setValue(autoStopEnabled, forKey: "autoStopEnabled")
1188
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1189
        session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
1190
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1191
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1192
        session.setValue(
1193
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1194
            forKey: "lastObservedVoltageVolts"
1195
        )
Bogdan Timofte authored a month ago
1196
        session.setValue(
1197
            hasObservedChargeFlow(
1198
                currentAmps: snapshot.currentAmps,
1199
                chargingTransportMode: chargingTransportMode,
1200
                charger: charger,
1201
                stopThreshold: stopThreshold
1202
            ),
1203
            forKey: "hasObservedChargeFlow"
1204
        )
Bogdan Timofte authored a month ago
1205
        session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
1206
        session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
1207
        session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
1208
        session.setValue(
1209
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1210
            forKey: "maximumObservedVoltageVolts"
1211
        )
1212
        session.setValue(boolValue(chargedDevice, key: "supportsChargingWhileOff"), forKey: "supportsChargingWhileOff")
1213
        if let selectedDataGroup = snapshot.selectedDataGroup {
1214
            session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup")
1215
        }
1216
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1217
            session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1218
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1219
        }
1220
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1221
            session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1222
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1223
        }
Bogdan Timofte authored a month ago
1224
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1225
            setValue(meterRecordingDurationSeconds, on: session, key: "meterDurationBaselineSeconds")
1226
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1227
        }
Bogdan Timofte authored a month ago
1228
        session.setValue(now, forKey: "createdAt")
1229
        session.setValue(now, forKey: "updatedAt")
1230

            
1231
        chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
1232
        chargedDevice.setValue(now, forKey: "updatedAt")
1233
        return session
1234
    }
1235

            
1236
    private func update(
1237
        session: NSManagedObject,
1238
        with snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1239
        stopThreshold: Double?,
1240
        charger: NSManagedObject?
Bogdan Timofte authored a month ago
1241
    ) {
1242
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1243
        let lastObservedAt = dateValue(session, key: "lastObservedAt")
1244
        let previousPower = doubleValue(session, key: "lastObservedPowerWatts")
1245
        let previousCurrent = doubleValue(session, key: "lastObservedCurrentAmps")
1246
        var measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1247
        var measuredChargeAh = doubleValue(session, key: "measuredChargeAh")
1248
        var sourceMode = ChargeSessionSourceMode(rawValue: stringValue(session, key: "sourceModeRawValue") ?? "") ?? .live
1249
        var usedOfflineMeterCounters = boolValue(session, key: "usedOfflineMeterCounters")
1250

            
1251
        if let lastObservedAt {
1252
            let deltaSeconds = max(snapshot.observedAt.timeIntervalSince(lastObservedAt), 0)
1253
            if deltaSeconds > 0, deltaSeconds <= maximumLiveIntegrationGap {
1254
                measuredEnergyWh += max(previousPower, 0) * deltaSeconds / 3600
1255
                measuredChargeAh += max(previousCurrent, 0) * deltaSeconds / 3600
1256
                if sourceMode == .offline {
1257
                    sourceMode = .blended
1258
                }
1259
            }
1260
        }
1261

            
1262
        if let counterGroup = snapshot.selectedDataGroup,
1263
           let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
1264
           UInt8(storedGroup) != counterGroup {
1265
            session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
1266
            session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1267
            session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1268
        }
1269

            
1270
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1271
            let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
1272
            let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
1273
            if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
1274
                session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
1275
            }
1276

            
1277
            if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
1278
                let offlineEnergy = meterEnergyCounterWh - baselineEnergy
Bogdan Timofte authored a month ago
1279
                measuredEnergyWh = max(offlineEnergy, 0)
Bogdan Timofte authored a month ago
1280
                usedOfflineMeterCounters = true
Bogdan Timofte authored a month ago
1281
                sourceMode = .offline
Bogdan Timofte authored a month ago
1282
            } else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
1283
                let delta = meterEnergyCounterWh - lastEnergy
1284
                if delta > 0 {
1285
                    measuredEnergyWh += delta
1286
                    usedOfflineMeterCounters = true
1287
                    sourceMode = .blended
1288
                }
1289
            }
1290
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1291
        }
1292

            
1293
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1294
            let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
1295
            let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
1296
            if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
1297
                session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
1298
            }
1299

            
1300
            if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
1301
                let offlineCharge = meterChargeCounterAh - baselineCharge
Bogdan Timofte authored a month ago
1302
                measuredChargeAh = max(offlineCharge, 0)
Bogdan Timofte authored a month ago
1303
                usedOfflineMeterCounters = true
1304
            } else if let lastCharge, meterChargeCounterAh > lastCharge {
1305
                let delta = meterChargeCounterAh - lastCharge
1306
                if delta > 0 {
1307
                    measuredChargeAh += delta
1308
                    usedOfflineMeterCounters = true
1309
                }
1310
            }
1311
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1312
        }
1313

            
Bogdan Timofte authored a month ago
1314
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1315
            let baselineDuration = optionalDoubleValue(session, key: "meterDurationBaselineSeconds") ?? meterRecordingDurationSeconds
1316
            if optionalDoubleValue(session, key: "meterDurationBaselineSeconds") == nil {
1317
                setValue(baselineDuration, on: session, key: "meterDurationBaselineSeconds")
1318
            }
1319
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1320
        }
1321

            
Bogdan Timofte authored a month ago
1322
        let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps")
1323
        let updatedMinimum: Double
1324
        if snapshot.currentAmps > 0 {
1325
            updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps)
1326
        } else {
1327
            updatedMinimum = existingMinimum ?? 0
1328
        }
1329

            
Bogdan Timofte authored a month ago
1330
        let effectiveCurrent = effectiveCurrentAmps(
1331
            fromMeasuredCurrent: snapshot.currentAmps,
1332
            chargingTransportMode: sessionChargingTransportMode,
1333
            charger: charger
1334
        )
1335
        let observedChargeFlow = boolValue(session, key: "hasObservedChargeFlow")
1336
            || hasObservedChargeFlow(
1337
                currentAmps: snapshot.currentAmps,
1338
                chargingTransportMode: sessionChargingTransportMode,
1339
                charger: charger,
1340
                stopThreshold: stopThreshold
1341
            )
1342

            
Bogdan Timofte authored a month ago
1343
        session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh")
1344
        session.setValue(measuredChargeAh, forKey: "measuredChargeAh")
1345
        session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps")
Bogdan Timofte authored a month ago
1346
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1347
        session.setValue(snapshot.observedAt, forKey: "lastObservedAt")
1348
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1349
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1350
        session.setValue(
1351
            sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1352
            forKey: "lastObservedVoltageVolts"
1353
        )
Bogdan Timofte authored a month ago
1354
        session.setValue(observedChargeFlow, forKey: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
1355
        session.setValue(
1356
            max(optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? snapshot.currentAmps, snapshot.currentAmps),
1357
            forKey: "maximumObservedCurrentAmps"
1358
        )
1359
        session.setValue(
1360
            max(optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? snapshot.powerWatts, snapshot.powerWatts),
1361
            forKey: "maximumObservedPowerWatts"
1362
        )
1363
        session.setValue(
1364
            sessionChargingTransportMode == .wired
1365
                ? max(optionalDoubleValue(session, key: "maximumObservedVoltageVolts") ?? snapshot.voltageVolts, snapshot.voltageVolts)
1366
                : nil,
1367
            forKey: "maximumObservedVoltageVolts"
1368
        )
1369
        session.setValue(usedOfflineMeterCounters, forKey: "usedOfflineMeterCounters")
1370
        session.setValue(sourceMode.rawValue, forKey: "sourceModeRawValue")
1371
        maybeTriggerTargetBatteryAlert(for: session, observedAt: snapshot.observedAt)
1372

            
Bogdan Timofte authored a month ago
1373
        guard boolValue(session, key: "autoStopEnabled"), let stopThreshold, stopThreshold > 0, observedChargeFlow else {
1374
            session.setValue(nil, forKey: "belowThresholdSince")
1375
            clearCompletionConfirmationState(for: session)
1376
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1377
            return
1378
        }
1379

            
1380
        if effectiveCurrent <= stopThreshold {
Bogdan Timofte authored a month ago
1381
            let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt
1382
            session.setValue(belowThresholdSince, forKey: "belowThresholdSince")
1383
            if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
1384
                if boolValue(session, key: "requiresCompletionConfirmation") {
1385
                    // Leave the session active until the user explicitly confirms or charging resumes.
1386
                    return
1387
                }
1388

            
1389
                if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
1390
                    requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt)
1391
                } else {
Bogdan Timofte authored a month ago
1392
                    finishSession(
1393
                        session,
1394
                        observedAt: snapshot.observedAt,
1395
                        finalBatteryPercent: nil,
1396
                        status: .completed
1397
                    )
Bogdan Timofte authored a month ago
1398
                }
1399
            }
1400
        } else {
1401
            session.setValue(nil, forKey: "belowThresholdSince")
1402
            clearCompletionConfirmationState(for: session)
1403
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1404
        }
1405
    }
1406

            
1407
    private func updateAggregatedSample(
1408
        session: NSManagedObject,
1409
        with snapshot: ChargingMonitorSnapshot
Bogdan Timofte authored a month ago
1410
    ) -> NSManagedObject? {
Bogdan Timofte authored a month ago
1411
        guard
1412
            let sessionID = stringValue(session, key: "id"),
1413
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1414
            let startedAt = dateValue(session, key: "startedAt"),
1415
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context)
1416
        else {
Bogdan Timofte authored a month ago
1417
            return nil
Bogdan Timofte authored a month ago
1418
        }
1419

            
1420
        let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0)
1421
        let bucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
1422
        let bucketStart = startedAt.addingTimeInterval(Double(bucketIndex) * Self.aggregatedSampleBucketDuration)
1423
        let bucketIdentifier = "\(sessionID)-\(bucketIndex)"
1424
        let sample = fetchSessionSampleObject(sessionID: sessionID, bucketIndex: bucketIndex)
1425
            ?? NSManagedObject(entity: entity, insertInto: context)
1426
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1427
        let sampleVoltage = sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil
1428

            
1429
        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
1430
        let updatedCount = existingCount + 1
1431

            
1432
        sample.setValue(bucketIdentifier, forKey: "id")
1433
        sample.setValue(sessionID, forKey: "sessionID")
1434
        sample.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1435
        sample.setValue(bucketIndex, forKey: "bucketIndex")
1436
        sample.setValue(max(snapshot.observedAt, bucketStart), forKey: "timestamp")
1437
        sample.setValue(
1438
            runningAverage(
1439
                currentAverage: optionalDoubleValue(sample, key: "averageCurrentAmps") ?? snapshot.currentAmps,
1440
                currentCount: Int(existingCount),
1441
                newValue: snapshot.currentAmps
1442
            ),
1443
            forKey: "averageCurrentAmps"
1444
        )
1445
        sample.setValue(
1446
            sampleVoltage.flatMap { voltage in
1447
                runningAverage(
1448
                    currentAverage: optionalDoubleValue(sample, key: "averageVoltageVolts") ?? voltage,
1449
                    currentCount: Int(existingCount),
1450
                    newValue: voltage
1451
                )
1452
            },
1453
            forKey: "averageVoltageVolts"
1454
        )
1455
        sample.setValue(
1456
            runningAverage(
1457
                currentAverage: optionalDoubleValue(sample, key: "averagePowerWatts") ?? snapshot.powerWatts,
1458
                currentCount: Int(existingCount),
1459
                newValue: snapshot.powerWatts
1460
            ),
1461
            forKey: "averagePowerWatts"
1462
        )
1463
        sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh")
1464
        sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
1465
        sample.setValue(Int16(updatedCount), forKey: "sampleCount")
1466
        sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
1467
        sample.setValue(snapshot.observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1468
        return sample
Bogdan Timofte authored a month ago
1469
    }
1470

            
Bogdan Timofte authored a month ago
1471
    private func maybeTriggerTargetBatteryAlert(
1472
        for session: NSManagedObject,
1473
        observedAt: Date,
1474
        completionFallbackPercent: Double? = nil
1475
    ) {
Bogdan Timofte authored a month ago
1476
        guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
1477
            return
1478
        }
1479

            
1480
        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
1481
            return
1482
        }
1483

            
1484
        let predictedBatteryPercent = predictedBatteryPercent(for: session)
1485
            ?? optionalDoubleValue(session, key: "endBatteryPercent")
Bogdan Timofte authored a month ago
1486
            ?? completionFallbackPercent
Bogdan Timofte authored a month ago
1487

            
1488
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
1489
            return
1490
        }
1491

            
1492
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
1493
    }
1494

            
1495
    private func shouldRequireCompletionConfirmation(
1496
        for session: NSManagedObject,
1497
        observedAt: Date
1498
    ) -> Bool {
1499
        if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"),
1500
           cooldownUntil > observedAt {
1501
            return false
1502
        }
1503

            
1504
        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
1505
            return false
1506
        }
1507

            
1508
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
1509
            ?? defaultCompletionPercentThreshold
1510

            
1511
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1512
    }
1513

            
1514
    private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
1515
        guard !boolValue(session, key: "requiresCompletionConfirmation") else {
1516
            return
1517
        }
1518

            
1519
        session.setValue(true, forKey: "requiresCompletionConfirmation")
1520
        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
1521
        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
1522
    }
1523

            
1524
    private func clearCompletionConfirmationState(for session: NSManagedObject) {
1525
        session.setValue(false, forKey: "requiresCompletionConfirmation")
1526
        session.setValue(nil, forKey: "completionConfirmationRequestedAt")
1527
        session.setValue(nil, forKey: "completionContradictionPercent")
1528
    }
1529

            
Bogdan Timofte authored a month ago
1530
    private func snapshotDateForManualStop(_ session: NSManagedObject) -> Date {
1531
        if statusValue(session, key: "statusRawValue") == .paused {
1532
            return dateValue(session, key: "pausedAt")
1533
                ?? dateValue(session, key: "lastObservedAt")
1534
                ?? Date()
1535
        }
1536
        return dateValue(session, key: "lastObservedAt") ?? Date()
1537
    }
1538

            
1539
    @discardableResult
1540
    private func maybeCompletePausedSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
1541
        guard statusValue(session, key: "statusRawValue") == .paused else {
1542
            return false
1543
        }
1544

            
1545
        guard let pausedAt = dateValue(session, key: "pausedAt"),
1546
              observedAt.timeIntervalSince(pausedAt) >= pausedSessionTimeout else {
1547
            return false
1548
        }
1549

            
1550
        finishSession(
1551
            session,
1552
            observedAt: pausedAt.addingTimeInterval(pausedSessionTimeout),
1553
            finalBatteryPercent: nil,
1554
            status: .completed
1555
        )
1556

            
1557
        guard saveContext() else {
1558
            return false
1559
        }
1560

            
1561
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1562
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1563
            return saveContext()
1564
        }
1565

            
1566
        return true
1567
    }
1568

            
1569
    private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
1570
        let chargingTransportMode = chargingTransportMode(for: session)
1571
        let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps")
1572
            ?? doubleValue(session, key: "lastObservedCurrentAmps")
1573

            
1574
        guard measuredCurrent > 0 else {
1575
            return nil
1576
        }
1577

            
1578
        let charger = chargingTransportMode == .wireless
1579
            ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
1580
            : nil
1581

            
1582
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
1583
            return nil
1584
        }
1585

            
1586
        let effectiveCurrent = effectiveCurrentAmps(
1587
            fromMeasuredCurrent: measuredCurrent,
1588
            chargingTransportMode: chargingTransportMode,
1589
            charger: charger
1590
        )
1591
        guard effectiveCurrent > 0 else {
1592
            return nil
1593
        }
1594
        return effectiveCurrent
1595
    }
1596

            
1597
    private func finishSession(
1598
        _ session: NSManagedObject,
1599
        observedAt: Date,
1600
        finalBatteryPercent: Double?,
1601
        status: ChargeSessionStatus
1602
    ) {
1603
        if let finalBatteryPercent {
1604
            _ = insertBatteryCheckpoint(
1605
                percent: finalBatteryPercent,
Bogdan Timofte authored a month ago
1606
                flag: .final,
Bogdan Timofte authored a month ago
1607
                timestamp: observedAt,
1608
                to: session
1609
            )
1610
        }
1611

            
1612
        session.setValue(status.rawValue, forKey: "statusRawValue")
1613
        session.setValue(nil, forKey: "pausedAt")
1614
        session.setValue(nil, forKey: "belowThresholdSince")
1615
        session.setValue(observedAt, forKey: "endedAt")
1616
        session.setValue(observedAt, forKey: "lastObservedAt")
1617
        session.setValue(completionCurrentForSessionEnd(session), forKey: "completionCurrentAmps")
1618
        clearCompletionConfirmationState(for: session)
1619
        session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1620
        updateCapacityEstimate(for: session)
1621
        session.setValue(observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1622

            
1623
        if status == .completed {
1624
            maybeTriggerTargetBatteryAlert(
1625
                for: session,
1626
                observedAt: observedAt,
1627
                completionFallbackPercent: defaultCompletionPercentThreshold
1628
            )
1629
        }
Bogdan Timofte authored a month ago
1630
    }
1631

            
Bogdan Timofte authored a month ago
1632
    private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
1633
        guard
1634
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1635
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
1636
            let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice),
1637
            estimatedCapacityWh > 0
1638
        else {
1639
            return nil
1640
        }
1641

            
Bogdan Timofte authored a month ago
1642
        // Compute effective battery energy dynamically so the prediction uses the
1643
        // most current measuredEnergyWh rather than the stale effectiveBatteryEnergyWh
1644
        // (which is only refreshed at session start, checkpoint insertion, and finish).
1645
        let rawMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1646
        let measuredEnergyWh: Double
1647
        switch chargingTransportMode(for: session) {
1648
        case .wired:
1649
            measuredEnergyWh = rawMeasuredEnergyWh
1650
        case .wireless:
1651
            if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 {
1652
                measuredEnergyWh = rawMeasuredEnergyWh * factor
1653
            } else {
1654
                measuredEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1655
                    ?? rawMeasuredEnergyWh
1656
            }
1657
        }
Bogdan Timofte authored a month ago
1658
        let sessionID = stringValue(session, key: "id") ?? ""
1659

            
1660
        struct Anchor {
1661
            let percent: Double
1662
            let energyWh: Double
Bogdan Timofte authored a month ago
1663
            let timestamp: Date
1664
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1665
        }
1666

            
1667
        var anchors: [Anchor] = []
Bogdan Timofte authored a month ago
1668
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1669
           startBatteryPercent >= 0 {
1670
            anchors.append(
1671
                Anchor(
1672
                    percent: startBatteryPercent,
1673
                    energyWh: 0,
Bogdan Timofte authored a month ago
1674
                    timestamp: dateValue(session, key: "trimStart")
1675
                        ?? dateValue(session, key: "startedAt")
1676
                        ?? Date.distantPast,
Bogdan Timofte authored a month ago
1677
                    isCheckpoint: false
1678
                )
1679
            )
Bogdan Timofte authored a month ago
1680
        }
1681

            
1682
        let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
1683
            .compactMap(makeCheckpointSummary(from:))
1684
            .sorted { lhs, rhs in
1685
                if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1686
                    return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1687
                }
1688
                return lhs.timestamp < rhs.timestamp
1689
            }
Bogdan Timofte authored a month ago
1690
            .filter { $0.batteryPercent >= 0 }
1691
            .map {
1692
                Anchor(
1693
                    percent: $0.batteryPercent,
1694
                    energyWh: $0.measuredEnergyWh,
1695
                    timestamp: $0.timestamp,
1696
                    isCheckpoint: true
1697
                )
1698
            }
Bogdan Timofte authored a month ago
1699
        anchors.append(contentsOf: checkpointAnchors)
1700

            
1701
        guard !anchors.isEmpty else {
1702
            return optionalDoubleValue(session, key: "endBatteryPercent")
1703
        }
1704

            
1705
        let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
Bogdan Timofte authored a month ago
1706
        return BatteryLevelPredictionTuning.predictedPercent(
1707
            anchorPercent: anchor.percent,
1708
            anchorEnergyWh: anchor.energyWh,
1709
            anchorTimestamp: anchor.timestamp,
1710
            anchorIsCheckpoint: anchor.isCheckpoint,
1711
            effectiveEnergyWh: measuredEnergyWh,
1712
            referenceTimestamp: dateValue(session, key: "lastObservedAt") ?? anchor.timestamp,
1713
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1714
        )
1715
    }
1716

            
1717
    private func resolvedEstimatedBatteryCapacityWh(
1718
        for session: NSManagedObject,
1719
        chargedDevice: NSManagedObject
1720
    ) -> Double? {
1721
        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
1722
           sessionCapacityEstimate > 0 {
1723
            return sessionCapacityEstimate
1724
        }
1725

            
1726
        switch chargingTransportMode(for: session) {
1727
        case .wired:
1728
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1729
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1730
        case .wireless:
1731
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1732
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1733
        }
1734
    }
1735

            
1736
    private func updateCapacityEstimate(for session: NSManagedObject) {
1737
        guard
1738
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1739
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
1740
        else {
1741
            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
1742
            session.setValue(nil, forKey: "capacityEstimateWh")
1743
            return
1744
        }
1745

            
1746
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1747
        let chargingMode = chargingTransportMode(for: session)
1748
        let wirelessResolution = chargingMode == .wireless
1749
            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
1750
            : nil
1751
        let effectiveBatteryEnergyWh = chargingMode == .wired
1752
            ? measuredEnergyWh
1753
            : wirelessResolution.map { measuredEnergyWh * $0.factor }
1754

            
1755
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
1756
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
1757
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
1758
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
1759

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

            
1762
        guard let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
Bogdan Timofte authored a month ago
1763
            session.setValue(nil, forKey: "capacityEstimateWh")
1764
            return
1765
        }
1766

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

            
1773
        var anchors: [CapacityAnchor] = []
1774

            
1775
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1776
           startBatteryPercent >= 0 {
1777
            anchors.append(
1778
                CapacityAnchor(
1779
                    percent: startBatteryPercent,
1780
                    energyWh: 0,
1781
                    timestamp: dateValue(session, key: "trimStart")
1782
                        ?? dateValue(session, key: "startedAt")
1783
                        ?? Date.distantPast
1784
                )
1785
            )
1786
        }
1787

            
1788
        if let sessionID = stringValue(session, key: "id") {
1789
            anchors.append(
1790
                contentsOf: fetchCheckpointObjects(forSessionID: sessionID).compactMap { checkpoint in
1791
                    guard
1792
                        let percent = optionalDoubleValue(checkpoint, key: "batteryPercent"),
1793
                        percent >= 0,
1794
                        let timestamp = dateValue(checkpoint, key: "timestamp")
1795
                    else {
1796
                        return nil
1797
                    }
1798

            
1799
                    return CapacityAnchor(
1800
                        percent: percent,
1801
                        energyWh: doubleValue(checkpoint, key: "measuredEnergyWh"),
1802
                        timestamp: timestamp
1803
                    )
1804
                }
1805
            )
1806
        }
1807

            
1808
        if let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"),
1809
           endBatteryPercent >= 0 {
1810
            anchors.append(
1811
                CapacityAnchor(
1812
                    percent: endBatteryPercent,
1813
                    energyWh: effectiveBatteryEnergyWh,
1814
                    timestamp: dateValue(session, key: "endedAt")
1815
                        ?? dateValue(session, key: "lastObservedAt")
1816
                        ?? Date.distantPast
1817
                )
1818
            )
1819
        }
1820

            
1821
        let sortedAnchors = anchors.sorted { lhs, rhs in
1822
            if lhs.energyWh != rhs.energyWh {
1823
                return lhs.energyWh < rhs.energyWh
1824
            }
1825
            return lhs.timestamp < rhs.timestamp
1826
        }
1827

            
1828
        guard let firstAnchor = sortedAnchors.first,
1829
              let lastAnchor = sortedAnchors.last else {
Bogdan Timofte authored a month ago
1830
            session.setValue(nil, forKey: "capacityEstimateWh")
1831
            return
1832
        }
1833

            
Bogdan Timofte authored a month ago
1834
        let percentDelta = lastAnchor.percent - firstAnchor.percent
1835
        let energyDelta = lastAnchor.energyWh - firstAnchor.energyWh
Bogdan Timofte authored a month ago
1836

            
Bogdan Timofte authored a month ago
1837
        guard percentDelta >= 20, energyDelta > 0 else {
Bogdan Timofte authored a month ago
1838
            session.setValue(nil, forKey: "capacityEstimateWh")
1839
            return
1840
        }
1841

            
Bogdan Timofte authored a month ago
1842
        if !supportsChargingWhileOff && lastAnchor.percent >= 99.5 {
Bogdan Timofte authored a month ago
1843
            session.setValue(nil, forKey: "capacityEstimateWh")
1844
            return
1845
        }
1846

            
Bogdan Timofte authored a month ago
1847
        let capacityEstimateWh = energyDelta / (percentDelta / 100)
Bogdan Timofte authored a month ago
1848
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
1849
    }
1850

            
1851
    @discardableResult
Bogdan Timofte authored a month ago
1852
    private func insertBatteryCheckpoint(
Bogdan Timofte authored a month ago
1853
        percent: Double,
Bogdan Timofte authored a month ago
1854
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
1855
        timestamp: Date = Date(),
Bogdan Timofte authored a month ago
1856
        measuredEnergyWhOverride: Double? = nil,
1857
        measuredChargeAhOverride: Double? = nil,
Bogdan Timofte authored a month ago
1858
        to session: NSManagedObject
Bogdan Timofte authored a month ago
1859
    ) -> String? {
Bogdan Timofte authored a month ago
1860
        guard
1861
            let sessionID = stringValue(session, key: "id"),
1862
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1863
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
1864
        else {
Bogdan Timofte authored a month ago
1865
            return nil
Bogdan Timofte authored a month ago
1866
        }
1867

            
1868
        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
Bogdan Timofte authored a month ago
1869
        let checkpointEnergyWh = measuredEnergyWhOverride
1870
            ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
Bogdan Timofte authored a month ago
1871
            ?? doubleValue(session, key: "measuredEnergyWh")
Bogdan Timofte authored a month ago
1872
        let checkpointChargeAh = measuredChargeAhOverride
1873
            ?? doubleValue(session, key: "measuredChargeAh")
Bogdan Timofte authored a month ago
1874
        checkpoint.setValue(UUID().uuidString, forKey: "id")
1875
        checkpoint.setValue(sessionID, forKey: "sessionID")
1876
        checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
Bogdan Timofte authored a month ago
1877
        checkpoint.setValue(timestamp, forKey: "timestamp")
Bogdan Timofte authored a month ago
1878
        checkpoint.setValue(percent, forKey: "batteryPercent")
1879
        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
Bogdan Timofte authored a month ago
1880
        checkpoint.setValue(checkpointChargeAh, forKey: "measuredChargeAh")
Bogdan Timofte authored a month ago
1881
        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
1882
        checkpoint.setValue(
1883
            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
1884
            forKey: "voltageVolts"
1885
        )
Bogdan Timofte authored a month ago
1886
        checkpoint.setValue(flag.rawValue, forKey: "label")
Bogdan Timofte authored a month ago
1887
        checkpoint.setValue(timestamp, forKey: "createdAt")
Bogdan Timofte authored a month ago
1888

            
Bogdan Timofte authored a month ago
1889
        let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
1890
        if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
Bogdan Timofte authored a month ago
1891
            session.setValue(percent, forKey: "startBatteryPercent")
1892
        }
Bogdan Timofte authored a month ago
1893
        if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
1894
            session.setValue(percent, forKey: "endBatteryPercent")
1895
        }
Bogdan Timofte authored a month ago
1896
        session.setValue(timestamp, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1897
        updateCapacityEstimate(for: session)
1898

            
Bogdan Timofte authored a month ago
1899
        return chargedDeviceID
1900
    }
1901

            
Bogdan Timofte authored a month ago
1902
    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
1903
        guard let sessionID = stringValue(session, key: "id") else {
1904
            return
1905
        }
1906

            
1907
        let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
1908
        if let latestCheckpoint = remainingCheckpoints.last {
1909
            session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
1910
        } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1911
                  startBatteryPercent >= 0 {
1912
            session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
1913
        } else {
1914
            session.setValue(nil, forKey: "endBatteryPercent")
1915
        }
1916

            
1917
        session.setValue(Date(), forKey: "updatedAt")
1918
        updateCapacityEstimate(for: session)
1919
    }
1920

            
Bogdan Timofte authored a month ago
1921
    @discardableResult
1922
    private func addBatteryCheckpoint(
1923
        percent: Double,
Bogdan Timofte authored a month ago
1924
        measuredEnergyWh: Double? = nil,
1925
        measuredChargeAh: Double? = nil,
Bogdan Timofte authored a month ago
1926
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
1927
        to session: NSManagedObject,
1928
        timestamp: Date = Date()
1929
    ) -> Bool {
Bogdan Timofte authored a month ago
1930
        if let measuredEnergyWh, measuredEnergyWh.isFinite {
1931
            session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
1932
        }
1933
        if let measuredChargeAh, measuredChargeAh.isFinite {
1934
            session.setValue(max(measuredChargeAh, 0), forKey: "measuredChargeAh")
1935
        }
1936

            
Bogdan Timofte authored a month ago
1937
        guard let chargedDeviceID = insertBatteryCheckpoint(
1938
            percent: percent,
Bogdan Timofte authored a month ago
1939
            flag: flag,
Bogdan Timofte authored a month ago
1940
            timestamp: timestamp,
Bogdan Timofte authored a month ago
1941
            measuredEnergyWhOverride: measuredEnergyWh,
1942
            measuredChargeAhOverride: measuredChargeAh,
Bogdan Timofte authored a month ago
1943
            to: session
1944
        ) else {
1945
            return false
1946
        }
1947

            
Bogdan Timofte authored a month ago
1948
        guard saveContext() else {
1949
            return false
1950
        }
1951

            
1952
        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1953
        return saveContext()
1954
    }
1955

            
1956
    private func resolvedWirelessEfficiency(
1957
        for session: NSManagedObject,
1958
        chargedDevice: NSManagedObject
1959
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
1960
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
1961
           storedFactor > 0 {
1962
            return (
1963
                factor: storedFactor,
1964
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
1965
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
1966
            )
1967
        }
1968

            
1969
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
1970
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1971
        guard measuredEnergyWh > 0 else {
1972
            return nil
1973
        }
1974

            
1975
        if chargingProfile == .magsafe,
1976
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
1977
           calibratedFactor > 0 {
1978
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
1979
        }
1980

            
1981
        guard
1982
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1983
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1984
        else {
1985
            return nil
1986
        }
1987

            
1988
        let percentDelta = endBatteryPercent - startBatteryPercent
1989
        guard percentDelta >= 20 else {
1990
            return nil
1991
        }
1992

            
1993
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
Bogdan Timofte authored a month ago
1994
            ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
Bogdan Timofte authored a month ago
1995
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1996
                : nil),
1997
              wiredCapacityWh > 0
1998
        else {
1999
            return nil
2000
        }
2001

            
2002
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
2003
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
2004
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
2005
        let usesEstimated = chargingProfile != .magsafe
2006
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
2007

            
2008
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
2009
    }
2010

            
2011
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
2012
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
2013
            return
2014
        }
2015

            
Bogdan Timofte authored a month ago
2016
        let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
Bogdan Timofte authored a month ago
2017
        let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
2018
        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
2019
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
2020
        let sessions = relevantSessionObjects(
2021
            for: chargedDeviceID,
2022
            deviceClass: deviceClass,
2023
            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
2024
            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
2025
        )
Bogdan Timofte authored a month ago
2026
        let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
Bogdan Timofte authored a month ago
2027
        let wiredMinimumCurrent = derivedMinimumCurrent(
2028
            from: sessions,
2029
            chargingTransportMode: .wired
2030
        )
2031
        let wirelessMinimumCurrent = derivedMinimumCurrent(
2032
            from: sessions,
2033
            chargingTransportMode: .wireless
2034
        )
2035

            
2036
        let wiredCapacity = derivedCapacity(
2037
            from: sessions,
2038
            chargingTransportMode: .wired,
2039
            supportsChargingWhileOff: supportsChargingWhileOff
2040
        )
2041
        let wirelessCapacity = derivedCapacity(
2042
            from: sessions,
2043
            chargingTransportMode: .wireless,
2044
            supportsChargingWhileOff: supportsChargingWhileOff
2045
        )
2046
        let wirelessEfficiency = derivedWirelessEfficiency(
2047
            from: sessions,
2048
            chargingProfile: wirelessProfile
2049
        )
Bogdan Timofte authored a month ago
2050
        let configuredCompletionCurrents = decodedCompletionCurrents(
2051
            from: chargedDevice,
2052
            key: "configuredCompletionCurrentsRawValue"
2053
        )
Bogdan Timofte authored a month ago
2054
        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
2055
        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
2056
        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
2057
        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
2058
        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
2059
        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
2060

            
Bogdan Timofte authored a month ago
2061
        let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
2062
        let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
Bogdan Timofte authored a month ago
2063
        let preferredMinimumCurrent: Double?
2064
        let preferredCapacity: Double?
2065
        switch preferredChargingTransportMode {
2066
        case .wired:
Bogdan Timofte authored a month ago
2067
            preferredMinimumCurrent = configuredCompletionCurrents[
2068
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2069
            ] ?? learnedCompletionCurrents[
2070
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2071
            ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
Bogdan Timofte authored a month ago
2072
            preferredCapacity = wiredCapacity ?? wirelessCapacity
2073
        case .wireless:
Bogdan Timofte authored a month ago
2074
            preferredMinimumCurrent = configuredCompletionCurrents[
2075
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2076
            ] ?? learnedCompletionCurrents[
2077
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2078
            ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
Bogdan Timofte authored a month ago
2079
            preferredCapacity = wirelessCapacity ?? wiredCapacity
2080
        }
2081

            
Bogdan Timofte authored a month ago
2082
        setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
2083
        setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
2084
        setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
2085
        setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
2086
        setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
2087
        setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
2088
        setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
2089
        setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
2090
        setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
2091
        setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
2092
        setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
2093
        setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
2094
        setValue(Date(), on: chargedDevice, key: "updatedAt")
Bogdan Timofte authored a month ago
2095
    }
2096

            
2097
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
2098
        sessions
2099
            .filter { $0.status == .completed }
2100
            .compactMap { session in
2101
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
2102
                let timestamp = session.endedAt ?? session.lastObservedAt
2103
                return CapacityTrendPoint(
2104
                    sessionID: session.id,
2105
                    timestamp: timestamp,
2106
                    capacityWh: capacityEstimateWh,
2107
                    chargingTransportMode: session.chargingTransportMode
2108
                )
2109
            }
2110
            .sorted { $0.timestamp < $1.timestamp }
2111
    }
2112

            
2113
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
2114
        var groupedEnergyByBin: [Int: [Double]] = [:]
2115
        var groupedChargeByBin: [Int: [Double]] = [:]
2116

            
2117
        for session in sessions where session.status == .completed {
Bogdan Timofte authored a month ago
2118
            let anchors = normalizedTypicalCurveAnchors(for: session)
2119
            guard anchors.count >= 2 else {
2120
                continue
Bogdan Timofte authored a month ago
2121
            }
2122

            
Bogdan Timofte authored a month ago
2123
            for percentBin in stride(from: 0, through: 100, by: 10) {
2124
                guard let interpolatedPoint = interpolatedTypicalCurvePoint(
2125
                    for: Double(percentBin),
2126
                    anchors: anchors
2127
                ) else {
2128
                    continue
2129
                }
Bogdan Timofte authored a month ago
2130

            
Bogdan Timofte authored a month ago
2131
                groupedEnergyByBin[percentBin, default: []].append(interpolatedPoint.energyWh)
2132
                groupedChargeByBin[percentBin, default: []].append(interpolatedPoint.chargeAh)
Bogdan Timofte authored a month ago
2133
            }
2134
        }
2135

            
Bogdan Timofte authored a month ago
2136
        let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
Bogdan Timofte authored a month ago
2137
            guard
2138
                let energies = groupedEnergyByBin[percentBin],
2139
                let charges = groupedChargeByBin[percentBin],
2140
                !energies.isEmpty,
2141
                !charges.isEmpty
2142
            else {
2143
                return nil
2144
            }
2145

            
2146
            return TypicalChargeCurvePoint(
2147
                percentBin: percentBin,
2148
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
2149
                averageChargeAh: charges.reduce(0, +) / Double(charges.count),
2150
                sampleCount: min(energies.count, charges.count)
2151
            )
2152
        }
Bogdan Timofte authored a month ago
2153

            
2154
        var runningMaximumEnergyWh = 0.0
2155
        var runningMaximumChargeAh = 0.0
2156

            
2157
        return averagedPoints.map { point in
2158
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
2159
            runningMaximumChargeAh = max(runningMaximumChargeAh, point.averageChargeAh)
2160
            return TypicalChargeCurvePoint(
2161
                percentBin: point.percentBin,
2162
                averageEnergyWh: runningMaximumEnergyWh,
2163
                averageChargeAh: runningMaximumChargeAh,
2164
                sampleCount: point.sampleCount
2165
            )
2166
        }
2167
    }
2168

            
2169
    private func normalizedTypicalCurveAnchors(
2170
        for session: ChargeSessionSummary
2171
    ) -> [(percent: Double, energyWh: Double, chargeAh: Double)] {
2172
        struct Anchor {
2173
            let percent: Double
2174
            let energyWh: Double
2175
            let chargeAh: Double
2176
            let timestamp: Date
2177
        }
2178

            
2179
        var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
2180
            guard checkpoint.batteryPercent.isFinite,
2181
                  checkpoint.measuredEnergyWh.isFinite,
2182
                  checkpoint.measuredChargeAh.isFinite,
2183
                  checkpoint.batteryPercent >= 0,
2184
                  checkpoint.batteryPercent <= 100,
2185
                  checkpoint.measuredEnergyWh >= 0,
2186
                  checkpoint.measuredChargeAh >= 0 else {
2187
                return nil
2188
            }
2189

            
2190
            return Anchor(
2191
                percent: checkpoint.batteryPercent,
2192
                energyWh: checkpoint.measuredEnergyWh,
2193
                chargeAh: checkpoint.measuredChargeAh,
2194
                timestamp: checkpoint.timestamp
2195
            )
2196
        }
2197

            
2198
        if let startBatteryPercent = session.startBatteryPercent,
2199
           startBatteryPercent.isFinite,
2200
           startBatteryPercent >= 0,
2201
           startBatteryPercent <= 100 {
2202
            anchors.append(
2203
                Anchor(
2204
                    percent: startBatteryPercent,
2205
                    energyWh: 0,
2206
                    chargeAh: 0,
2207
                    timestamp: session.startedAt
2208
                )
2209
            )
2210
        }
2211

            
2212
        if let endBatteryPercent = session.endBatteryPercent,
2213
           endBatteryPercent.isFinite,
2214
           endBatteryPercent >= 0,
2215
           endBatteryPercent <= 100 {
2216
            anchors.append(
2217
                Anchor(
2218
                    percent: endBatteryPercent,
2219
                    energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
2220
                    chargeAh: session.measuredChargeAh,
2221
                    timestamp: session.endedAt ?? session.lastObservedAt
2222
                )
2223
            )
2224
        }
2225

            
2226
        let sortedAnchors = anchors.sorted { lhs, rhs in
2227
            if lhs.percent != rhs.percent {
2228
                return lhs.percent < rhs.percent
2229
            }
2230
            if lhs.energyWh != rhs.energyWh {
2231
                return lhs.energyWh < rhs.energyWh
2232
            }
2233
            return lhs.timestamp < rhs.timestamp
2234
        }
2235

            
2236
        var collapsedAnchors: [(percent: Double, energyWh: Double, chargeAh: Double)] = []
2237

            
2238
        for anchor in sortedAnchors {
2239
            if let lastIndex = collapsedAnchors.indices.last,
2240
               abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
2241
                collapsedAnchors[lastIndex] = (
2242
                    percent: collapsedAnchors[lastIndex].percent,
2243
                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh),
2244
                    chargeAh: max(collapsedAnchors[lastIndex].chargeAh, anchor.chargeAh)
2245
                )
2246
            } else {
2247
                collapsedAnchors.append(
2248
                    (percent: anchor.percent, energyWh: anchor.energyWh, chargeAh: anchor.chargeAh)
2249
                )
2250
            }
2251
        }
2252

            
2253
        var runningMaximumEnergyWh = 0.0
2254
        var runningMaximumChargeAh = 0.0
2255

            
2256
        return collapsedAnchors.map { anchor in
2257
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
2258
            runningMaximumChargeAh = max(runningMaximumChargeAh, anchor.chargeAh)
2259
            return (
2260
                percent: anchor.percent,
2261
                energyWh: runningMaximumEnergyWh,
2262
                chargeAh: runningMaximumChargeAh
2263
            )
2264
        }
2265
    }
2266

            
2267
    private func interpolatedTypicalCurvePoint(
2268
        for percent: Double,
2269
        anchors: [(percent: Double, energyWh: Double, chargeAh: Double)]
2270
    ) -> (energyWh: Double, chargeAh: Double)? {
2271
        guard
2272
            let firstAnchor = anchors.first,
2273
            let lastAnchor = anchors.last,
2274
            percent >= firstAnchor.percent,
2275
            percent <= lastAnchor.percent
2276
        else {
2277
            return nil
2278
        }
2279

            
2280
        if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
2281
            return (energyWh: exactAnchor.energyWh, chargeAh: exactAnchor.chargeAh)
2282
        }
2283

            
2284
        guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
2285
              upperIndex > 0 else {
2286
            return nil
2287
        }
2288

            
2289
        let lowerAnchor = anchors[upperIndex - 1]
2290
        let upperAnchor = anchors[upperIndex]
2291
        let span = upperAnchor.percent - lowerAnchor.percent
2292
        guard span > 0.000_1 else {
2293
            return nil
2294
        }
2295

            
2296
        let ratio = (percent - lowerAnchor.percent) / span
2297
        let energyWh = lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
2298
        let chargeAh = lowerAnchor.chargeAh + ((upperAnchor.chargeAh - lowerAnchor.chargeAh) * ratio)
2299
        return (energyWh: energyWh, chargeAh: chargeAh)
Bogdan Timofte authored a month ago
2300
    }
2301

            
2302
    private func makeSessionSummary(
2303
        from object: NSManagedObject,
2304
        checkpoints: [NSManagedObject],
2305
        samples: [NSManagedObject]
2306
    ) -> ChargeSessionSummary? {
2307
        let chargingTransportMode = chargingTransportMode(for: object)
2308

            
2309
        guard
2310
            let id = uuidValue(object, key: "id"),
2311
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2312
            let startedAt = dateValue(object, key: "startedAt"),
2313
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
2314
            let status = statusValue(object, key: "statusRawValue"),
2315
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
2316
        else {
2317
            return nil
2318
        }
2319

            
2320
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
2321
            .sorted { $0.timestamp < $1.timestamp }
2322
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
2323
            .sorted { lhs, rhs in
2324
                if lhs.bucketIndex != rhs.bucketIndex {
2325
                    return lhs.bucketIndex < rhs.bucketIndex
2326
                }
2327
                return lhs.timestamp < rhs.timestamp
2328
            }
2329

            
2330
        return ChargeSessionSummary(
2331
            id: id,
2332
            chargedDeviceID: chargedDeviceID,
2333
            chargerID: uuidValue(object, key: "chargerID"),
2334
            meterMACAddress: stringValue(object, key: "meterMACAddress"),
2335
            meterName: stringValue(object, key: "meterName"),
2336
            meterModel: stringValue(object, key: "meterModel"),
2337
            startedAt: startedAt,
2338
            endedAt: dateValue(object, key: "endedAt"),
2339
            lastObservedAt: lastObservedAt,
Bogdan Timofte authored a month ago
2340
            pausedAt: dateValue(object, key: "pausedAt"),
Bogdan Timofte authored a month ago
2341
            status: status,
2342
            sourceMode: sourceMode,
2343
            chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
2344
            chargingStateMode: chargingStateMode(for: object),
2345
            autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
Bogdan Timofte authored a month ago
2346
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2347
            effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
2348
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
Bogdan Timofte authored a month ago
2349
            meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
2350
            meterChargeBaselineAh: optionalDoubleValue(object, key: "meterChargeBaselineAh"),
Bogdan Timofte authored a month ago
2351
            meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"),
2352
            meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"),
Bogdan Timofte authored a month ago
2353
            minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
2354
            maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
2355
            maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
2356
            maximumObservedVoltageVolts: chargingTransportMode == .wired
2357
                ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
2358
                : nil,
2359
            selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
2360
            completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
2361
            stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
2362
            startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
2363
            endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
2364
            capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
2365
            wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
2366
            usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
2367
            shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
2368
            supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
2369
            usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
2370
            targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
2371
            targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
2372
            requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
2373
            completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
2374
            completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
2375
            selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
Bogdan Timofte authored a month ago
2376
            trimStart: dateValue(object, key: "trimStart"),
2377
            trimEnd: dateValue(object, key: "trimEnd"),
Bogdan Timofte authored a month ago
2378
            checkpoints: checkpointSummaries,
2379
            aggregatedSamples: sampleSummaries
2380
        )
2381
    }
2382

            
2383
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
2384
        guard
2385
            let id = uuidValue(object, key: "id"),
2386
            let sessionID = uuidValue(object, key: "sessionID"),
2387
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2388
            let timestamp = dateValue(object, key: "timestamp")
2389
        else {
2390
            return nil
2391
        }
2392

            
2393
        return ChargeCheckpointSummary(
2394
            id: id,
2395
            sessionID: sessionID,
2396
            chargedDeviceID: chargedDeviceID,
2397
            timestamp: timestamp,
2398
            batteryPercent: doubleValue(object, key: "batteryPercent"),
2399
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2400
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2401
            currentAmps: doubleValue(object, key: "currentAmps"),
2402
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
2403
            label: stringValue(object, key: "label")
2404
        )
2405
    }
2406

            
2407
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
2408
        guard
2409
            let sessionID = uuidValue(object, key: "sessionID"),
2410
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2411
            let timestamp = dateValue(object, key: "timestamp")
2412
        else {
2413
            return nil
2414
        }
2415

            
2416
        return ChargeSessionSampleSummary(
2417
            sessionID: sessionID,
2418
            chargedDeviceID: chargedDeviceID,
2419
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
2420
            timestamp: timestamp,
2421
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
2422
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
2423
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
2424
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2425
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2426
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
2427
        )
2428
    }
2429

            
Bogdan Timofte authored a month ago
2430
    private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2431
        fetchSessionObject(
2432
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2433
                format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
Bogdan Timofte authored a month ago
2434
                normalizedMACAddress(meterMACAddress),
Bogdan Timofte authored a month ago
2435
                ChargeSessionStatus.active.rawValue,
2436
                ChargeSessionStatus.paused.rawValue
Bogdan Timofte authored a month ago
2437
            )
2438
        )
2439
    }
2440

            
Bogdan Timofte authored a month ago
2441
    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2442
        fetchSessionObject(
2443
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2444
                format: "meterMACAddress == %@ AND statusRawValue == %@",
2445
                normalizedMACAddress(meterMACAddress),
2446
                ChargeSessionStatus.active.rawValue
Bogdan Timofte authored a month ago
2447
            )
2448
        )
2449
    }
2450

            
2451
    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
2452
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2453
        request.predicate = predicate
2454
        request.fetchLimit = 1
2455
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
2456
        return (try? context.fetch(request))?.first
2457
    }
2458

            
2459
    private func fetchSessionObject(id: String) -> NSManagedObject? {
2460
        fetchSessionObject(
2461
            predicate: NSPredicate(format: "id == %@", id)
2462
        )
2463
    }
2464

            
2465
    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
2466
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2467
        request.predicate = NSPredicate(
2468
            format: "sessionID == %@ AND bucketIndex == %d",
2469
            sessionID,
2470
            bucketIndex
2471
        )
2472
        request.fetchLimit = 1
2473
        return (try? context.fetch(request))?.first
2474
    }
2475

            
2476
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2477
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2478
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2479
        return (try? context.fetch(request)) ?? []
2480
    }
2481

            
Bogdan Timofte authored a month ago
2482
    private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
2483
        guard !sessionIDs.isEmpty else {
2484
            return []
2485
        }
2486

            
2487
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2488
        request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs)
2489
        return (try? context.fetch(request)) ?? []
2490
    }
2491

            
Bogdan Timofte authored a month ago
2492
    private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
2493
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2494
        request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
2495
        request.fetchLimit = 1
2496
        return (try? context.fetch(request))?.first
2497
    }
2498

            
Bogdan Timofte authored a month ago
2499
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2500
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2501
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2502
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
2503
        return (try? context.fetch(request)) ?? []
2504
    }
2505

            
2506
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
2507
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2508
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
2509
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2510
        return (try? context.fetch(request)) ?? []
2511
    }
2512

            
2513
    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
2514
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2515
        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
2516
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2517
        return (try? context.fetch(request)) ?? []
2518
    }
2519

            
Bogdan Timofte authored a month ago
2520
    private func sampleBackedSessionIDs(
2521
        devices: [NSManagedObject],
2522
        sessionsByDeviceID: [String: [NSManagedObject]],
2523
        sessionsByChargerID: [String: [NSManagedObject]]
2524
    ) -> Set<String> {
2525
        var sessionIDs: Set<String> = []
2526

            
2527
        for device in devices {
2528
            guard
2529
                let deviceID = stringValue(device, key: "id"),
2530
                let rawClass = stringValue(device, key: "deviceClassRawValue"),
2531
                let deviceClass = ChargedDeviceClass(rawValue: rawClass)
2532
            else {
2533
                continue
2534
            }
2535

            
2536
            let relevantSessions = relevantSessionObjects(
2537
                for: deviceID,
2538
                deviceClass: deviceClass,
2539
                sessionsByDeviceID: sessionsByDeviceID,
2540
                sessionsByChargerID: sessionsByChargerID
2541
            )
2542
            .sorted { lhs, rhs in
2543
                let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed
2544
                let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed
2545

            
2546
                if lhsStatus.isOpen && !rhsStatus.isOpen {
2547
                    return true
2548
                }
2549
                if !lhsStatus.isOpen && rhsStatus.isOpen {
2550
                    return false
2551
                }
2552

            
2553
                return (dateValue(lhs, key: "startedAt") ?? .distantPast)
2554
                    > (dateValue(rhs, key: "startedAt") ?? .distantPast)
2555
            }
2556

            
2557
            var recentCompletedSamplesIncluded = 0
2558

            
2559
            for session in relevantSessions {
2560
                guard let sessionID = stringValue(session, key: "id"),
2561
                      let status = statusValue(session, key: "statusRawValue") else {
2562
                    continue
2563
                }
2564

            
2565
                if status.isOpen {
2566
                    sessionIDs.insert(sessionID)
2567
                    continue
2568
                }
2569

            
2570
                guard recentCompletedSamplesIncluded < 2 else {
2571
                    continue
2572
                }
2573

            
2574
                sessionIDs.insert(sessionID)
2575
                recentCompletedSamplesIncluded += 1
2576
            }
2577
        }
2578

            
2579
        return sessionIDs
2580
    }
2581

            
Bogdan Timofte authored a month ago
2582
    private func relevantSessionObjects(
2583
        for chargedDeviceID: String,
2584
        deviceClass: ChargedDeviceClass,
2585
        sessionsByDeviceID: [String: [NSManagedObject]],
2586
        sessionsByChargerID: [String: [NSManagedObject]]
2587
    ) -> [NSManagedObject] {
2588
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
2589
        guard deviceClass == .charger else {
2590
            return directSessions
2591
        }
2592

            
2593
        var seenSessionIDs = Set<String>()
2594
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
2595
            .filter { session in
2596
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
2597
                return seenSessionIDs.insert(sessionID).inserted
2598
            }
2599
            .sorted {
2600
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
2601
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
2602
                return lhsDate < rhsDate
2603
            }
2604
    }
2605

            
2606
    private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
2607
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false)
2608
    }
2609

            
2610
    private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
2611
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true)
2612
    }
2613

            
2614
    private func resolvedAssignedObject(
2615
        for meterMACAddress: String,
2616
        expectsChargerClass: Bool
2617
    ) -> NSManagedObject? {
2618
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
2619
        guard !normalizedMAC.isEmpty else { return nil }
2620

            
2621
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2622
        request.predicate = NSPredicate(format: "lastAssociatedMeterMAC == %@", normalizedMAC)
2623
        request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
2624
        let matches = (try? context.fetch(request)) ?? []
2625
        return matches.first { object in
Bogdan Timofte authored a month ago
2626
            isChargerObject(object) == expectsChargerClass
Bogdan Timofte authored a month ago
2627
        }
2628
    }
2629

            
Bogdan Timofte authored a month ago
2630
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
2631
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2632
    }
2633

            
Bogdan Timofte authored a month ago
2634
    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
2635
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2636
        request.predicate = NSPredicate(format: "id == %@", id)
2637
        request.fetchLimit = 1
2638
        return (try? context.fetch(request))?.first
2639
    }
2640

            
2641
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
2642
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
2643
        return (try? context.fetch(request)) ?? []
2644
    }
2645

            
2646
    private func resolvedStopThreshold(
2647
        for chargedDevice: NSManagedObject,
2648
        chargingTransportMode: ChargingTransportMode,
Bogdan Timofte authored a month ago
2649
        chargingStateMode: ChargingStateMode,
2650
        charger: NSManagedObject?,
2651
        fallback: Double?
2652
    ) -> Double? {
2653
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
2654
            return nil
2655
        }
2656

            
2657
        let sessionKind = ChargeSessionKind(
2658
            chargingTransportMode: chargingTransportMode,
2659
            chargingStateMode: chargingStateMode
2660
        )
2661
        let configuredCurrents = decodedCompletionCurrents(
2662
            from: chargedDevice,
2663
            key: "configuredCompletionCurrentsRawValue"
2664
        )
2665
        let learnedCurrents = decodedCompletionCurrents(
2666
            from: chargedDevice,
2667
            key: "learnedCompletionCurrentsRawValue"
2668
        )
2669
        let legacyCurrent: Double?
Bogdan Timofte authored a month ago
2670
        switch chargingTransportMode {
2671
        case .wired:
Bogdan Timofte authored a month ago
2672
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2673
                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
2674
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2675
        case .wireless:
Bogdan Timofte authored a month ago
2676
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2677
                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
2678
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2679
        }
Bogdan Timofte authored a month ago
2680

            
2681
        let resolvedCurrent = configuredCurrents[sessionKind]
2682
            ?? learnedCurrents[sessionKind]
2683
            ?? legacyCurrent
2684
            ?? fallback
2685
        guard let resolvedCurrent, resolvedCurrent > 0 else {
2686
            return nil
2687
        }
2688
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
2689
    }
2690

            
Bogdan Timofte authored a month ago
2691
    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
Bogdan Timofte authored a month ago
2692
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
2693
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
2694
        return resolvedPreferredChargingTransportMode(
Bogdan Timofte authored a month ago
2695
            .wired,
Bogdan Timofte authored a month ago
2696
            supportsWiredCharging: supportsWiredCharging,
2697
            supportsWirelessCharging: supportsWirelessCharging
2698
        )
2699
    }
2700

            
Bogdan Timofte authored a month ago
2701
    private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
2702
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other
2703
    }
2704

            
2705
    private func normalizedTemplateID(
2706
        _ templateID: String?,
2707
        kind: ChargedDeviceKind
2708
    ) -> String? {
2709
        guard let templateID,
2710
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2711
              templateDefinition.kind == kind else {
2712
            return nil
Bogdan Timofte authored a month ago
2713
        }
Bogdan Timofte authored a month ago
2714
        return templateDefinition.id
Bogdan Timofte authored a month ago
2715
    }
2716

            
Bogdan Timofte authored a month ago
2717
    private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
2718
        guard let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
2719
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2720
              templateDefinition.kind == deviceClass(for: chargedDevice).kind else {
2721
            return nil
Bogdan Timofte authored a month ago
2722
        }
Bogdan Timofte authored a month ago
2723
        return templateDefinition
2724
    }
2725

            
2726
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
2727
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2728
            ? true
2729
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2730
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2731
            ? false
2732
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2733
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2734
            supportsWiredCharging: persistedWiredCharging,
2735
            supportsWirelessCharging: persistedWirelessCharging
2736
        ).wired
2737
    }
2738

            
2739
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
2740
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2741
            ? true
2742
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2743
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2744
            ? false
2745
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2746
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2747
            supportsWiredCharging: persistedWiredCharging,
2748
            supportsWirelessCharging: persistedWirelessCharging
2749
        ).wireless
2750
    }
2751

            
2752
    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
2753
        let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue")
2754
            .flatMap(ChargingStateAvailability.init(rawValue:))
2755
            ?? ChargingStateAvailability.fallback(
Bogdan Timofte authored a month ago
2756
            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
2757
        )
Bogdan Timofte authored a month ago
2758
        return deviceClass(for: chargedDevice).normalizedChargingStateAvailability(persistedAvailability)
Bogdan Timofte authored a month ago
2759
    }
2760

            
2761
    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
Bogdan Timofte authored a month ago
2762
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2763
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2764
            let persistedChargingStateMode = stringValue(session, key: "chargingStateRawValue")
2765
                .flatMap(ChargingStateMode.init(rawValue:))
2766
                ?? .on
2767
            return resolvedChargingStateMode(
2768
                persistedChargingStateMode,
2769
                availability: chargingStateAvailability(for: chargedDevice)
2770
            )
2771
        }
2772

            
Bogdan Timofte authored a month ago
2773
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
2774
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
2775
            return chargingStateMode
2776
        }
2777

            
2778
        return .on
2779
    }
2780

            
2781
    private func resolvedChargingStateMode(
2782
        _ chargingStateMode: ChargingStateMode,
2783
        availability: ChargingStateAvailability
2784
    ) -> ChargingStateMode {
2785
        if availability.supportedModes.contains(chargingStateMode) {
2786
            return chargingStateMode
2787
        }
2788
        return availability.supportedModes.first ?? .on
2789
    }
2790

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

            
2795
        // Primary: chargerTypeRawValue (set on v13+)
2796
        if let rawValue = stringValue(chargedDevice, key: "chargerTypeRawValue"),
2797
           let type = ChargerType(rawValue: rawValue) {
2798
            return type
2799
        }
2800

            
2801
        // Migration fallback: derive from old deviceTemplateID
2802
        switch stringValue(chargedDevice, key: "deviceTemplateID") {
2803
        case "apple-magsafe-charger": return .appleMagSafe
2804
        case "apple-watch-charger": return .appleWatch
2805
        default: break
2806
        }
2807

            
2808
        // Last resort: derive from wirelessChargingProfileRawValue
2809
        if let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2810
           let profile = WirelessChargingProfile(rawValue: rawValue),
2811
           profile == .magsafe {
2812
            return .genericMagSafe
2813
        }
2814

            
2815
        return .genericQi
2816
    }
2817

            
Bogdan Timofte authored a month ago
2818
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
Bogdan Timofte authored a month ago
2819
        if let type = chargerType(for: chargedDevice) {
2820
            return type.wirelessChargingProfile
2821
        }
Bogdan Timofte authored a month ago
2822
        guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2823
              let profile = WirelessChargingProfile(rawValue: rawValue) else {
2824
            return .genericQi
2825
        }
2826
        return profile
2827
    }
2828

            
2829
    private func resolvedPreferredChargingTransportMode(
2830
        _ preferredChargingTransportMode: ChargingTransportMode,
2831
        supportsWiredCharging: Bool,
2832
        supportsWirelessCharging: Bool
2833
    ) -> ChargingTransportMode {
2834
        switch preferredChargingTransportMode {
2835
        case .wired where supportsWiredCharging:
2836
            return .wired
2837
        case .wireless where supportsWirelessCharging:
2838
            return .wireless
2839
        default:
2840
            if supportsWiredCharging {
2841
                return .wired
2842
            }
2843
            if supportsWirelessCharging {
2844
                return .wireless
2845
            }
2846
            return .wired
2847
        }
2848
    }
2849

            
Bogdan Timofte authored a month ago
2850
    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
2851
        let payload = Dictionary(
2852
            uniqueKeysWithValues: currents.map { key, value in
2853
                (key.rawValue, value)
2854
            }
2855
        )
2856
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
2857
            return nil
2858
        }
2859
        return String(data: data, encoding: .utf8)
2860
    }
2861

            
2862
    private func decodedCompletionCurrents(
2863
        from object: NSManagedObject,
2864
        key: String
2865
    ) -> [ChargeSessionKind: Double] {
2866
        guard let rawValue = stringValue(object, key: key),
2867
              let data = rawValue.data(using: .utf8),
2868
              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
2869
            return [:]
2870
        }
2871

            
2872
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2873
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
2874
                return
2875
            }
2876
            result[sessionKind] = entry.value
2877
        }
2878
    }
2879

            
2880
    private func legacyConfiguredCompletionCurrent(
2881
        for currents: [ChargeSessionKind: Double],
2882
        chargingTransportMode: ChargingTransportMode
2883
    ) -> Double? {
2884
        let candidates = currents
2885
            .filter { $0.key.chargingTransportMode == chargingTransportMode }
2886
            .sorted { lhs, rhs in
2887
                lhs.key.rawValue < rhs.key.rawValue
2888
            }
2889
            .map(\.value)
2890
        return candidates.first
2891
    }
2892

            
2893
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
2894
        guard let charger else {
2895
            return nil
2896
        }
2897
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
2898
        guard let idleCurrent, idleCurrent >= 0 else {
2899
            return nil
2900
        }
2901
        return idleCurrent
2902
    }
2903

            
2904
    private func effectiveCurrentAmps(
2905
        fromMeasuredCurrent currentAmps: Double,
2906
        chargingTransportMode: ChargingTransportMode,
2907
        charger: NSManagedObject?
2908
    ) -> Double {
2909
        switch chargingTransportMode {
2910
        case .wired:
2911
            return max(currentAmps, 0)
2912
        case .wireless:
2913
            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
2914
                return max(currentAmps, 0)
2915
            }
2916
            return max(currentAmps - idleCurrent, 0)
2917
        }
2918
    }
2919

            
2920
    private func hasObservedChargeFlow(
2921
        currentAmps: Double,
2922
        chargingTransportMode: ChargingTransportMode,
2923
        charger: NSManagedObject?,
2924
        stopThreshold: Double?
2925
    ) -> Bool {
2926
        let effectiveCurrent = effectiveCurrentAmps(
2927
            fromMeasuredCurrent: currentAmps,
2928
            chargingTransportMode: chargingTransportMode,
2929
            charger: charger
2930
        )
2931
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
2932
    }
2933

            
Bogdan Timofte authored a month ago
2934
    private func derivedMinimumCurrent(
2935
        from sessions: [NSManagedObject],
2936
        chargingTransportMode: ChargingTransportMode
2937
    ) -> Double? {
2938
        let completionCurrents = sessions.compactMap { session -> Double? in
2939
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2940
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
2941
                return nil
2942
            }
Bogdan Timofte authored a month ago
2943
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2944
                return nil
2945
            }
Bogdan Timofte authored a month ago
2946
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
2947
                return nil
2948
            }
2949
            return completionCurrent
2950
        }
2951

            
2952
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
2953
        guard !recentCompletionCurrents.isEmpty else { return nil }
2954
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
2955
    }
2956

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

            
2960
        for session in sessions {
2961
            guard statusValue(session, key: "statusRawValue") == .completed else {
2962
                continue
2963
            }
2964
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2965
                continue
2966
            }
2967
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
2968
                  completionCurrent > 0 else {
2969
                continue
2970
            }
2971

            
2972
            let sessionKind = ChargeSessionKind(
2973
                chargingTransportMode: chargingTransportMode(for: session),
2974
                chargingStateMode: chargingStateMode(for: session)
2975
            )
2976
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
2977
        }
2978

            
2979
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2980
            let recentCurrents = Array(entry.value.suffix(5))
2981
            guard !recentCurrents.isEmpty else {
2982
                return
2983
            }
2984
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
2985
        }
2986
    }
2987

            
Bogdan Timofte authored a month ago
2988
    private func derivedCapacity(
2989
        from sessions: [NSManagedObject],
2990
        chargingTransportMode: ChargingTransportMode,
2991
        supportsChargingWhileOff: Bool
2992
    ) -> Double? {
2993
        let capacityCandidates = sessions.compactMap { session -> Double? in
2994
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2995
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
2996
                return nil
2997
            }
2998
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
2999
                return nil
3000
            }
3001
            if supportsChargingWhileOff {
3002
                return capacityEstimate
3003
            }
3004
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
3005
                return nil
3006
            }
3007
            return capacityEstimate
3008
        }
3009

            
3010
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
3011
        guard !recentCapacityCandidates.isEmpty else { return nil }
3012
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
3013
    }
3014

            
3015
    private func derivedWirelessEfficiency(
3016
        from sessions: [NSManagedObject],
3017
        chargingProfile: WirelessChargingProfile
3018
    ) -> Double? {
3019
        guard chargingProfile == .magsafe else {
3020
            return nil
3021
        }
3022

            
3023
        let candidates = sessions.compactMap { session -> Double? in
3024
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3025
            guard chargingTransportMode(for: session) == .wireless else { return nil }
3026
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
3027
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3028
                return nil
3029
            }
3030
            return factor
3031
        }
3032

            
3033
        let recentCandidates = Array(candidates.suffix(6))
3034
        guard !recentCandidates.isEmpty else { return nil }
3035
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3036
    }
3037

            
3038
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
3039
        let candidates = sessions.compactMap { session -> Double? in
3040
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3041
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
3042
                return nil
3043
            }
3044
            return (sourceVoltage * 10).rounded() / 10
3045
        }
3046

            
3047
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
3048
        return counts.keys.sorted()
3049
    }
3050

            
3051
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
3052
        let candidates = sessions.compactMap { session -> Double? in
3053
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3054
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
3055
                return nil
3056
            }
3057
            return minimumObservedCurrent
3058
        }
3059

            
3060
        let recentCandidates = Array(candidates.suffix(6))
3061
        guard !recentCandidates.isEmpty else { return nil }
3062
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3063
    }
3064

            
3065
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
3066
        let candidates = sessions.compactMap { session -> Double? in
3067
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3068
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3069
                return nil
3070
            }
3071
            return factor
3072
        }
3073

            
3074
        let recentCandidates = Array(candidates.suffix(6))
3075
        guard !recentCandidates.isEmpty else { return nil }
3076
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3077
    }
3078

            
3079
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
3080
        sessions.compactMap { session -> Double? in
3081
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3082
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
3083
                return nil
3084
            }
3085
            return maximumObservedPower
3086
        }
3087
        .max()
3088
    }
3089

            
3090
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
3091
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
3092
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
3093
            return resolvedPreferredChargingTransportMode(
3094
                chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired,
3095
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
3096
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
3097
            )
3098
        }
3099

            
3100
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
3101
            return persistedChargingTransportMode
Bogdan Timofte authored a month ago
3102
        }
3103

            
3104
        return .wired
3105
    }
3106

            
3107
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
3108
        if session.isInserted {
3109
            return .created
3110
        }
3111

            
3112
        let committedValues = session.committedValues(
3113
            forKeys: [
3114
                "statusRawValue",
3115
                "updatedAt",
3116
                "targetBatteryAlertTriggeredAt",
3117
                "requiresCompletionConfirmation"
3118
            ]
3119
        )
3120
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
3121
        let currentStatus = statusValue(session, key: "statusRawValue")
3122
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
3123
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
3124
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
3125
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
3126
            ?? false
3127
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
3128

            
3129
        if currentStatus == .completed, committedStatus != .completed {
3130
            return .completed
3131
        }
3132

            
Bogdan Timofte authored a month ago
3133
        if currentStatus != committedStatus {
3134
            return .event
3135
        }
3136

            
Bogdan Timofte authored a month ago
3137
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
3138
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
3139
            return .event
3140
        }
3141

            
3142
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3143
            ?? dateValue(session, key: "createdAt")
3144
            ?? observedAt
3145

            
3146
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
3147
            return .periodic
3148
        }
3149

            
3150
        return .none
3151
    }
3152

            
Bogdan Timofte authored a month ago
3153
    private func shouldPersistAggregatedSample(
3154
        _ sample: NSManagedObject,
3155
        observedAt: Date
3156
    ) -> Bool {
3157
        if sample.isInserted {
3158
            return true
3159
        }
3160

            
3161
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
3162
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3163
            ?? dateValue(sample, key: "createdAt")
3164
            ?? observedAt
3165

            
3166
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
3167
    }
3168

            
Bogdan Timofte authored a month ago
3169
    private func generateQRIdentifier() -> String {
3170
        "device:\(UUID().uuidString)"
3171
    }
3172

            
3173
    @discardableResult
3174
    private func saveContext() -> Bool {
3175
        guard context.hasChanges else { return true }
3176
        do {
3177
            try context.save()
3178
            return true
3179
        } catch {
3180
            track("Failed saving charge insights context: \(error)")
3181
            context.rollback()
3182
            return false
3183
        }
3184
    }
3185

            
3186
    private func normalizedText(_ text: String) -> String {
3187
        text.trimmingCharacters(in: .whitespacesAndNewlines)
3188
    }
3189

            
3190
    private func normalizedOptionalText(_ text: String?) -> String? {
3191
        guard let text else { return nil }
3192
        let normalized = normalizedText(text)
3193
        return normalized.isEmpty ? nil : normalized
3194
    }
3195

            
3196
    private func normalizedMACAddress(_ macAddress: String) -> String {
3197
        normalizedText(macAddress).uppercased()
3198
    }
3199

            
Bogdan Timofte authored a month ago
3200
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
3201
        guard object.entity.propertiesByName[key] != nil else {
3202
            return nil
3203
        }
3204
        return object.value(forKey: key)
3205
    }
3206

            
3207
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
3208
        guard object.entity.propertiesByName[key] != nil else {
3209
            return
3210
        }
3211
        object.setValue(value, forKey: key)
3212
    }
3213

            
Bogdan Timofte authored a month ago
3214
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
3215
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
3216
        let normalized = normalizedOptionalText(value)
3217
        return normalized
3218
    }
3219

            
3220
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
3221
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
3222
    }
3223

            
3224
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
3225
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
3226
            return value
3227
        }
Bogdan Timofte authored a month ago
3228
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3229
            return value.doubleValue
3230
        }
3231
        return 0
3232
    }
3233

            
3234
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
3235
        let value = rawValue(object, key: key)
3236
        if value == nil {
Bogdan Timofte authored a month ago
3237
            return nil
3238
        }
3239
        return doubleValue(object, key: key)
3240
    }
3241

            
3242
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
Bogdan Timofte authored a month ago
3243
        if let value = rawValue(object, key: key) as? Int16 {
Bogdan Timofte authored a month ago
3244
            return value
3245
        }
Bogdan Timofte authored a month ago
3246
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3247
            return value.int16Value
3248
        }
3249
        return nil
3250
    }
3251

            
3252
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
3253
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
3254
            return value
3255
        }
Bogdan Timofte authored a month ago
3256
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3257
            return value.int32Value
3258
        }
3259
        return nil
3260
    }
3261

            
3262
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
Bogdan Timofte authored a month ago
3263
        if let value = rawValue(object, key: key) as? Bool {
Bogdan Timofte authored a month ago
3264
            return value
3265
        }
Bogdan Timofte authored a month ago
3266
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3267
            return value.boolValue
3268
        }
3269
        return false
3270
    }
3271

            
3272
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
3273
        guard let value = stringValue(object, key: key) else { return nil }
3274
        return UUID(uuidString: value)
3275
    }
3276

            
3277
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
3278
        guard let value = stringValue(object, key: key) else { return nil }
3279
        return ChargeSessionStatus(rawValue: value)
3280
    }
3281

            
3282
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
3283
        guard let value = stringValue(object, key: key) else { return nil }
3284
        return ChargingTransportMode(rawValue: value)
3285
    }
3286

            
3287
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
3288
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
3289
            return []
3290
        }
3291
        return rawValue
3292
            .split(separator: ",")
3293
            .compactMap { Double($0) }
3294
            .sorted()
3295
    }
3296

            
3297
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
3298
        let uniqueVoltages = Array(Set(voltages)).sorted()
3299
        guard !uniqueVoltages.isEmpty else {
3300
            return nil
3301
        }
3302
        return uniqueVoltages
3303
            .map { String(format: "%.1f", $0) }
3304
            .joined(separator: ",")
3305
    }
3306

            
3307
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
3308
        guard currentCount > 0 else {
3309
            return newValue
3310
        }
3311
        let total = (currentAverage * Double(currentCount)) + newValue
3312
        return total / Double(currentCount + 1)
3313
    }
3314
}
3315

            
3316
private enum ObservationSaveReason {
3317
    case none
3318
    case created
3319
    case periodic
3320
    case completed
3321
    case event
3322
}