USB-Meter / USB Meter / Model / ChargeInsightsStore.swift
Newer Older
3315 lines | 144.928kb
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
557
        context.performAndWait {
558
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
559
                return
560
            }
561

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
744
    @discardableResult
745
    func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
746
        var didSave = false
747
        context.performAndWait {
748
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
749
                return
750
            }
751

            
752
            let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast
753
            let sessionEnd   = dateValue(session, key: "endedAt")
754
                ?? dateValue(session, key: "lastObservedAt")
755
                ?? Date.distantFuture
756

            
757
            let effectiveStart = min(max(start ?? sessionStart, sessionStart), sessionEnd)
758
            let effectiveEnd   = max(min(end ?? sessionEnd, sessionEnd), effectiveStart)
759
            let persistedStart = effectiveStart == sessionStart ? nil : effectiveStart
760
            let persistedEnd   = effectiveEnd == sessionEnd ? nil : effectiveEnd
761

            
762
            let allSamples = fetchSessionSampleObjects(forSessionID: sessionID.uuidString)
763
                .compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in
764
                    guard let ts = dateValue(obj, key: "timestamp") else { return nil }
765
                    return (
766
                        timestamp: ts,
767
                        energy: doubleValue(obj, key: "measuredEnergyWh"),
768
                        charge: doubleValue(obj, key: "measuredChargeAh")
769
                    )
770
                }
771
                .sorted { $0.timestamp < $1.timestamp }
772

            
773
            // Each sample stores cumulative energy since session start.
774
            // Trimmed energy = value at trimEnd  -  value just before trimStart.
775
            let baselineSample = allSamples.last { $0.timestamp <= effectiveStart }
776
            let endSample      = allSamples.last { $0.timestamp <= effectiveEnd }
777
            let baselineEnergy = baselineSample?.energy ?? 0
778
            let baselineCharge = baselineSample?.charge ?? 0
779

            
780
            if let endSample {
781
                let trimmedEnergy  = max(endSample.energy - baselineEnergy, 0)
782
                let trimmedCharge  = max(endSample.charge - baselineCharge, 0)
783
                session.setValue(trimmedEnergy, forKey: "measuredEnergyWh")
784
                session.setValue(trimmedCharge, forKey: "measuredChargeAh")
785
            } else {
786
                session.setValue(0, forKey: "measuredEnergyWh")
787
                session.setValue(0, forKey: "measuredChargeAh")
788
            }
789

            
790
            session.setValue(persistedStart, forKey: "trimStart")
791
            session.setValue(persistedEnd,   forKey: "trimEnd")
792
            session.setValue(Date(), forKey: "updatedAt")
793

            
794
            let checkpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
795
            for checkpoint in checkpoints {
796
                guard let timestamp = dateValue(checkpoint, key: "timestamp") else { continue }
797

            
798
                if timestamp < effectiveStart || timestamp > effectiveEnd {
799
                    context.delete(checkpoint)
800
                    continue
801
                }
802

            
803
                let checkpointSample = allSamples.last { $0.timestamp <= timestamp }
804
                let rebasedEnergy = max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0)
805
                let rebasedCharge = max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0)
806
                checkpoint.setValue(rebasedEnergy, forKey: "measuredEnergyWh")
807
                checkpoint.setValue(rebasedCharge, forKey: "measuredChargeAh")
808
            }
809

            
810
            let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
811
                .sorted {
812
                    (dateValue($0, key: "timestamp") ?? .distantPast)
813
                        < (dateValue($1, key: "timestamp") ?? .distantPast)
814
                }
815
            let restoredInitialCheckpoint = remainingCheckpoints.first { checkpoint in
816
                let label = stringValue(checkpoint, key: "label")
817
                let timestamp = dateValue(checkpoint, key: "timestamp") ?? .distantFuture
818
                return ChargeCheckpointFlag.fromStoredLabel(label) == .initial || timestamp <= effectiveStart
819
            }
820

            
821
            if persistedStart == nil {
822
                if let restoredInitialCheckpoint,
823
                   let percent = optionalDoubleValue(restoredInitialCheckpoint, key: "batteryPercent"),
824
                   percent >= 0 {
825
                    session.setValue(percent, forKey: "startBatteryPercent")
826
                }
827
            } else {
828
                session.setValue(nil, forKey: "startBatteryPercent")
829
            }
830

            
831
            refreshCheckpointDerivedValues(for: session)
832

            
833
            if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
834
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
835
            }
836

            
837
            didSave = saveContext()
838
        }
839
        return didSave
840
    }
841

            
Bogdan Timofte authored a month ago
842
    @discardableResult
843
    func deleteChargeSession(id sessionID: UUID) -> Bool {
844
        var didSave = false
845
        context.performAndWait {
846
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
847
                return
848
            }
849

            
850
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
851

            
852
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
853
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
854
            context.delete(session)
855

            
856
            guard saveContext() else {
857
                return
858
            }
859

            
860
            if let chargedDeviceID {
861
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
862
                didSave = saveContext()
863
            } else {
864
                didSave = true
865
            }
866
        }
867
        return didSave
868
    }
869

            
870
    @discardableResult
871
    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
872
        var didSave = false
873

            
874
        context.performAndWait {
875
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
876
                return
877
            }
878

            
879
            let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
880
            let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
881
            let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
882

            
883
            var impactedChargedDeviceIDs = Set<String>()
884

            
885
            for session in deviceSessions {
886
                if let impactedID = stringValue(session, key: "chargedDeviceID") {
887
                    impactedChargedDeviceIDs.insert(impactedID)
888
                }
889
                if let impactedChargerID = stringValue(session, key: "chargerID") {
890
                    impactedChargedDeviceIDs.insert(impactedChargerID)
891
                }
892
                if let sessionID = stringValue(session, key: "id") {
893
                    fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete)
894
                    fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete)
895
                }
896
                context.delete(session)
897
            }
898

            
899
            if deviceClass == .charger {
900
                for session in linkedWirelessSessions {
901
                    guard stringValue(session, key: "chargedDeviceID") != chargedDeviceID.uuidString else {
902
                        continue
903
                    }
904
                    if let impactedID = stringValue(session, key: "chargedDeviceID") {
905
                        impactedChargedDeviceIDs.insert(impactedID)
906
                    }
907
                    session.setValue(nil, forKey: "chargerID")
908
                    session.setValue(Date(), forKey: "updatedAt")
909
                }
910
            }
911

            
912
            context.delete(chargedDevice)
913

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

            
918
            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
919
            for impactedID in impactedChargedDeviceIDs {
920
                refreshDerivedMetrics(forChargedDeviceID: impactedID)
921
            }
922
            didSave = saveContext()
923
        }
924

            
925
        return didSave
926
    }
927

            
928
    @discardableResult
929
    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
930
        var didSave = false
931

            
932
        context.performAndWait {
Bogdan Timofte authored a month ago
933
            guard let session = fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress),
934
                  let resolvedDevice = stringValue(session, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:)) else {
935
                return
936
            }
Bogdan Timofte authored a month ago
937

            
Bogdan Timofte authored a month ago
938
            if statusValue(session, key: "statusRawValue") == .paused {
939
                if maybeCompletePausedSession(session, observedAt: snapshot.observedAt) {
940
                    didSave = true
941
                }
Bogdan Timofte authored a month ago
942
                return
943
            }
944

            
Bogdan Timofte authored a month ago
945
            let chargingTransportMode = self.chargingTransportMode(for: session)
946
            let chargingStateMode = self.chargingStateMode(for: session)
Bogdan Timofte authored a month ago
947
            let charger = chargingTransportMode == .wireless
Bogdan Timofte authored a month ago
948
                ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
949
                : nil
950
            guard chargingTransportMode == .wired || charger != nil else {
951
                return
952
            }
953
            let stopThreshold = resolvedStopThreshold(
954
                for: resolvedDevice,
955
                chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
956
                chargingStateMode: chargingStateMode,
957
                charger: charger,
958
                fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
Bogdan Timofte authored a month ago
959
            )
960

            
Bogdan Timofte authored a month ago
961
            update(session: session, with: snapshot, stopThreshold: stopThreshold, charger: charger)
Bogdan Timofte authored a month ago
962
            let aggregatedSample = updateAggregatedSample(session: session, with: snapshot)
Bogdan Timofte authored a month ago
963

            
964
            let saveReason = saveReason(for: session, observedAt: snapshot.observedAt)
Bogdan Timofte authored a month ago
965
            let shouldPersistAggregatedCurve = aggregatedSample.map {
966
                shouldPersistAggregatedSample($0, observedAt: snapshot.observedAt)
967
            } ?? false
968

            
969
            guard saveReason != .none || shouldPersistAggregatedCurve else {
Bogdan Timofte authored a month ago
970
                return
971
            }
972

            
973
            session.setValue(snapshot.observedAt, forKey: "updatedAt")
974

            
975
            if saveContext() {
976
                if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
977
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
978
                    didSave = saveContext()
979
                } else {
980
                    didSave = true
981
                }
982
            }
983
        }
984

            
985
        return didSave
986
    }
987

            
988
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
989
        var summaries: [ChargedDeviceSummary] = []
990

            
991
        context.performAndWait {
992
            let devices = fetchObjects(entityName: EntityName.chargedDevice)
993
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
994
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
995

            
996
            let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
997
            let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
998
            let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
Bogdan Timofte authored a month ago
999
            let sampleBackedSessionIDs = sampleBackedSessionIDs(
1000
                devices: devices,
1001
                sessionsByDeviceID: sessionsByDeviceID,
1002
                sessionsByChargerID: sessionsByChargerID
1003
            )
1004
            let samplesBySessionID = Dictionary(
1005
                grouping: fetchSessionSampleObjects(forSessionIDs: Array(sampleBackedSessionIDs))
1006
            ) { stringValue($0, key: "sessionID") ?? "" }
Bogdan Timofte authored a month ago
1007

            
1008
            summaries = devices.compactMap { device in
1009
                guard
1010
                    let id = uuidValue(device, key: "id"),
1011
                    let name = stringValue(device, key: "name"),
1012
                    let qrIdentifier = stringValue(device, key: "qrIdentifier"),
1013
                    let rawClass = stringValue(device, key: "deviceClassRawValue"),
1014
                    let deviceClass = ChargedDeviceClass(rawValue: rawClass)
1015
                else {
1016
                    return nil
1017
                }
1018

            
Bogdan Timofte authored a month ago
1019
                let chargingStateAvailability = chargingStateAvailability(for: device)
1020
                let supportsWiredCharging = supportsWiredCharging(for: device)
1021
                let supportsWirelessCharging = supportsWirelessCharging(for: device)
1022
                let templateDefinition = templateDefinition(for: device)
1023

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

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

            
1106
        return summaries
1107
    }
1108

            
1109
    func resolvedChargedDeviceSummary(forMeterMACAddress meterMACAddress: String) -> ChargedDeviceSummary? {
1110
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1111
        guard !normalizedMAC.isEmpty else { return nil }
1112

            
1113
        let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
1114

            
1115
        if let activeMatch = summaries.first(where: { summary in
1116
            summary.activeSession?.meterMACAddress == normalizedMAC
1117
        }) {
1118
            return activeMatch
1119
        }
1120

            
1121
        return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
1122
    }
1123

            
1124
    func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
1125
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1126
        guard !normalizedMAC.isEmpty else { return nil }
1127

            
Bogdan Timofte authored a month ago
1128
        var summary: ChargeSessionSummary?
1129

            
1130
        context.performAndWait {
Bogdan Timofte authored a month ago
1131
            guard let session = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
Bogdan Timofte authored a month ago
1132
                  let sessionID = stringValue(session, key: "id") else {
1133
                return
1134
            }
1135

            
1136
            summary = makeSessionSummary(
1137
                from: session,
1138
                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
1139
                samples: fetchSessionSampleObjects(forSessionID: sessionID)
1140
            )
1141
        }
1142

            
1143
        return summary
Bogdan Timofte authored a month ago
1144
    }
1145

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

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

            
1224
        chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
1225
        chargedDevice.setValue(now, forKey: "updatedAt")
1226
        return session
1227
    }
1228

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

            
1244
        if let lastObservedAt {
1245
            let deltaSeconds = max(snapshot.observedAt.timeIntervalSince(lastObservedAt), 0)
1246
            if deltaSeconds > 0, deltaSeconds <= maximumLiveIntegrationGap {
1247
                measuredEnergyWh += max(previousPower, 0) * deltaSeconds / 3600
1248
                measuredChargeAh += max(previousCurrent, 0) * deltaSeconds / 3600
1249
                if sourceMode == .offline {
1250
                    sourceMode = .blended
1251
                }
1252
            }
1253
        }
1254

            
1255
        if let counterGroup = snapshot.selectedDataGroup,
1256
           let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
1257
           UInt8(storedGroup) != counterGroup {
1258
            session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
1259
            session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1260
            session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1261
        }
1262

            
1263
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1264
            let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
1265
            let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
1266
            if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
1267
                session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
1268
            }
1269

            
1270
            if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
1271
                let offlineEnergy = meterEnergyCounterWh - baselineEnergy
Bogdan Timofte authored a month ago
1272
                measuredEnergyWh = max(offlineEnergy, 0)
Bogdan Timofte authored a month ago
1273
                usedOfflineMeterCounters = true
Bogdan Timofte authored a month ago
1274
                sourceMode = .offline
Bogdan Timofte authored a month ago
1275
            } else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
1276
                let delta = meterEnergyCounterWh - lastEnergy
1277
                if delta > 0 {
1278
                    measuredEnergyWh += delta
1279
                    usedOfflineMeterCounters = true
1280
                    sourceMode = .blended
1281
                }
1282
            }
1283
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1284
        }
1285

            
1286
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1287
            let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
1288
            let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
1289
            if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
1290
                session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
1291
            }
1292

            
1293
            if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
1294
                let offlineCharge = meterChargeCounterAh - baselineCharge
Bogdan Timofte authored a month ago
1295
                measuredChargeAh = max(offlineCharge, 0)
Bogdan Timofte authored a month ago
1296
                usedOfflineMeterCounters = true
1297
            } else if let lastCharge, meterChargeCounterAh > lastCharge {
1298
                let delta = meterChargeCounterAh - lastCharge
1299
                if delta > 0 {
1300
                    measuredChargeAh += delta
1301
                    usedOfflineMeterCounters = true
1302
                }
1303
            }
1304
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1305
        }
1306

            
Bogdan Timofte authored a month ago
1307
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1308
            let baselineDuration = optionalDoubleValue(session, key: "meterDurationBaselineSeconds") ?? meterRecordingDurationSeconds
1309
            if optionalDoubleValue(session, key: "meterDurationBaselineSeconds") == nil {
1310
                setValue(baselineDuration, on: session, key: "meterDurationBaselineSeconds")
1311
            }
1312
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1313
        }
1314

            
Bogdan Timofte authored a month ago
1315
        let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps")
1316
        let updatedMinimum: Double
1317
        if snapshot.currentAmps > 0 {
1318
            updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps)
1319
        } else {
1320
            updatedMinimum = existingMinimum ?? 0
1321
        }
1322

            
Bogdan Timofte authored a month ago
1323
        let effectiveCurrent = effectiveCurrentAmps(
1324
            fromMeasuredCurrent: snapshot.currentAmps,
1325
            chargingTransportMode: sessionChargingTransportMode,
1326
            charger: charger
1327
        )
1328
        let observedChargeFlow = boolValue(session, key: "hasObservedChargeFlow")
1329
            || hasObservedChargeFlow(
1330
                currentAmps: snapshot.currentAmps,
1331
                chargingTransportMode: sessionChargingTransportMode,
1332
                charger: charger,
1333
                stopThreshold: stopThreshold
1334
            )
1335

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

            
Bogdan Timofte authored a month ago
1366
        guard boolValue(session, key: "autoStopEnabled"), let stopThreshold, stopThreshold > 0, observedChargeFlow else {
1367
            session.setValue(nil, forKey: "belowThresholdSince")
1368
            clearCompletionConfirmationState(for: session)
1369
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1370
            return
1371
        }
1372

            
1373
        if effectiveCurrent <= stopThreshold {
Bogdan Timofte authored a month ago
1374
            let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt
1375
            session.setValue(belowThresholdSince, forKey: "belowThresholdSince")
1376
            if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
1377
                if boolValue(session, key: "requiresCompletionConfirmation") {
1378
                    // Leave the session active until the user explicitly confirms or charging resumes.
1379
                    return
1380
                }
1381

            
1382
                if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
1383
                    requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt)
1384
                } else {
Bogdan Timofte authored a month ago
1385
                    finishSession(
1386
                        session,
1387
                        observedAt: snapshot.observedAt,
1388
                        finalBatteryPercent: nil,
1389
                        status: .completed
1390
                    )
Bogdan Timofte authored a month ago
1391
                }
1392
            }
1393
        } else {
1394
            session.setValue(nil, forKey: "belowThresholdSince")
1395
            clearCompletionConfirmationState(for: session)
1396
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1397
        }
1398
    }
1399

            
1400
    private func updateAggregatedSample(
1401
        session: NSManagedObject,
1402
        with snapshot: ChargingMonitorSnapshot
Bogdan Timofte authored a month ago
1403
    ) -> NSManagedObject? {
Bogdan Timofte authored a month ago
1404
        guard
1405
            let sessionID = stringValue(session, key: "id"),
1406
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1407
            let startedAt = dateValue(session, key: "startedAt"),
1408
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context)
1409
        else {
Bogdan Timofte authored a month ago
1410
            return nil
Bogdan Timofte authored a month ago
1411
        }
1412

            
1413
        let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0)
1414
        let bucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
1415
        let bucketStart = startedAt.addingTimeInterval(Double(bucketIndex) * Self.aggregatedSampleBucketDuration)
1416
        let bucketIdentifier = "\(sessionID)-\(bucketIndex)"
1417
        let sample = fetchSessionSampleObject(sessionID: sessionID, bucketIndex: bucketIndex)
1418
            ?? NSManagedObject(entity: entity, insertInto: context)
1419
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1420
        let sampleVoltage = sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil
1421

            
1422
        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
1423
        let updatedCount = existingCount + 1
1424

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

            
Bogdan Timofte authored a month ago
1464
    private func maybeTriggerTargetBatteryAlert(
1465
        for session: NSManagedObject,
1466
        observedAt: Date,
1467
        completionFallbackPercent: Double? = nil
1468
    ) {
Bogdan Timofte authored a month ago
1469
        guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
1470
            return
1471
        }
1472

            
1473
        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
1474
            return
1475
        }
1476

            
1477
        let predictedBatteryPercent = predictedBatteryPercent(for: session)
1478
            ?? optionalDoubleValue(session, key: "endBatteryPercent")
Bogdan Timofte authored a month ago
1479
            ?? completionFallbackPercent
Bogdan Timofte authored a month ago
1480

            
1481
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
1482
            return
1483
        }
1484

            
1485
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
1486
    }
1487

            
1488
    private func shouldRequireCompletionConfirmation(
1489
        for session: NSManagedObject,
1490
        observedAt: Date
1491
    ) -> Bool {
1492
        if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"),
1493
           cooldownUntil > observedAt {
1494
            return false
1495
        }
1496

            
1497
        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
1498
            return false
1499
        }
1500

            
1501
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
1502
            ?? defaultCompletionPercentThreshold
1503

            
1504
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1505
    }
1506

            
1507
    private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
1508
        guard !boolValue(session, key: "requiresCompletionConfirmation") else {
1509
            return
1510
        }
1511

            
1512
        session.setValue(true, forKey: "requiresCompletionConfirmation")
1513
        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
1514
        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
1515
    }
1516

            
1517
    private func clearCompletionConfirmationState(for session: NSManagedObject) {
1518
        session.setValue(false, forKey: "requiresCompletionConfirmation")
1519
        session.setValue(nil, forKey: "completionConfirmationRequestedAt")
1520
        session.setValue(nil, forKey: "completionContradictionPercent")
1521
    }
1522

            
Bogdan Timofte authored a month ago
1523
    private func snapshotDateForManualStop(_ session: NSManagedObject) -> Date {
1524
        if statusValue(session, key: "statusRawValue") == .paused {
1525
            return dateValue(session, key: "pausedAt")
1526
                ?? dateValue(session, key: "lastObservedAt")
1527
                ?? Date()
1528
        }
1529
        return dateValue(session, key: "lastObservedAt") ?? Date()
1530
    }
1531

            
1532
    @discardableResult
1533
    private func maybeCompletePausedSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
1534
        guard statusValue(session, key: "statusRawValue") == .paused else {
1535
            return false
1536
        }
1537

            
1538
        guard let pausedAt = dateValue(session, key: "pausedAt"),
1539
              observedAt.timeIntervalSince(pausedAt) >= pausedSessionTimeout else {
1540
            return false
1541
        }
1542

            
1543
        finishSession(
1544
            session,
1545
            observedAt: pausedAt.addingTimeInterval(pausedSessionTimeout),
1546
            finalBatteryPercent: nil,
1547
            status: .completed
1548
        )
1549

            
1550
        guard saveContext() else {
1551
            return false
1552
        }
1553

            
1554
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1555
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1556
            return saveContext()
1557
        }
1558

            
1559
        return true
1560
    }
1561

            
1562
    private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
1563
        let chargingTransportMode = chargingTransportMode(for: session)
1564
        let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps")
1565
            ?? doubleValue(session, key: "lastObservedCurrentAmps")
1566

            
1567
        guard measuredCurrent > 0 else {
1568
            return nil
1569
        }
1570

            
1571
        let charger = chargingTransportMode == .wireless
1572
            ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
1573
            : nil
1574

            
1575
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
1576
            return nil
1577
        }
1578

            
1579
        let effectiveCurrent = effectiveCurrentAmps(
1580
            fromMeasuredCurrent: measuredCurrent,
1581
            chargingTransportMode: chargingTransportMode,
1582
            charger: charger
1583
        )
1584
        guard effectiveCurrent > 0 else {
1585
            return nil
1586
        }
1587
        return effectiveCurrent
1588
    }
1589

            
1590
    private func finishSession(
1591
        _ session: NSManagedObject,
1592
        observedAt: Date,
1593
        finalBatteryPercent: Double?,
1594
        status: ChargeSessionStatus
1595
    ) {
1596
        if let finalBatteryPercent {
1597
            _ = insertBatteryCheckpoint(
1598
                percent: finalBatteryPercent,
Bogdan Timofte authored a month ago
1599
                flag: .final,
Bogdan Timofte authored a month ago
1600
                timestamp: observedAt,
1601
                to: session
1602
            )
1603
        }
1604

            
1605
        session.setValue(status.rawValue, forKey: "statusRawValue")
1606
        session.setValue(nil, forKey: "pausedAt")
1607
        session.setValue(nil, forKey: "belowThresholdSince")
1608
        session.setValue(observedAt, forKey: "endedAt")
1609
        session.setValue(observedAt, forKey: "lastObservedAt")
1610
        session.setValue(completionCurrentForSessionEnd(session), forKey: "completionCurrentAmps")
1611
        clearCompletionConfirmationState(for: session)
1612
        session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1613
        updateCapacityEstimate(for: session)
1614
        session.setValue(observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1615

            
1616
        if status == .completed {
1617
            maybeTriggerTargetBatteryAlert(
1618
                for: session,
1619
                observedAt: observedAt,
1620
                completionFallbackPercent: defaultCompletionPercentThreshold
1621
            )
1622
        }
Bogdan Timofte authored a month ago
1623
    }
1624

            
Bogdan Timofte authored a month ago
1625
    private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
1626
        guard
1627
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1628
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
1629
            let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice),
1630
            estimatedCapacityWh > 0
1631
        else {
1632
            return nil
1633
        }
1634

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

            
1653
        struct Anchor {
1654
            let percent: Double
1655
            let energyWh: Double
Bogdan Timofte authored a month ago
1656
            let timestamp: Date
1657
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1658
        }
1659

            
1660
        var anchors: [Anchor] = []
Bogdan Timofte authored a month ago
1661
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1662
           startBatteryPercent >= 0 {
1663
            anchors.append(
1664
                Anchor(
1665
                    percent: startBatteryPercent,
1666
                    energyWh: 0,
Bogdan Timofte authored a month ago
1667
                    timestamp: dateValue(session, key: "trimStart")
1668
                        ?? dateValue(session, key: "startedAt")
1669
                        ?? Date.distantPast,
Bogdan Timofte authored a month ago
1670
                    isCheckpoint: false
1671
                )
1672
            )
Bogdan Timofte authored a month ago
1673
        }
1674

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

            
1694
        guard !anchors.isEmpty else {
1695
            return optionalDoubleValue(session, key: "endBatteryPercent")
1696
        }
1697

            
1698
        let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
Bogdan Timofte authored a month ago
1699
        return BatteryLevelPredictionTuning.predictedPercent(
1700
            anchorPercent: anchor.percent,
1701
            anchorEnergyWh: anchor.energyWh,
1702
            anchorTimestamp: anchor.timestamp,
1703
            anchorIsCheckpoint: anchor.isCheckpoint,
1704
            effectiveEnergyWh: measuredEnergyWh,
1705
            referenceTimestamp: dateValue(session, key: "lastObservedAt") ?? anchor.timestamp,
1706
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1707
        )
1708
    }
1709

            
1710
    private func resolvedEstimatedBatteryCapacityWh(
1711
        for session: NSManagedObject,
1712
        chargedDevice: NSManagedObject
1713
    ) -> Double? {
1714
        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
1715
           sessionCapacityEstimate > 0 {
1716
            return sessionCapacityEstimate
1717
        }
1718

            
1719
        switch chargingTransportMode(for: session) {
1720
        case .wired:
1721
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1722
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1723
        case .wireless:
1724
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1725
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1726
        }
1727
    }
1728

            
1729
    private func updateCapacityEstimate(for session: NSManagedObject) {
1730
        guard
1731
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1732
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
1733
        else {
1734
            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
1735
            session.setValue(nil, forKey: "capacityEstimateWh")
1736
            return
1737
        }
1738

            
1739
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1740
        let chargingMode = chargingTransportMode(for: session)
1741
        let wirelessResolution = chargingMode == .wireless
1742
            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
1743
            : nil
1744
        let effectiveBatteryEnergyWh = chargingMode == .wired
1745
            ? measuredEnergyWh
1746
            : wirelessResolution.map { measuredEnergyWh * $0.factor }
1747

            
1748
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
1749
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
1750
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
1751
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
1752

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

            
1755
        guard let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
Bogdan Timofte authored a month ago
1756
            session.setValue(nil, forKey: "capacityEstimateWh")
1757
            return
1758
        }
1759

            
Bogdan Timofte authored a month ago
1760
        struct CapacityAnchor {
1761
            let percent: Double
1762
            let energyWh: Double
1763
            let timestamp: Date
1764
        }
1765

            
1766
        var anchors: [CapacityAnchor] = []
1767

            
1768
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1769
           startBatteryPercent >= 0 {
1770
            anchors.append(
1771
                CapacityAnchor(
1772
                    percent: startBatteryPercent,
1773
                    energyWh: 0,
1774
                    timestamp: dateValue(session, key: "trimStart")
1775
                        ?? dateValue(session, key: "startedAt")
1776
                        ?? Date.distantPast
1777
                )
1778
            )
1779
        }
1780

            
1781
        if let sessionID = stringValue(session, key: "id") {
1782
            anchors.append(
1783
                contentsOf: fetchCheckpointObjects(forSessionID: sessionID).compactMap { checkpoint in
1784
                    guard
1785
                        let percent = optionalDoubleValue(checkpoint, key: "batteryPercent"),
1786
                        percent >= 0,
1787
                        let timestamp = dateValue(checkpoint, key: "timestamp")
1788
                    else {
1789
                        return nil
1790
                    }
1791

            
1792
                    return CapacityAnchor(
1793
                        percent: percent,
1794
                        energyWh: doubleValue(checkpoint, key: "measuredEnergyWh"),
1795
                        timestamp: timestamp
1796
                    )
1797
                }
1798
            )
1799
        }
1800

            
1801
        if let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"),
1802
           endBatteryPercent >= 0 {
1803
            anchors.append(
1804
                CapacityAnchor(
1805
                    percent: endBatteryPercent,
1806
                    energyWh: effectiveBatteryEnergyWh,
1807
                    timestamp: dateValue(session, key: "endedAt")
1808
                        ?? dateValue(session, key: "lastObservedAt")
1809
                        ?? Date.distantPast
1810
                )
1811
            )
1812
        }
1813

            
1814
        let sortedAnchors = anchors.sorted { lhs, rhs in
1815
            if lhs.energyWh != rhs.energyWh {
1816
                return lhs.energyWh < rhs.energyWh
1817
            }
1818
            return lhs.timestamp < rhs.timestamp
1819
        }
1820

            
1821
        guard let firstAnchor = sortedAnchors.first,
1822
              let lastAnchor = sortedAnchors.last else {
Bogdan Timofte authored a month ago
1823
            session.setValue(nil, forKey: "capacityEstimateWh")
1824
            return
1825
        }
1826

            
Bogdan Timofte authored a month ago
1827
        let percentDelta = lastAnchor.percent - firstAnchor.percent
1828
        let energyDelta = lastAnchor.energyWh - firstAnchor.energyWh
Bogdan Timofte authored a month ago
1829

            
Bogdan Timofte authored a month ago
1830
        guard percentDelta >= 20, energyDelta > 0 else {
Bogdan Timofte authored a month ago
1831
            session.setValue(nil, forKey: "capacityEstimateWh")
1832
            return
1833
        }
1834

            
Bogdan Timofte authored a month ago
1835
        if !supportsChargingWhileOff && lastAnchor.percent >= 99.5 {
Bogdan Timofte authored a month ago
1836
            session.setValue(nil, forKey: "capacityEstimateWh")
1837
            return
1838
        }
1839

            
Bogdan Timofte authored a month ago
1840
        let capacityEstimateWh = energyDelta / (percentDelta / 100)
Bogdan Timofte authored a month ago
1841
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
1842
    }
1843

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

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

            
Bogdan Timofte authored a month ago
1882
        let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
1883
        if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
Bogdan Timofte authored a month ago
1884
            session.setValue(percent, forKey: "startBatteryPercent")
1885
        }
Bogdan Timofte authored a month ago
1886
        if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
1887
            session.setValue(percent, forKey: "endBatteryPercent")
1888
        }
Bogdan Timofte authored a month ago
1889
        session.setValue(timestamp, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1890
        updateCapacityEstimate(for: session)
1891

            
Bogdan Timofte authored a month ago
1892
        return chargedDeviceID
1893
    }
1894

            
Bogdan Timofte authored a month ago
1895
    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
1896
        guard let sessionID = stringValue(session, key: "id") else {
1897
            return
1898
        }
1899

            
1900
        let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
1901
        if let latestCheckpoint = remainingCheckpoints.last {
1902
            session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
1903
        } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1904
                  startBatteryPercent >= 0 {
1905
            session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
1906
        } else {
1907
            session.setValue(nil, forKey: "endBatteryPercent")
1908
        }
1909

            
1910
        session.setValue(Date(), forKey: "updatedAt")
1911
        updateCapacityEstimate(for: session)
1912
    }
1913

            
Bogdan Timofte authored a month ago
1914
    @discardableResult
1915
    private func addBatteryCheckpoint(
1916
        percent: Double,
Bogdan Timofte authored a month ago
1917
        measuredEnergyWh: Double? = nil,
1918
        measuredChargeAh: Double? = nil,
Bogdan Timofte authored a month ago
1919
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
1920
        to session: NSManagedObject,
1921
        timestamp: Date = Date()
1922
    ) -> Bool {
Bogdan Timofte authored a month ago
1923
        if let measuredEnergyWh, measuredEnergyWh.isFinite {
1924
            session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
1925
        }
1926
        if let measuredChargeAh, measuredChargeAh.isFinite {
1927
            session.setValue(max(measuredChargeAh, 0), forKey: "measuredChargeAh")
1928
        }
1929

            
Bogdan Timofte authored a month ago
1930
        guard let chargedDeviceID = insertBatteryCheckpoint(
1931
            percent: percent,
Bogdan Timofte authored a month ago
1932
            flag: flag,
Bogdan Timofte authored a month ago
1933
            timestamp: timestamp,
Bogdan Timofte authored a month ago
1934
            measuredEnergyWhOverride: measuredEnergyWh,
1935
            measuredChargeAhOverride: measuredChargeAh,
Bogdan Timofte authored a month ago
1936
            to: session
1937
        ) else {
1938
            return false
1939
        }
1940

            
Bogdan Timofte authored a month ago
1941
        guard saveContext() else {
1942
            return false
1943
        }
1944

            
1945
        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1946
        return saveContext()
1947
    }
1948

            
1949
    private func resolvedWirelessEfficiency(
1950
        for session: NSManagedObject,
1951
        chargedDevice: NSManagedObject
1952
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
1953
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
1954
           storedFactor > 0 {
1955
            return (
1956
                factor: storedFactor,
1957
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
1958
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
1959
            )
1960
        }
1961

            
1962
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
1963
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1964
        guard measuredEnergyWh > 0 else {
1965
            return nil
1966
        }
1967

            
1968
        if chargingProfile == .magsafe,
1969
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
1970
           calibratedFactor > 0 {
1971
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
1972
        }
1973

            
1974
        guard
1975
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1976
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1977
        else {
1978
            return nil
1979
        }
1980

            
1981
        let percentDelta = endBatteryPercent - startBatteryPercent
1982
        guard percentDelta >= 20 else {
1983
            return nil
1984
        }
1985

            
1986
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
Bogdan Timofte authored a month ago
1987
            ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
Bogdan Timofte authored a month ago
1988
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1989
                : nil),
1990
              wiredCapacityWh > 0
1991
        else {
1992
            return nil
1993
        }
1994

            
1995
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
1996
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
1997
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
1998
        let usesEstimated = chargingProfile != .magsafe
1999
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
2000

            
2001
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
2002
    }
2003

            
2004
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
2005
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
2006
            return
2007
        }
2008

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

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

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

            
Bogdan Timofte authored a month ago
2075
        setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
2076
        setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
2077
        setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
2078
        setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
2079
        setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
2080
        setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
2081
        setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
2082
        setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
2083
        setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
2084
        setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
2085
        setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
2086
        setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
2087
        setValue(Date(), on: chargedDevice, key: "updatedAt")
Bogdan Timofte authored a month ago
2088
    }
2089

            
2090
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
2091
        sessions
2092
            .filter { $0.status == .completed }
2093
            .compactMap { session in
2094
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
2095
                let timestamp = session.endedAt ?? session.lastObservedAt
2096
                return CapacityTrendPoint(
2097
                    sessionID: session.id,
2098
                    timestamp: timestamp,
2099
                    capacityWh: capacityEstimateWh,
2100
                    chargingTransportMode: session.chargingTransportMode
2101
                )
2102
            }
2103
            .sorted { $0.timestamp < $1.timestamp }
2104
    }
2105

            
2106
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
2107
        var groupedEnergyByBin: [Int: [Double]] = [:]
2108
        var groupedChargeByBin: [Int: [Double]] = [:]
2109

            
2110
        for session in sessions where session.status == .completed {
Bogdan Timofte authored a month ago
2111
            let anchors = normalizedTypicalCurveAnchors(for: session)
2112
            guard anchors.count >= 2 else {
2113
                continue
Bogdan Timofte authored a month ago
2114
            }
2115

            
Bogdan Timofte authored a month ago
2116
            for percentBin in stride(from: 0, through: 100, by: 10) {
2117
                guard let interpolatedPoint = interpolatedTypicalCurvePoint(
2118
                    for: Double(percentBin),
2119
                    anchors: anchors
2120
                ) else {
2121
                    continue
2122
                }
Bogdan Timofte authored a month ago
2123

            
Bogdan Timofte authored a month ago
2124
                groupedEnergyByBin[percentBin, default: []].append(interpolatedPoint.energyWh)
2125
                groupedChargeByBin[percentBin, default: []].append(interpolatedPoint.chargeAh)
Bogdan Timofte authored a month ago
2126
            }
2127
        }
2128

            
Bogdan Timofte authored a month ago
2129
        let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
Bogdan Timofte authored a month ago
2130
            guard
2131
                let energies = groupedEnergyByBin[percentBin],
2132
                let charges = groupedChargeByBin[percentBin],
2133
                !energies.isEmpty,
2134
                !charges.isEmpty
2135
            else {
2136
                return nil
2137
            }
2138

            
2139
            return TypicalChargeCurvePoint(
2140
                percentBin: percentBin,
2141
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
2142
                averageChargeAh: charges.reduce(0, +) / Double(charges.count),
2143
                sampleCount: min(energies.count, charges.count)
2144
            )
2145
        }
Bogdan Timofte authored a month ago
2146

            
2147
        var runningMaximumEnergyWh = 0.0
2148
        var runningMaximumChargeAh = 0.0
2149

            
2150
        return averagedPoints.map { point in
2151
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
2152
            runningMaximumChargeAh = max(runningMaximumChargeAh, point.averageChargeAh)
2153
            return TypicalChargeCurvePoint(
2154
                percentBin: point.percentBin,
2155
                averageEnergyWh: runningMaximumEnergyWh,
2156
                averageChargeAh: runningMaximumChargeAh,
2157
                sampleCount: point.sampleCount
2158
            )
2159
        }
2160
    }
2161

            
2162
    private func normalizedTypicalCurveAnchors(
2163
        for session: ChargeSessionSummary
2164
    ) -> [(percent: Double, energyWh: Double, chargeAh: Double)] {
2165
        struct Anchor {
2166
            let percent: Double
2167
            let energyWh: Double
2168
            let chargeAh: Double
2169
            let timestamp: Date
2170
        }
2171

            
2172
        var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
2173
            guard checkpoint.batteryPercent.isFinite,
2174
                  checkpoint.measuredEnergyWh.isFinite,
2175
                  checkpoint.measuredChargeAh.isFinite,
2176
                  checkpoint.batteryPercent >= 0,
2177
                  checkpoint.batteryPercent <= 100,
2178
                  checkpoint.measuredEnergyWh >= 0,
2179
                  checkpoint.measuredChargeAh >= 0 else {
2180
                return nil
2181
            }
2182

            
2183
            return Anchor(
2184
                percent: checkpoint.batteryPercent,
2185
                energyWh: checkpoint.measuredEnergyWh,
2186
                chargeAh: checkpoint.measuredChargeAh,
2187
                timestamp: checkpoint.timestamp
2188
            )
2189
        }
2190

            
2191
        if let startBatteryPercent = session.startBatteryPercent,
2192
           startBatteryPercent.isFinite,
2193
           startBatteryPercent >= 0,
2194
           startBatteryPercent <= 100 {
2195
            anchors.append(
2196
                Anchor(
2197
                    percent: startBatteryPercent,
2198
                    energyWh: 0,
2199
                    chargeAh: 0,
2200
                    timestamp: session.startedAt
2201
                )
2202
            )
2203
        }
2204

            
2205
        if let endBatteryPercent = session.endBatteryPercent,
2206
           endBatteryPercent.isFinite,
2207
           endBatteryPercent >= 0,
2208
           endBatteryPercent <= 100 {
2209
            anchors.append(
2210
                Anchor(
2211
                    percent: endBatteryPercent,
2212
                    energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
2213
                    chargeAh: session.measuredChargeAh,
2214
                    timestamp: session.endedAt ?? session.lastObservedAt
2215
                )
2216
            )
2217
        }
2218

            
2219
        let sortedAnchors = anchors.sorted { lhs, rhs in
2220
            if lhs.percent != rhs.percent {
2221
                return lhs.percent < rhs.percent
2222
            }
2223
            if lhs.energyWh != rhs.energyWh {
2224
                return lhs.energyWh < rhs.energyWh
2225
            }
2226
            return lhs.timestamp < rhs.timestamp
2227
        }
2228

            
2229
        var collapsedAnchors: [(percent: Double, energyWh: Double, chargeAh: Double)] = []
2230

            
2231
        for anchor in sortedAnchors {
2232
            if let lastIndex = collapsedAnchors.indices.last,
2233
               abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
2234
                collapsedAnchors[lastIndex] = (
2235
                    percent: collapsedAnchors[lastIndex].percent,
2236
                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh),
2237
                    chargeAh: max(collapsedAnchors[lastIndex].chargeAh, anchor.chargeAh)
2238
                )
2239
            } else {
2240
                collapsedAnchors.append(
2241
                    (percent: anchor.percent, energyWh: anchor.energyWh, chargeAh: anchor.chargeAh)
2242
                )
2243
            }
2244
        }
2245

            
2246
        var runningMaximumEnergyWh = 0.0
2247
        var runningMaximumChargeAh = 0.0
2248

            
2249
        return collapsedAnchors.map { anchor in
2250
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
2251
            runningMaximumChargeAh = max(runningMaximumChargeAh, anchor.chargeAh)
2252
            return (
2253
                percent: anchor.percent,
2254
                energyWh: runningMaximumEnergyWh,
2255
                chargeAh: runningMaximumChargeAh
2256
            )
2257
        }
2258
    }
2259

            
2260
    private func interpolatedTypicalCurvePoint(
2261
        for percent: Double,
2262
        anchors: [(percent: Double, energyWh: Double, chargeAh: Double)]
2263
    ) -> (energyWh: Double, chargeAh: Double)? {
2264
        guard
2265
            let firstAnchor = anchors.first,
2266
            let lastAnchor = anchors.last,
2267
            percent >= firstAnchor.percent,
2268
            percent <= lastAnchor.percent
2269
        else {
2270
            return nil
2271
        }
2272

            
2273
        if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
2274
            return (energyWh: exactAnchor.energyWh, chargeAh: exactAnchor.chargeAh)
2275
        }
2276

            
2277
        guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
2278
              upperIndex > 0 else {
2279
            return nil
2280
        }
2281

            
2282
        let lowerAnchor = anchors[upperIndex - 1]
2283
        let upperAnchor = anchors[upperIndex]
2284
        let span = upperAnchor.percent - lowerAnchor.percent
2285
        guard span > 0.000_1 else {
2286
            return nil
2287
        }
2288

            
2289
        let ratio = (percent - lowerAnchor.percent) / span
2290
        let energyWh = lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
2291
        let chargeAh = lowerAnchor.chargeAh + ((upperAnchor.chargeAh - lowerAnchor.chargeAh) * ratio)
2292
        return (energyWh: energyWh, chargeAh: chargeAh)
Bogdan Timofte authored a month ago
2293
    }
2294

            
2295
    private func makeSessionSummary(
2296
        from object: NSManagedObject,
2297
        checkpoints: [NSManagedObject],
2298
        samples: [NSManagedObject]
2299
    ) -> ChargeSessionSummary? {
2300
        let chargingTransportMode = chargingTransportMode(for: object)
2301

            
2302
        guard
2303
            let id = uuidValue(object, key: "id"),
2304
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2305
            let startedAt = dateValue(object, key: "startedAt"),
2306
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
2307
            let status = statusValue(object, key: "statusRawValue"),
2308
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
2309
        else {
2310
            return nil
2311
        }
2312

            
2313
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
2314
            .sorted { $0.timestamp < $1.timestamp }
2315
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
2316
            .sorted { lhs, rhs in
2317
                if lhs.bucketIndex != rhs.bucketIndex {
2318
                    return lhs.bucketIndex < rhs.bucketIndex
2319
                }
2320
                return lhs.timestamp < rhs.timestamp
2321
            }
2322

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

            
2376
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
2377
        guard
2378
            let id = uuidValue(object, key: "id"),
2379
            let sessionID = uuidValue(object, key: "sessionID"),
2380
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2381
            let timestamp = dateValue(object, key: "timestamp")
2382
        else {
2383
            return nil
2384
        }
2385

            
2386
        return ChargeCheckpointSummary(
2387
            id: id,
2388
            sessionID: sessionID,
2389
            chargedDeviceID: chargedDeviceID,
2390
            timestamp: timestamp,
2391
            batteryPercent: doubleValue(object, key: "batteryPercent"),
2392
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2393
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2394
            currentAmps: doubleValue(object, key: "currentAmps"),
2395
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
2396
            label: stringValue(object, key: "label")
2397
        )
2398
    }
2399

            
2400
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
2401
        guard
2402
            let sessionID = uuidValue(object, key: "sessionID"),
2403
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2404
            let timestamp = dateValue(object, key: "timestamp")
2405
        else {
2406
            return nil
2407
        }
2408

            
2409
        return ChargeSessionSampleSummary(
2410
            sessionID: sessionID,
2411
            chargedDeviceID: chargedDeviceID,
2412
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
2413
            timestamp: timestamp,
2414
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
2415
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
2416
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
2417
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2418
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2419
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
2420
        )
2421
    }
2422

            
Bogdan Timofte authored a month ago
2423
    private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2424
        fetchSessionObject(
2425
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2426
                format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
Bogdan Timofte authored a month ago
2427
                normalizedMACAddress(meterMACAddress),
Bogdan Timofte authored a month ago
2428
                ChargeSessionStatus.active.rawValue,
2429
                ChargeSessionStatus.paused.rawValue
Bogdan Timofte authored a month ago
2430
            )
2431
        )
2432
    }
2433

            
Bogdan Timofte authored a month ago
2434
    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2435
        fetchSessionObject(
2436
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2437
                format: "meterMACAddress == %@ AND statusRawValue == %@",
2438
                normalizedMACAddress(meterMACAddress),
2439
                ChargeSessionStatus.active.rawValue
Bogdan Timofte authored a month ago
2440
            )
2441
        )
2442
    }
2443

            
2444
    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
2445
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2446
        request.predicate = predicate
2447
        request.fetchLimit = 1
2448
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
2449
        return (try? context.fetch(request))?.first
2450
    }
2451

            
2452
    private func fetchSessionObject(id: String) -> NSManagedObject? {
2453
        fetchSessionObject(
2454
            predicate: NSPredicate(format: "id == %@", id)
2455
        )
2456
    }
2457

            
2458
    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
2459
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2460
        request.predicate = NSPredicate(
2461
            format: "sessionID == %@ AND bucketIndex == %d",
2462
            sessionID,
2463
            bucketIndex
2464
        )
2465
        request.fetchLimit = 1
2466
        return (try? context.fetch(request))?.first
2467
    }
2468

            
2469
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2470
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2471
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2472
        return (try? context.fetch(request)) ?? []
2473
    }
2474

            
Bogdan Timofte authored a month ago
2475
    private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
2476
        guard !sessionIDs.isEmpty else {
2477
            return []
2478
        }
2479

            
2480
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2481
        request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs)
2482
        return (try? context.fetch(request)) ?? []
2483
    }
2484

            
Bogdan Timofte authored a month ago
2485
    private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
2486
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2487
        request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
2488
        request.fetchLimit = 1
2489
        return (try? context.fetch(request))?.first
2490
    }
2491

            
Bogdan Timofte authored a month ago
2492
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2493
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2494
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2495
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
2496
        return (try? context.fetch(request)) ?? []
2497
    }
2498

            
2499
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
2500
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2501
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
2502
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2503
        return (try? context.fetch(request)) ?? []
2504
    }
2505

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

            
Bogdan Timofte authored a month ago
2513
    private func sampleBackedSessionIDs(
2514
        devices: [NSManagedObject],
2515
        sessionsByDeviceID: [String: [NSManagedObject]],
2516
        sessionsByChargerID: [String: [NSManagedObject]]
2517
    ) -> Set<String> {
2518
        var sessionIDs: Set<String> = []
2519

            
2520
        for device in devices {
2521
            guard
2522
                let deviceID = stringValue(device, key: "id"),
2523
                let rawClass = stringValue(device, key: "deviceClassRawValue"),
2524
                let deviceClass = ChargedDeviceClass(rawValue: rawClass)
2525
            else {
2526
                continue
2527
            }
2528

            
2529
            let relevantSessions = relevantSessionObjects(
2530
                for: deviceID,
2531
                deviceClass: deviceClass,
2532
                sessionsByDeviceID: sessionsByDeviceID,
2533
                sessionsByChargerID: sessionsByChargerID
2534
            )
2535
            .sorted { lhs, rhs in
2536
                let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed
2537
                let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed
2538

            
2539
                if lhsStatus.isOpen && !rhsStatus.isOpen {
2540
                    return true
2541
                }
2542
                if !lhsStatus.isOpen && rhsStatus.isOpen {
2543
                    return false
2544
                }
2545

            
2546
                return (dateValue(lhs, key: "startedAt") ?? .distantPast)
2547
                    > (dateValue(rhs, key: "startedAt") ?? .distantPast)
2548
            }
2549

            
2550
            var recentCompletedSamplesIncluded = 0
2551

            
2552
            for session in relevantSessions {
2553
                guard let sessionID = stringValue(session, key: "id"),
2554
                      let status = statusValue(session, key: "statusRawValue") else {
2555
                    continue
2556
                }
2557

            
2558
                if status.isOpen {
2559
                    sessionIDs.insert(sessionID)
2560
                    continue
2561
                }
2562

            
2563
                guard recentCompletedSamplesIncluded < 2 else {
2564
                    continue
2565
                }
2566

            
2567
                sessionIDs.insert(sessionID)
2568
                recentCompletedSamplesIncluded += 1
2569
            }
2570
        }
2571

            
2572
        return sessionIDs
2573
    }
2574

            
Bogdan Timofte authored a month ago
2575
    private func relevantSessionObjects(
2576
        for chargedDeviceID: String,
2577
        deviceClass: ChargedDeviceClass,
2578
        sessionsByDeviceID: [String: [NSManagedObject]],
2579
        sessionsByChargerID: [String: [NSManagedObject]]
2580
    ) -> [NSManagedObject] {
2581
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
2582
        guard deviceClass == .charger else {
2583
            return directSessions
2584
        }
2585

            
2586
        var seenSessionIDs = Set<String>()
2587
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
2588
            .filter { session in
2589
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
2590
                return seenSessionIDs.insert(sessionID).inserted
2591
            }
2592
            .sorted {
2593
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
2594
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
2595
                return lhsDate < rhsDate
2596
            }
2597
    }
2598

            
2599
    private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
2600
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false)
2601
    }
2602

            
2603
    private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
2604
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true)
2605
    }
2606

            
2607
    private func resolvedAssignedObject(
2608
        for meterMACAddress: String,
2609
        expectsChargerClass: Bool
2610
    ) -> NSManagedObject? {
2611
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
2612
        guard !normalizedMAC.isEmpty else { return nil }
2613

            
2614
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2615
        request.predicate = NSPredicate(format: "lastAssociatedMeterMAC == %@", normalizedMAC)
2616
        request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
2617
        let matches = (try? context.fetch(request)) ?? []
2618
        return matches.first { object in
Bogdan Timofte authored a month ago
2619
            isChargerObject(object) == expectsChargerClass
Bogdan Timofte authored a month ago
2620
        }
2621
    }
2622

            
Bogdan Timofte authored a month ago
2623
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
2624
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2625
    }
2626

            
Bogdan Timofte authored a month ago
2627
    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
2628
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2629
        request.predicate = NSPredicate(format: "id == %@", id)
2630
        request.fetchLimit = 1
2631
        return (try? context.fetch(request))?.first
2632
    }
2633

            
2634
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
2635
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
2636
        return (try? context.fetch(request)) ?? []
2637
    }
2638

            
2639
    private func resolvedStopThreshold(
2640
        for chargedDevice: NSManagedObject,
2641
        chargingTransportMode: ChargingTransportMode,
Bogdan Timofte authored a month ago
2642
        chargingStateMode: ChargingStateMode,
2643
        charger: NSManagedObject?,
2644
        fallback: Double?
2645
    ) -> Double? {
2646
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
2647
            return nil
2648
        }
2649

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

            
2674
        let resolvedCurrent = configuredCurrents[sessionKind]
2675
            ?? learnedCurrents[sessionKind]
2676
            ?? legacyCurrent
2677
            ?? fallback
2678
        guard let resolvedCurrent, resolvedCurrent > 0 else {
2679
            return nil
2680
        }
2681
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
2682
    }
2683

            
Bogdan Timofte authored a month ago
2684
    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
Bogdan Timofte authored a month ago
2685
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
2686
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
2687
        return resolvedPreferredChargingTransportMode(
Bogdan Timofte authored a month ago
2688
            .wired,
Bogdan Timofte authored a month ago
2689
            supportsWiredCharging: supportsWiredCharging,
2690
            supportsWirelessCharging: supportsWirelessCharging
2691
        )
2692
    }
2693

            
Bogdan Timofte authored a month ago
2694
    private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
2695
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other
2696
    }
2697

            
2698
    private func normalizedTemplateID(
2699
        _ templateID: String?,
2700
        kind: ChargedDeviceKind
2701
    ) -> String? {
2702
        guard let templateID,
2703
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2704
              templateDefinition.kind == kind else {
2705
            return nil
Bogdan Timofte authored a month ago
2706
        }
Bogdan Timofte authored a month ago
2707
        return templateDefinition.id
Bogdan Timofte authored a month ago
2708
    }
2709

            
Bogdan Timofte authored a month ago
2710
    private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
2711
        guard let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
2712
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2713
              templateDefinition.kind == deviceClass(for: chargedDevice).kind else {
2714
            return nil
Bogdan Timofte authored a month ago
2715
        }
Bogdan Timofte authored a month ago
2716
        return templateDefinition
2717
    }
2718

            
2719
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
2720
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2721
            ? true
2722
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2723
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2724
            ? false
2725
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2726
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2727
            supportsWiredCharging: persistedWiredCharging,
2728
            supportsWirelessCharging: persistedWirelessCharging
2729
        ).wired
2730
    }
2731

            
2732
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
2733
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2734
            ? true
2735
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2736
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2737
            ? false
2738
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2739
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2740
            supportsWiredCharging: persistedWiredCharging,
2741
            supportsWirelessCharging: persistedWirelessCharging
2742
        ).wireless
2743
    }
2744

            
2745
    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
2746
        let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue")
2747
            .flatMap(ChargingStateAvailability.init(rawValue:))
2748
            ?? ChargingStateAvailability.fallback(
Bogdan Timofte authored a month ago
2749
            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
2750
        )
Bogdan Timofte authored a month ago
2751
        return deviceClass(for: chargedDevice).normalizedChargingStateAvailability(persistedAvailability)
Bogdan Timofte authored a month ago
2752
    }
2753

            
2754
    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
Bogdan Timofte authored a month ago
2755
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2756
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2757
            let persistedChargingStateMode = stringValue(session, key: "chargingStateRawValue")
2758
                .flatMap(ChargingStateMode.init(rawValue:))
2759
                ?? .on
2760
            return resolvedChargingStateMode(
2761
                persistedChargingStateMode,
2762
                availability: chargingStateAvailability(for: chargedDevice)
2763
            )
2764
        }
2765

            
Bogdan Timofte authored a month ago
2766
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
2767
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
2768
            return chargingStateMode
2769
        }
2770

            
2771
        return .on
2772
    }
2773

            
2774
    private func resolvedChargingStateMode(
2775
        _ chargingStateMode: ChargingStateMode,
2776
        availability: ChargingStateAvailability
2777
    ) -> ChargingStateMode {
2778
        if availability.supportedModes.contains(chargingStateMode) {
2779
            return chargingStateMode
2780
        }
2781
        return availability.supportedModes.first ?? .on
2782
    }
2783

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

            
2788
        // Primary: chargerTypeRawValue (set on v13+)
2789
        if let rawValue = stringValue(chargedDevice, key: "chargerTypeRawValue"),
2790
           let type = ChargerType(rawValue: rawValue) {
2791
            return type
2792
        }
2793

            
2794
        // Migration fallback: derive from old deviceTemplateID
2795
        switch stringValue(chargedDevice, key: "deviceTemplateID") {
2796
        case "apple-magsafe-charger": return .appleMagSafe
2797
        case "apple-watch-charger": return .appleWatch
2798
        default: break
2799
        }
2800

            
2801
        // Last resort: derive from wirelessChargingProfileRawValue
2802
        if let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2803
           let profile = WirelessChargingProfile(rawValue: rawValue),
2804
           profile == .magsafe {
2805
            return .genericMagSafe
2806
        }
2807

            
2808
        return .genericQi
2809
    }
2810

            
Bogdan Timofte authored a month ago
2811
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
Bogdan Timofte authored a month ago
2812
        if let type = chargerType(for: chargedDevice) {
2813
            return type.wirelessChargingProfile
2814
        }
Bogdan Timofte authored a month ago
2815
        guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2816
              let profile = WirelessChargingProfile(rawValue: rawValue) else {
2817
            return .genericQi
2818
        }
2819
        return profile
2820
    }
2821

            
2822
    private func resolvedPreferredChargingTransportMode(
2823
        _ preferredChargingTransportMode: ChargingTransportMode,
2824
        supportsWiredCharging: Bool,
2825
        supportsWirelessCharging: Bool
2826
    ) -> ChargingTransportMode {
2827
        switch preferredChargingTransportMode {
2828
        case .wired where supportsWiredCharging:
2829
            return .wired
2830
        case .wireless where supportsWirelessCharging:
2831
            return .wireless
2832
        default:
2833
            if supportsWiredCharging {
2834
                return .wired
2835
            }
2836
            if supportsWirelessCharging {
2837
                return .wireless
2838
            }
2839
            return .wired
2840
        }
2841
    }
2842

            
Bogdan Timofte authored a month ago
2843
    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
2844
        let payload = Dictionary(
2845
            uniqueKeysWithValues: currents.map { key, value in
2846
                (key.rawValue, value)
2847
            }
2848
        )
2849
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
2850
            return nil
2851
        }
2852
        return String(data: data, encoding: .utf8)
2853
    }
2854

            
2855
    private func decodedCompletionCurrents(
2856
        from object: NSManagedObject,
2857
        key: String
2858
    ) -> [ChargeSessionKind: Double] {
2859
        guard let rawValue = stringValue(object, key: key),
2860
              let data = rawValue.data(using: .utf8),
2861
              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
2862
            return [:]
2863
        }
2864

            
2865
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2866
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
2867
                return
2868
            }
2869
            result[sessionKind] = entry.value
2870
        }
2871
    }
2872

            
2873
    private func legacyConfiguredCompletionCurrent(
2874
        for currents: [ChargeSessionKind: Double],
2875
        chargingTransportMode: ChargingTransportMode
2876
    ) -> Double? {
2877
        let candidates = currents
2878
            .filter { $0.key.chargingTransportMode == chargingTransportMode }
2879
            .sorted { lhs, rhs in
2880
                lhs.key.rawValue < rhs.key.rawValue
2881
            }
2882
            .map(\.value)
2883
        return candidates.first
2884
    }
2885

            
2886
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
2887
        guard let charger else {
2888
            return nil
2889
        }
2890
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
2891
        guard let idleCurrent, idleCurrent >= 0 else {
2892
            return nil
2893
        }
2894
        return idleCurrent
2895
    }
2896

            
2897
    private func effectiveCurrentAmps(
2898
        fromMeasuredCurrent currentAmps: Double,
2899
        chargingTransportMode: ChargingTransportMode,
2900
        charger: NSManagedObject?
2901
    ) -> Double {
2902
        switch chargingTransportMode {
2903
        case .wired:
2904
            return max(currentAmps, 0)
2905
        case .wireless:
2906
            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
2907
                return max(currentAmps, 0)
2908
            }
2909
            return max(currentAmps - idleCurrent, 0)
2910
        }
2911
    }
2912

            
2913
    private func hasObservedChargeFlow(
2914
        currentAmps: Double,
2915
        chargingTransportMode: ChargingTransportMode,
2916
        charger: NSManagedObject?,
2917
        stopThreshold: Double?
2918
    ) -> Bool {
2919
        let effectiveCurrent = effectiveCurrentAmps(
2920
            fromMeasuredCurrent: currentAmps,
2921
            chargingTransportMode: chargingTransportMode,
2922
            charger: charger
2923
        )
2924
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
2925
    }
2926

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

            
2945
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
2946
        guard !recentCompletionCurrents.isEmpty else { return nil }
2947
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
2948
    }
2949

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

            
2953
        for session in sessions {
2954
            guard statusValue(session, key: "statusRawValue") == .completed else {
2955
                continue
2956
            }
2957
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2958
                continue
2959
            }
2960
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
2961
                  completionCurrent > 0 else {
2962
                continue
2963
            }
2964

            
2965
            let sessionKind = ChargeSessionKind(
2966
                chargingTransportMode: chargingTransportMode(for: session),
2967
                chargingStateMode: chargingStateMode(for: session)
2968
            )
2969
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
2970
        }
2971

            
2972
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2973
            let recentCurrents = Array(entry.value.suffix(5))
2974
            guard !recentCurrents.isEmpty else {
2975
                return
2976
            }
2977
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
2978
        }
2979
    }
2980

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

            
3003
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
3004
        guard !recentCapacityCandidates.isEmpty else { return nil }
3005
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
3006
    }
3007

            
3008
    private func derivedWirelessEfficiency(
3009
        from sessions: [NSManagedObject],
3010
        chargingProfile: WirelessChargingProfile
3011
    ) -> Double? {
3012
        guard chargingProfile == .magsafe else {
3013
            return nil
3014
        }
3015

            
3016
        let candidates = sessions.compactMap { session -> Double? in
3017
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3018
            guard chargingTransportMode(for: session) == .wireless else { return nil }
3019
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
3020
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3021
                return nil
3022
            }
3023
            return factor
3024
        }
3025

            
3026
        let recentCandidates = Array(candidates.suffix(6))
3027
        guard !recentCandidates.isEmpty else { return nil }
3028
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3029
    }
3030

            
3031
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
3032
        let candidates = sessions.compactMap { session -> Double? in
3033
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3034
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
3035
                return nil
3036
            }
3037
            return (sourceVoltage * 10).rounded() / 10
3038
        }
3039

            
3040
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
3041
        return counts.keys.sorted()
3042
    }
3043

            
3044
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
3045
        let candidates = sessions.compactMap { session -> Double? in
3046
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3047
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
3048
                return nil
3049
            }
3050
            return minimumObservedCurrent
3051
        }
3052

            
3053
        let recentCandidates = Array(candidates.suffix(6))
3054
        guard !recentCandidates.isEmpty else { return nil }
3055
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3056
    }
3057

            
3058
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
3059
        let candidates = sessions.compactMap { session -> Double? in
3060
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3061
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3062
                return nil
3063
            }
3064
            return factor
3065
        }
3066

            
3067
        let recentCandidates = Array(candidates.suffix(6))
3068
        guard !recentCandidates.isEmpty else { return nil }
3069
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3070
    }
3071

            
3072
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
3073
        sessions.compactMap { session -> Double? in
3074
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3075
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
3076
                return nil
3077
            }
3078
            return maximumObservedPower
3079
        }
3080
        .max()
3081
    }
3082

            
3083
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
3084
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
3085
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
3086
            return resolvedPreferredChargingTransportMode(
3087
                chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired,
3088
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
3089
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
3090
            )
3091
        }
3092

            
3093
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
3094
            return persistedChargingTransportMode
Bogdan Timofte authored a month ago
3095
        }
3096

            
3097
        return .wired
3098
    }
3099

            
3100
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
3101
        if session.isInserted {
3102
            return .created
3103
        }
3104

            
3105
        let committedValues = session.committedValues(
3106
            forKeys: [
3107
                "statusRawValue",
3108
                "updatedAt",
3109
                "targetBatteryAlertTriggeredAt",
3110
                "requiresCompletionConfirmation"
3111
            ]
3112
        )
3113
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
3114
        let currentStatus = statusValue(session, key: "statusRawValue")
3115
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
3116
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
3117
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
3118
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
3119
            ?? false
3120
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
3121

            
3122
        if currentStatus == .completed, committedStatus != .completed {
3123
            return .completed
3124
        }
3125

            
Bogdan Timofte authored a month ago
3126
        if currentStatus != committedStatus {
3127
            return .event
3128
        }
3129

            
Bogdan Timofte authored a month ago
3130
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
3131
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
3132
            return .event
3133
        }
3134

            
3135
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3136
            ?? dateValue(session, key: "createdAt")
3137
            ?? observedAt
3138

            
3139
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
3140
            return .periodic
3141
        }
3142

            
3143
        return .none
3144
    }
3145

            
Bogdan Timofte authored a month ago
3146
    private func shouldPersistAggregatedSample(
3147
        _ sample: NSManagedObject,
3148
        observedAt: Date
3149
    ) -> Bool {
3150
        if sample.isInserted {
3151
            return true
3152
        }
3153

            
3154
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
3155
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
3156
            ?? dateValue(sample, key: "createdAt")
3157
            ?? observedAt
3158

            
3159
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
3160
    }
3161

            
Bogdan Timofte authored a month ago
3162
    private func generateQRIdentifier() -> String {
3163
        "device:\(UUID().uuidString)"
3164
    }
3165

            
3166
    @discardableResult
3167
    private func saveContext() -> Bool {
3168
        guard context.hasChanges else { return true }
3169
        do {
3170
            try context.save()
3171
            return true
3172
        } catch {
3173
            track("Failed saving charge insights context: \(error)")
3174
            context.rollback()
3175
            return false
3176
        }
3177
    }
3178

            
3179
    private func normalizedText(_ text: String) -> String {
3180
        text.trimmingCharacters(in: .whitespacesAndNewlines)
3181
    }
3182

            
3183
    private func normalizedOptionalText(_ text: String?) -> String? {
3184
        guard let text else { return nil }
3185
        let normalized = normalizedText(text)
3186
        return normalized.isEmpty ? nil : normalized
3187
    }
3188

            
3189
    private func normalizedMACAddress(_ macAddress: String) -> String {
3190
        normalizedText(macAddress).uppercased()
3191
    }
3192

            
Bogdan Timofte authored a month ago
3193
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
3194
        guard object.entity.propertiesByName[key] != nil else {
3195
            return nil
3196
        }
3197
        return object.value(forKey: key)
3198
    }
3199

            
3200
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
3201
        guard object.entity.propertiesByName[key] != nil else {
3202
            return
3203
        }
3204
        object.setValue(value, forKey: key)
3205
    }
3206

            
Bogdan Timofte authored a month ago
3207
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
3208
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
3209
        let normalized = normalizedOptionalText(value)
3210
        return normalized
3211
    }
3212

            
3213
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
3214
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
3215
    }
3216

            
3217
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
3218
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
3219
            return value
3220
        }
Bogdan Timofte authored a month ago
3221
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3222
            return value.doubleValue
3223
        }
3224
        return 0
3225
    }
3226

            
3227
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
3228
        let value = rawValue(object, key: key)
3229
        if value == nil {
Bogdan Timofte authored a month ago
3230
            return nil
3231
        }
3232
        return doubleValue(object, key: key)
3233
    }
3234

            
3235
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
Bogdan Timofte authored a month ago
3236
        if let value = rawValue(object, key: key) as? Int16 {
Bogdan Timofte authored a month ago
3237
            return value
3238
        }
Bogdan Timofte authored a month ago
3239
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3240
            return value.int16Value
3241
        }
3242
        return nil
3243
    }
3244

            
3245
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
3246
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
3247
            return value
3248
        }
Bogdan Timofte authored a month ago
3249
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3250
            return value.int32Value
3251
        }
3252
        return nil
3253
    }
3254

            
3255
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
Bogdan Timofte authored a month ago
3256
        if let value = rawValue(object, key: key) as? Bool {
Bogdan Timofte authored a month ago
3257
            return value
3258
        }
Bogdan Timofte authored a month ago
3259
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3260
            return value.boolValue
3261
        }
3262
        return false
3263
    }
3264

            
3265
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
3266
        guard let value = stringValue(object, key: key) else { return nil }
3267
        return UUID(uuidString: value)
3268
    }
3269

            
3270
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
3271
        guard let value = stringValue(object, key: key) else { return nil }
3272
        return ChargeSessionStatus(rawValue: value)
3273
    }
3274

            
3275
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
3276
        guard let value = stringValue(object, key: key) else { return nil }
3277
        return ChargingTransportMode(rawValue: value)
3278
    }
3279

            
3280
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
3281
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
3282
            return []
3283
        }
3284
        return rawValue
3285
            .split(separator: ",")
3286
            .compactMap { Double($0) }
3287
            .sorted()
3288
    }
3289

            
3290
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
3291
        let uniqueVoltages = Array(Set(voltages)).sorted()
3292
        guard !uniqueVoltages.isEmpty else {
3293
            return nil
3294
        }
3295
        return uniqueVoltages
3296
            .map { String(format: "%.1f", $0) }
3297
            .joined(separator: ",")
3298
    }
3299

            
3300
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
3301
        guard currentCount > 0 else {
3302
            return newValue
3303
        }
3304
        let total = (currentAverage * Double(currentCount)) + newValue
3305
        return total / Double(currentCount + 1)
3306
    }
3307
}
3308

            
3309
private enum ObservationSaveReason {
3310
    case none
3311
    case created
3312
    case periodic
3313
    case completed
3314
    case event
3315
}