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

            
8
import CoreData
9
import Foundation
10

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
281
        return didSave
282
    }
283

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
567
            didSave = addBatteryCheckpoint(percent: percent, label: label, to: session)
568
        }
569
        return didSave
570
    }
571

            
572
    @discardableResult
573
    func addBatteryCheckpoint(
574
        percent: Double,
575
        label: String?,
576
        for sessionID: UUID
577
    ) -> Bool {
578
        guard percent.isFinite, percent >= 0, percent <= 100 else {
579
            return false
580
        }
581

            
582
        var didSave = false
583
        context.performAndWait {
584
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
585
                return
586
            }
587

            
588
            didSave = addBatteryCheckpoint(percent: percent, label: label, to: session)
589
        }
590
        return didSave
591
    }
592

            
Bogdan Timofte authored a month ago
593
    @discardableResult
594
    func deleteBatteryCheckpoint(
595
        id checkpointID: UUID,
596
        from sessionID: UUID
597
    ) -> Bool {
598
        var didSave = false
599
        context.performAndWait {
600
            guard let session = fetchSessionObject(id: sessionID.uuidString),
601
                  let checkpoint = fetchCheckpointObject(
602
                    id: checkpointID.uuidString,
603
                    sessionID: sessionID.uuidString
604
                  ) else {
605
                return
606
            }
607

            
608
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
609
            context.delete(checkpoint)
610
            refreshCheckpointDerivedValues(for: session)
611

            
612
            guard saveContext() else {
613
                return
614
            }
615

            
616
            if let chargedDeviceID {
617
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
618
                didSave = saveContext()
619
            } else {
620
                didSave = true
621
            }
622
        }
623
        return didSave
624
    }
625

            
Bogdan Timofte authored a month ago
626
    @discardableResult
627
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
628
        if let percent, (!percent.isFinite || percent <= 0 || percent > 100) {
629
            return false
630
        }
631

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

            
638
            session.setValue(percent, forKey: "targetBatteryPercent")
639
            session.setValue(nil, forKey: "targetBatteryAlertTriggeredAt")
640
            session.setValue(Date(), forKey: "updatedAt")
641
            didSave = saveContext()
642
        }
643
        return didSave
644
    }
645

            
646
    @discardableResult
647
    func confirmCompletion(for sessionID: UUID) -> Bool {
648
        var didSave = false
649
        context.performAndWait {
650
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
651
                return
652
            }
653

            
654
            guard statusValue(session, key: "statusRawValue") == .active else {
655
                return
656
            }
657

            
Bogdan Timofte authored a month ago
658
            finishSession(
659
                session,
660
                observedAt: dateValue(session, key: "lastObservedAt") ?? Date(),
661
                finalBatteryPercent: nil,
662
                label: nil,
663
                status: .completed
664
            )
Bogdan Timofte authored a month ago
665

            
666
            if saveContext() {
667
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
668
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
669
                    didSave = saveContext()
670
                } else {
671
                    didSave = true
672
                }
673
            }
674
        }
675
        return didSave
676
    }
677

            
678
    @discardableResult
679
    func continueMonitoringDespiteCompletionContradiction(for sessionID: UUID) -> Bool {
680
        var didSave = false
681
        context.performAndWait {
682
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
683
                return
684
            }
685

            
686
            guard statusValue(session, key: "statusRawValue") == .active else {
687
                return
688
            }
689

            
690
            clearCompletionConfirmationState(for: session)
691
            session.setValue(Date().addingTimeInterval(completionConfirmationCooldown), forKey: "completionConfirmationCooldownUntil")
692
            session.setValue(Date(), forKey: "updatedAt")
693
            didSave = saveContext()
694
        }
695
        return didSave
696
    }
697

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

            
706
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
707

            
708
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
709
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
710
            context.delete(session)
711

            
712
            guard saveContext() else {
713
                return
714
            }
715

            
716
            if let chargedDeviceID {
717
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
718
                didSave = saveContext()
719
            } else {
720
                didSave = true
721
            }
722
        }
723
        return didSave
724
    }
725

            
726
    @discardableResult
727
    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
728
        var didSave = false
729

            
730
        context.performAndWait {
731
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
732
                return
733
            }
734

            
735
            let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
736
            let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
737
            let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
738

            
739
            var impactedChargedDeviceIDs = Set<String>()
740

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

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

            
768
            context.delete(chargedDevice)
769

            
770
            guard saveContext() else {
771
                return
772
            }
773

            
774
            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
775
            for impactedID in impactedChargedDeviceIDs {
776
                refreshDerivedMetrics(forChargedDeviceID: impactedID)
777
            }
778
            didSave = saveContext()
779
        }
780

            
781
        return didSave
782
    }
783

            
784
    @discardableResult
785
    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
786
        var didSave = false
787

            
788
        context.performAndWait {
Bogdan Timofte authored a month ago
789
            guard let session = fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress),
790
                  let resolvedDevice = stringValue(session, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:)) else {
791
                return
792
            }
Bogdan Timofte authored a month ago
793

            
Bogdan Timofte authored a month ago
794
            if statusValue(session, key: "statusRawValue") == .paused {
795
                if maybeCompletePausedSession(session, observedAt: snapshot.observedAt) {
796
                    didSave = true
797
                }
Bogdan Timofte authored a month ago
798
                return
799
            }
800

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

            
Bogdan Timofte authored a month ago
817
            update(session: session, with: snapshot, stopThreshold: stopThreshold, charger: charger)
Bogdan Timofte authored a month ago
818
            let aggregatedSample = updateAggregatedSample(session: session, with: snapshot)
Bogdan Timofte authored a month ago
819

            
820
            let saveReason = saveReason(for: session, observedAt: snapshot.observedAt)
Bogdan Timofte authored a month ago
821
            let shouldPersistAggregatedCurve = aggregatedSample.map {
822
                shouldPersistAggregatedSample($0, observedAt: snapshot.observedAt)
823
            } ?? false
824

            
825
            guard saveReason != .none || shouldPersistAggregatedCurve else {
Bogdan Timofte authored a month ago
826
                return
827
            }
828

            
829
            session.setValue(snapshot.observedAt, forKey: "updatedAt")
830

            
831
            if saveContext() {
832
                if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
833
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
834
                    didSave = saveContext()
835
                } else {
836
                    didSave = true
837
                }
838
            }
839
        }
840

            
841
        return didSave
842
    }
843

            
844
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
845
        var summaries: [ChargedDeviceSummary] = []
846

            
847
        context.performAndWait {
848
            let devices = fetchObjects(entityName: EntityName.chargedDevice)
849
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
850
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
851
            let sessionSamples = fetchObjects(entityName: EntityName.chargeSessionSample)
852

            
853
            let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
854
            let samplesBySessionID = Dictionary(grouping: sessionSamples) { stringValue($0, key: "sessionID") ?? "" }
855
            let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
856
            let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
857

            
858
            summaries = devices.compactMap { device in
859
                guard
860
                    let id = uuidValue(device, key: "id"),
861
                    let name = stringValue(device, key: "name"),
862
                    let qrIdentifier = stringValue(device, key: "qrIdentifier"),
863
                    let rawClass = stringValue(device, key: "deviceClassRawValue"),
864
                    let deviceClass = ChargedDeviceClass(rawValue: rawClass)
865
                else {
866
                    return nil
867
                }
868

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

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

            
947
        return summaries
948
    }
949

            
950
    func resolvedChargedDeviceSummary(forMeterMACAddress meterMACAddress: String) -> ChargedDeviceSummary? {
951
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
952
        guard !normalizedMAC.isEmpty else { return nil }
953

            
954
        let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
955

            
956
        if let activeMatch = summaries.first(where: { summary in
957
            summary.activeSession?.meterMACAddress == normalizedMAC
958
        }) {
959
            return activeMatch
960
        }
961

            
962
        return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
963
    }
964

            
965
    func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
966
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
967
        guard !normalizedMAC.isEmpty else { return nil }
968

            
Bogdan Timofte authored a month ago
969
        var summary: ChargeSessionSummary?
970

            
971
        context.performAndWait {
972
            guard let session = fetchActiveSessionObject(forMeterMACAddress: normalizedMAC),
973
                  let sessionID = stringValue(session, key: "id") else {
974
                return
975
            }
976

            
977
            summary = makeSessionSummary(
978
                from: session,
979
                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
980
                samples: fetchSessionSampleObjects(forSessionID: sessionID)
981
            )
982
        }
983

            
984
        return summary
Bogdan Timofte authored a month ago
985
    }
986

            
987
    private func createSessionObject(
988
        for chargedDevice: NSManagedObject,
989
        charger: NSManagedObject?,
990
        snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
991
        stopThreshold: Double?,
992
        chargingTransportMode: ChargingTransportMode,
993
        chargingStateMode: ChargingStateMode,
994
        autoStopEnabled: Bool
Bogdan Timofte authored a month ago
995
    ) -> NSManagedObject? {
996
        guard
997
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
998
            let chargedDeviceID = stringValue(chargedDevice, key: "id")
999
        else {
1000
            return nil
1001
        }
1002

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

            
1061
        chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
1062
        chargedDevice.setValue(now, forKey: "updatedAt")
1063
        return session
1064
    }
1065

            
1066
    private func update(
1067
        session: NSManagedObject,
1068
        with snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1069
        stopThreshold: Double?,
1070
        charger: NSManagedObject?
Bogdan Timofte authored a month ago
1071
    ) {
1072
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1073
        let lastObservedAt = dateValue(session, key: "lastObservedAt")
1074
        let previousPower = doubleValue(session, key: "lastObservedPowerWatts")
1075
        let previousCurrent = doubleValue(session, key: "lastObservedCurrentAmps")
1076
        var measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1077
        var measuredChargeAh = doubleValue(session, key: "measuredChargeAh")
1078
        var sourceMode = ChargeSessionSourceMode(rawValue: stringValue(session, key: "sourceModeRawValue") ?? "") ?? .live
1079
        var usedOfflineMeterCounters = boolValue(session, key: "usedOfflineMeterCounters")
1080

            
1081
        if let lastObservedAt {
1082
            let deltaSeconds = max(snapshot.observedAt.timeIntervalSince(lastObservedAt), 0)
1083
            if deltaSeconds > 0, deltaSeconds <= maximumLiveIntegrationGap {
1084
                measuredEnergyWh += max(previousPower, 0) * deltaSeconds / 3600
1085
                measuredChargeAh += max(previousCurrent, 0) * deltaSeconds / 3600
1086
                if sourceMode == .offline {
1087
                    sourceMode = .blended
1088
                }
1089
            }
1090
        }
1091

            
1092
        if let counterGroup = snapshot.selectedDataGroup,
1093
           let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
1094
           UInt8(storedGroup) != counterGroup {
1095
            session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
1096
            session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1097
            session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1098
        }
1099

            
1100
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1101
            let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
1102
            let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
1103
            if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
1104
                session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
1105
            }
1106

            
1107
            if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
1108
                let offlineEnergy = meterEnergyCounterWh - baselineEnergy
Bogdan Timofte authored a month ago
1109
                measuredEnergyWh = max(offlineEnergy, 0)
Bogdan Timofte authored a month ago
1110
                usedOfflineMeterCounters = true
Bogdan Timofte authored a month ago
1111
                sourceMode = .offline
Bogdan Timofte authored a month ago
1112
            } else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
1113
                let delta = meterEnergyCounterWh - lastEnergy
1114
                if delta > 0 {
1115
                    measuredEnergyWh += delta
1116
                    usedOfflineMeterCounters = true
1117
                    sourceMode = .blended
1118
                }
1119
            }
1120
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1121
        }
1122

            
1123
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1124
            let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
1125
            let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
1126
            if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
1127
                session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
1128
            }
1129

            
1130
            if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
1131
                let offlineCharge = meterChargeCounterAh - baselineCharge
Bogdan Timofte authored a month ago
1132
                measuredChargeAh = max(offlineCharge, 0)
Bogdan Timofte authored a month ago
1133
                usedOfflineMeterCounters = true
1134
            } else if let lastCharge, meterChargeCounterAh > lastCharge {
1135
                let delta = meterChargeCounterAh - lastCharge
1136
                if delta > 0 {
1137
                    measuredChargeAh += delta
1138
                    usedOfflineMeterCounters = true
1139
                }
1140
            }
1141
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1142
        }
1143

            
1144
        let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps")
1145
        let updatedMinimum: Double
1146
        if snapshot.currentAmps > 0 {
1147
            updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps)
1148
        } else {
1149
            updatedMinimum = existingMinimum ?? 0
1150
        }
1151

            
Bogdan Timofte authored a month ago
1152
        let effectiveCurrent = effectiveCurrentAmps(
1153
            fromMeasuredCurrent: snapshot.currentAmps,
1154
            chargingTransportMode: sessionChargingTransportMode,
1155
            charger: charger
1156
        )
1157
        let observedChargeFlow = boolValue(session, key: "hasObservedChargeFlow")
1158
            || hasObservedChargeFlow(
1159
                currentAmps: snapshot.currentAmps,
1160
                chargingTransportMode: sessionChargingTransportMode,
1161
                charger: charger,
1162
                stopThreshold: stopThreshold
1163
            )
1164

            
Bogdan Timofte authored a month ago
1165
        session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh")
1166
        session.setValue(measuredChargeAh, forKey: "measuredChargeAh")
1167
        session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps")
Bogdan Timofte authored a month ago
1168
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1169
        session.setValue(snapshot.observedAt, forKey: "lastObservedAt")
1170
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1171
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1172
        session.setValue(
1173
            sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1174
            forKey: "lastObservedVoltageVolts"
1175
        )
Bogdan Timofte authored a month ago
1176
        session.setValue(observedChargeFlow, forKey: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
1177
        session.setValue(
1178
            max(optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? snapshot.currentAmps, snapshot.currentAmps),
1179
            forKey: "maximumObservedCurrentAmps"
1180
        )
1181
        session.setValue(
1182
            max(optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? snapshot.powerWatts, snapshot.powerWatts),
1183
            forKey: "maximumObservedPowerWatts"
1184
        )
1185
        session.setValue(
1186
            sessionChargingTransportMode == .wired
1187
                ? max(optionalDoubleValue(session, key: "maximumObservedVoltageVolts") ?? snapshot.voltageVolts, snapshot.voltageVolts)
1188
                : nil,
1189
            forKey: "maximumObservedVoltageVolts"
1190
        )
1191
        session.setValue(usedOfflineMeterCounters, forKey: "usedOfflineMeterCounters")
1192
        session.setValue(sourceMode.rawValue, forKey: "sourceModeRawValue")
1193
        maybeTriggerTargetBatteryAlert(for: session, observedAt: snapshot.observedAt)
1194

            
Bogdan Timofte authored a month ago
1195
        guard boolValue(session, key: "autoStopEnabled"), let stopThreshold, stopThreshold > 0, observedChargeFlow else {
1196
            session.setValue(nil, forKey: "belowThresholdSince")
1197
            clearCompletionConfirmationState(for: session)
1198
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1199
            return
1200
        }
1201

            
1202
        if effectiveCurrent <= stopThreshold {
Bogdan Timofte authored a month ago
1203
            let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt
1204
            session.setValue(belowThresholdSince, forKey: "belowThresholdSince")
1205
            if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
1206
                if boolValue(session, key: "requiresCompletionConfirmation") {
1207
                    // Leave the session active until the user explicitly confirms or charging resumes.
1208
                    return
1209
                }
1210

            
1211
                if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
1212
                    requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt)
1213
                } else {
Bogdan Timofte authored a month ago
1214
                    finishSession(
1215
                        session,
1216
                        observedAt: snapshot.observedAt,
1217
                        finalBatteryPercent: nil,
1218
                        label: nil,
1219
                        status: .completed
1220
                    )
Bogdan Timofte authored a month ago
1221
                }
1222
            }
1223
        } else {
1224
            session.setValue(nil, forKey: "belowThresholdSince")
1225
            clearCompletionConfirmationState(for: session)
1226
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1227
        }
1228
    }
1229

            
1230
    private func updateAggregatedSample(
1231
        session: NSManagedObject,
1232
        with snapshot: ChargingMonitorSnapshot
Bogdan Timofte authored a month ago
1233
    ) -> NSManagedObject? {
Bogdan Timofte authored a month ago
1234
        guard
1235
            let sessionID = stringValue(session, key: "id"),
1236
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1237
            let startedAt = dateValue(session, key: "startedAt"),
1238
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context)
1239
        else {
Bogdan Timofte authored a month ago
1240
            return nil
Bogdan Timofte authored a month ago
1241
        }
1242

            
1243
        let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0)
1244
        let bucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
1245
        let bucketStart = startedAt.addingTimeInterval(Double(bucketIndex) * Self.aggregatedSampleBucketDuration)
1246
        let bucketIdentifier = "\(sessionID)-\(bucketIndex)"
1247
        let sample = fetchSessionSampleObject(sessionID: sessionID, bucketIndex: bucketIndex)
1248
            ?? NSManagedObject(entity: entity, insertInto: context)
1249
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1250
        let sampleVoltage = sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil
1251

            
1252
        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
1253
        let updatedCount = existingCount + 1
1254

            
1255
        sample.setValue(bucketIdentifier, forKey: "id")
1256
        sample.setValue(sessionID, forKey: "sessionID")
1257
        sample.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1258
        sample.setValue(bucketIndex, forKey: "bucketIndex")
1259
        sample.setValue(max(snapshot.observedAt, bucketStart), forKey: "timestamp")
1260
        sample.setValue(
1261
            runningAverage(
1262
                currentAverage: optionalDoubleValue(sample, key: "averageCurrentAmps") ?? snapshot.currentAmps,
1263
                currentCount: Int(existingCount),
1264
                newValue: snapshot.currentAmps
1265
            ),
1266
            forKey: "averageCurrentAmps"
1267
        )
1268
        sample.setValue(
1269
            sampleVoltage.flatMap { voltage in
1270
                runningAverage(
1271
                    currentAverage: optionalDoubleValue(sample, key: "averageVoltageVolts") ?? voltage,
1272
                    currentCount: Int(existingCount),
1273
                    newValue: voltage
1274
                )
1275
            },
1276
            forKey: "averageVoltageVolts"
1277
        )
1278
        sample.setValue(
1279
            runningAverage(
1280
                currentAverage: optionalDoubleValue(sample, key: "averagePowerWatts") ?? snapshot.powerWatts,
1281
                currentCount: Int(existingCount),
1282
                newValue: snapshot.powerWatts
1283
            ),
1284
            forKey: "averagePowerWatts"
1285
        )
1286
        sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh")
1287
        sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
1288
        sample.setValue(Int16(updatedCount), forKey: "sampleCount")
1289
        sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
1290
        sample.setValue(snapshot.observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1291
        return sample
Bogdan Timofte authored a month ago
1292
    }
1293

            
1294
    private func maybeTriggerTargetBatteryAlert(for session: NSManagedObject, observedAt: Date) {
1295
        guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
1296
            return
1297
        }
1298

            
1299
        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
1300
            return
1301
        }
1302

            
1303
        let predictedBatteryPercent = predictedBatteryPercent(for: session)
1304
            ?? optionalDoubleValue(session, key: "endBatteryPercent")
1305

            
1306
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
1307
            return
1308
        }
1309

            
1310
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
1311
    }
1312

            
1313
    private func shouldRequireCompletionConfirmation(
1314
        for session: NSManagedObject,
1315
        observedAt: Date
1316
    ) -> Bool {
1317
        if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"),
1318
           cooldownUntil > observedAt {
1319
            return false
1320
        }
1321

            
1322
        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
1323
            return false
1324
        }
1325

            
1326
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
1327
            ?? defaultCompletionPercentThreshold
1328

            
1329
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1330
    }
1331

            
1332
    private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
1333
        guard !boolValue(session, key: "requiresCompletionConfirmation") else {
1334
            return
1335
        }
1336

            
1337
        session.setValue(true, forKey: "requiresCompletionConfirmation")
1338
        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
1339
        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
1340
    }
1341

            
1342
    private func clearCompletionConfirmationState(for session: NSManagedObject) {
1343
        session.setValue(false, forKey: "requiresCompletionConfirmation")
1344
        session.setValue(nil, forKey: "completionConfirmationRequestedAt")
1345
        session.setValue(nil, forKey: "completionContradictionPercent")
1346
    }
1347

            
Bogdan Timofte authored a month ago
1348
    private func snapshotDateForManualStop(_ session: NSManagedObject) -> Date {
1349
        if statusValue(session, key: "statusRawValue") == .paused {
1350
            return dateValue(session, key: "pausedAt")
1351
                ?? dateValue(session, key: "lastObservedAt")
1352
                ?? Date()
1353
        }
1354
        return dateValue(session, key: "lastObservedAt") ?? Date()
1355
    }
1356

            
1357
    @discardableResult
1358
    private func maybeCompletePausedSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
1359
        guard statusValue(session, key: "statusRawValue") == .paused else {
1360
            return false
1361
        }
1362

            
1363
        guard let pausedAt = dateValue(session, key: "pausedAt"),
1364
              observedAt.timeIntervalSince(pausedAt) >= pausedSessionTimeout else {
1365
            return false
1366
        }
1367

            
1368
        finishSession(
1369
            session,
1370
            observedAt: pausedAt.addingTimeInterval(pausedSessionTimeout),
1371
            finalBatteryPercent: nil,
1372
            label: nil,
1373
            status: .completed
1374
        )
1375

            
1376
        guard saveContext() else {
1377
            return false
1378
        }
1379

            
1380
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1381
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1382
            return saveContext()
1383
        }
1384

            
1385
        return true
1386
    }
1387

            
1388
    private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
1389
        let chargingTransportMode = chargingTransportMode(for: session)
1390
        let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps")
1391
            ?? doubleValue(session, key: "lastObservedCurrentAmps")
1392

            
1393
        guard measuredCurrent > 0 else {
1394
            return nil
1395
        }
1396

            
1397
        let charger = chargingTransportMode == .wireless
1398
            ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
1399
            : nil
1400

            
1401
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
1402
            return nil
1403
        }
1404

            
1405
        let effectiveCurrent = effectiveCurrentAmps(
1406
            fromMeasuredCurrent: measuredCurrent,
1407
            chargingTransportMode: chargingTransportMode,
1408
            charger: charger
1409
        )
1410
        guard effectiveCurrent > 0 else {
1411
            return nil
1412
        }
1413
        return effectiveCurrent
1414
    }
1415

            
1416
    private func finishSession(
1417
        _ session: NSManagedObject,
1418
        observedAt: Date,
1419
        finalBatteryPercent: Double?,
1420
        label: String?,
1421
        status: ChargeSessionStatus
1422
    ) {
1423
        if let finalBatteryPercent {
1424
            _ = insertBatteryCheckpoint(
1425
                percent: finalBatteryPercent,
1426
                label: label,
1427
                timestamp: observedAt,
1428
                to: session
1429
            )
1430
        }
1431

            
1432
        session.setValue(status.rawValue, forKey: "statusRawValue")
1433
        session.setValue(nil, forKey: "pausedAt")
1434
        session.setValue(nil, forKey: "belowThresholdSince")
1435
        session.setValue(observedAt, forKey: "endedAt")
1436
        session.setValue(observedAt, forKey: "lastObservedAt")
1437
        session.setValue(completionCurrentForSessionEnd(session), forKey: "completionCurrentAmps")
1438
        clearCompletionConfirmationState(for: session)
1439
        session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1440
        updateCapacityEstimate(for: session)
1441
        session.setValue(observedAt, forKey: "updatedAt")
1442
    }
1443

            
Bogdan Timofte authored a month ago
1444
    private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
1445
        guard
1446
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1447
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
1448
            let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice),
1449
            estimatedCapacityWh > 0
1450
        else {
1451
            return nil
1452
        }
1453

            
1454
        let measuredEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1455
            ?? doubleValue(session, key: "measuredEnergyWh")
1456
        let sessionID = stringValue(session, key: "id") ?? ""
1457

            
1458
        struct Anchor {
1459
            let percent: Double
1460
            let energyWh: Double
Bogdan Timofte authored a month ago
1461
            let timestamp: Date
1462
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1463
        }
1464

            
1465
        var anchors: [Anchor] = []
Bogdan Timofte authored a month ago
1466
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1467
           startBatteryPercent >= 0 {
1468
            anchors.append(
1469
                Anchor(
1470
                    percent: startBatteryPercent,
1471
                    energyWh: 0,
1472
                    timestamp: dateValue(session, key: "startedAt") ?? Date.distantPast,
1473
                    isCheckpoint: false
1474
                )
1475
            )
Bogdan Timofte authored a month ago
1476
        }
1477

            
1478
        let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
1479
            .compactMap(makeCheckpointSummary(from:))
1480
            .sorted { lhs, rhs in
1481
                if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1482
                    return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1483
                }
1484
                return lhs.timestamp < rhs.timestamp
1485
            }
Bogdan Timofte authored a month ago
1486
            .filter { $0.batteryPercent >= 0 }
1487
            .map {
1488
                Anchor(
1489
                    percent: $0.batteryPercent,
1490
                    energyWh: $0.measuredEnergyWh,
1491
                    timestamp: $0.timestamp,
1492
                    isCheckpoint: true
1493
                )
1494
            }
Bogdan Timofte authored a month ago
1495
        anchors.append(contentsOf: checkpointAnchors)
1496

            
1497
        guard !anchors.isEmpty else {
1498
            return optionalDoubleValue(session, key: "endBatteryPercent")
1499
        }
1500

            
1501
        let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
Bogdan Timofte authored a month ago
1502
        return BatteryLevelPredictionTuning.predictedPercent(
1503
            anchorPercent: anchor.percent,
1504
            anchorEnergyWh: anchor.energyWh,
1505
            anchorTimestamp: anchor.timestamp,
1506
            anchorIsCheckpoint: anchor.isCheckpoint,
1507
            effectiveEnergyWh: measuredEnergyWh,
1508
            referenceTimestamp: dateValue(session, key: "lastObservedAt") ?? anchor.timestamp,
1509
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1510
        )
1511
    }
1512

            
1513
    private func resolvedEstimatedBatteryCapacityWh(
1514
        for session: NSManagedObject,
1515
        chargedDevice: NSManagedObject
1516
    ) -> Double? {
1517
        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
1518
           sessionCapacityEstimate > 0 {
1519
            return sessionCapacityEstimate
1520
        }
1521

            
1522
        switch chargingTransportMode(for: session) {
1523
        case .wired:
1524
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1525
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1526
        case .wireless:
1527
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1528
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1529
        }
1530
    }
1531

            
1532
    private func updateCapacityEstimate(for session: NSManagedObject) {
1533
        guard
1534
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1535
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
1536
        else {
1537
            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
1538
            session.setValue(nil, forKey: "capacityEstimateWh")
1539
            return
1540
        }
1541

            
1542
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1543
        let chargingMode = chargingTransportMode(for: session)
1544
        let wirelessResolution = chargingMode == .wireless
1545
            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
1546
            : nil
1547
        let effectiveBatteryEnergyWh = chargingMode == .wired
1548
            ? measuredEnergyWh
1549
            : wirelessResolution.map { measuredEnergyWh * $0.factor }
1550

            
1551
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
1552
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
1553
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
1554
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
1555

            
1556
        guard
1557
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1558
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1559
        else {
1560
            session.setValue(nil, forKey: "capacityEstimateWh")
1561
            return
1562
        }
1563

            
Bogdan Timofte authored a month ago
1564
        guard startBatteryPercent >= 0, endBatteryPercent >= 0 else {
1565
            session.setValue(nil, forKey: "capacityEstimateWh")
1566
            return
1567
        }
1568

            
Bogdan Timofte authored a month ago
1569
        let percentDelta = endBatteryPercent - startBatteryPercent
1570
        let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
1571

            
1572
        guard percentDelta >= 20, let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
1573
            session.setValue(nil, forKey: "capacityEstimateWh")
1574
            return
1575
        }
1576

            
1577
        if !supportsChargingWhileOff && endBatteryPercent >= 99.5 {
1578
            session.setValue(nil, forKey: "capacityEstimateWh")
1579
            return
1580
        }
1581

            
1582
        let capacityEstimateWh = effectiveBatteryEnergyWh / (percentDelta / 100)
1583
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
1584
    }
1585

            
1586
    @discardableResult
Bogdan Timofte authored a month ago
1587
    private func insertBatteryCheckpoint(
Bogdan Timofte authored a month ago
1588
        percent: Double,
1589
        label: String?,
Bogdan Timofte authored a month ago
1590
        timestamp: Date = Date(),
Bogdan Timofte authored a month ago
1591
        to session: NSManagedObject
Bogdan Timofte authored a month ago
1592
    ) -> String? {
Bogdan Timofte authored a month ago
1593
        guard
1594
            let sessionID = stringValue(session, key: "id"),
1595
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1596
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
1597
        else {
Bogdan Timofte authored a month ago
1598
            return nil
Bogdan Timofte authored a month ago
1599
        }
1600

            
1601
        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
1602
        let checkpointEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1603
            ?? doubleValue(session, key: "measuredEnergyWh")
1604
        checkpoint.setValue(UUID().uuidString, forKey: "id")
1605
        checkpoint.setValue(sessionID, forKey: "sessionID")
1606
        checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
Bogdan Timofte authored a month ago
1607
        checkpoint.setValue(timestamp, forKey: "timestamp")
Bogdan Timofte authored a month ago
1608
        checkpoint.setValue(percent, forKey: "batteryPercent")
1609
        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
1610
        checkpoint.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
1611
        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
1612
        checkpoint.setValue(
1613
            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
1614
            forKey: "voltageVolts"
1615
        )
1616
        checkpoint.setValue(normalizedOptionalText(label), forKey: "label")
Bogdan Timofte authored a month ago
1617
        checkpoint.setValue(timestamp, forKey: "createdAt")
Bogdan Timofte authored a month ago
1618

            
Bogdan Timofte authored a month ago
1619
        let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
1620
        if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
Bogdan Timofte authored a month ago
1621
            session.setValue(percent, forKey: "startBatteryPercent")
1622
        }
Bogdan Timofte authored a month ago
1623
        if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
1624
            session.setValue(percent, forKey: "endBatteryPercent")
1625
        }
Bogdan Timofte authored a month ago
1626
        session.setValue(timestamp, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1627
        updateCapacityEstimate(for: session)
1628

            
Bogdan Timofte authored a month ago
1629
        return chargedDeviceID
1630
    }
1631

            
Bogdan Timofte authored a month ago
1632
    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
1633
        guard let sessionID = stringValue(session, key: "id") else {
1634
            return
1635
        }
1636

            
1637
        let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
1638
        if let latestCheckpoint = remainingCheckpoints.last {
1639
            session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
1640
        } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1641
                  startBatteryPercent >= 0 {
1642
            session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
1643
        } else {
1644
            session.setValue(nil, forKey: "endBatteryPercent")
1645
        }
1646

            
1647
        session.setValue(Date(), forKey: "updatedAt")
1648
        updateCapacityEstimate(for: session)
1649
    }
1650

            
Bogdan Timofte authored a month ago
1651
    @discardableResult
1652
    private func addBatteryCheckpoint(
1653
        percent: Double,
1654
        label: String?,
1655
        to session: NSManagedObject,
1656
        timestamp: Date = Date()
1657
    ) -> Bool {
1658
        guard let chargedDeviceID = insertBatteryCheckpoint(
1659
            percent: percent,
1660
            label: label,
1661
            timestamp: timestamp,
1662
            to: session
1663
        ) else {
1664
            return false
1665
        }
1666

            
Bogdan Timofte authored a month ago
1667
        guard saveContext() else {
1668
            return false
1669
        }
1670

            
1671
        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1672
        return saveContext()
1673
    }
1674

            
1675
    private func resolvedWirelessEfficiency(
1676
        for session: NSManagedObject,
1677
        chargedDevice: NSManagedObject
1678
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
1679
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
1680
           storedFactor > 0 {
1681
            return (
1682
                factor: storedFactor,
1683
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
1684
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
1685
            )
1686
        }
1687

            
1688
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
1689
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1690
        guard measuredEnergyWh > 0 else {
1691
            return nil
1692
        }
1693

            
1694
        if chargingProfile == .magsafe,
1695
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
1696
           calibratedFactor > 0 {
1697
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
1698
        }
1699

            
1700
        guard
1701
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1702
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1703
        else {
1704
            return nil
1705
        }
1706

            
1707
        let percentDelta = endBatteryPercent - startBatteryPercent
1708
        guard percentDelta >= 20 else {
1709
            return nil
1710
        }
1711

            
1712
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
Bogdan Timofte authored a month ago
1713
            ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
Bogdan Timofte authored a month ago
1714
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1715
                : nil),
1716
              wiredCapacityWh > 0
1717
        else {
1718
            return nil
1719
        }
1720

            
1721
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
1722
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
1723
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
1724
        let usesEstimated = chargingProfile != .magsafe
1725
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
1726

            
1727
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
1728
    }
1729

            
1730
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
1731
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
1732
            return
1733
        }
1734

            
1735
        let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
Bogdan Timofte authored a month ago
1736
        let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
Bogdan Timofte authored a month ago
1737
        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
1738
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
1739
        let sessions = relevantSessionObjects(
1740
            for: chargedDeviceID,
1741
            deviceClass: deviceClass,
1742
            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
1743
            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
1744
        )
Bogdan Timofte authored a month ago
1745
        let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
Bogdan Timofte authored a month ago
1746
        let wiredMinimumCurrent = derivedMinimumCurrent(
1747
            from: sessions,
1748
            chargingTransportMode: .wired
1749
        )
1750
        let wirelessMinimumCurrent = derivedMinimumCurrent(
1751
            from: sessions,
1752
            chargingTransportMode: .wireless
1753
        )
1754

            
1755
        let wiredCapacity = derivedCapacity(
1756
            from: sessions,
1757
            chargingTransportMode: .wired,
1758
            supportsChargingWhileOff: supportsChargingWhileOff
1759
        )
1760
        let wirelessCapacity = derivedCapacity(
1761
            from: sessions,
1762
            chargingTransportMode: .wireless,
1763
            supportsChargingWhileOff: supportsChargingWhileOff
1764
        )
1765
        let wirelessEfficiency = derivedWirelessEfficiency(
1766
            from: sessions,
1767
            chargingProfile: wirelessProfile
1768
        )
Bogdan Timofte authored a month ago
1769
        let configuredCompletionCurrents = decodedCompletionCurrents(
1770
            from: chargedDevice,
1771
            key: "configuredCompletionCurrentsRawValue"
1772
        )
Bogdan Timofte authored a month ago
1773
        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
1774
        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
1775
        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
1776
        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
1777
        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
1778
        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
1779

            
Bogdan Timofte authored a month ago
1780
        let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
1781
        let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
Bogdan Timofte authored a month ago
1782
        let preferredMinimumCurrent: Double?
1783
        let preferredCapacity: Double?
1784
        switch preferredChargingTransportMode {
1785
        case .wired:
Bogdan Timofte authored a month ago
1786
            preferredMinimumCurrent = configuredCompletionCurrents[
1787
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
1788
            ] ?? learnedCompletionCurrents[
1789
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
1790
            ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
Bogdan Timofte authored a month ago
1791
            preferredCapacity = wiredCapacity ?? wirelessCapacity
1792
        case .wireless:
Bogdan Timofte authored a month ago
1793
            preferredMinimumCurrent = configuredCompletionCurrents[
1794
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
1795
            ] ?? learnedCompletionCurrents[
1796
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
1797
            ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
Bogdan Timofte authored a month ago
1798
            preferredCapacity = wirelessCapacity ?? wiredCapacity
1799
        }
1800

            
Bogdan Timofte authored a month ago
1801
        setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
1802
        setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
1803
        setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
1804
        setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1805
        setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1806
        setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
1807
        setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
1808
        setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
1809
        setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
1810
        setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
1811
        setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
1812
        setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
1813
        setValue(Date(), on: chargedDevice, key: "updatedAt")
Bogdan Timofte authored a month ago
1814
    }
1815

            
1816
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
1817
        sessions
1818
            .filter { $0.status == .completed }
1819
            .compactMap { session in
1820
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
1821
                let timestamp = session.endedAt ?? session.lastObservedAt
1822
                return CapacityTrendPoint(
1823
                    sessionID: session.id,
1824
                    timestamp: timestamp,
1825
                    capacityWh: capacityEstimateWh,
1826
                    chargingTransportMode: session.chargingTransportMode
1827
                )
1828
            }
1829
            .sorted { $0.timestamp < $1.timestamp }
1830
    }
1831

            
1832
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
1833
        var groupedEnergyByBin: [Int: [Double]] = [:]
1834
        var groupedChargeByBin: [Int: [Double]] = [:]
1835

            
1836
        for session in sessions where session.status == .completed {
1837
            var points = session.checkpoints
1838

            
1839
            if let startBatteryPercent = session.startBatteryPercent {
1840
                points.append(
1841
                    ChargeCheckpointSummary(
1842
                        id: UUID(),
1843
                        sessionID: session.id,
1844
                        chargedDeviceID: session.chargedDeviceID,
1845
                        timestamp: session.startedAt,
1846
                        batteryPercent: startBatteryPercent,
1847
                        measuredEnergyWh: 0,
1848
                        measuredChargeAh: 0,
1849
                        currentAmps: 0,
1850
                        voltageVolts: nil,
1851
                        label: "Start"
1852
                    )
1853
                )
1854
            }
1855

            
1856
            if let endBatteryPercent = session.endBatteryPercent {
1857
                points.append(
1858
                    ChargeCheckpointSummary(
1859
                        id: UUID(),
1860
                        sessionID: session.id,
1861
                        chargedDeviceID: session.chargedDeviceID,
1862
                        timestamp: session.endedAt ?? session.lastObservedAt,
1863
                        batteryPercent: endBatteryPercent,
1864
                        measuredEnergyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
1865
                        measuredChargeAh: session.measuredChargeAh,
1866
                        currentAmps: 0,
1867
                        voltageVolts: nil,
1868
                        label: "End"
1869
                    )
1870
                )
1871
            }
1872

            
1873
            for point in points {
1874
                let percentBin = Int((point.batteryPercent / 10).rounded(.toNearestOrEven)) * 10
1875
                groupedEnergyByBin[percentBin, default: []].append(point.measuredEnergyWh)
1876
                groupedChargeByBin[percentBin, default: []].append(point.measuredChargeAh)
1877
            }
1878
        }
1879

            
1880
        return groupedEnergyByBin.keys.sorted().compactMap { percentBin in
1881
            guard
1882
                let energies = groupedEnergyByBin[percentBin],
1883
                let charges = groupedChargeByBin[percentBin],
1884
                !energies.isEmpty,
1885
                !charges.isEmpty
1886
            else {
1887
                return nil
1888
            }
1889

            
1890
            return TypicalChargeCurvePoint(
1891
                percentBin: percentBin,
1892
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
1893
                averageChargeAh: charges.reduce(0, +) / Double(charges.count),
1894
                sampleCount: min(energies.count, charges.count)
1895
            )
1896
        }
1897
    }
1898

            
1899
    private func makeSessionSummary(
1900
        from object: NSManagedObject,
1901
        checkpoints: [NSManagedObject],
1902
        samples: [NSManagedObject]
1903
    ) -> ChargeSessionSummary? {
1904
        let chargingTransportMode = chargingTransportMode(for: object)
1905

            
1906
        guard
1907
            let id = uuidValue(object, key: "id"),
1908
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
1909
            let startedAt = dateValue(object, key: "startedAt"),
1910
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
1911
            let status = statusValue(object, key: "statusRawValue"),
1912
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
1913
        else {
1914
            return nil
1915
        }
1916

            
1917
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
1918
            .sorted { $0.timestamp < $1.timestamp }
1919
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
1920
            .sorted { lhs, rhs in
1921
                if lhs.bucketIndex != rhs.bucketIndex {
1922
                    return lhs.bucketIndex < rhs.bucketIndex
1923
                }
1924
                return lhs.timestamp < rhs.timestamp
1925
            }
1926

            
1927
        return ChargeSessionSummary(
1928
            id: id,
1929
            chargedDeviceID: chargedDeviceID,
1930
            chargerID: uuidValue(object, key: "chargerID"),
1931
            meterMACAddress: stringValue(object, key: "meterMACAddress"),
1932
            meterName: stringValue(object, key: "meterName"),
1933
            meterModel: stringValue(object, key: "meterModel"),
1934
            startedAt: startedAt,
1935
            endedAt: dateValue(object, key: "endedAt"),
1936
            lastObservedAt: lastObservedAt,
Bogdan Timofte authored a month ago
1937
            pausedAt: dateValue(object, key: "pausedAt"),
Bogdan Timofte authored a month ago
1938
            status: status,
1939
            sourceMode: sourceMode,
1940
            chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
1941
            chargingStateMode: chargingStateMode(for: object),
1942
            autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
Bogdan Timofte authored a month ago
1943
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
1944
            effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
1945
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
Bogdan Timofte authored a month ago
1946
            meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
1947
            meterChargeBaselineAh: optionalDoubleValue(object, key: "meterChargeBaselineAh"),
Bogdan Timofte authored a month ago
1948
            minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
1949
            maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
1950
            maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
1951
            maximumObservedVoltageVolts: chargingTransportMode == .wired
1952
                ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
1953
                : nil,
1954
            selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
1955
            completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
1956
            stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
1957
            startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
1958
            endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
1959
            capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
1960
            wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
1961
            usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
1962
            shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
1963
            supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
1964
            usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
1965
            targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
1966
            targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
1967
            requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
1968
            completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
1969
            completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
1970
            selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
1971
            checkpoints: checkpointSummaries,
1972
            aggregatedSamples: sampleSummaries
1973
        )
1974
    }
1975

            
1976
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
1977
        guard
1978
            let id = uuidValue(object, key: "id"),
1979
            let sessionID = uuidValue(object, key: "sessionID"),
1980
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
1981
            let timestamp = dateValue(object, key: "timestamp")
1982
        else {
1983
            return nil
1984
        }
1985

            
1986
        return ChargeCheckpointSummary(
1987
            id: id,
1988
            sessionID: sessionID,
1989
            chargedDeviceID: chargedDeviceID,
1990
            timestamp: timestamp,
1991
            batteryPercent: doubleValue(object, key: "batteryPercent"),
1992
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
1993
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
1994
            currentAmps: doubleValue(object, key: "currentAmps"),
1995
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
1996
            label: stringValue(object, key: "label")
1997
        )
1998
    }
1999

            
2000
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
2001
        guard
2002
            let sessionID = uuidValue(object, key: "sessionID"),
2003
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2004
            let timestamp = dateValue(object, key: "timestamp")
2005
        else {
2006
            return nil
2007
        }
2008

            
2009
        return ChargeSessionSampleSummary(
2010
            sessionID: sessionID,
2011
            chargedDeviceID: chargedDeviceID,
2012
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
2013
            timestamp: timestamp,
2014
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
2015
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
2016
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
2017
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2018
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2019
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
2020
        )
2021
    }
2022

            
Bogdan Timofte authored a month ago
2023
    private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2024
        fetchSessionObject(
2025
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2026
                format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
Bogdan Timofte authored a month ago
2027
                normalizedMACAddress(meterMACAddress),
Bogdan Timofte authored a month ago
2028
                ChargeSessionStatus.active.rawValue,
2029
                ChargeSessionStatus.paused.rawValue
Bogdan Timofte authored a month ago
2030
            )
2031
        )
2032
    }
2033

            
Bogdan Timofte authored a month ago
2034
    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2035
        fetchSessionObject(
2036
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2037
                format: "meterMACAddress == %@ AND statusRawValue == %@",
2038
                normalizedMACAddress(meterMACAddress),
2039
                ChargeSessionStatus.active.rawValue
Bogdan Timofte authored a month ago
2040
            )
2041
        )
2042
    }
2043

            
2044
    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
2045
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2046
        request.predicate = predicate
2047
        request.fetchLimit = 1
2048
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
2049
        return (try? context.fetch(request))?.first
2050
    }
2051

            
2052
    private func fetchSessionObject(id: String) -> NSManagedObject? {
2053
        fetchSessionObject(
2054
            predicate: NSPredicate(format: "id == %@", id)
2055
        )
2056
    }
2057

            
2058
    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
2059
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2060
        request.predicate = NSPredicate(
2061
            format: "sessionID == %@ AND bucketIndex == %d",
2062
            sessionID,
2063
            bucketIndex
2064
        )
2065
        request.fetchLimit = 1
2066
        return (try? context.fetch(request))?.first
2067
    }
2068

            
2069
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2070
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2071
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2072
        return (try? context.fetch(request)) ?? []
2073
    }
2074

            
Bogdan Timofte authored a month ago
2075
    private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
2076
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2077
        request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
2078
        request.fetchLimit = 1
2079
        return (try? context.fetch(request))?.first
2080
    }
2081

            
Bogdan Timofte authored a month ago
2082
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2083
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2084
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2085
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
2086
        return (try? context.fetch(request)) ?? []
2087
    }
2088

            
2089
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
2090
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2091
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
2092
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2093
        return (try? context.fetch(request)) ?? []
2094
    }
2095

            
2096
    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
2097
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2098
        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
2099
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2100
        return (try? context.fetch(request)) ?? []
2101
    }
2102

            
2103
    private func relevantSessionObjects(
2104
        for chargedDeviceID: String,
2105
        deviceClass: ChargedDeviceClass,
2106
        sessionsByDeviceID: [String: [NSManagedObject]],
2107
        sessionsByChargerID: [String: [NSManagedObject]]
2108
    ) -> [NSManagedObject] {
2109
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
2110
        guard deviceClass == .charger else {
2111
            return directSessions
2112
        }
2113

            
2114
        var seenSessionIDs = Set<String>()
2115
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
2116
            .filter { session in
2117
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
2118
                return seenSessionIDs.insert(sessionID).inserted
2119
            }
2120
            .sorted {
2121
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
2122
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
2123
                return lhsDate < rhsDate
2124
            }
2125
    }
2126

            
2127
    private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
2128
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false)
2129
    }
2130

            
2131
    private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
2132
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true)
2133
    }
2134

            
2135
    private func resolvedAssignedObject(
2136
        for meterMACAddress: String,
2137
        expectsChargerClass: Bool
2138
    ) -> NSManagedObject? {
2139
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
2140
        guard !normalizedMAC.isEmpty else { return nil }
2141

            
2142
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2143
        request.predicate = NSPredicate(format: "lastAssociatedMeterMAC == %@", normalizedMAC)
2144
        request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
2145
        let matches = (try? context.fetch(request)) ?? []
2146
        return matches.first { object in
Bogdan Timofte authored a month ago
2147
            isChargerObject(object) == expectsChargerClass
Bogdan Timofte authored a month ago
2148
        }
2149
    }
2150

            
Bogdan Timofte authored a month ago
2151
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
2152
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2153
    }
2154

            
Bogdan Timofte authored a month ago
2155
    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
2156
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2157
        request.predicate = NSPredicate(format: "id == %@", id)
2158
        request.fetchLimit = 1
2159
        return (try? context.fetch(request))?.first
2160
    }
2161

            
2162
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
2163
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
2164
        return (try? context.fetch(request)) ?? []
2165
    }
2166

            
2167
    private func resolvedStopThreshold(
2168
        for chargedDevice: NSManagedObject,
2169
        chargingTransportMode: ChargingTransportMode,
Bogdan Timofte authored a month ago
2170
        chargingStateMode: ChargingStateMode,
2171
        charger: NSManagedObject?,
2172
        fallback: Double?
2173
    ) -> Double? {
2174
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
2175
            return nil
2176
        }
2177

            
2178
        let sessionKind = ChargeSessionKind(
2179
            chargingTransportMode: chargingTransportMode,
2180
            chargingStateMode: chargingStateMode
2181
        )
2182
        let configuredCurrents = decodedCompletionCurrents(
2183
            from: chargedDevice,
2184
            key: "configuredCompletionCurrentsRawValue"
2185
        )
2186
        let learnedCurrents = decodedCompletionCurrents(
2187
            from: chargedDevice,
2188
            key: "learnedCompletionCurrentsRawValue"
2189
        )
2190
        let legacyCurrent: Double?
Bogdan Timofte authored a month ago
2191
        switch chargingTransportMode {
2192
        case .wired:
Bogdan Timofte authored a month ago
2193
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2194
                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
2195
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2196
        case .wireless:
Bogdan Timofte authored a month ago
2197
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2198
                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
2199
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2200
        }
Bogdan Timofte authored a month ago
2201

            
2202
        let resolvedCurrent = configuredCurrents[sessionKind]
2203
            ?? learnedCurrents[sessionKind]
2204
            ?? legacyCurrent
2205
            ?? fallback
2206
        guard let resolvedCurrent, resolvedCurrent > 0 else {
2207
            return nil
2208
        }
2209
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
2210
    }
2211

            
Bogdan Timofte authored a month ago
2212
    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
Bogdan Timofte authored a month ago
2213
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
2214
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
2215
        return resolvedPreferredChargingTransportMode(
Bogdan Timofte authored a month ago
2216
            .wired,
Bogdan Timofte authored a month ago
2217
            supportsWiredCharging: supportsWiredCharging,
2218
            supportsWirelessCharging: supportsWirelessCharging
2219
        )
2220
    }
2221

            
2222
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
2223
        if chargedDevice.value(forKey: "supportsWiredCharging") == nil {
2224
            return true
2225
        }
2226
        return boolValue(chargedDevice, key: "supportsWiredCharging")
2227
    }
2228

            
2229
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
2230
        if chargedDevice.value(forKey: "supportsWirelessCharging") == nil {
2231
            return false
2232
        }
2233
        return boolValue(chargedDevice, key: "supportsWirelessCharging")
2234
    }
2235

            
Bogdan Timofte authored a month ago
2236
    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
2237
        if let rawValue = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue"),
2238
           let availability = ChargingStateAvailability(rawValue: rawValue) {
2239
            return availability
2240
        }
2241
        return ChargingStateAvailability.fallback(
2242
            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
2243
        )
2244
    }
2245

            
2246
    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
2247
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
2248
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
2249
            return chargingStateMode
2250
        }
2251

            
2252
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2253
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2254
            return chargingStateAvailability(for: chargedDevice).supportedModes.first ?? .on
2255
        }
2256

            
2257
        return .on
2258
    }
2259

            
2260
    private func resolvedChargingStateMode(
2261
        _ chargingStateMode: ChargingStateMode,
2262
        availability: ChargingStateAvailability
2263
    ) -> ChargingStateMode {
2264
        if availability.supportedModes.contains(chargingStateMode) {
2265
            return chargingStateMode
2266
        }
2267
        return availability.supportedModes.first ?? .on
2268
    }
2269

            
Bogdan Timofte authored a month ago
2270
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
2271
        guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2272
              let profile = WirelessChargingProfile(rawValue: rawValue) else {
2273
            return .genericQi
2274
        }
2275
        return profile
2276
    }
2277

            
2278
    private func resolvedPreferredChargingTransportMode(
2279
        _ preferredChargingTransportMode: ChargingTransportMode,
2280
        supportsWiredCharging: Bool,
2281
        supportsWirelessCharging: Bool
2282
    ) -> ChargingTransportMode {
2283
        switch preferredChargingTransportMode {
2284
        case .wired where supportsWiredCharging:
2285
            return .wired
2286
        case .wireless where supportsWirelessCharging:
2287
            return .wireless
2288
        default:
2289
            if supportsWiredCharging {
2290
                return .wired
2291
            }
2292
            if supportsWirelessCharging {
2293
                return .wireless
2294
            }
2295
            return .wired
2296
        }
2297
    }
2298

            
Bogdan Timofte authored a month ago
2299
    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
2300
        let payload = Dictionary(
2301
            uniqueKeysWithValues: currents.map { key, value in
2302
                (key.rawValue, value)
2303
            }
2304
        )
2305
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
2306
            return nil
2307
        }
2308
        return String(data: data, encoding: .utf8)
2309
    }
2310

            
2311
    private func decodedCompletionCurrents(
2312
        from object: NSManagedObject,
2313
        key: String
2314
    ) -> [ChargeSessionKind: Double] {
2315
        guard let rawValue = stringValue(object, key: key),
2316
              let data = rawValue.data(using: .utf8),
2317
              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
2318
            return [:]
2319
        }
2320

            
2321
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2322
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
2323
                return
2324
            }
2325
            result[sessionKind] = entry.value
2326
        }
2327
    }
2328

            
2329
    private func legacyConfiguredCompletionCurrent(
2330
        for currents: [ChargeSessionKind: Double],
2331
        chargingTransportMode: ChargingTransportMode
2332
    ) -> Double? {
2333
        let candidates = currents
2334
            .filter { $0.key.chargingTransportMode == chargingTransportMode }
2335
            .sorted { lhs, rhs in
2336
                lhs.key.rawValue < rhs.key.rawValue
2337
            }
2338
            .map(\.value)
2339
        return candidates.first
2340
    }
2341

            
2342
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
2343
        guard let charger else {
2344
            return nil
2345
        }
2346
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
2347
        guard let idleCurrent, idleCurrent >= 0 else {
2348
            return nil
2349
        }
2350
        return idleCurrent
2351
    }
2352

            
2353
    private func effectiveCurrentAmps(
2354
        fromMeasuredCurrent currentAmps: Double,
2355
        chargingTransportMode: ChargingTransportMode,
2356
        charger: NSManagedObject?
2357
    ) -> Double {
2358
        switch chargingTransportMode {
2359
        case .wired:
2360
            return max(currentAmps, 0)
2361
        case .wireless:
2362
            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
2363
                return max(currentAmps, 0)
2364
            }
2365
            return max(currentAmps - idleCurrent, 0)
2366
        }
2367
    }
2368

            
2369
    private func hasObservedChargeFlow(
2370
        currentAmps: Double,
2371
        chargingTransportMode: ChargingTransportMode,
2372
        charger: NSManagedObject?,
2373
        stopThreshold: Double?
2374
    ) -> Bool {
2375
        let effectiveCurrent = effectiveCurrentAmps(
2376
            fromMeasuredCurrent: currentAmps,
2377
            chargingTransportMode: chargingTransportMode,
2378
            charger: charger
2379
        )
2380
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
2381
    }
2382

            
Bogdan Timofte authored a month ago
2383
    private func derivedMinimumCurrent(
2384
        from sessions: [NSManagedObject],
2385
        chargingTransportMode: ChargingTransportMode
2386
    ) -> Double? {
2387
        let completionCurrents = sessions.compactMap { session -> Double? in
2388
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2389
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
2390
                return nil
2391
            }
Bogdan Timofte authored a month ago
2392
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2393
                return nil
2394
            }
Bogdan Timofte authored a month ago
2395
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
2396
                return nil
2397
            }
2398
            return completionCurrent
2399
        }
2400

            
2401
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
2402
        guard !recentCompletionCurrents.isEmpty else { return nil }
2403
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
2404
    }
2405

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

            
2409
        for session in sessions {
2410
            guard statusValue(session, key: "statusRawValue") == .completed else {
2411
                continue
2412
            }
2413
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2414
                continue
2415
            }
2416
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
2417
                  completionCurrent > 0 else {
2418
                continue
2419
            }
2420

            
2421
            let sessionKind = ChargeSessionKind(
2422
                chargingTransportMode: chargingTransportMode(for: session),
2423
                chargingStateMode: chargingStateMode(for: session)
2424
            )
2425
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
2426
        }
2427

            
2428
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2429
            let recentCurrents = Array(entry.value.suffix(5))
2430
            guard !recentCurrents.isEmpty else {
2431
                return
2432
            }
2433
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
2434
        }
2435
    }
2436

            
Bogdan Timofte authored a month ago
2437
    private func derivedCapacity(
2438
        from sessions: [NSManagedObject],
2439
        chargingTransportMode: ChargingTransportMode,
2440
        supportsChargingWhileOff: Bool
2441
    ) -> Double? {
2442
        let capacityCandidates = sessions.compactMap { session -> Double? in
2443
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2444
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
2445
                return nil
2446
            }
2447
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
2448
                return nil
2449
            }
2450
            if supportsChargingWhileOff {
2451
                return capacityEstimate
2452
            }
2453
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
2454
                return nil
2455
            }
2456
            return capacityEstimate
2457
        }
2458

            
2459
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
2460
        guard !recentCapacityCandidates.isEmpty else { return nil }
2461
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
2462
    }
2463

            
2464
    private func derivedWirelessEfficiency(
2465
        from sessions: [NSManagedObject],
2466
        chargingProfile: WirelessChargingProfile
2467
    ) -> Double? {
2468
        guard chargingProfile == .magsafe else {
2469
            return nil
2470
        }
2471

            
2472
        let candidates = sessions.compactMap { session -> Double? in
2473
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2474
            guard chargingTransportMode(for: session) == .wireless else { return nil }
2475
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
2476
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
2477
                return nil
2478
            }
2479
            return factor
2480
        }
2481

            
2482
        let recentCandidates = Array(candidates.suffix(6))
2483
        guard !recentCandidates.isEmpty else { return nil }
2484
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2485
    }
2486

            
2487
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
2488
        let candidates = sessions.compactMap { session -> Double? in
2489
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2490
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
2491
                return nil
2492
            }
2493
            return (sourceVoltage * 10).rounded() / 10
2494
        }
2495

            
2496
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
2497
        return counts.keys.sorted()
2498
    }
2499

            
2500
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
2501
        let candidates = sessions.compactMap { session -> Double? in
2502
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2503
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
2504
                return nil
2505
            }
2506
            return minimumObservedCurrent
2507
        }
2508

            
2509
        let recentCandidates = Array(candidates.suffix(6))
2510
        guard !recentCandidates.isEmpty else { return nil }
2511
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2512
    }
2513

            
2514
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
2515
        let candidates = sessions.compactMap { session -> Double? in
2516
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2517
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
2518
                return nil
2519
            }
2520
            return factor
2521
        }
2522

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

            
2528
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
2529
        sessions.compactMap { session -> Double? in
2530
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2531
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
2532
                return nil
2533
            }
2534
            return maximumObservedPower
2535
        }
2536
        .max()
2537
    }
2538

            
2539
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
2540
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
2541
            return persistedChargingTransportMode
2542
        }
2543

            
2544
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2545
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
2546
            return fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
2547
        }
2548

            
2549
        return .wired
2550
    }
2551

            
2552
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
2553
        if session.isInserted {
2554
            return .created
2555
        }
2556

            
2557
        let committedValues = session.committedValues(
2558
            forKeys: [
2559
                "statusRawValue",
2560
                "updatedAt",
2561
                "targetBatteryAlertTriggeredAt",
2562
                "requiresCompletionConfirmation"
2563
            ]
2564
        )
2565
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
2566
        let currentStatus = statusValue(session, key: "statusRawValue")
2567
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
2568
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
2569
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
2570
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
2571
            ?? false
2572
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
2573

            
2574
        if currentStatus == .completed, committedStatus != .completed {
2575
            return .completed
2576
        }
2577

            
Bogdan Timofte authored a month ago
2578
        if currentStatus != committedStatus {
2579
            return .event
2580
        }
2581

            
Bogdan Timofte authored a month ago
2582
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
2583
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
2584
            return .event
2585
        }
2586

            
2587
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
2588
            ?? dateValue(session, key: "createdAt")
2589
            ?? observedAt
2590

            
2591
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
2592
            return .periodic
2593
        }
2594

            
2595
        return .none
2596
    }
2597

            
Bogdan Timofte authored a month ago
2598
    private func shouldPersistAggregatedSample(
2599
        _ sample: NSManagedObject,
2600
        observedAt: Date
2601
    ) -> Bool {
2602
        if sample.isInserted {
2603
            return true
2604
        }
2605

            
2606
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
2607
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
2608
            ?? dateValue(sample, key: "createdAt")
2609
            ?? observedAt
2610

            
2611
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
2612
    }
2613

            
Bogdan Timofte authored a month ago
2614
    private func generateQRIdentifier() -> String {
2615
        "device:\(UUID().uuidString)"
2616
    }
2617

            
2618
    @discardableResult
2619
    private func saveContext() -> Bool {
2620
        guard context.hasChanges else { return true }
2621
        do {
2622
            try context.save()
2623
            return true
2624
        } catch {
2625
            track("Failed saving charge insights context: \(error)")
2626
            context.rollback()
2627
            return false
2628
        }
2629
    }
2630

            
2631
    private func normalizedText(_ text: String) -> String {
2632
        text.trimmingCharacters(in: .whitespacesAndNewlines)
2633
    }
2634

            
2635
    private func normalizedOptionalText(_ text: String?) -> String? {
2636
        guard let text else { return nil }
2637
        let normalized = normalizedText(text)
2638
        return normalized.isEmpty ? nil : normalized
2639
    }
2640

            
2641
    private func normalizedMACAddress(_ macAddress: String) -> String {
2642
        normalizedText(macAddress).uppercased()
2643
    }
2644

            
Bogdan Timofte authored a month ago
2645
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
2646
        guard object.entity.propertiesByName[key] != nil else {
2647
            return nil
2648
        }
2649
        return object.value(forKey: key)
2650
    }
2651

            
2652
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
2653
        guard object.entity.propertiesByName[key] != nil else {
2654
            return
2655
        }
2656
        object.setValue(value, forKey: key)
2657
    }
2658

            
Bogdan Timofte authored a month ago
2659
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
2660
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
2661
        let normalized = normalizedOptionalText(value)
2662
        return normalized
2663
    }
2664

            
2665
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
2666
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
2667
    }
2668

            
2669
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
2670
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
2671
            return value
2672
        }
Bogdan Timofte authored a month ago
2673
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
2674
            return value.doubleValue
2675
        }
2676
        return 0
2677
    }
2678

            
2679
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
2680
        let value = rawValue(object, key: key)
2681
        if value == nil {
Bogdan Timofte authored a month ago
2682
            return nil
2683
        }
2684
        return doubleValue(object, key: key)
2685
    }
2686

            
2687
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
Bogdan Timofte authored a month ago
2688
        if let value = rawValue(object, key: key) as? Int16 {
Bogdan Timofte authored a month ago
2689
            return value
2690
        }
Bogdan Timofte authored a month ago
2691
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
2692
            return value.int16Value
2693
        }
2694
        return nil
2695
    }
2696

            
2697
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
2698
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
2699
            return value
2700
        }
Bogdan Timofte authored a month ago
2701
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
2702
            return value.int32Value
2703
        }
2704
        return nil
2705
    }
2706

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

            
2717
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
2718
        guard let value = stringValue(object, key: key) else { return nil }
2719
        return UUID(uuidString: value)
2720
    }
2721

            
2722
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
2723
        guard let value = stringValue(object, key: key) else { return nil }
2724
        return ChargeSessionStatus(rawValue: value)
2725
    }
2726

            
2727
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
2728
        guard let value = stringValue(object, key: key) else { return nil }
2729
        return ChargingTransportMode(rawValue: value)
2730
    }
2731

            
2732
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
2733
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
2734
            return []
2735
        }
2736
        return rawValue
2737
            .split(separator: ",")
2738
            .compactMap { Double($0) }
2739
            .sorted()
2740
    }
2741

            
2742
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
2743
        let uniqueVoltages = Array(Set(voltages)).sorted()
2744
        guard !uniqueVoltages.isEmpty else {
2745
            return nil
2746
        }
2747
        return uniqueVoltages
2748
            .map { String(format: "%.1f", $0) }
2749
            .joined(separator: ",")
2750
    }
2751

            
2752
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
2753
        guard currentCount > 0 else {
2754
            return newValue
2755
        }
2756
        let total = (currentAverage * Double(currentCount)) + newValue
2757
        return total / Double(currentCount + 1)
2758
    }
2759
}
2760

            
2761
private enum ObservationSaveReason {
2762
    case none
2763
    case created
2764
    case periodic
2765
    case completed
2766
    case event
2767
}