USB-Meter / USB Meter / Model / ChargeInsightsStore.swift
Newer Older
2878 lines | 125.528kb
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,
Bogdan Timofte authored a month ago
417
                    flag: .initial,
Bogdan Timofte authored a month ago
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
                    status: .completed
471
                )
472
                guard saveContext() else {
473
                    return
474
                }
475
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
476
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
477
                    didSave = saveContext()
478
                } else {
479
                    didSave = true
480
                }
481
                return
482
            }
483

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

            
507
    @discardableResult
508
    func stopSession(
509
        id sessionID: UUID,
Bogdan Timofte authored a month ago
510
        finalBatteryPercent: Double
Bogdan Timofte authored a month ago
511
    ) -> Bool {
512
        guard finalBatteryPercent.isFinite, finalBatteryPercent >= 0, finalBatteryPercent <= 100 else {
513
            return false
514
        }
515

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

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

            
526
            let observedAt = snapshotDateForManualStop(session)
527
            finishSession(
528
                session,
529
                observedAt: observedAt,
530
                finalBatteryPercent: finalBatteryPercent,
531
                status: .completed
532
            )
533

            
534
            guard saveContext() else {
535
                return
536
            }
537

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

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

            
559
        var didSave = false
560
        context.performAndWait {
Bogdan Timofte authored a month ago
561
            guard let session = fetchOpenSessionObject(forMeterMACAddress: meterMACAddress) else {
Bogdan Timofte authored a month ago
562
                return
563
            }
564

            
Bogdan Timofte authored a month ago
565
            didSave = addBatteryCheckpoint(
566
                percent: percent,
567
                measuredEnergyWh: measuredEnergyWh,
568
                measuredChargeAh: measuredChargeAh,
Bogdan Timofte authored a month ago
569
                flag: .intermediate,
Bogdan Timofte authored a month ago
570
                to: session
571
            )
Bogdan Timofte authored a month ago
572
        }
573
        return didSave
574
    }
575

            
576
    @discardableResult
577
    func addBatteryCheckpoint(
578
        percent: Double,
Bogdan Timofte authored a month ago
579
        for sessionID: UUID,
580
        measuredEnergyWh: Double? = nil,
581
        measuredChargeAh: Double? = nil
Bogdan Timofte authored a month ago
582
    ) -> Bool {
583
        guard percent.isFinite, percent >= 0, percent <= 100 else {
584
            return false
585
        }
586

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

            
Bogdan Timofte authored a month ago
593
            didSave = addBatteryCheckpoint(
594
                percent: percent,
595
                measuredEnergyWh: measuredEnergyWh,
596
                measuredChargeAh: measuredChargeAh,
Bogdan Timofte authored a month ago
597
                flag: .intermediate,
Bogdan Timofte authored a month ago
598
                to: session
599
            )
Bogdan Timofte authored a month ago
600
        }
601
        return didSave
602
    }
603

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

            
619
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
620
            context.delete(checkpoint)
621
            refreshCheckpointDerivedValues(for: session)
622

            
623
            if let chargedDeviceID {
624
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
625
            }
Bogdan Timofte authored a month ago
626

            
627
            didSave = saveContext()
Bogdan Timofte authored a month ago
628
        }
629
        return didSave
630
    }
631

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

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

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

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

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

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

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

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

            
691
            guard statusValue(session, key: "statusRawValue") == .active else {
692
                return
693
            }
694

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

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

            
711
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
712

            
713
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
714
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
715
            context.delete(session)
716

            
717
            guard saveContext() else {
718
                return
719
            }
720

            
721
            if let chargedDeviceID {
722
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
723
                didSave = saveContext()
724
            } else {
725
                didSave = true
726
            }
727
        }
728
        return didSave
729
    }
730

            
731
    @discardableResult
732
    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
733
        var didSave = false
734

            
735
        context.performAndWait {
736
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
737
                return
738
            }
739

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

            
744
            var impactedChargedDeviceIDs = Set<String>()
745

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

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

            
773
            context.delete(chargedDevice)
774

            
775
            guard saveContext() else {
776
                return
777
            }
778

            
779
            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
780
            for impactedID in impactedChargedDeviceIDs {
781
                refreshDerivedMetrics(forChargedDeviceID: impactedID)
782
            }
783
            didSave = saveContext()
784
        }
785

            
786
        return didSave
787
    }
788

            
789
    @discardableResult
790
    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
791
        var didSave = false
792

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

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

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

            
Bogdan Timofte authored a month ago
822
            update(session: session, with: snapshot, stopThreshold: stopThreshold, charger: charger)
Bogdan Timofte authored a month ago
823
            let aggregatedSample = updateAggregatedSample(session: session, with: snapshot)
Bogdan Timofte authored a month ago
824

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

            
830
            guard saveReason != .none || shouldPersistAggregatedCurve else {
Bogdan Timofte authored a month ago
831
                return
832
            }
833

            
834
            session.setValue(snapshot.observedAt, forKey: "updatedAt")
835

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

            
846
        return didSave
847
    }
848

            
849
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
850
        var summaries: [ChargedDeviceSummary] = []
851

            
852
        context.performAndWait {
853
            let devices = fetchObjects(entityName: EntityName.chargedDevice)
854
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
855
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
856

            
857
            let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
858
            let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
859
            let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
Bogdan Timofte authored a month ago
860
            let sampleBackedSessionIDs = sampleBackedSessionIDs(
861
                devices: devices,
862
                sessionsByDeviceID: sessionsByDeviceID,
863
                sessionsByChargerID: sessionsByChargerID
864
            )
865
            let samplesBySessionID = Dictionary(
866
                grouping: fetchSessionSampleObjects(forSessionIDs: Array(sampleBackedSessionIDs))
867
            ) { stringValue($0, key: "sessionID") ?? "" }
Bogdan Timofte authored a month ago
868

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

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

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

            
959
        return summaries
960
    }
961

            
962
    func resolvedChargedDeviceSummary(forMeterMACAddress meterMACAddress: String) -> ChargedDeviceSummary? {
963
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
964
        guard !normalizedMAC.isEmpty else { return nil }
965

            
966
        let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
967

            
968
        if let activeMatch = summaries.first(where: { summary in
969
            summary.activeSession?.meterMACAddress == normalizedMAC
970
        }) {
971
            return activeMatch
972
        }
973

            
974
        return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
975
    }
976

            
977
    func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
978
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
979
        guard !normalizedMAC.isEmpty else { return nil }
980

            
Bogdan Timofte authored a month ago
981
        var summary: ChargeSessionSummary?
982

            
983
        context.performAndWait {
984
            guard let session = fetchActiveSessionObject(forMeterMACAddress: normalizedMAC),
985
                  let sessionID = stringValue(session, key: "id") else {
986
                return
987
            }
988

            
989
            summary = makeSessionSummary(
990
                from: session,
991
                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
992
                samples: fetchSessionSampleObjects(forSessionID: sessionID)
993
            )
994
        }
995

            
996
        return summary
Bogdan Timofte authored a month ago
997
    }
998

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

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

            
1077
        chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
1078
        chargedDevice.setValue(now, forKey: "updatedAt")
1079
        return session
1080
    }
1081

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

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

            
1108
        if let counterGroup = snapshot.selectedDataGroup,
1109
           let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
1110
           UInt8(storedGroup) != counterGroup {
1111
            session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
1112
            session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1113
            session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1114
        }
1115

            
1116
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1117
            let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
1118
            let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
1119
            if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
1120
                session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
1121
            }
1122

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

            
1139
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1140
            let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
1141
            let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
1142
            if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
1143
                session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
1144
            }
1145

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

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

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

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

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

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

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

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

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

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

            
1275
        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
1276
        let updatedCount = existingCount + 1
1277

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

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

            
1322
        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
1323
            return
1324
        }
1325

            
1326
        let predictedBatteryPercent = predictedBatteryPercent(for: session)
1327
            ?? optionalDoubleValue(session, key: "endBatteryPercent")
1328

            
1329
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
1330
            return
1331
        }
1332

            
1333
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
1334
    }
1335

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

            
1345
        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
1346
            return false
1347
        }
1348

            
1349
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
1350
            ?? defaultCompletionPercentThreshold
1351

            
1352
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1353
    }
1354

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

            
1360
        session.setValue(true, forKey: "requiresCompletionConfirmation")
1361
        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
1362
        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
1363
    }
1364

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

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

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

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

            
1391
        finishSession(
1392
            session,
1393
            observedAt: pausedAt.addingTimeInterval(pausedSessionTimeout),
1394
            finalBatteryPercent: nil,
1395
            status: .completed
1396
        )
1397

            
1398
        guard saveContext() else {
1399
            return false
1400
        }
1401

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

            
1407
        return true
1408
    }
1409

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

            
1415
        guard measuredCurrent > 0 else {
1416
            return nil
1417
        }
1418

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

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

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

            
1438
    private func finishSession(
1439
        _ session: NSManagedObject,
1440
        observedAt: Date,
1441
        finalBatteryPercent: Double?,
1442
        status: ChargeSessionStatus
1443
    ) {
1444
        if let finalBatteryPercent {
1445
            _ = insertBatteryCheckpoint(
1446
                percent: finalBatteryPercent,
Bogdan Timofte authored a month ago
1447
                flag: .final,
Bogdan Timofte authored a month ago
1448
                timestamp: observedAt,
1449
                to: session
1450
            )
1451
        }
1452

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1876
            if let startBatteryPercent = session.startBatteryPercent {
1877
                points.append(
1878
                    ChargeCheckpointSummary(
1879
                        id: UUID(),
1880
                        sessionID: session.id,
1881
                        chargedDeviceID: session.chargedDeviceID,
1882
                        timestamp: session.startedAt,
1883
                        batteryPercent: startBatteryPercent,
1884
                        measuredEnergyWh: 0,
1885
                        measuredChargeAh: 0,
1886
                        currentAmps: 0,
1887
                        voltageVolts: nil,
Bogdan Timofte authored a month ago
1888
                        label: ChargeCheckpointFlag.initial.rawValue
Bogdan Timofte authored a month ago
1889
                    )
1890
                )
1891
            }
1892

            
1893
            if let endBatteryPercent = session.endBatteryPercent {
1894
                points.append(
1895
                    ChargeCheckpointSummary(
1896
                        id: UUID(),
1897
                        sessionID: session.id,
1898
                        chargedDeviceID: session.chargedDeviceID,
1899
                        timestamp: session.endedAt ?? session.lastObservedAt,
1900
                        batteryPercent: endBatteryPercent,
1901
                        measuredEnergyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
1902
                        measuredChargeAh: session.measuredChargeAh,
1903
                        currentAmps: 0,
1904
                        voltageVolts: nil,
Bogdan Timofte authored a month ago
1905
                        label: ChargeCheckpointFlag.final.rawValue
Bogdan Timofte authored a month ago
1906
                    )
1907
                )
1908
            }
1909

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
2114
    private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
2115
        guard !sessionIDs.isEmpty else {
2116
            return []
2117
        }
2118

            
2119
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2120
        request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs)
2121
        return (try? context.fetch(request)) ?? []
2122
    }
2123

            
Bogdan Timofte authored a month ago
2124
    private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
2125
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2126
        request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
2127
        request.fetchLimit = 1
2128
        return (try? context.fetch(request))?.first
2129
    }
2130

            
Bogdan Timofte authored a month ago
2131
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2132
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2133
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2134
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
2135
        return (try? context.fetch(request)) ?? []
2136
    }
2137

            
2138
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
2139
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2140
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
2141
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2142
        return (try? context.fetch(request)) ?? []
2143
    }
2144

            
2145
    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
2146
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2147
        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
2148
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2149
        return (try? context.fetch(request)) ?? []
2150
    }
2151

            
Bogdan Timofte authored a month ago
2152
    private func sampleBackedSessionIDs(
2153
        devices: [NSManagedObject],
2154
        sessionsByDeviceID: [String: [NSManagedObject]],
2155
        sessionsByChargerID: [String: [NSManagedObject]]
2156
    ) -> Set<String> {
2157
        var sessionIDs: Set<String> = []
2158

            
2159
        for device in devices {
2160
            guard
2161
                let deviceID = stringValue(device, key: "id"),
2162
                let rawClass = stringValue(device, key: "deviceClassRawValue"),
2163
                let deviceClass = ChargedDeviceClass(rawValue: rawClass)
2164
            else {
2165
                continue
2166
            }
2167

            
2168
            let relevantSessions = relevantSessionObjects(
2169
                for: deviceID,
2170
                deviceClass: deviceClass,
2171
                sessionsByDeviceID: sessionsByDeviceID,
2172
                sessionsByChargerID: sessionsByChargerID
2173
            )
2174
            .sorted { lhs, rhs in
2175
                let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed
2176
                let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed
2177

            
2178
                if lhsStatus.isOpen && !rhsStatus.isOpen {
2179
                    return true
2180
                }
2181
                if !lhsStatus.isOpen && rhsStatus.isOpen {
2182
                    return false
2183
                }
2184

            
2185
                return (dateValue(lhs, key: "startedAt") ?? .distantPast)
2186
                    > (dateValue(rhs, key: "startedAt") ?? .distantPast)
2187
            }
2188

            
2189
            var recentCompletedSamplesIncluded = 0
2190

            
2191
            for session in relevantSessions {
2192
                guard let sessionID = stringValue(session, key: "id"),
2193
                      let status = statusValue(session, key: "statusRawValue") else {
2194
                    continue
2195
                }
2196

            
2197
                if status.isOpen {
2198
                    sessionIDs.insert(sessionID)
2199
                    continue
2200
                }
2201

            
2202
                guard recentCompletedSamplesIncluded < 2 else {
2203
                    continue
2204
                }
2205

            
2206
                sessionIDs.insert(sessionID)
2207
                recentCompletedSamplesIncluded += 1
2208
            }
2209
        }
2210

            
2211
        return sessionIDs
2212
    }
2213

            
Bogdan Timofte authored a month ago
2214
    private func relevantSessionObjects(
2215
        for chargedDeviceID: String,
2216
        deviceClass: ChargedDeviceClass,
2217
        sessionsByDeviceID: [String: [NSManagedObject]],
2218
        sessionsByChargerID: [String: [NSManagedObject]]
2219
    ) -> [NSManagedObject] {
2220
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
2221
        guard deviceClass == .charger else {
2222
            return directSessions
2223
        }
2224

            
2225
        var seenSessionIDs = Set<String>()
2226
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
2227
            .filter { session in
2228
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
2229
                return seenSessionIDs.insert(sessionID).inserted
2230
            }
2231
            .sorted {
2232
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
2233
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
2234
                return lhsDate < rhsDate
2235
            }
2236
    }
2237

            
2238
    private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
2239
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false)
2240
    }
2241

            
2242
    private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
2243
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true)
2244
    }
2245

            
2246
    private func resolvedAssignedObject(
2247
        for meterMACAddress: String,
2248
        expectsChargerClass: Bool
2249
    ) -> NSManagedObject? {
2250
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
2251
        guard !normalizedMAC.isEmpty else { return nil }
2252

            
2253
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2254
        request.predicate = NSPredicate(format: "lastAssociatedMeterMAC == %@", normalizedMAC)
2255
        request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
2256
        let matches = (try? context.fetch(request)) ?? []
2257
        return matches.first { object in
Bogdan Timofte authored a month ago
2258
            isChargerObject(object) == expectsChargerClass
Bogdan Timofte authored a month ago
2259
        }
2260
    }
2261

            
Bogdan Timofte authored a month ago
2262
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
2263
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2264
    }
2265

            
Bogdan Timofte authored a month ago
2266
    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
2267
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2268
        request.predicate = NSPredicate(format: "id == %@", id)
2269
        request.fetchLimit = 1
2270
        return (try? context.fetch(request))?.first
2271
    }
2272

            
2273
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
2274
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
2275
        return (try? context.fetch(request)) ?? []
2276
    }
2277

            
2278
    private func resolvedStopThreshold(
2279
        for chargedDevice: NSManagedObject,
2280
        chargingTransportMode: ChargingTransportMode,
Bogdan Timofte authored a month ago
2281
        chargingStateMode: ChargingStateMode,
2282
        charger: NSManagedObject?,
2283
        fallback: Double?
2284
    ) -> Double? {
2285
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
2286
            return nil
2287
        }
2288

            
2289
        let sessionKind = ChargeSessionKind(
2290
            chargingTransportMode: chargingTransportMode,
2291
            chargingStateMode: chargingStateMode
2292
        )
2293
        let configuredCurrents = decodedCompletionCurrents(
2294
            from: chargedDevice,
2295
            key: "configuredCompletionCurrentsRawValue"
2296
        )
2297
        let learnedCurrents = decodedCompletionCurrents(
2298
            from: chargedDevice,
2299
            key: "learnedCompletionCurrentsRawValue"
2300
        )
2301
        let legacyCurrent: Double?
Bogdan Timofte authored a month ago
2302
        switch chargingTransportMode {
2303
        case .wired:
Bogdan Timofte authored a month ago
2304
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2305
                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
2306
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2307
        case .wireless:
Bogdan Timofte authored a month ago
2308
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2309
                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
2310
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2311
        }
Bogdan Timofte authored a month ago
2312

            
2313
        let resolvedCurrent = configuredCurrents[sessionKind]
2314
            ?? learnedCurrents[sessionKind]
2315
            ?? legacyCurrent
2316
            ?? fallback
2317
        guard let resolvedCurrent, resolvedCurrent > 0 else {
2318
            return nil
2319
        }
2320
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
2321
    }
2322

            
Bogdan Timofte authored a month ago
2323
    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
Bogdan Timofte authored a month ago
2324
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
2325
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
2326
        return resolvedPreferredChargingTransportMode(
Bogdan Timofte authored a month ago
2327
            .wired,
Bogdan Timofte authored a month ago
2328
            supportsWiredCharging: supportsWiredCharging,
2329
            supportsWirelessCharging: supportsWirelessCharging
2330
        )
2331
    }
2332

            
2333
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
2334
        if chargedDevice.value(forKey: "supportsWiredCharging") == nil {
2335
            return true
2336
        }
2337
        return boolValue(chargedDevice, key: "supportsWiredCharging")
2338
    }
2339

            
2340
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
2341
        if chargedDevice.value(forKey: "supportsWirelessCharging") == nil {
2342
            return false
2343
        }
2344
        return boolValue(chargedDevice, key: "supportsWirelessCharging")
2345
    }
2346

            
Bogdan Timofte authored a month ago
2347
    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
2348
        if let rawValue = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue"),
2349
           let availability = ChargingStateAvailability(rawValue: rawValue) {
2350
            return availability
2351
        }
2352
        return ChargingStateAvailability.fallback(
2353
            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
2354
        )
2355
    }
2356

            
2357
    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
2358
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
2359
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
2360
            return chargingStateMode
2361
        }
2362

            
2363
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2364
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2365
            return chargingStateAvailability(for: chargedDevice).supportedModes.first ?? .on
2366
        }
2367

            
2368
        return .on
2369
    }
2370

            
2371
    private func resolvedChargingStateMode(
2372
        _ chargingStateMode: ChargingStateMode,
2373
        availability: ChargingStateAvailability
2374
    ) -> ChargingStateMode {
2375
        if availability.supportedModes.contains(chargingStateMode) {
2376
            return chargingStateMode
2377
        }
2378
        return availability.supportedModes.first ?? .on
2379
    }
2380

            
Bogdan Timofte authored a month ago
2381
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
2382
        guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2383
              let profile = WirelessChargingProfile(rawValue: rawValue) else {
2384
            return .genericQi
2385
        }
2386
        return profile
2387
    }
2388

            
2389
    private func resolvedPreferredChargingTransportMode(
2390
        _ preferredChargingTransportMode: ChargingTransportMode,
2391
        supportsWiredCharging: Bool,
2392
        supportsWirelessCharging: Bool
2393
    ) -> ChargingTransportMode {
2394
        switch preferredChargingTransportMode {
2395
        case .wired where supportsWiredCharging:
2396
            return .wired
2397
        case .wireless where supportsWirelessCharging:
2398
            return .wireless
2399
        default:
2400
            if supportsWiredCharging {
2401
                return .wired
2402
            }
2403
            if supportsWirelessCharging {
2404
                return .wireless
2405
            }
2406
            return .wired
2407
        }
2408
    }
2409

            
Bogdan Timofte authored a month ago
2410
    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
2411
        let payload = Dictionary(
2412
            uniqueKeysWithValues: currents.map { key, value in
2413
                (key.rawValue, value)
2414
            }
2415
        )
2416
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
2417
            return nil
2418
        }
2419
        return String(data: data, encoding: .utf8)
2420
    }
2421

            
2422
    private func decodedCompletionCurrents(
2423
        from object: NSManagedObject,
2424
        key: String
2425
    ) -> [ChargeSessionKind: Double] {
2426
        guard let rawValue = stringValue(object, key: key),
2427
              let data = rawValue.data(using: .utf8),
2428
              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
2429
            return [:]
2430
        }
2431

            
2432
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2433
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
2434
                return
2435
            }
2436
            result[sessionKind] = entry.value
2437
        }
2438
    }
2439

            
2440
    private func legacyConfiguredCompletionCurrent(
2441
        for currents: [ChargeSessionKind: Double],
2442
        chargingTransportMode: ChargingTransportMode
2443
    ) -> Double? {
2444
        let candidates = currents
2445
            .filter { $0.key.chargingTransportMode == chargingTransportMode }
2446
            .sorted { lhs, rhs in
2447
                lhs.key.rawValue < rhs.key.rawValue
2448
            }
2449
            .map(\.value)
2450
        return candidates.first
2451
    }
2452

            
2453
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
2454
        guard let charger else {
2455
            return nil
2456
        }
2457
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
2458
        guard let idleCurrent, idleCurrent >= 0 else {
2459
            return nil
2460
        }
2461
        return idleCurrent
2462
    }
2463

            
2464
    private func effectiveCurrentAmps(
2465
        fromMeasuredCurrent currentAmps: Double,
2466
        chargingTransportMode: ChargingTransportMode,
2467
        charger: NSManagedObject?
2468
    ) -> Double {
2469
        switch chargingTransportMode {
2470
        case .wired:
2471
            return max(currentAmps, 0)
2472
        case .wireless:
2473
            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
2474
                return max(currentAmps, 0)
2475
            }
2476
            return max(currentAmps - idleCurrent, 0)
2477
        }
2478
    }
2479

            
2480
    private func hasObservedChargeFlow(
2481
        currentAmps: Double,
2482
        chargingTransportMode: ChargingTransportMode,
2483
        charger: NSManagedObject?,
2484
        stopThreshold: Double?
2485
    ) -> Bool {
2486
        let effectiveCurrent = effectiveCurrentAmps(
2487
            fromMeasuredCurrent: currentAmps,
2488
            chargingTransportMode: chargingTransportMode,
2489
            charger: charger
2490
        )
2491
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
2492
    }
2493

            
Bogdan Timofte authored a month ago
2494
    private func derivedMinimumCurrent(
2495
        from sessions: [NSManagedObject],
2496
        chargingTransportMode: ChargingTransportMode
2497
    ) -> Double? {
2498
        let completionCurrents = sessions.compactMap { session -> Double? in
2499
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2500
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
2501
                return nil
2502
            }
Bogdan Timofte authored a month ago
2503
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2504
                return nil
2505
            }
Bogdan Timofte authored a month ago
2506
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
2507
                return nil
2508
            }
2509
            return completionCurrent
2510
        }
2511

            
2512
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
2513
        guard !recentCompletionCurrents.isEmpty else { return nil }
2514
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
2515
    }
2516

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

            
2520
        for session in sessions {
2521
            guard statusValue(session, key: "statusRawValue") == .completed else {
2522
                continue
2523
            }
2524
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2525
                continue
2526
            }
2527
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
2528
                  completionCurrent > 0 else {
2529
                continue
2530
            }
2531

            
2532
            let sessionKind = ChargeSessionKind(
2533
                chargingTransportMode: chargingTransportMode(for: session),
2534
                chargingStateMode: chargingStateMode(for: session)
2535
            )
2536
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
2537
        }
2538

            
2539
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2540
            let recentCurrents = Array(entry.value.suffix(5))
2541
            guard !recentCurrents.isEmpty else {
2542
                return
2543
            }
2544
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
2545
        }
2546
    }
2547

            
Bogdan Timofte authored a month ago
2548
    private func derivedCapacity(
2549
        from sessions: [NSManagedObject],
2550
        chargingTransportMode: ChargingTransportMode,
2551
        supportsChargingWhileOff: Bool
2552
    ) -> Double? {
2553
        let capacityCandidates = sessions.compactMap { session -> Double? in
2554
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2555
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
2556
                return nil
2557
            }
2558
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
2559
                return nil
2560
            }
2561
            if supportsChargingWhileOff {
2562
                return capacityEstimate
2563
            }
2564
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
2565
                return nil
2566
            }
2567
            return capacityEstimate
2568
        }
2569

            
2570
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
2571
        guard !recentCapacityCandidates.isEmpty else { return nil }
2572
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
2573
    }
2574

            
2575
    private func derivedWirelessEfficiency(
2576
        from sessions: [NSManagedObject],
2577
        chargingProfile: WirelessChargingProfile
2578
    ) -> Double? {
2579
        guard chargingProfile == .magsafe else {
2580
            return nil
2581
        }
2582

            
2583
        let candidates = sessions.compactMap { session -> Double? in
2584
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2585
            guard chargingTransportMode(for: session) == .wireless else { return nil }
2586
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
2587
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
2588
                return nil
2589
            }
2590
            return factor
2591
        }
2592

            
2593
        let recentCandidates = Array(candidates.suffix(6))
2594
        guard !recentCandidates.isEmpty else { return nil }
2595
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2596
    }
2597

            
2598
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
2599
        let candidates = sessions.compactMap { session -> Double? in
2600
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2601
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
2602
                return nil
2603
            }
2604
            return (sourceVoltage * 10).rounded() / 10
2605
        }
2606

            
2607
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
2608
        return counts.keys.sorted()
2609
    }
2610

            
2611
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
2612
        let candidates = sessions.compactMap { session -> Double? in
2613
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2614
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
2615
                return nil
2616
            }
2617
            return minimumObservedCurrent
2618
        }
2619

            
2620
        let recentCandidates = Array(candidates.suffix(6))
2621
        guard !recentCandidates.isEmpty else { return nil }
2622
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2623
    }
2624

            
2625
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
2626
        let candidates = sessions.compactMap { session -> Double? in
2627
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2628
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
2629
                return nil
2630
            }
2631
            return factor
2632
        }
2633

            
2634
        let recentCandidates = Array(candidates.suffix(6))
2635
        guard !recentCandidates.isEmpty else { return nil }
2636
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2637
    }
2638

            
2639
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
2640
        sessions.compactMap { session -> Double? in
2641
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2642
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
2643
                return nil
2644
            }
2645
            return maximumObservedPower
2646
        }
2647
        .max()
2648
    }
2649

            
2650
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
2651
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
2652
            return persistedChargingTransportMode
2653
        }
2654

            
2655
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2656
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
2657
            return fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
2658
        }
2659

            
2660
        return .wired
2661
    }
2662

            
2663
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
2664
        if session.isInserted {
2665
            return .created
2666
        }
2667

            
2668
        let committedValues = session.committedValues(
2669
            forKeys: [
2670
                "statusRawValue",
2671
                "updatedAt",
2672
                "targetBatteryAlertTriggeredAt",
2673
                "requiresCompletionConfirmation"
2674
            ]
2675
        )
2676
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
2677
        let currentStatus = statusValue(session, key: "statusRawValue")
2678
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
2679
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
2680
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
2681
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
2682
            ?? false
2683
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
2684

            
2685
        if currentStatus == .completed, committedStatus != .completed {
2686
            return .completed
2687
        }
2688

            
Bogdan Timofte authored a month ago
2689
        if currentStatus != committedStatus {
2690
            return .event
2691
        }
2692

            
Bogdan Timofte authored a month ago
2693
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
2694
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
2695
            return .event
2696
        }
2697

            
2698
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
2699
            ?? dateValue(session, key: "createdAt")
2700
            ?? observedAt
2701

            
2702
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
2703
            return .periodic
2704
        }
2705

            
2706
        return .none
2707
    }
2708

            
Bogdan Timofte authored a month ago
2709
    private func shouldPersistAggregatedSample(
2710
        _ sample: NSManagedObject,
2711
        observedAt: Date
2712
    ) -> Bool {
2713
        if sample.isInserted {
2714
            return true
2715
        }
2716

            
2717
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
2718
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
2719
            ?? dateValue(sample, key: "createdAt")
2720
            ?? observedAt
2721

            
2722
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
2723
    }
2724

            
Bogdan Timofte authored a month ago
2725
    private func generateQRIdentifier() -> String {
2726
        "device:\(UUID().uuidString)"
2727
    }
2728

            
2729
    @discardableResult
2730
    private func saveContext() -> Bool {
2731
        guard context.hasChanges else { return true }
2732
        do {
2733
            try context.save()
2734
            return true
2735
        } catch {
2736
            track("Failed saving charge insights context: \(error)")
2737
            context.rollback()
2738
            return false
2739
        }
2740
    }
2741

            
2742
    private func normalizedText(_ text: String) -> String {
2743
        text.trimmingCharacters(in: .whitespacesAndNewlines)
2744
    }
2745

            
2746
    private func normalizedOptionalText(_ text: String?) -> String? {
2747
        guard let text else { return nil }
2748
        let normalized = normalizedText(text)
2749
        return normalized.isEmpty ? nil : normalized
2750
    }
2751

            
2752
    private func normalizedMACAddress(_ macAddress: String) -> String {
2753
        normalizedText(macAddress).uppercased()
2754
    }
2755

            
Bogdan Timofte authored a month ago
2756
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
2757
        guard object.entity.propertiesByName[key] != nil else {
2758
            return nil
2759
        }
2760
        return object.value(forKey: key)
2761
    }
2762

            
2763
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
2764
        guard object.entity.propertiesByName[key] != nil else {
2765
            return
2766
        }
2767
        object.setValue(value, forKey: key)
2768
    }
2769

            
Bogdan Timofte authored a month ago
2770
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
2771
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
2772
        let normalized = normalizedOptionalText(value)
2773
        return normalized
2774
    }
2775

            
2776
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
2777
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
2778
    }
2779

            
2780
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
2781
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
2782
            return value
2783
        }
Bogdan Timofte authored a month ago
2784
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
2785
            return value.doubleValue
2786
        }
2787
        return 0
2788
    }
2789

            
2790
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
2791
        let value = rawValue(object, key: key)
2792
        if value == nil {
Bogdan Timofte authored a month ago
2793
            return nil
2794
        }
2795
        return doubleValue(object, key: key)
2796
    }
2797

            
2798
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
Bogdan Timofte authored a month ago
2799
        if let value = rawValue(object, key: key) as? Int16 {
Bogdan Timofte authored a month ago
2800
            return value
2801
        }
Bogdan Timofte authored a month ago
2802
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
2803
            return value.int16Value
2804
        }
2805
        return nil
2806
    }
2807

            
2808
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
2809
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
2810
            return value
2811
        }
Bogdan Timofte authored a month ago
2812
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
2813
            return value.int32Value
2814
        }
2815
        return nil
2816
    }
2817

            
2818
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
Bogdan Timofte authored a month ago
2819
        if let value = rawValue(object, key: key) as? Bool {
Bogdan Timofte authored a month ago
2820
            return value
2821
        }
Bogdan Timofte authored a month ago
2822
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
2823
            return value.boolValue
2824
        }
2825
        return false
2826
    }
2827

            
2828
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
2829
        guard let value = stringValue(object, key: key) else { return nil }
2830
        return UUID(uuidString: value)
2831
    }
2832

            
2833
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
2834
        guard let value = stringValue(object, key: key) else { return nil }
2835
        return ChargeSessionStatus(rawValue: value)
2836
    }
2837

            
2838
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
2839
        guard let value = stringValue(object, key: key) else { return nil }
2840
        return ChargingTransportMode(rawValue: value)
2841
    }
2842

            
2843
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
2844
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
2845
            return []
2846
        }
2847
        return rawValue
2848
            .split(separator: ",")
2849
            .compactMap { Double($0) }
2850
            .sorted()
2851
    }
2852

            
2853
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
2854
        let uniqueVoltages = Array(Set(voltages)).sorted()
2855
        guard !uniqueVoltages.isEmpty else {
2856
            return nil
2857
        }
2858
        return uniqueVoltages
2859
            .map { String(format: "%.1f", $0) }
2860
            .joined(separator: ",")
2861
    }
2862

            
2863
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
2864
        guard currentCount > 0 else {
2865
            return newValue
2866
        }
2867
        let total = (currentAverage * Double(currentCount)) + newValue
2868
        return total / Double(currentCount + 1)
2869
    }
2870
}
2871

            
2872
private enum ObservationSaveReason {
2873
    case none
2874
    case created
2875
    case periodic
2876
    case completed
2877
    case event
2878
}