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

            
8
import CoreData
9
import Foundation
10

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

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

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

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

            
36
    private let context: NSManagedObjectContext
37
    private let stopDetectionHoldDuration: TimeInterval = 20
38
    private let maximumLiveIntegrationGap: TimeInterval = 20
39
    private let activeSessionSaveInterval: TimeInterval = 15
Bogdan Timofte authored a month ago
40
    private let aggregatedSampleSaveInterval: TimeInterval = 5
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

            
61
    @discardableResult
62
    func flushPendingChanges() -> Bool {
63
        var didSave = false
64
        context.performAndWait {
65
            context.processPendingChanges()
66
            didSave = saveContext()
67
        }
68
        return didSave
69
    }
70

            
71
    @discardableResult
Bogdan Timofte authored a month ago
72
    func createDevice(
Bogdan Timofte authored a month ago
73
        name: String,
74
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
75
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
76
        supportsWiredCharging: Bool,
77
        supportsWirelessCharging: Bool,
78
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
79
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
80
        notes: String?,
81
        assignTo meterMACAddress: String?
82
    ) -> Bool {
Bogdan Timofte authored a month ago
83
        guard deviceClass.kind == .device else { return false }
Bogdan Timofte authored a month ago
84
        let normalizedName = normalizedText(name)
85
        guard !normalizedName.isEmpty else { return false }
86
        guard supportsWiredCharging || supportsWirelessCharging else { return false }
87

            
88
        var didSave = false
89
        context.performAndWait {
90
            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
91
                return
92
            }
93

            
94
            let object = NSManagedObject(entity: entity, insertInto: context)
95
            let now = Date()
96
            object.setValue(UUID().uuidString, forKey: "id")
97
            object.setValue(normalizedName, forKey: "name")
98
            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
99
            object.setValue(chargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
100
            object.setValue(chargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
Bogdan Timofte authored a month ago
101
            object.setValue(supportsWiredCharging, forKey: "supportsWiredCharging")
102
            object.setValue(supportsWirelessCharging, forKey: "supportsWirelessCharging")
103
            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
104
            object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
105
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
106
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
107
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
108
            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
109
            object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC")
110
            object.setValue(now, forKey: "createdAt")
111
            object.setValue(now, forKey: "updatedAt")
112
            didSave = saveContext()
113
        }
114
        return didSave
115
    }
116

            
117
    @discardableResult
Bogdan Timofte authored a month ago
118
    func createCharger(
119
        name: String,
120
        notes: String?,
121
        assignTo meterMACAddress: String?
122
    ) -> Bool {
123
        let normalizedName = normalizedText(name)
124
        guard !normalizedName.isEmpty else { return false }
125

            
126
        var didSave = false
127
        context.performAndWait {
128
            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
129
                return
130
            }
131

            
132
            let object = NSManagedObject(entity: entity, insertInto: context)
133
            let now = Date()
134
            object.setValue(UUID().uuidString, forKey: "id")
135
            object.setValue(normalizedName, forKey: "name")
136
            object.setValue(ChargedDeviceClass.charger.rawValue, forKey: "deviceClassRawValue")
137
            object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue")
138
            object.setValue(false, forKey: "supportsChargingWhileOff")
139
            object.setValue(false, forKey: "supportsWiredCharging")
140
            object.setValue(true, forKey: "supportsWirelessCharging")
141
            object.setValue(WirelessChargingProfile.genericQi.rawValue, forKey: "wirelessChargingProfileRawValue")
142
            object.setValue(encodedCompletionCurrents([:]), forKey: "configuredCompletionCurrentsRawValue")
143
            object.setValue(nil, forKey: "wiredChargeCompletionCurrentAmps")
144
            object.setValue(nil, forKey: "wirelessChargeCompletionCurrentAmps")
145
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
146
            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
147
            object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC")
148
            object.setValue(now, forKey: "createdAt")
149
            object.setValue(now, forKey: "updatedAt")
150
            didSave = saveContext()
151
        }
152
        return didSave
153
    }
154

            
155
    @discardableResult
156
    func updateDevice(
Bogdan Timofte authored a month ago
157
        id: UUID,
158
        name: String,
159
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
160
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
161
        supportsWiredCharging: Bool,
162
        supportsWirelessCharging: Bool,
163
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
164
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
165
        notes: String?
166
    ) -> Bool {
Bogdan Timofte authored a month ago
167
        guard deviceClass.kind == .device else { return false }
Bogdan Timofte authored a month ago
168
        let normalizedName = normalizedText(name)
169
        guard !normalizedName.isEmpty else { return false }
170
        guard supportsWiredCharging || supportsWirelessCharging else { return false }
171

            
172
        var didSave = false
173
        context.performAndWait {
174
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
175
                return
176
            }
Bogdan Timofte authored a month ago
177
            guard isChargerObject(object) == false else {
178
                return
179
            }
Bogdan Timofte authored a month ago
180

            
181
            let previousSupportsChargingWhileOff = boolValue(object, key: "supportsChargingWhileOff")
Bogdan Timofte authored a month ago
182
            let previousChargingStateAvailability = self.chargingStateAvailability(for: object)
Bogdan Timofte authored a month ago
183
            let previousSupportsWiredCharging = self.supportsWiredCharging(for: object)
184
            let previousSupportsWirelessCharging = self.supportsWirelessCharging(for: object)
185
            let now = Date()
186

            
187
            object.setValue(normalizedName, forKey: "name")
188
            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
189
            object.setValue(chargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
190
            object.setValue(chargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
Bogdan Timofte authored a month ago
191
            object.setValue(supportsWiredCharging, forKey: "supportsWiredCharging")
192
            object.setValue(supportsWirelessCharging, forKey: "supportsWirelessCharging")
193
            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
194
            object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
195
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
196
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
197
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
198
            object.setValue(now, forKey: "updatedAt")
199

            
Bogdan Timofte authored a month ago
200
            let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
201
            let shouldRecalculateSessionCapacity = previousSupportsChargingWhileOff != supportsChargingWhileOff
202
            let shouldRefreshActiveSessions = shouldRecalculateSessionCapacity
Bogdan Timofte authored a month ago
203
                || previousChargingStateAvailability != chargingStateAvailability
Bogdan Timofte authored a month ago
204
                || previousSupportsWiredCharging != supportsWiredCharging
205
                || previousSupportsWirelessCharging != supportsWirelessCharging
206

            
207
            if shouldRecalculateSessionCapacity || shouldRefreshActiveSessions {
208
                let sessions = fetchSessions(forChargedDeviceID: id.uuidString)
209
                for session in sessions {
Bogdan Timofte authored a month ago
210
                    let isOpen = statusValue(session, key: "statusRawValue")?.isOpen == true
Bogdan Timofte authored a month ago
211

            
212
                    if shouldRecalculateSessionCapacity {
213
                        session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
214
                        updateCapacityEstimate(for: session)
215
                        session.setValue(now, forKey: "updatedAt")
216
                    }
217

            
Bogdan Timofte authored a month ago
218
                    guard isOpen, shouldRefreshActiveSessions else {
Bogdan Timofte authored a month ago
219
                        continue
220
                    }
221

            
222
                    let resolvedSessionChargingTransportMode = resolvedPreferredChargingTransportMode(
223
                        chargingTransportMode(for: session),
224
                        supportsWiredCharging: supportsWiredCharging,
225
                        supportsWirelessCharging: supportsWirelessCharging
226
                    )
Bogdan Timofte authored a month ago
227
                    let resolvedSessionChargingStateMode = resolvedChargingStateMode(
228
                        chargingStateMode(for: session),
229
                        availability: chargingStateAvailability
230
                    )
231
                    let charger = stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
232

            
233
                    session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
234
                    session.setValue(resolvedSessionChargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
235
                    session.setValue(resolvedSessionChargingStateMode.rawValue, forKey: "chargingStateRawValue")
Bogdan Timofte authored a month ago
236
                    session.setValue(
237
                        resolvedStopThreshold(
238
                            for: object,
239
                            chargingTransportMode: resolvedSessionChargingTransportMode,
Bogdan Timofte authored a month ago
240
                            chargingStateMode: resolvedSessionChargingStateMode,
241
                            charger: charger,
242
                            fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
243
                        ) ?? 0,
Bogdan Timofte authored a month ago
244
                        forKey: "stopThresholdAmps"
245
                    )
246
                    session.setValue(now, forKey: "updatedAt")
247
                    updateCapacityEstimate(for: session)
248
                }
249
            }
250

            
251
            refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
252
            didSave = saveContext()
253
        }
254
        return didSave
255
    }
256

            
Bogdan Timofte authored a month ago
257
    @discardableResult
258
    func updateCharger(
259
        id: UUID,
260
        name: String,
261
        notes: String?
262
    ) -> Bool {
263
        let normalizedName = normalizedText(name)
264
        guard !normalizedName.isEmpty else { return false }
265

            
266
        var didSave = false
267
        context.performAndWait {
268
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
269
                return
270
            }
271
            guard isChargerObject(object) else {
272
                return
273
            }
274

            
275
            object.setValue(normalizedName, forKey: "name")
276
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
277
            object.setValue(Date(), forKey: "updatedAt")
278
            didSave = saveContext()
279
        }
280

            
281
        return didSave
282
    }
283

            
Bogdan Timofte authored a month ago
284
    @discardableResult
285
    func assignChargedDevice(id: UUID, to meterMACAddress: String) -> Bool {
286
        assign(itemWithID: id, to: meterMACAddress, kind: .chargedDevice)
287
    }
288

            
289
    @discardableResult
290
    func assignCharger(id: UUID, to meterMACAddress: String) -> Bool {
291
        assign(itemWithID: id, to: meterMACAddress, kind: .charger)
292
    }
293

            
294
    @discardableResult
295
    private func assign(
296
        itemWithID id: UUID,
297
        to meterMACAddress: String,
298
        kind: MeterAssignmentKind
299
    ) -> Bool {
300
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
301
        guard !normalizedMAC.isEmpty else { return false }
302

            
303
        var didSave = false
304
        context.performAndWait {
305
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
306
                return
307
            }
308

            
309
            let isCharger = ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
310
            guard isCharger == kind.expectsChargerClass else {
311
                return
312
            }
313

            
314
            let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
315
            request.predicate = NSPredicate(
316
                format: "lastAssociatedMeterMAC == %@ AND id != %@",
317
                normalizedMAC,
318
                id.uuidString
319
            )
320
            let previouslyAssignedDevices = (try? context.fetch(request)) ?? []
321
            for previousDevice in previouslyAssignedDevices {
322
                let previousIsCharger = ChargedDeviceClass(rawValue: stringValue(previousDevice, key: "deviceClassRawValue") ?? "") == .charger
323
                guard previousIsCharger == kind.expectsChargerClass else {
324
                    continue
325
                }
326
                previousDevice.setValue(nil, forKey: "lastAssociatedMeterMAC")
327
                previousDevice.setValue(Date(), forKey: "updatedAt")
328
            }
329

            
330
            object.setValue(normalizedMAC, forKey: "lastAssociatedMeterMAC")
331
            object.setValue(Date(), forKey: "updatedAt")
332

            
333
            if kind == .charger,
Bogdan Timofte authored a month ago
334
               let openSession = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
335
               chargingTransportMode(for: openSession) == .wireless {
336
                openSession.setValue(id.uuidString, forKey: "chargerID")
337
                openSession.setValue(Date(), forKey: "updatedAt")
Bogdan Timofte authored a month ago
338
            }
339

            
340
            didSave = saveContext()
341
        }
342
        return didSave
343
    }
344

            
345
    @discardableResult
Bogdan Timofte authored a month ago
346
    func startSession(
347
        for snapshot: ChargingMonitorSnapshot,
348
        chargedDeviceID: UUID,
349
        chargerID: UUID?,
350
        chargingTransportMode: ChargingTransportMode,
351
        chargingStateMode: ChargingStateMode,
352
        autoStopEnabled: Bool,
Bogdan Timofte authored a month ago
353
        initialBatteryPercent: Double?,
354
        startsFromFlatBattery: Bool
Bogdan Timofte authored a month ago
355
    ) -> Bool {
Bogdan Timofte authored a month ago
356
        if let initialBatteryPercent,
357
           (initialBatteryPercent.isFinite == false || initialBatteryPercent < 0 || initialBatteryPercent > 100) {
Bogdan Timofte authored a month ago
358
            return false
359
        }
360

            
Bogdan Timofte authored a month ago
361
        var didSave = false
362
        context.performAndWait {
Bogdan Timofte authored a month ago
363
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
Bogdan Timofte authored a month ago
364
                return
365
            }
Bogdan Timofte authored a month ago
366
            guard isChargerObject(chargedDevice) == false else {
367
                return
368
            }
Bogdan Timofte authored a month ago
369

            
Bogdan Timofte authored a month ago
370
            guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
Bogdan Timofte authored a month ago
371
                return
372
            }
373

            
Bogdan Timofte authored a month ago
374
            let resolvedChargingTransportMode = resolvedPreferredChargingTransportMode(
375
                chargingTransportMode,
376
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
377
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
Bogdan Timofte authored a month ago
378
            )
Bogdan Timofte authored a month ago
379
            let resolvedChargingStateMode = resolvedChargingStateMode(
380
                chargingStateMode,
381
                availability: chargingStateAvailability(for: chargedDevice)
382
            )
383
            let charger = resolvedChargingTransportMode == .wireless
384
                ? chargerID.flatMap { fetchChargedDeviceObject(id: $0.uuidString) }
385
                : nil
Bogdan Timofte authored a month ago
386
            if let charger, isChargerObject(charger) == false {
387
                return
388
            }
Bogdan Timofte authored a month ago
389
            guard resolvedChargingTransportMode == .wired || charger != nil else {
Bogdan Timofte authored a month ago
390
                return
391
            }
Bogdan Timofte authored a month ago
392
            let stopThreshold = resolvedStopThreshold(
Bogdan Timofte authored a month ago
393
                for: chargedDevice,
394
                chargingTransportMode: resolvedChargingTransportMode,
395
                chargingStateMode: resolvedChargingStateMode,
396
                charger: charger,
Bogdan Timofte authored a month ago
397
                fallback: snapshot.fallbackStopThresholdAmps > 0 ? snapshot.fallbackStopThresholdAmps : nil
398
            )
Bogdan Timofte authored a month ago
399
            guard let session = createSessionObject(
400
                for: chargedDevice,
Bogdan Timofte authored a month ago
401
                charger: charger,
402
                snapshot: snapshot,
403
                stopThreshold: stopThreshold,
Bogdan Timofte authored a month ago
404
                chargingTransportMode: resolvedChargingTransportMode,
405
                chargingStateMode: resolvedChargingStateMode,
406
                autoStopEnabled: autoStopEnabled
407
            ) else {
408
                return
409
            }
410

            
Bogdan Timofte authored a month ago
411
            if startsFromFlatBattery {
412
                session.setValue(unresolvedFlatBatteryPercent, forKey: "startBatteryPercent")
413
                session.setValue(nil, forKey: "endBatteryPercent")
414
            } else if let initialBatteryPercent {
415
                guard insertBatteryCheckpoint(
416
                    percent: initialBatteryPercent,
417
                    label: "Start",
418
                    timestamp: snapshot.observedAt,
419
                    to: session
420
                ) != nil else {
421
                    return
422
                }
Bogdan Timofte authored a month ago
423
            }
Bogdan Timofte authored a month ago
424
            didSave = saveContext()
425
        }
426
        return didSave
427
    }
428

            
Bogdan Timofte authored a month ago
429
    @discardableResult
430
    func pauseSession(id sessionID: UUID, observedAt: Date) -> Bool {
431
        var didSave = false
432
        context.performAndWait {
433
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
434
                return
435
            }
436

            
437
            guard statusValue(session, key: "statusRawValue") == .active else {
438
                return
439
            }
440

            
441
            session.setValue(ChargeSessionStatus.paused.rawValue, forKey: "statusRawValue")
442
            session.setValue(observedAt, forKey: "pausedAt")
443
            session.setValue(nil, forKey: "belowThresholdSince")
444
            clearCompletionConfirmationState(for: session)
445
            session.setValue(observedAt, forKey: "updatedAt")
446
            didSave = saveContext()
447
        }
448
        return didSave
449
    }
450

            
451
    @discardableResult
452
    func resumeSession(id sessionID: UUID, snapshot: ChargingMonitorSnapshot?) -> Bool {
453
        var didSave = false
454
        context.performAndWait {
455
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
456
                return
457
            }
458

            
459
            guard statusValue(session, key: "statusRawValue") == .paused else {
460
                return
461
            }
462

            
463
            let pausedAt = dateValue(session, key: "pausedAt") ?? Date()
464
            let resumedAt = snapshot?.observedAt ?? Date()
465
            if resumedAt.timeIntervalSince(pausedAt) >= pausedSessionTimeout {
466
                finishSession(
467
                    session,
468
                    observedAt: pausedAt.addingTimeInterval(pausedSessionTimeout),
469
                    finalBatteryPercent: nil,
470
                    label: nil,
471
                    status: .completed
472
                )
473
                guard saveContext() else {
474
                    return
475
                }
476
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
477
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
478
                    didSave = saveContext()
479
                } else {
480
                    didSave = true
481
                }
482
                return
483
            }
484

            
485
            session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
486
            session.setValue(nil, forKey: "pausedAt")
487
            session.setValue(nil, forKey: "belowThresholdSince")
488
            clearCompletionConfirmationState(for: session)
489
            session.setValue(resumedAt, forKey: "lastObservedAt")
490
            if let snapshot {
491
                session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
492
                session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
493
                session.setValue(
494
                    chargingTransportMode(for: session) == .wired ? snapshot.voltageVolts : nil,
495
                    forKey: "lastObservedVoltageVolts"
496
                )
497
            } else {
498
                session.setValue(0, forKey: "lastObservedCurrentAmps")
499
                session.setValue(0, forKey: "lastObservedPowerWatts")
500
                session.setValue(nil, forKey: "lastObservedVoltageVolts")
501
            }
502
            session.setValue(resumedAt, forKey: "updatedAt")
503
            didSave = saveContext()
504
        }
505
        return didSave
506
    }
507

            
508
    @discardableResult
509
    func stopSession(
510
        id sessionID: UUID,
511
        finalBatteryPercent: Double,
512
        label: String?
513
    ) -> Bool {
514
        guard finalBatteryPercent.isFinite, finalBatteryPercent >= 0, finalBatteryPercent <= 100 else {
515
            return false
516
        }
517

            
518
        var didSave = false
519
        context.performAndWait {
520
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
521
                return
522
            }
523

            
524
            guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
525
                return
526
            }
527

            
528
            let observedAt = snapshotDateForManualStop(session)
529
            finishSession(
530
                session,
531
                observedAt: observedAt,
532
                finalBatteryPercent: finalBatteryPercent,
533
                label: label,
534
                status: .completed
535
            )
536

            
537
            guard saveContext() else {
538
                return
539
            }
540

            
541
            if let deviceID = stringValue(session, key: "chargedDeviceID") {
542
                refreshDerivedMetrics(forChargedDeviceID: deviceID)
543
                didSave = saveContext()
544
            } else {
545
                didSave = true
546
            }
547
        }
548
        return didSave
549
    }
550

            
Bogdan Timofte authored a month ago
551
    @discardableResult
552
    func addBatteryCheckpoint(
553
        percent: Double,
554
        label: String?,
Bogdan Timofte authored a month ago
555
        for meterMACAddress: String,
556
        measuredEnergyWh: Double? = nil,
557
        measuredChargeAh: Double? = nil
Bogdan Timofte authored a month ago
558
    ) -> Bool {
559
        guard percent.isFinite, percent >= 0, percent <= 100 else {
560
            return false
561
        }
562

            
563
        var didSave = false
564
        context.performAndWait {
Bogdan Timofte authored a month ago
565
            guard let session = fetchOpenSessionObject(forMeterMACAddress: meterMACAddress) else {
Bogdan Timofte authored a month ago
566
                return
567
            }
568

            
Bogdan Timofte authored a month ago
569
            didSave = addBatteryCheckpoint(
570
                percent: percent,
571
                label: label,
572
                measuredEnergyWh: measuredEnergyWh,
573
                measuredChargeAh: measuredChargeAh,
574
                to: session
575
            )
Bogdan Timofte authored a month ago
576
        }
577
        return didSave
578
    }
579

            
580
    @discardableResult
581
    func addBatteryCheckpoint(
582
        percent: Double,
583
        label: String?,
584
        for sessionID: UUID
585
    ) -> Bool {
586
        guard percent.isFinite, percent >= 0, percent <= 100 else {
587
            return false
588
        }
589

            
590
        var didSave = false
591
        context.performAndWait {
592
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
593
                return
594
            }
595

            
596
            didSave = addBatteryCheckpoint(percent: percent, label: label, to: session)
597
        }
598
        return didSave
599
    }
600

            
Bogdan Timofte authored a month ago
601
    @discardableResult
602
    func deleteBatteryCheckpoint(
603
        id checkpointID: UUID,
604
        from sessionID: UUID
605
    ) -> Bool {
606
        var didSave = false
607
        context.performAndWait {
608
            guard let session = fetchSessionObject(id: sessionID.uuidString),
609
                  let checkpoint = fetchCheckpointObject(
610
                    id: checkpointID.uuidString,
611
                    sessionID: sessionID.uuidString
612
                  ) else {
613
                return
614
            }
615

            
616
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
617
            context.delete(checkpoint)
618
            refreshCheckpointDerivedValues(for: session)
619

            
620
            guard saveContext() else {
621
                return
622
            }
623

            
624
            if let chargedDeviceID {
625
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
626
                didSave = saveContext()
627
            } else {
628
                didSave = true
629
            }
630
        }
631
        return didSave
632
    }
633

            
Bogdan Timofte authored a month ago
634
    @discardableResult
635
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
636
        if let percent, (!percent.isFinite || percent <= 0 || percent > 100) {
637
            return false
638
        }
639

            
640
        var didSave = false
641
        context.performAndWait {
642
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
643
                return
644
            }
645

            
646
            session.setValue(percent, forKey: "targetBatteryPercent")
647
            session.setValue(nil, forKey: "targetBatteryAlertTriggeredAt")
648
            session.setValue(Date(), forKey: "updatedAt")
649
            didSave = saveContext()
650
        }
651
        return didSave
652
    }
653

            
654
    @discardableResult
655
    func confirmCompletion(for sessionID: UUID) -> Bool {
656
        var didSave = false
657
        context.performAndWait {
658
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
659
                return
660
            }
661

            
662
            guard statusValue(session, key: "statusRawValue") == .active else {
663
                return
664
            }
665

            
Bogdan Timofte authored a month ago
666
            finishSession(
667
                session,
668
                observedAt: dateValue(session, key: "lastObservedAt") ?? Date(),
669
                finalBatteryPercent: nil,
670
                label: nil,
671
                status: .completed
672
            )
Bogdan Timofte authored a month ago
673

            
674
            if saveContext() {
675
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
676
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
677
                    didSave = saveContext()
678
                } else {
679
                    didSave = true
680
                }
681
            }
682
        }
683
        return didSave
684
    }
685

            
686
    @discardableResult
687
    func continueMonitoringDespiteCompletionContradiction(for sessionID: UUID) -> Bool {
688
        var didSave = false
689
        context.performAndWait {
690
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
691
                return
692
            }
693

            
694
            guard statusValue(session, key: "statusRawValue") == .active else {
695
                return
696
            }
697

            
698
            clearCompletionConfirmationState(for: session)
699
            session.setValue(Date().addingTimeInterval(completionConfirmationCooldown), forKey: "completionConfirmationCooldownUntil")
700
            session.setValue(Date(), forKey: "updatedAt")
701
            didSave = saveContext()
702
        }
703
        return didSave
704
    }
705

            
706
    @discardableResult
707
    func deleteChargeSession(id sessionID: UUID) -> Bool {
708
        var didSave = false
709
        context.performAndWait {
710
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
711
                return
712
            }
713

            
714
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
715

            
716
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
717
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
718
            context.delete(session)
719

            
720
            guard saveContext() else {
721
                return
722
            }
723

            
724
            if let chargedDeviceID {
725
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
726
                didSave = saveContext()
727
            } else {
728
                didSave = true
729
            }
730
        }
731
        return didSave
732
    }
733

            
734
    @discardableResult
735
    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
736
        var didSave = false
737

            
738
        context.performAndWait {
739
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
740
                return
741
            }
742

            
743
            let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
744
            let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
745
            let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
746

            
747
            var impactedChargedDeviceIDs = Set<String>()
748

            
749
            for session in deviceSessions {
750
                if let impactedID = stringValue(session, key: "chargedDeviceID") {
751
                    impactedChargedDeviceIDs.insert(impactedID)
752
                }
753
                if let impactedChargerID = stringValue(session, key: "chargerID") {
754
                    impactedChargedDeviceIDs.insert(impactedChargerID)
755
                }
756
                if let sessionID = stringValue(session, key: "id") {
757
                    fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete)
758
                    fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete)
759
                }
760
                context.delete(session)
761
            }
762

            
763
            if deviceClass == .charger {
764
                for session in linkedWirelessSessions {
765
                    guard stringValue(session, key: "chargedDeviceID") != chargedDeviceID.uuidString else {
766
                        continue
767
                    }
768
                    if let impactedID = stringValue(session, key: "chargedDeviceID") {
769
                        impactedChargedDeviceIDs.insert(impactedID)
770
                    }
771
                    session.setValue(nil, forKey: "chargerID")
772
                    session.setValue(Date(), forKey: "updatedAt")
773
                }
774
            }
775

            
776
            context.delete(chargedDevice)
777

            
778
            guard saveContext() else {
779
                return
780
            }
781

            
782
            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
783
            for impactedID in impactedChargedDeviceIDs {
784
                refreshDerivedMetrics(forChargedDeviceID: impactedID)
785
            }
786
            didSave = saveContext()
787
        }
788

            
789
        return didSave
790
    }
791

            
792
    @discardableResult
793
    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
794
        var didSave = false
795

            
796
        context.performAndWait {
Bogdan Timofte authored a month ago
797
            guard let session = fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress),
798
                  let resolvedDevice = stringValue(session, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:)) else {
799
                return
800
            }
Bogdan Timofte authored a month ago
801

            
Bogdan Timofte authored a month ago
802
            if statusValue(session, key: "statusRawValue") == .paused {
803
                if maybeCompletePausedSession(session, observedAt: snapshot.observedAt) {
804
                    didSave = true
805
                }
Bogdan Timofte authored a month ago
806
                return
807
            }
808

            
Bogdan Timofte authored a month ago
809
            let chargingTransportMode = self.chargingTransportMode(for: session)
810
            let chargingStateMode = self.chargingStateMode(for: session)
Bogdan Timofte authored a month ago
811
            let charger = chargingTransportMode == .wireless
Bogdan Timofte authored a month ago
812
                ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
813
                : nil
814
            guard chargingTransportMode == .wired || charger != nil else {
815
                return
816
            }
817
            let stopThreshold = resolvedStopThreshold(
818
                for: resolvedDevice,
819
                chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
820
                chargingStateMode: chargingStateMode,
821
                charger: charger,
822
                fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
Bogdan Timofte authored a month ago
823
            )
824

            
Bogdan Timofte authored a month ago
825
            update(session: session, with: snapshot, stopThreshold: stopThreshold, charger: charger)
Bogdan Timofte authored a month ago
826
            let aggregatedSample = updateAggregatedSample(session: session, with: snapshot)
Bogdan Timofte authored a month ago
827

            
828
            let saveReason = saveReason(for: session, observedAt: snapshot.observedAt)
Bogdan Timofte authored a month ago
829
            let shouldPersistAggregatedCurve = aggregatedSample.map {
830
                shouldPersistAggregatedSample($0, observedAt: snapshot.observedAt)
831
            } ?? false
832

            
833
            guard saveReason != .none || shouldPersistAggregatedCurve else {
Bogdan Timofte authored a month ago
834
                return
835
            }
836

            
837
            session.setValue(snapshot.observedAt, forKey: "updatedAt")
838

            
839
            if saveContext() {
840
                if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
841
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
842
                    didSave = saveContext()
843
                } else {
844
                    didSave = true
845
                }
846
            }
847
        }
848

            
849
        return didSave
850
    }
851

            
852
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
853
        var summaries: [ChargedDeviceSummary] = []
854

            
855
        context.performAndWait {
856
            let devices = fetchObjects(entityName: EntityName.chargedDevice)
857
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
858
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
859
            let sessionSamples = fetchObjects(entityName: EntityName.chargeSessionSample)
860

            
861
            let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
862
            let samplesBySessionID = Dictionary(grouping: sessionSamples) { stringValue($0, key: "sessionID") ?? "" }
863
            let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
864
            let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
865

            
866
            summaries = devices.compactMap { device in
867
                guard
868
                    let id = uuidValue(device, key: "id"),
869
                    let name = stringValue(device, key: "name"),
870
                    let qrIdentifier = stringValue(device, key: "qrIdentifier"),
871
                    let rawClass = stringValue(device, key: "deviceClassRawValue"),
872
                    let deviceClass = ChargedDeviceClass(rawValue: rawClass)
873
                else {
874
                    return nil
875
                }
876

            
877
                let sessionObjects = relevantSessionObjects(
878
                    for: id.uuidString,
879
                    deviceClass: deviceClass,
880
                    sessionsByDeviceID: sessionsByDeviceID,
881
                    sessionsByChargerID: sessionsByChargerID
882
                )
883
                let sessionSummaries = sessionObjects
884
                    .compactMap { session in
885
                        makeSessionSummary(
886
                            from: session,
887
                            checkpoints: checkpointsBySessionID[stringValue(session, key: "id") ?? ""] ?? [],
888
                            samples: samplesBySessionID[stringValue(session, key: "id") ?? ""] ?? []
889
                        )
890
                    }
891
                    .sorted { lhs, rhs in
Bogdan Timofte authored a month ago
892
                        if lhs.status.isOpen && !rhs.status.isOpen {
Bogdan Timofte authored a month ago
893
                            return true
894
                        }
Bogdan Timofte authored a month ago
895
                        if !lhs.status.isOpen && rhs.status.isOpen {
896
                            return false
897
                        }
898
                        if lhs.status == .active && rhs.status == .paused {
899
                            return true
900
                        }
901
                        if lhs.status == .paused && rhs.status == .active {
Bogdan Timofte authored a month ago
902
                            return false
903
                        }
904
                        return lhs.startedAt > rhs.startedAt
905
                    }
906

            
907
                return ChargedDeviceSummary(
908
                    id: id,
909
                    qrIdentifier: qrIdentifier,
910
                    name: name,
911
                    deviceClass: deviceClass,
912
                    supportsChargingWhileOff: boolValue(device, key: "supportsChargingWhileOff"),
Bogdan Timofte authored a month ago
913
                    chargingStateAvailability: chargingStateAvailability(for: device),
Bogdan Timofte authored a month ago
914
                    supportsWiredCharging: supportsWiredCharging(for: device),
915
                    supportsWirelessCharging: supportsWirelessCharging(for: device),
916
                    wirelessChargingProfile: wirelessChargingProfile(for: device),
Bogdan Timofte authored a month ago
917
                    configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"),
918
                    learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"),
Bogdan Timofte authored a month ago
919
                    wirelessChargerEfficiencyFactor: optionalDoubleValue(device, key: "wirelessChargerEfficiencyFactor"),
920
                    wiredChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wiredChargeCompletionCurrentAmps"),
921
                    wirelessChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wirelessChargeCompletionCurrentAmps"),
922
                    chargerObservedVoltageSelections: decodedObservedVoltageSelections(from: device),
923
                    chargerIdleCurrentAmps: optionalDoubleValue(device, key: "chargerIdleCurrentAmps"),
924
                    chargerEfficiencyFactor: optionalDoubleValue(device, key: "chargerEfficiencyFactor"),
925
                    chargerMaximumPowerWatts: optionalDoubleValue(device, key: "chargerMaximumPowerWatts"),
926
                    notes: stringValue(device, key: "notes"),
927
                    minimumCurrentAmps: optionalDoubleValue(device, key: "minimumCurrentAmps"),
928
                    estimatedBatteryCapacityWh: optionalDoubleValue(device, key: "estimatedBatteryCapacityWh"),
929
                    wiredMinimumCurrentAmps: optionalDoubleValue(device, key: "wiredMinimumCurrentAmps"),
930
                    wirelessMinimumCurrentAmps: optionalDoubleValue(device, key: "wirelessMinimumCurrentAmps"),
931
                    wiredEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wiredEstimatedBatteryCapacityWh"),
932
                    wirelessEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wirelessEstimatedBatteryCapacityWh"),
933
                    lastAssociatedMeterMAC: stringValue(device, key: "lastAssociatedMeterMAC"),
934
                    createdAt: dateValue(device, key: "createdAt") ?? .distantPast,
935
                    updatedAt: dateValue(device, key: "updatedAt") ?? .distantPast,
936
                    sessions: sessionSummaries,
937
                    capacityHistory: buildCapacityHistory(from: sessionSummaries),
938
                    typicalCurve: buildTypicalCurve(from: sessionSummaries)
939
                )
940
            }
941
            .sorted { lhs, rhs in
942
                if lhs.activeSession != nil && rhs.activeSession == nil {
943
                    return true
944
                }
945
                if lhs.activeSession == nil && rhs.activeSession != nil {
946
                    return false
947
                }
948
                if lhs.updatedAt != rhs.updatedAt {
949
                    return lhs.updatedAt > rhs.updatedAt
950
                }
951
                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
952
            }
953
        }
954

            
955
        return summaries
956
    }
957

            
958
    func resolvedChargedDeviceSummary(forMeterMACAddress meterMACAddress: String) -> ChargedDeviceSummary? {
959
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
960
        guard !normalizedMAC.isEmpty else { return nil }
961

            
962
        let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
963

            
964
        if let activeMatch = summaries.first(where: { summary in
965
            summary.activeSession?.meterMACAddress == normalizedMAC
966
        }) {
967
            return activeMatch
968
        }
969

            
970
        return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
971
    }
972

            
973
    func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
974
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
975
        guard !normalizedMAC.isEmpty else { return nil }
976

            
Bogdan Timofte authored a month ago
977
        var summary: ChargeSessionSummary?
978

            
979
        context.performAndWait {
980
            guard let session = fetchActiveSessionObject(forMeterMACAddress: normalizedMAC),
981
                  let sessionID = stringValue(session, key: "id") else {
982
                return
983
            }
984

            
985
            summary = makeSessionSummary(
986
                from: session,
987
                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
988
                samples: fetchSessionSampleObjects(forSessionID: sessionID)
989
            )
990
        }
991

            
992
        return summary
Bogdan Timofte authored a month ago
993
    }
994

            
995
    private func createSessionObject(
996
        for chargedDevice: NSManagedObject,
997
        charger: NSManagedObject?,
998
        snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
999
        stopThreshold: Double?,
1000
        chargingTransportMode: ChargingTransportMode,
1001
        chargingStateMode: ChargingStateMode,
1002
        autoStopEnabled: Bool
Bogdan Timofte authored a month ago
1003
    ) -> NSManagedObject? {
1004
        guard
1005
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
1006
            let chargedDeviceID = stringValue(chargedDevice, key: "id")
1007
        else {
1008
            return nil
1009
        }
1010

            
1011
        let session = NSManagedObject(entity: entity, insertInto: context)
1012
        let now = snapshot.observedAt
1013
        session.setValue(UUID().uuidString, forKey: "id")
1014
        session.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1015
        session.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
1016
        session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress")
1017
        session.setValue(snapshot.meterName, forKey: "meterName")
1018
        session.setValue(snapshot.meterModel, forKey: "meterModel")
1019
        session.setValue(now, forKey: "startedAt")
1020
        session.setValue(now, forKey: "lastObservedAt")
1021
        session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
Bogdan Timofte authored a month ago
1022
        let usesMeterCounters = snapshot.meterEnergyCounterWh != nil || snapshot.meterChargeCounterAh != nil
1023
        session.setValue(
1024
            (usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue,
1025
            forKey: "sourceModeRawValue"
1026
        )
Bogdan Timofte authored a month ago
1027
        session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
1028
        session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue")
1029
        session.setValue(autoStopEnabled, forKey: "autoStopEnabled")
1030
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1031
        session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
1032
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1033
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1034
        session.setValue(
1035
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1036
            forKey: "lastObservedVoltageVolts"
1037
        )
Bogdan Timofte authored a month ago
1038
        session.setValue(
1039
            hasObservedChargeFlow(
1040
                currentAmps: snapshot.currentAmps,
1041
                chargingTransportMode: chargingTransportMode,
1042
                charger: charger,
1043
                stopThreshold: stopThreshold
1044
            ),
1045
            forKey: "hasObservedChargeFlow"
1046
        )
Bogdan Timofte authored a month ago
1047
        session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
1048
        session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
1049
        session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
1050
        session.setValue(
1051
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1052
            forKey: "maximumObservedVoltageVolts"
1053
        )
1054
        session.setValue(boolValue(chargedDevice, key: "supportsChargingWhileOff"), forKey: "supportsChargingWhileOff")
1055
        if let selectedDataGroup = snapshot.selectedDataGroup {
1056
            session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup")
1057
        }
1058
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1059
            session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1060
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1061
        }
1062
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1063
            session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1064
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1065
        }
Bogdan Timofte authored a month ago
1066
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1067
            setValue(meterRecordingDurationSeconds, on: session, key: "meterDurationBaselineSeconds")
1068
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1069
        }
Bogdan Timofte authored a month ago
1070
        session.setValue(now, forKey: "createdAt")
1071
        session.setValue(now, forKey: "updatedAt")
1072

            
1073
        chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
1074
        chargedDevice.setValue(now, forKey: "updatedAt")
1075
        return session
1076
    }
1077

            
1078
    private func update(
1079
        session: NSManagedObject,
1080
        with snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1081
        stopThreshold: Double?,
1082
        charger: NSManagedObject?
Bogdan Timofte authored a month ago
1083
    ) {
1084
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1085
        let lastObservedAt = dateValue(session, key: "lastObservedAt")
1086
        let previousPower = doubleValue(session, key: "lastObservedPowerWatts")
1087
        let previousCurrent = doubleValue(session, key: "lastObservedCurrentAmps")
1088
        var measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1089
        var measuredChargeAh = doubleValue(session, key: "measuredChargeAh")
1090
        var sourceMode = ChargeSessionSourceMode(rawValue: stringValue(session, key: "sourceModeRawValue") ?? "") ?? .live
1091
        var usedOfflineMeterCounters = boolValue(session, key: "usedOfflineMeterCounters")
1092

            
1093
        if let lastObservedAt {
1094
            let deltaSeconds = max(snapshot.observedAt.timeIntervalSince(lastObservedAt), 0)
1095
            if deltaSeconds > 0, deltaSeconds <= maximumLiveIntegrationGap {
1096
                measuredEnergyWh += max(previousPower, 0) * deltaSeconds / 3600
1097
                measuredChargeAh += max(previousCurrent, 0) * deltaSeconds / 3600
1098
                if sourceMode == .offline {
1099
                    sourceMode = .blended
1100
                }
1101
            }
1102
        }
1103

            
1104
        if let counterGroup = snapshot.selectedDataGroup,
1105
           let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
1106
           UInt8(storedGroup) != counterGroup {
1107
            session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
1108
            session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1109
            session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1110
        }
1111

            
1112
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1113
            let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
1114
            let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
1115
            if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
1116
                session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
1117
            }
1118

            
1119
            if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
1120
                let offlineEnergy = meterEnergyCounterWh - baselineEnergy
Bogdan Timofte authored a month ago
1121
                measuredEnergyWh = max(offlineEnergy, 0)
Bogdan Timofte authored a month ago
1122
                usedOfflineMeterCounters = true
Bogdan Timofte authored a month ago
1123
                sourceMode = .offline
Bogdan Timofte authored a month ago
1124
            } else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
1125
                let delta = meterEnergyCounterWh - lastEnergy
1126
                if delta > 0 {
1127
                    measuredEnergyWh += delta
1128
                    usedOfflineMeterCounters = true
1129
                    sourceMode = .blended
1130
                }
1131
            }
1132
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1133
        }
1134

            
1135
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1136
            let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
1137
            let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
1138
            if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
1139
                session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
1140
            }
1141

            
1142
            if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
1143
                let offlineCharge = meterChargeCounterAh - baselineCharge
Bogdan Timofte authored a month ago
1144
                measuredChargeAh = max(offlineCharge, 0)
Bogdan Timofte authored a month ago
1145
                usedOfflineMeterCounters = true
1146
            } else if let lastCharge, meterChargeCounterAh > lastCharge {
1147
                let delta = meterChargeCounterAh - lastCharge
1148
                if delta > 0 {
1149
                    measuredChargeAh += delta
1150
                    usedOfflineMeterCounters = true
1151
                }
1152
            }
1153
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1154
        }
1155

            
Bogdan Timofte authored a month ago
1156
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1157
            let baselineDuration = optionalDoubleValue(session, key: "meterDurationBaselineSeconds") ?? meterRecordingDurationSeconds
1158
            if optionalDoubleValue(session, key: "meterDurationBaselineSeconds") == nil {
1159
                setValue(baselineDuration, on: session, key: "meterDurationBaselineSeconds")
1160
            }
1161
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1162
        }
1163

            
Bogdan Timofte authored a month ago
1164
        let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps")
1165
        let updatedMinimum: Double
1166
        if snapshot.currentAmps > 0 {
1167
            updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps)
1168
        } else {
1169
            updatedMinimum = existingMinimum ?? 0
1170
        }
1171

            
Bogdan Timofte authored a month ago
1172
        let effectiveCurrent = effectiveCurrentAmps(
1173
            fromMeasuredCurrent: snapshot.currentAmps,
1174
            chargingTransportMode: sessionChargingTransportMode,
1175
            charger: charger
1176
        )
1177
        let observedChargeFlow = boolValue(session, key: "hasObservedChargeFlow")
1178
            || hasObservedChargeFlow(
1179
                currentAmps: snapshot.currentAmps,
1180
                chargingTransportMode: sessionChargingTransportMode,
1181
                charger: charger,
1182
                stopThreshold: stopThreshold
1183
            )
1184

            
Bogdan Timofte authored a month ago
1185
        session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh")
1186
        session.setValue(measuredChargeAh, forKey: "measuredChargeAh")
1187
        session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps")
Bogdan Timofte authored a month ago
1188
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1189
        session.setValue(snapshot.observedAt, forKey: "lastObservedAt")
1190
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1191
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1192
        session.setValue(
1193
            sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1194
            forKey: "lastObservedVoltageVolts"
1195
        )
Bogdan Timofte authored a month ago
1196
        session.setValue(observedChargeFlow, forKey: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
1197
        session.setValue(
1198
            max(optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? snapshot.currentAmps, snapshot.currentAmps),
1199
            forKey: "maximumObservedCurrentAmps"
1200
        )
1201
        session.setValue(
1202
            max(optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? snapshot.powerWatts, snapshot.powerWatts),
1203
            forKey: "maximumObservedPowerWatts"
1204
        )
1205
        session.setValue(
1206
            sessionChargingTransportMode == .wired
1207
                ? max(optionalDoubleValue(session, key: "maximumObservedVoltageVolts") ?? snapshot.voltageVolts, snapshot.voltageVolts)
1208
                : nil,
1209
            forKey: "maximumObservedVoltageVolts"
1210
        )
1211
        session.setValue(usedOfflineMeterCounters, forKey: "usedOfflineMeterCounters")
1212
        session.setValue(sourceMode.rawValue, forKey: "sourceModeRawValue")
1213
        maybeTriggerTargetBatteryAlert(for: session, observedAt: snapshot.observedAt)
1214

            
Bogdan Timofte authored a month ago
1215
        guard boolValue(session, key: "autoStopEnabled"), let stopThreshold, stopThreshold > 0, observedChargeFlow else {
1216
            session.setValue(nil, forKey: "belowThresholdSince")
1217
            clearCompletionConfirmationState(for: session)
1218
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1219
            return
1220
        }
1221

            
1222
        if effectiveCurrent <= stopThreshold {
Bogdan Timofte authored a month ago
1223
            let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt
1224
            session.setValue(belowThresholdSince, forKey: "belowThresholdSince")
1225
            if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
1226
                if boolValue(session, key: "requiresCompletionConfirmation") {
1227
                    // Leave the session active until the user explicitly confirms or charging resumes.
1228
                    return
1229
                }
1230

            
1231
                if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
1232
                    requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt)
1233
                } else {
Bogdan Timofte authored a month ago
1234
                    finishSession(
1235
                        session,
1236
                        observedAt: snapshot.observedAt,
1237
                        finalBatteryPercent: nil,
1238
                        label: nil,
1239
                        status: .completed
1240
                    )
Bogdan Timofte authored a month ago
1241
                }
1242
            }
1243
        } else {
1244
            session.setValue(nil, forKey: "belowThresholdSince")
1245
            clearCompletionConfirmationState(for: session)
1246
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1247
        }
1248
    }
1249

            
1250
    private func updateAggregatedSample(
1251
        session: NSManagedObject,
1252
        with snapshot: ChargingMonitorSnapshot
Bogdan Timofte authored a month ago
1253
    ) -> NSManagedObject? {
Bogdan Timofte authored a month ago
1254
        guard
1255
            let sessionID = stringValue(session, key: "id"),
1256
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1257
            let startedAt = dateValue(session, key: "startedAt"),
1258
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context)
1259
        else {
Bogdan Timofte authored a month ago
1260
            return nil
Bogdan Timofte authored a month ago
1261
        }
1262

            
1263
        let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0)
1264
        let bucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
1265
        let bucketStart = startedAt.addingTimeInterval(Double(bucketIndex) * Self.aggregatedSampleBucketDuration)
1266
        let bucketIdentifier = "\(sessionID)-\(bucketIndex)"
1267
        let sample = fetchSessionSampleObject(sessionID: sessionID, bucketIndex: bucketIndex)
1268
            ?? NSManagedObject(entity: entity, insertInto: context)
1269
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1270
        let sampleVoltage = sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil
1271

            
1272
        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
1273
        let updatedCount = existingCount + 1
1274

            
1275
        sample.setValue(bucketIdentifier, forKey: "id")
1276
        sample.setValue(sessionID, forKey: "sessionID")
1277
        sample.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1278
        sample.setValue(bucketIndex, forKey: "bucketIndex")
1279
        sample.setValue(max(snapshot.observedAt, bucketStart), forKey: "timestamp")
1280
        sample.setValue(
1281
            runningAverage(
1282
                currentAverage: optionalDoubleValue(sample, key: "averageCurrentAmps") ?? snapshot.currentAmps,
1283
                currentCount: Int(existingCount),
1284
                newValue: snapshot.currentAmps
1285
            ),
1286
            forKey: "averageCurrentAmps"
1287
        )
1288
        sample.setValue(
1289
            sampleVoltage.flatMap { voltage in
1290
                runningAverage(
1291
                    currentAverage: optionalDoubleValue(sample, key: "averageVoltageVolts") ?? voltage,
1292
                    currentCount: Int(existingCount),
1293
                    newValue: voltage
1294
                )
1295
            },
1296
            forKey: "averageVoltageVolts"
1297
        )
1298
        sample.setValue(
1299
            runningAverage(
1300
                currentAverage: optionalDoubleValue(sample, key: "averagePowerWatts") ?? snapshot.powerWatts,
1301
                currentCount: Int(existingCount),
1302
                newValue: snapshot.powerWatts
1303
            ),
1304
            forKey: "averagePowerWatts"
1305
        )
1306
        sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh")
1307
        sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
1308
        sample.setValue(Int16(updatedCount), forKey: "sampleCount")
1309
        sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
1310
        sample.setValue(snapshot.observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1311
        return sample
Bogdan Timofte authored a month ago
1312
    }
1313

            
1314
    private func maybeTriggerTargetBatteryAlert(for session: NSManagedObject, observedAt: Date) {
1315
        guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
1316
            return
1317
        }
1318

            
1319
        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
1320
            return
1321
        }
1322

            
1323
        let predictedBatteryPercent = predictedBatteryPercent(for: session)
1324
            ?? optionalDoubleValue(session, key: "endBatteryPercent")
1325

            
1326
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
1327
            return
1328
        }
1329

            
1330
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
1331
    }
1332

            
1333
    private func shouldRequireCompletionConfirmation(
1334
        for session: NSManagedObject,
1335
        observedAt: Date
1336
    ) -> Bool {
1337
        if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"),
1338
           cooldownUntil > observedAt {
1339
            return false
1340
        }
1341

            
1342
        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
1343
            return false
1344
        }
1345

            
1346
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
1347
            ?? defaultCompletionPercentThreshold
1348

            
1349
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1350
    }
1351

            
1352
    private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
1353
        guard !boolValue(session, key: "requiresCompletionConfirmation") else {
1354
            return
1355
        }
1356

            
1357
        session.setValue(true, forKey: "requiresCompletionConfirmation")
1358
        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
1359
        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
1360
    }
1361

            
1362
    private func clearCompletionConfirmationState(for session: NSManagedObject) {
1363
        session.setValue(false, forKey: "requiresCompletionConfirmation")
1364
        session.setValue(nil, forKey: "completionConfirmationRequestedAt")
1365
        session.setValue(nil, forKey: "completionContradictionPercent")
1366
    }
1367

            
Bogdan Timofte authored a month ago
1368
    private func snapshotDateForManualStop(_ session: NSManagedObject) -> Date {
1369
        if statusValue(session, key: "statusRawValue") == .paused {
1370
            return dateValue(session, key: "pausedAt")
1371
                ?? dateValue(session, key: "lastObservedAt")
1372
                ?? Date()
1373
        }
1374
        return dateValue(session, key: "lastObservedAt") ?? Date()
1375
    }
1376

            
1377
    @discardableResult
1378
    private func maybeCompletePausedSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
1379
        guard statusValue(session, key: "statusRawValue") == .paused else {
1380
            return false
1381
        }
1382

            
1383
        guard let pausedAt = dateValue(session, key: "pausedAt"),
1384
              observedAt.timeIntervalSince(pausedAt) >= pausedSessionTimeout else {
1385
            return false
1386
        }
1387

            
1388
        finishSession(
1389
            session,
1390
            observedAt: pausedAt.addingTimeInterval(pausedSessionTimeout),
1391
            finalBatteryPercent: nil,
1392
            label: nil,
1393
            status: .completed
1394
        )
1395

            
1396
        guard saveContext() else {
1397
            return false
1398
        }
1399

            
1400
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1401
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1402
            return saveContext()
1403
        }
1404

            
1405
        return true
1406
    }
1407

            
1408
    private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
1409
        let chargingTransportMode = chargingTransportMode(for: session)
1410
        let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps")
1411
            ?? doubleValue(session, key: "lastObservedCurrentAmps")
1412

            
1413
        guard measuredCurrent > 0 else {
1414
            return nil
1415
        }
1416

            
1417
        let charger = chargingTransportMode == .wireless
1418
            ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
1419
            : nil
1420

            
1421
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
1422
            return nil
1423
        }
1424

            
1425
        let effectiveCurrent = effectiveCurrentAmps(
1426
            fromMeasuredCurrent: measuredCurrent,
1427
            chargingTransportMode: chargingTransportMode,
1428
            charger: charger
1429
        )
1430
        guard effectiveCurrent > 0 else {
1431
            return nil
1432
        }
1433
        return effectiveCurrent
1434
    }
1435

            
1436
    private func finishSession(
1437
        _ session: NSManagedObject,
1438
        observedAt: Date,
1439
        finalBatteryPercent: Double?,
1440
        label: String?,
1441
        status: ChargeSessionStatus
1442
    ) {
1443
        if let finalBatteryPercent {
1444
            _ = insertBatteryCheckpoint(
1445
                percent: finalBatteryPercent,
1446
                label: label,
1447
                timestamp: observedAt,
1448
                to: session
1449
            )
1450
        }
1451

            
1452
        session.setValue(status.rawValue, forKey: "statusRawValue")
1453
        session.setValue(nil, forKey: "pausedAt")
1454
        session.setValue(nil, forKey: "belowThresholdSince")
1455
        session.setValue(observedAt, forKey: "endedAt")
1456
        session.setValue(observedAt, forKey: "lastObservedAt")
1457
        session.setValue(completionCurrentForSessionEnd(session), forKey: "completionCurrentAmps")
1458
        clearCompletionConfirmationState(for: session)
1459
        session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1460
        updateCapacityEstimate(for: session)
1461
        session.setValue(observedAt, forKey: "updatedAt")
1462
    }
1463

            
Bogdan Timofte authored a month ago
1464
    private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
1465
        guard
1466
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1467
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
1468
            let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice),
1469
            estimatedCapacityWh > 0
1470
        else {
1471
            return nil
1472
        }
1473

            
1474
        let measuredEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1475
            ?? doubleValue(session, key: "measuredEnergyWh")
1476
        let sessionID = stringValue(session, key: "id") ?? ""
1477

            
1478
        struct Anchor {
1479
            let percent: Double
1480
            let energyWh: Double
Bogdan Timofte authored a month ago
1481
            let timestamp: Date
1482
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1483
        }
1484

            
1485
        var anchors: [Anchor] = []
Bogdan Timofte authored a month ago
1486
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1487
           startBatteryPercent >= 0 {
1488
            anchors.append(
1489
                Anchor(
1490
                    percent: startBatteryPercent,
1491
                    energyWh: 0,
1492
                    timestamp: dateValue(session, key: "startedAt") ?? Date.distantPast,
1493
                    isCheckpoint: false
1494
                )
1495
            )
Bogdan Timofte authored a month ago
1496
        }
1497

            
1498
        let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
1499
            .compactMap(makeCheckpointSummary(from:))
1500
            .sorted { lhs, rhs in
1501
                if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1502
                    return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1503
                }
1504
                return lhs.timestamp < rhs.timestamp
1505
            }
Bogdan Timofte authored a month ago
1506
            .filter { $0.batteryPercent >= 0 }
1507
            .map {
1508
                Anchor(
1509
                    percent: $0.batteryPercent,
1510
                    energyWh: $0.measuredEnergyWh,
1511
                    timestamp: $0.timestamp,
1512
                    isCheckpoint: true
1513
                )
1514
            }
Bogdan Timofte authored a month ago
1515
        anchors.append(contentsOf: checkpointAnchors)
1516

            
1517
        guard !anchors.isEmpty else {
1518
            return optionalDoubleValue(session, key: "endBatteryPercent")
1519
        }
1520

            
1521
        let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
Bogdan Timofte authored a month ago
1522
        return BatteryLevelPredictionTuning.predictedPercent(
1523
            anchorPercent: anchor.percent,
1524
            anchorEnergyWh: anchor.energyWh,
1525
            anchorTimestamp: anchor.timestamp,
1526
            anchorIsCheckpoint: anchor.isCheckpoint,
1527
            effectiveEnergyWh: measuredEnergyWh,
1528
            referenceTimestamp: dateValue(session, key: "lastObservedAt") ?? anchor.timestamp,
1529
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1530
        )
1531
    }
1532

            
1533
    private func resolvedEstimatedBatteryCapacityWh(
1534
        for session: NSManagedObject,
1535
        chargedDevice: NSManagedObject
1536
    ) -> Double? {
1537
        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
1538
           sessionCapacityEstimate > 0 {
1539
            return sessionCapacityEstimate
1540
        }
1541

            
1542
        switch chargingTransportMode(for: session) {
1543
        case .wired:
1544
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1545
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1546
        case .wireless:
1547
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1548
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1549
        }
1550
    }
1551

            
1552
    private func updateCapacityEstimate(for session: NSManagedObject) {
1553
        guard
1554
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1555
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
1556
        else {
1557
            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
1558
            session.setValue(nil, forKey: "capacityEstimateWh")
1559
            return
1560
        }
1561

            
1562
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1563
        let chargingMode = chargingTransportMode(for: session)
1564
        let wirelessResolution = chargingMode == .wireless
1565
            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
1566
            : nil
1567
        let effectiveBatteryEnergyWh = chargingMode == .wired
1568
            ? measuredEnergyWh
1569
            : wirelessResolution.map { measuredEnergyWh * $0.factor }
1570

            
1571
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
1572
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
1573
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
1574
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
1575

            
1576
        guard
1577
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1578
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1579
        else {
1580
            session.setValue(nil, forKey: "capacityEstimateWh")
1581
            return
1582
        }
1583

            
Bogdan Timofte authored a month ago
1584
        guard startBatteryPercent >= 0, endBatteryPercent >= 0 else {
1585
            session.setValue(nil, forKey: "capacityEstimateWh")
1586
            return
1587
        }
1588

            
Bogdan Timofte authored a month ago
1589
        let percentDelta = endBatteryPercent - startBatteryPercent
1590
        let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
1591

            
1592
        guard percentDelta >= 20, let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
1593
            session.setValue(nil, forKey: "capacityEstimateWh")
1594
            return
1595
        }
1596

            
1597
        if !supportsChargingWhileOff && endBatteryPercent >= 99.5 {
1598
            session.setValue(nil, forKey: "capacityEstimateWh")
1599
            return
1600
        }
1601

            
1602
        let capacityEstimateWh = effectiveBatteryEnergyWh / (percentDelta / 100)
1603
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
1604
    }
1605

            
1606
    @discardableResult
Bogdan Timofte authored a month ago
1607
    private func insertBatteryCheckpoint(
Bogdan Timofte authored a month ago
1608
        percent: Double,
1609
        label: String?,
Bogdan Timofte authored a month ago
1610
        timestamp: Date = Date(),
Bogdan Timofte authored a month ago
1611
        measuredEnergyWhOverride: Double? = nil,
1612
        measuredChargeAhOverride: Double? = nil,
Bogdan Timofte authored a month ago
1613
        to session: NSManagedObject
Bogdan Timofte authored a month ago
1614
    ) -> String? {
Bogdan Timofte authored a month ago
1615
        guard
1616
            let sessionID = stringValue(session, key: "id"),
1617
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1618
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
1619
        else {
Bogdan Timofte authored a month ago
1620
            return nil
Bogdan Timofte authored a month ago
1621
        }
1622

            
1623
        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
Bogdan Timofte authored a month ago
1624
        let checkpointEnergyWh = measuredEnergyWhOverride
1625
            ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
Bogdan Timofte authored a month ago
1626
            ?? doubleValue(session, key: "measuredEnergyWh")
Bogdan Timofte authored a month ago
1627
        let checkpointChargeAh = measuredChargeAhOverride
1628
            ?? doubleValue(session, key: "measuredChargeAh")
Bogdan Timofte authored a month ago
1629
        checkpoint.setValue(UUID().uuidString, forKey: "id")
1630
        checkpoint.setValue(sessionID, forKey: "sessionID")
1631
        checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
Bogdan Timofte authored a month ago
1632
        checkpoint.setValue(timestamp, forKey: "timestamp")
Bogdan Timofte authored a month ago
1633
        checkpoint.setValue(percent, forKey: "batteryPercent")
1634
        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
Bogdan Timofte authored a month ago
1635
        checkpoint.setValue(checkpointChargeAh, forKey: "measuredChargeAh")
Bogdan Timofte authored a month ago
1636
        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
1637
        checkpoint.setValue(
1638
            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
1639
            forKey: "voltageVolts"
1640
        )
1641
        checkpoint.setValue(normalizedOptionalText(label), forKey: "label")
Bogdan Timofte authored a month ago
1642
        checkpoint.setValue(timestamp, forKey: "createdAt")
Bogdan Timofte authored a month ago
1643

            
Bogdan Timofte authored a month ago
1644
        let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
1645
        if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
Bogdan Timofte authored a month ago
1646
            session.setValue(percent, forKey: "startBatteryPercent")
1647
        }
Bogdan Timofte authored a month ago
1648
        if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
1649
            session.setValue(percent, forKey: "endBatteryPercent")
1650
        }
Bogdan Timofte authored a month ago
1651
        session.setValue(timestamp, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1652
        updateCapacityEstimate(for: session)
1653

            
Bogdan Timofte authored a month ago
1654
        return chargedDeviceID
1655
    }
1656

            
Bogdan Timofte authored a month ago
1657
    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
1658
        guard let sessionID = stringValue(session, key: "id") else {
1659
            return
1660
        }
1661

            
1662
        let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
1663
        if let latestCheckpoint = remainingCheckpoints.last {
1664
            session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
1665
        } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1666
                  startBatteryPercent >= 0 {
1667
            session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
1668
        } else {
1669
            session.setValue(nil, forKey: "endBatteryPercent")
1670
        }
1671

            
1672
        session.setValue(Date(), forKey: "updatedAt")
1673
        updateCapacityEstimate(for: session)
1674
    }
1675

            
Bogdan Timofte authored a month ago
1676
    @discardableResult
1677
    private func addBatteryCheckpoint(
1678
        percent: Double,
1679
        label: String?,
Bogdan Timofte authored a month ago
1680
        measuredEnergyWh: Double? = nil,
1681
        measuredChargeAh: Double? = nil,
Bogdan Timofte authored a month ago
1682
        to session: NSManagedObject,
1683
        timestamp: Date = Date()
1684
    ) -> Bool {
Bogdan Timofte authored a month ago
1685
        if let measuredEnergyWh, measuredEnergyWh.isFinite {
1686
            session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
1687
        }
1688
        if let measuredChargeAh, measuredChargeAh.isFinite {
1689
            session.setValue(max(measuredChargeAh, 0), forKey: "measuredChargeAh")
1690
        }
1691

            
Bogdan Timofte authored a month ago
1692
        guard let chargedDeviceID = insertBatteryCheckpoint(
1693
            percent: percent,
1694
            label: label,
1695
            timestamp: timestamp,
Bogdan Timofte authored a month ago
1696
            measuredEnergyWhOverride: measuredEnergyWh,
1697
            measuredChargeAhOverride: measuredChargeAh,
Bogdan Timofte authored a month ago
1698
            to: session
1699
        ) else {
1700
            return false
1701
        }
1702

            
Bogdan Timofte authored a month ago
1703
        guard saveContext() else {
1704
            return false
1705
        }
1706

            
1707
        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1708
        return saveContext()
1709
    }
1710

            
1711
    private func resolvedWirelessEfficiency(
1712
        for session: NSManagedObject,
1713
        chargedDevice: NSManagedObject
1714
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
1715
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
1716
           storedFactor > 0 {
1717
            return (
1718
                factor: storedFactor,
1719
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
1720
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
1721
            )
1722
        }
1723

            
1724
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
1725
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1726
        guard measuredEnergyWh > 0 else {
1727
            return nil
1728
        }
1729

            
1730
        if chargingProfile == .magsafe,
1731
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
1732
           calibratedFactor > 0 {
1733
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
1734
        }
1735

            
1736
        guard
1737
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1738
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1739
        else {
1740
            return nil
1741
        }
1742

            
1743
        let percentDelta = endBatteryPercent - startBatteryPercent
1744
        guard percentDelta >= 20 else {
1745
            return nil
1746
        }
1747

            
1748
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
Bogdan Timofte authored a month ago
1749
            ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
Bogdan Timofte authored a month ago
1750
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1751
                : nil),
1752
              wiredCapacityWh > 0
1753
        else {
1754
            return nil
1755
        }
1756

            
1757
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
1758
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
1759
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
1760
        let usesEstimated = chargingProfile != .magsafe
1761
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
1762

            
1763
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
1764
    }
1765

            
1766
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
1767
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
1768
            return
1769
        }
1770

            
1771
        let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
Bogdan Timofte authored a month ago
1772
        let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
Bogdan Timofte authored a month ago
1773
        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
1774
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
1775
        let sessions = relevantSessionObjects(
1776
            for: chargedDeviceID,
1777
            deviceClass: deviceClass,
1778
            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
1779
            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
1780
        )
Bogdan Timofte authored a month ago
1781
        let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
Bogdan Timofte authored a month ago
1782
        let wiredMinimumCurrent = derivedMinimumCurrent(
1783
            from: sessions,
1784
            chargingTransportMode: .wired
1785
        )
1786
        let wirelessMinimumCurrent = derivedMinimumCurrent(
1787
            from: sessions,
1788
            chargingTransportMode: .wireless
1789
        )
1790

            
1791
        let wiredCapacity = derivedCapacity(
1792
            from: sessions,
1793
            chargingTransportMode: .wired,
1794
            supportsChargingWhileOff: supportsChargingWhileOff
1795
        )
1796
        let wirelessCapacity = derivedCapacity(
1797
            from: sessions,
1798
            chargingTransportMode: .wireless,
1799
            supportsChargingWhileOff: supportsChargingWhileOff
1800
        )
1801
        let wirelessEfficiency = derivedWirelessEfficiency(
1802
            from: sessions,
1803
            chargingProfile: wirelessProfile
1804
        )
Bogdan Timofte authored a month ago
1805
        let configuredCompletionCurrents = decodedCompletionCurrents(
1806
            from: chargedDevice,
1807
            key: "configuredCompletionCurrentsRawValue"
1808
        )
Bogdan Timofte authored a month ago
1809
        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
1810
        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
1811
        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
1812
        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
1813
        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
1814
        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
1815

            
Bogdan Timofte authored a month ago
1816
        let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
1817
        let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
Bogdan Timofte authored a month ago
1818
        let preferredMinimumCurrent: Double?
1819
        let preferredCapacity: Double?
1820
        switch preferredChargingTransportMode {
1821
        case .wired:
Bogdan Timofte authored a month ago
1822
            preferredMinimumCurrent = configuredCompletionCurrents[
1823
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
1824
            ] ?? learnedCompletionCurrents[
1825
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
1826
            ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
Bogdan Timofte authored a month ago
1827
            preferredCapacity = wiredCapacity ?? wirelessCapacity
1828
        case .wireless:
Bogdan Timofte authored a month ago
1829
            preferredMinimumCurrent = configuredCompletionCurrents[
1830
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
1831
            ] ?? learnedCompletionCurrents[
1832
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
1833
            ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
Bogdan Timofte authored a month ago
1834
            preferredCapacity = wirelessCapacity ?? wiredCapacity
1835
        }
1836

            
Bogdan Timofte authored a month ago
1837
        setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
1838
        setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
1839
        setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
1840
        setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1841
        setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1842
        setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
1843
        setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
1844
        setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
1845
        setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
1846
        setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
1847
        setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
1848
        setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
1849
        setValue(Date(), on: chargedDevice, key: "updatedAt")
Bogdan Timofte authored a month ago
1850
    }
1851

            
1852
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
1853
        sessions
1854
            .filter { $0.status == .completed }
1855
            .compactMap { session in
1856
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
1857
                let timestamp = session.endedAt ?? session.lastObservedAt
1858
                return CapacityTrendPoint(
1859
                    sessionID: session.id,
1860
                    timestamp: timestamp,
1861
                    capacityWh: capacityEstimateWh,
1862
                    chargingTransportMode: session.chargingTransportMode
1863
                )
1864
            }
1865
            .sorted { $0.timestamp < $1.timestamp }
1866
    }
1867

            
1868
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
1869
        var groupedEnergyByBin: [Int: [Double]] = [:]
1870
        var groupedChargeByBin: [Int: [Double]] = [:]
1871

            
1872
        for session in sessions where session.status == .completed {
1873
            var points = session.checkpoints
1874

            
1875
            if let startBatteryPercent = session.startBatteryPercent {
1876
                points.append(
1877
                    ChargeCheckpointSummary(
1878
                        id: UUID(),
1879
                        sessionID: session.id,
1880
                        chargedDeviceID: session.chargedDeviceID,
1881
                        timestamp: session.startedAt,
1882
                        batteryPercent: startBatteryPercent,
1883
                        measuredEnergyWh: 0,
1884
                        measuredChargeAh: 0,
1885
                        currentAmps: 0,
1886
                        voltageVolts: nil,
1887
                        label: "Start"
1888
                    )
1889
                )
1890
            }
1891

            
1892
            if let endBatteryPercent = session.endBatteryPercent {
1893
                points.append(
1894
                    ChargeCheckpointSummary(
1895
                        id: UUID(),
1896
                        sessionID: session.id,
1897
                        chargedDeviceID: session.chargedDeviceID,
1898
                        timestamp: session.endedAt ?? session.lastObservedAt,
1899
                        batteryPercent: endBatteryPercent,
1900
                        measuredEnergyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
1901
                        measuredChargeAh: session.measuredChargeAh,
1902
                        currentAmps: 0,
1903
                        voltageVolts: nil,
1904
                        label: "End"
1905
                    )
1906
                )
1907
            }
1908

            
1909
            for point in points {
1910
                let percentBin = Int((point.batteryPercent / 10).rounded(.toNearestOrEven)) * 10
1911
                groupedEnergyByBin[percentBin, default: []].append(point.measuredEnergyWh)
1912
                groupedChargeByBin[percentBin, default: []].append(point.measuredChargeAh)
1913
            }
1914
        }
1915

            
1916
        return groupedEnergyByBin.keys.sorted().compactMap { percentBin in
1917
            guard
1918
                let energies = groupedEnergyByBin[percentBin],
1919
                let charges = groupedChargeByBin[percentBin],
1920
                !energies.isEmpty,
1921
                !charges.isEmpty
1922
            else {
1923
                return nil
1924
            }
1925

            
1926
            return TypicalChargeCurvePoint(
1927
                percentBin: percentBin,
1928
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
1929
                averageChargeAh: charges.reduce(0, +) / Double(charges.count),
1930
                sampleCount: min(energies.count, charges.count)
1931
            )
1932
        }
1933
    }
1934

            
1935
    private func makeSessionSummary(
1936
        from object: NSManagedObject,
1937
        checkpoints: [NSManagedObject],
1938
        samples: [NSManagedObject]
1939
    ) -> ChargeSessionSummary? {
1940
        let chargingTransportMode = chargingTransportMode(for: object)
1941

            
1942
        guard
1943
            let id = uuidValue(object, key: "id"),
1944
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
1945
            let startedAt = dateValue(object, key: "startedAt"),
1946
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
1947
            let status = statusValue(object, key: "statusRawValue"),
1948
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
1949
        else {
1950
            return nil
1951
        }
1952

            
1953
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
1954
            .sorted { $0.timestamp < $1.timestamp }
1955
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
1956
            .sorted { lhs, rhs in
1957
                if lhs.bucketIndex != rhs.bucketIndex {
1958
                    return lhs.bucketIndex < rhs.bucketIndex
1959
                }
1960
                return lhs.timestamp < rhs.timestamp
1961
            }
1962

            
1963
        return ChargeSessionSummary(
1964
            id: id,
1965
            chargedDeviceID: chargedDeviceID,
1966
            chargerID: uuidValue(object, key: "chargerID"),
1967
            meterMACAddress: stringValue(object, key: "meterMACAddress"),
1968
            meterName: stringValue(object, key: "meterName"),
1969
            meterModel: stringValue(object, key: "meterModel"),
1970
            startedAt: startedAt,
1971
            endedAt: dateValue(object, key: "endedAt"),
1972
            lastObservedAt: lastObservedAt,
Bogdan Timofte authored a month ago
1973
            pausedAt: dateValue(object, key: "pausedAt"),
Bogdan Timofte authored a month ago
1974
            status: status,
1975
            sourceMode: sourceMode,
1976
            chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
1977
            chargingStateMode: chargingStateMode(for: object),
1978
            autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
Bogdan Timofte authored a month ago
1979
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
1980
            effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
1981
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
Bogdan Timofte authored a month ago
1982
            meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
1983
            meterChargeBaselineAh: optionalDoubleValue(object, key: "meterChargeBaselineAh"),
Bogdan Timofte authored a month ago
1984
            meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"),
1985
            meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"),
Bogdan Timofte authored a month ago
1986
            minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
1987
            maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
1988
            maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
1989
            maximumObservedVoltageVolts: chargingTransportMode == .wired
1990
                ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
1991
                : nil,
1992
            selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
1993
            completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
1994
            stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
1995
            startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
1996
            endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
1997
            capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
1998
            wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
1999
            usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
2000
            shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
2001
            supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
2002
            usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
2003
            targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
2004
            targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
2005
            requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
2006
            completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
2007
            completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
2008
            selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
2009
            checkpoints: checkpointSummaries,
2010
            aggregatedSamples: sampleSummaries
2011
        )
2012
    }
2013

            
2014
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
2015
        guard
2016
            let id = uuidValue(object, key: "id"),
2017
            let sessionID = uuidValue(object, key: "sessionID"),
2018
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2019
            let timestamp = dateValue(object, key: "timestamp")
2020
        else {
2021
            return nil
2022
        }
2023

            
2024
        return ChargeCheckpointSummary(
2025
            id: id,
2026
            sessionID: sessionID,
2027
            chargedDeviceID: chargedDeviceID,
2028
            timestamp: timestamp,
2029
            batteryPercent: doubleValue(object, key: "batteryPercent"),
2030
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2031
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2032
            currentAmps: doubleValue(object, key: "currentAmps"),
2033
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
2034
            label: stringValue(object, key: "label")
2035
        )
2036
    }
2037

            
2038
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
2039
        guard
2040
            let sessionID = uuidValue(object, key: "sessionID"),
2041
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2042
            let timestamp = dateValue(object, key: "timestamp")
2043
        else {
2044
            return nil
2045
        }
2046

            
2047
        return ChargeSessionSampleSummary(
2048
            sessionID: sessionID,
2049
            chargedDeviceID: chargedDeviceID,
2050
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
2051
            timestamp: timestamp,
2052
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
2053
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
2054
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
2055
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2056
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2057
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
2058
        )
2059
    }
2060

            
Bogdan Timofte authored a month ago
2061
    private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2062
        fetchSessionObject(
2063
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2064
                format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
Bogdan Timofte authored a month ago
2065
                normalizedMACAddress(meterMACAddress),
Bogdan Timofte authored a month ago
2066
                ChargeSessionStatus.active.rawValue,
2067
                ChargeSessionStatus.paused.rawValue
Bogdan Timofte authored a month ago
2068
            )
2069
        )
2070
    }
2071

            
Bogdan Timofte authored a month ago
2072
    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2073
        fetchSessionObject(
2074
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2075
                format: "meterMACAddress == %@ AND statusRawValue == %@",
2076
                normalizedMACAddress(meterMACAddress),
2077
                ChargeSessionStatus.active.rawValue
Bogdan Timofte authored a month ago
2078
            )
2079
        )
2080
    }
2081

            
2082
    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
2083
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2084
        request.predicate = predicate
2085
        request.fetchLimit = 1
2086
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
2087
        return (try? context.fetch(request))?.first
2088
    }
2089

            
2090
    private func fetchSessionObject(id: String) -> NSManagedObject? {
2091
        fetchSessionObject(
2092
            predicate: NSPredicate(format: "id == %@", id)
2093
        )
2094
    }
2095

            
2096
    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
2097
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2098
        request.predicate = NSPredicate(
2099
            format: "sessionID == %@ AND bucketIndex == %d",
2100
            sessionID,
2101
            bucketIndex
2102
        )
2103
        request.fetchLimit = 1
2104
        return (try? context.fetch(request))?.first
2105
    }
2106

            
2107
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2108
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2109
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2110
        return (try? context.fetch(request)) ?? []
2111
    }
2112

            
Bogdan Timofte authored a month ago
2113
    private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
2114
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2115
        request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
2116
        request.fetchLimit = 1
2117
        return (try? context.fetch(request))?.first
2118
    }
2119

            
Bogdan Timofte authored a month ago
2120
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2121
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2122
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2123
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
2124
        return (try? context.fetch(request)) ?? []
2125
    }
2126

            
2127
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
2128
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2129
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
2130
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2131
        return (try? context.fetch(request)) ?? []
2132
    }
2133

            
2134
    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
2135
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2136
        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
2137
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2138
        return (try? context.fetch(request)) ?? []
2139
    }
2140

            
2141
    private func relevantSessionObjects(
2142
        for chargedDeviceID: String,
2143
        deviceClass: ChargedDeviceClass,
2144
        sessionsByDeviceID: [String: [NSManagedObject]],
2145
        sessionsByChargerID: [String: [NSManagedObject]]
2146
    ) -> [NSManagedObject] {
2147
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
2148
        guard deviceClass == .charger else {
2149
            return directSessions
2150
        }
2151

            
2152
        var seenSessionIDs = Set<String>()
2153
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
2154
            .filter { session in
2155
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
2156
                return seenSessionIDs.insert(sessionID).inserted
2157
            }
2158
            .sorted {
2159
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
2160
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
2161
                return lhsDate < rhsDate
2162
            }
2163
    }
2164

            
2165
    private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
2166
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false)
2167
    }
2168

            
2169
    private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
2170
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true)
2171
    }
2172

            
2173
    private func resolvedAssignedObject(
2174
        for meterMACAddress: String,
2175
        expectsChargerClass: Bool
2176
    ) -> NSManagedObject? {
2177
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
2178
        guard !normalizedMAC.isEmpty else { return nil }
2179

            
2180
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2181
        request.predicate = NSPredicate(format: "lastAssociatedMeterMAC == %@", normalizedMAC)
2182
        request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
2183
        let matches = (try? context.fetch(request)) ?? []
2184
        return matches.first { object in
Bogdan Timofte authored a month ago
2185
            isChargerObject(object) == expectsChargerClass
Bogdan Timofte authored a month ago
2186
        }
2187
    }
2188

            
Bogdan Timofte authored a month ago
2189
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
2190
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2191
    }
2192

            
Bogdan Timofte authored a month ago
2193
    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
2194
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2195
        request.predicate = NSPredicate(format: "id == %@", id)
2196
        request.fetchLimit = 1
2197
        return (try? context.fetch(request))?.first
2198
    }
2199

            
2200
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
2201
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
2202
        return (try? context.fetch(request)) ?? []
2203
    }
2204

            
2205
    private func resolvedStopThreshold(
2206
        for chargedDevice: NSManagedObject,
2207
        chargingTransportMode: ChargingTransportMode,
Bogdan Timofte authored a month ago
2208
        chargingStateMode: ChargingStateMode,
2209
        charger: NSManagedObject?,
2210
        fallback: Double?
2211
    ) -> Double? {
2212
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
2213
            return nil
2214
        }
2215

            
2216
        let sessionKind = ChargeSessionKind(
2217
            chargingTransportMode: chargingTransportMode,
2218
            chargingStateMode: chargingStateMode
2219
        )
2220
        let configuredCurrents = decodedCompletionCurrents(
2221
            from: chargedDevice,
2222
            key: "configuredCompletionCurrentsRawValue"
2223
        )
2224
        let learnedCurrents = decodedCompletionCurrents(
2225
            from: chargedDevice,
2226
            key: "learnedCompletionCurrentsRawValue"
2227
        )
2228
        let legacyCurrent: Double?
Bogdan Timofte authored a month ago
2229
        switch chargingTransportMode {
2230
        case .wired:
Bogdan Timofte authored a month ago
2231
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2232
                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
2233
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2234
        case .wireless:
Bogdan Timofte authored a month ago
2235
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2236
                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
2237
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2238
        }
Bogdan Timofte authored a month ago
2239

            
2240
        let resolvedCurrent = configuredCurrents[sessionKind]
2241
            ?? learnedCurrents[sessionKind]
2242
            ?? legacyCurrent
2243
            ?? fallback
2244
        guard let resolvedCurrent, resolvedCurrent > 0 else {
2245
            return nil
2246
        }
2247
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
2248
    }
2249

            
Bogdan Timofte authored a month ago
2250
    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
Bogdan Timofte authored a month ago
2251
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
2252
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
2253
        return resolvedPreferredChargingTransportMode(
Bogdan Timofte authored a month ago
2254
            .wired,
Bogdan Timofte authored a month ago
2255
            supportsWiredCharging: supportsWiredCharging,
2256
            supportsWirelessCharging: supportsWirelessCharging
2257
        )
2258
    }
2259

            
2260
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
2261
        if chargedDevice.value(forKey: "supportsWiredCharging") == nil {
2262
            return true
2263
        }
2264
        return boolValue(chargedDevice, key: "supportsWiredCharging")
2265
    }
2266

            
2267
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
2268
        if chargedDevice.value(forKey: "supportsWirelessCharging") == nil {
2269
            return false
2270
        }
2271
        return boolValue(chargedDevice, key: "supportsWirelessCharging")
2272
    }
2273

            
Bogdan Timofte authored a month ago
2274
    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
2275
        if let rawValue = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue"),
2276
           let availability = ChargingStateAvailability(rawValue: rawValue) {
2277
            return availability
2278
        }
2279
        return ChargingStateAvailability.fallback(
2280
            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
2281
        )
2282
    }
2283

            
2284
    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
2285
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
2286
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
2287
            return chargingStateMode
2288
        }
2289

            
2290
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2291
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2292
            return chargingStateAvailability(for: chargedDevice).supportedModes.first ?? .on
2293
        }
2294

            
2295
        return .on
2296
    }
2297

            
2298
    private func resolvedChargingStateMode(
2299
        _ chargingStateMode: ChargingStateMode,
2300
        availability: ChargingStateAvailability
2301
    ) -> ChargingStateMode {
2302
        if availability.supportedModes.contains(chargingStateMode) {
2303
            return chargingStateMode
2304
        }
2305
        return availability.supportedModes.first ?? .on
2306
    }
2307

            
Bogdan Timofte authored a month ago
2308
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
2309
        guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2310
              let profile = WirelessChargingProfile(rawValue: rawValue) else {
2311
            return .genericQi
2312
        }
2313
        return profile
2314
    }
2315

            
2316
    private func resolvedPreferredChargingTransportMode(
2317
        _ preferredChargingTransportMode: ChargingTransportMode,
2318
        supportsWiredCharging: Bool,
2319
        supportsWirelessCharging: Bool
2320
    ) -> ChargingTransportMode {
2321
        switch preferredChargingTransportMode {
2322
        case .wired where supportsWiredCharging:
2323
            return .wired
2324
        case .wireless where supportsWirelessCharging:
2325
            return .wireless
2326
        default:
2327
            if supportsWiredCharging {
2328
                return .wired
2329
            }
2330
            if supportsWirelessCharging {
2331
                return .wireless
2332
            }
2333
            return .wired
2334
        }
2335
    }
2336

            
Bogdan Timofte authored a month ago
2337
    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
2338
        let payload = Dictionary(
2339
            uniqueKeysWithValues: currents.map { key, value in
2340
                (key.rawValue, value)
2341
            }
2342
        )
2343
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
2344
            return nil
2345
        }
2346
        return String(data: data, encoding: .utf8)
2347
    }
2348

            
2349
    private func decodedCompletionCurrents(
2350
        from object: NSManagedObject,
2351
        key: String
2352
    ) -> [ChargeSessionKind: Double] {
2353
        guard let rawValue = stringValue(object, key: key),
2354
              let data = rawValue.data(using: .utf8),
2355
              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
2356
            return [:]
2357
        }
2358

            
2359
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2360
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
2361
                return
2362
            }
2363
            result[sessionKind] = entry.value
2364
        }
2365
    }
2366

            
2367
    private func legacyConfiguredCompletionCurrent(
2368
        for currents: [ChargeSessionKind: Double],
2369
        chargingTransportMode: ChargingTransportMode
2370
    ) -> Double? {
2371
        let candidates = currents
2372
            .filter { $0.key.chargingTransportMode == chargingTransportMode }
2373
            .sorted { lhs, rhs in
2374
                lhs.key.rawValue < rhs.key.rawValue
2375
            }
2376
            .map(\.value)
2377
        return candidates.first
2378
    }
2379

            
2380
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
2381
        guard let charger else {
2382
            return nil
2383
        }
2384
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
2385
        guard let idleCurrent, idleCurrent >= 0 else {
2386
            return nil
2387
        }
2388
        return idleCurrent
2389
    }
2390

            
2391
    private func effectiveCurrentAmps(
2392
        fromMeasuredCurrent currentAmps: Double,
2393
        chargingTransportMode: ChargingTransportMode,
2394
        charger: NSManagedObject?
2395
    ) -> Double {
2396
        switch chargingTransportMode {
2397
        case .wired:
2398
            return max(currentAmps, 0)
2399
        case .wireless:
2400
            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
2401
                return max(currentAmps, 0)
2402
            }
2403
            return max(currentAmps - idleCurrent, 0)
2404
        }
2405
    }
2406

            
2407
    private func hasObservedChargeFlow(
2408
        currentAmps: Double,
2409
        chargingTransportMode: ChargingTransportMode,
2410
        charger: NSManagedObject?,
2411
        stopThreshold: Double?
2412
    ) -> Bool {
2413
        let effectiveCurrent = effectiveCurrentAmps(
2414
            fromMeasuredCurrent: currentAmps,
2415
            chargingTransportMode: chargingTransportMode,
2416
            charger: charger
2417
        )
2418
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
2419
    }
2420

            
Bogdan Timofte authored a month ago
2421
    private func derivedMinimumCurrent(
2422
        from sessions: [NSManagedObject],
2423
        chargingTransportMode: ChargingTransportMode
2424
    ) -> Double? {
2425
        let completionCurrents = sessions.compactMap { session -> Double? in
2426
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2427
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
2428
                return nil
2429
            }
Bogdan Timofte authored a month ago
2430
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2431
                return nil
2432
            }
Bogdan Timofte authored a month ago
2433
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
2434
                return nil
2435
            }
2436
            return completionCurrent
2437
        }
2438

            
2439
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
2440
        guard !recentCompletionCurrents.isEmpty else { return nil }
2441
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
2442
    }
2443

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

            
2447
        for session in sessions {
2448
            guard statusValue(session, key: "statusRawValue") == .completed else {
2449
                continue
2450
            }
2451
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2452
                continue
2453
            }
2454
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
2455
                  completionCurrent > 0 else {
2456
                continue
2457
            }
2458

            
2459
            let sessionKind = ChargeSessionKind(
2460
                chargingTransportMode: chargingTransportMode(for: session),
2461
                chargingStateMode: chargingStateMode(for: session)
2462
            )
2463
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
2464
        }
2465

            
2466
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2467
            let recentCurrents = Array(entry.value.suffix(5))
2468
            guard !recentCurrents.isEmpty else {
2469
                return
2470
            }
2471
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
2472
        }
2473
    }
2474

            
Bogdan Timofte authored a month ago
2475
    private func derivedCapacity(
2476
        from sessions: [NSManagedObject],
2477
        chargingTransportMode: ChargingTransportMode,
2478
        supportsChargingWhileOff: Bool
2479
    ) -> Double? {
2480
        let capacityCandidates = sessions.compactMap { session -> Double? in
2481
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2482
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
2483
                return nil
2484
            }
2485
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
2486
                return nil
2487
            }
2488
            if supportsChargingWhileOff {
2489
                return capacityEstimate
2490
            }
2491
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
2492
                return nil
2493
            }
2494
            return capacityEstimate
2495
        }
2496

            
2497
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
2498
        guard !recentCapacityCandidates.isEmpty else { return nil }
2499
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
2500
    }
2501

            
2502
    private func derivedWirelessEfficiency(
2503
        from sessions: [NSManagedObject],
2504
        chargingProfile: WirelessChargingProfile
2505
    ) -> Double? {
2506
        guard chargingProfile == .magsafe else {
2507
            return nil
2508
        }
2509

            
2510
        let candidates = sessions.compactMap { session -> Double? in
2511
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2512
            guard chargingTransportMode(for: session) == .wireless else { return nil }
2513
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
2514
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
2515
                return nil
2516
            }
2517
            return factor
2518
        }
2519

            
2520
        let recentCandidates = Array(candidates.suffix(6))
2521
        guard !recentCandidates.isEmpty else { return nil }
2522
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2523
    }
2524

            
2525
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
2526
        let candidates = sessions.compactMap { session -> Double? in
2527
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2528
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
2529
                return nil
2530
            }
2531
            return (sourceVoltage * 10).rounded() / 10
2532
        }
2533

            
2534
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
2535
        return counts.keys.sorted()
2536
    }
2537

            
2538
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
2539
        let candidates = sessions.compactMap { session -> Double? in
2540
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2541
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
2542
                return nil
2543
            }
2544
            return minimumObservedCurrent
2545
        }
2546

            
2547
        let recentCandidates = Array(candidates.suffix(6))
2548
        guard !recentCandidates.isEmpty else { return nil }
2549
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2550
    }
2551

            
2552
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
2553
        let candidates = sessions.compactMap { session -> Double? in
2554
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2555
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
2556
                return nil
2557
            }
2558
            return factor
2559
        }
2560

            
2561
        let recentCandidates = Array(candidates.suffix(6))
2562
        guard !recentCandidates.isEmpty else { return nil }
2563
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2564
    }
2565

            
2566
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
2567
        sessions.compactMap { session -> Double? in
2568
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2569
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
2570
                return nil
2571
            }
2572
            return maximumObservedPower
2573
        }
2574
        .max()
2575
    }
2576

            
2577
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
2578
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
2579
            return persistedChargingTransportMode
2580
        }
2581

            
2582
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2583
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
2584
            return fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
2585
        }
2586

            
2587
        return .wired
2588
    }
2589

            
2590
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
2591
        if session.isInserted {
2592
            return .created
2593
        }
2594

            
2595
        let committedValues = session.committedValues(
2596
            forKeys: [
2597
                "statusRawValue",
2598
                "updatedAt",
2599
                "targetBatteryAlertTriggeredAt",
2600
                "requiresCompletionConfirmation"
2601
            ]
2602
        )
2603
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
2604
        let currentStatus = statusValue(session, key: "statusRawValue")
2605
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
2606
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
2607
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
2608
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
2609
            ?? false
2610
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
2611

            
2612
        if currentStatus == .completed, committedStatus != .completed {
2613
            return .completed
2614
        }
2615

            
Bogdan Timofte authored a month ago
2616
        if currentStatus != committedStatus {
2617
            return .event
2618
        }
2619

            
Bogdan Timofte authored a month ago
2620
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
2621
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
2622
            return .event
2623
        }
2624

            
2625
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
2626
            ?? dateValue(session, key: "createdAt")
2627
            ?? observedAt
2628

            
2629
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
2630
            return .periodic
2631
        }
2632

            
2633
        return .none
2634
    }
2635

            
Bogdan Timofte authored a month ago
2636
    private func shouldPersistAggregatedSample(
2637
        _ sample: NSManagedObject,
2638
        observedAt: Date
2639
    ) -> Bool {
2640
        if sample.isInserted {
2641
            return true
2642
        }
2643

            
2644
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
2645
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
2646
            ?? dateValue(sample, key: "createdAt")
2647
            ?? observedAt
2648

            
2649
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
2650
    }
2651

            
Bogdan Timofte authored a month ago
2652
    private func generateQRIdentifier() -> String {
2653
        "device:\(UUID().uuidString)"
2654
    }
2655

            
2656
    @discardableResult
2657
    private func saveContext() -> Bool {
2658
        guard context.hasChanges else { return true }
2659
        do {
2660
            try context.save()
2661
            return true
2662
        } catch {
2663
            track("Failed saving charge insights context: \(error)")
2664
            context.rollback()
2665
            return false
2666
        }
2667
    }
2668

            
2669
    private func normalizedText(_ text: String) -> String {
2670
        text.trimmingCharacters(in: .whitespacesAndNewlines)
2671
    }
2672

            
2673
    private func normalizedOptionalText(_ text: String?) -> String? {
2674
        guard let text else { return nil }
2675
        let normalized = normalizedText(text)
2676
        return normalized.isEmpty ? nil : normalized
2677
    }
2678

            
2679
    private func normalizedMACAddress(_ macAddress: String) -> String {
2680
        normalizedText(macAddress).uppercased()
2681
    }
2682

            
Bogdan Timofte authored a month ago
2683
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
2684
        guard object.entity.propertiesByName[key] != nil else {
2685
            return nil
2686
        }
2687
        return object.value(forKey: key)
2688
    }
2689

            
2690
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
2691
        guard object.entity.propertiesByName[key] != nil else {
2692
            return
2693
        }
2694
        object.setValue(value, forKey: key)
2695
    }
2696

            
Bogdan Timofte authored a month ago
2697
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
2698
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
2699
        let normalized = normalizedOptionalText(value)
2700
        return normalized
2701
    }
2702

            
2703
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
2704
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
2705
    }
2706

            
2707
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
2708
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
2709
            return value
2710
        }
Bogdan Timofte authored a month ago
2711
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
2712
            return value.doubleValue
2713
        }
2714
        return 0
2715
    }
2716

            
2717
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
2718
        let value = rawValue(object, key: key)
2719
        if value == nil {
Bogdan Timofte authored a month ago
2720
            return nil
2721
        }
2722
        return doubleValue(object, key: key)
2723
    }
2724

            
2725
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
Bogdan Timofte authored a month ago
2726
        if let value = rawValue(object, key: key) as? Int16 {
Bogdan Timofte authored a month ago
2727
            return value
2728
        }
Bogdan Timofte authored a month ago
2729
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
2730
            return value.int16Value
2731
        }
2732
        return nil
2733
    }
2734

            
2735
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
2736
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
2737
            return value
2738
        }
Bogdan Timofte authored a month ago
2739
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
2740
            return value.int32Value
2741
        }
2742
        return nil
2743
    }
2744

            
2745
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
Bogdan Timofte authored a month ago
2746
        if let value = rawValue(object, key: key) as? Bool {
Bogdan Timofte authored a month ago
2747
            return value
2748
        }
Bogdan Timofte authored a month ago
2749
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
2750
            return value.boolValue
2751
        }
2752
        return false
2753
    }
2754

            
2755
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
2756
        guard let value = stringValue(object, key: key) else { return nil }
2757
        return UUID(uuidString: value)
2758
    }
2759

            
2760
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
2761
        guard let value = stringValue(object, key: key) else { return nil }
2762
        return ChargeSessionStatus(rawValue: value)
2763
    }
2764

            
2765
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
2766
        guard let value = stringValue(object, key: key) else { return nil }
2767
        return ChargingTransportMode(rawValue: value)
2768
    }
2769

            
2770
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
2771
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
2772
            return []
2773
        }
2774
        return rawValue
2775
            .split(separator: ",")
2776
            .compactMap { Double($0) }
2777
            .sorted()
2778
    }
2779

            
2780
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
2781
        let uniqueVoltages = Array(Set(voltages)).sorted()
2782
        guard !uniqueVoltages.isEmpty else {
2783
            return nil
2784
        }
2785
        return uniqueVoltages
2786
            .map { String(format: "%.1f", $0) }
2787
            .joined(separator: ",")
2788
    }
2789

            
2790
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
2791
        guard currentCount > 0 else {
2792
            return newValue
2793
        }
2794
        let total = (currentAverage * Double(currentCount)) + newValue
2795
        return total / Double(currentCount + 1)
2796
    }
2797
}
2798

            
2799
private enum ObservationSaveReason {
2800
    case none
2801
    case created
2802
    case periodic
2803
    case completed
2804
    case event
2805
}