USB-Meter / USB Meter / Model / ChargeInsightsStore.swift
Newer Older
3148 lines | 138.316kb
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
        templateID: String?,
Bogdan Timofte authored a month ago
76
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
77
        supportsWiredCharging: Bool,
78
        supportsWirelessCharging: Bool,
79
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
80
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
81
        notes: String?,
82
        assignTo meterMACAddress: String?
83
    ) -> Bool {
Bogdan Timofte authored a month ago
84
        guard deviceClass.kind == .device else { return false }
Bogdan Timofte authored a month ago
85
        let normalizedName = normalizedText(name)
Bogdan Timofte authored a month ago
86
        let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability)
87
        let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
88
            supportsWiredCharging: supportsWiredCharging,
89
            supportsWirelessCharging: supportsWirelessCharging
90
        )
91
        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device)
Bogdan Timofte authored a month ago
92
        guard !normalizedName.isEmpty else { return false }
Bogdan Timofte authored a month ago
93
        guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
Bogdan Timofte authored a month ago
94

            
95
        var didSave = false
96
        context.performAndWait {
97
            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
98
                return
99
            }
100

            
101
            let object = NSManagedObject(entity: entity, insertInto: context)
102
            let now = Date()
103
            object.setValue(UUID().uuidString, forKey: "id")
104
            object.setValue(normalizedName, forKey: "name")
105
            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
106
            object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
107
            object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
108
            object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
109
            object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging")
110
            object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging")
Bogdan Timofte authored a month ago
111
            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
112
            object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
113
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
114
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
115
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
116
            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
117
            object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC")
118
            object.setValue(now, forKey: "createdAt")
119
            object.setValue(now, forKey: "updatedAt")
120
            didSave = saveContext()
121
        }
122
        return didSave
123
    }
124

            
125
    @discardableResult
Bogdan Timofte authored a month ago
126
    func createCharger(
127
        name: String,
Bogdan Timofte authored a month ago
128
        templateID: String?,
Bogdan Timofte authored a month ago
129
        notes: String?,
130
        assignTo meterMACAddress: String?
131
    ) -> Bool {
132
        let normalizedName = normalizedText(name)
Bogdan Timofte authored a month ago
133
        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .charger)
134
        let chargerTemplateConfiguration = resolvedChargerTemplateConfiguration(templateID: normalizedTemplateID)
Bogdan Timofte authored a month ago
135
        guard !normalizedName.isEmpty else { return false }
136

            
137
        var didSave = false
138
        context.performAndWait {
139
            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
140
                return
141
            }
142

            
143
            let object = NSManagedObject(entity: entity, insertInto: context)
144
            let now = Date()
145
            object.setValue(UUID().uuidString, forKey: "id")
146
            object.setValue(normalizedName, forKey: "name")
147
            object.setValue(ChargedDeviceClass.charger.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
148
            object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
149
            object.setValue(chargerTemplateConfiguration.chargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
150
            object.setValue(chargerTemplateConfiguration.chargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
151
            object.setValue(chargerTemplateConfiguration.supportsWiredCharging, forKey: "supportsWiredCharging")
152
            object.setValue(chargerTemplateConfiguration.supportsWirelessCharging, forKey: "supportsWirelessCharging")
153
            object.setValue(chargerTemplateConfiguration.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
154
            object.setValue(encodedCompletionCurrents([:]), forKey: "configuredCompletionCurrentsRawValue")
155
            object.setValue(nil, forKey: "wiredChargeCompletionCurrentAmps")
156
            object.setValue(nil, forKey: "wirelessChargeCompletionCurrentAmps")
157
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
158
            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
159
            object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC")
160
            object.setValue(now, forKey: "createdAt")
161
            object.setValue(now, forKey: "updatedAt")
162
            didSave = saveContext()
163
        }
164
        return didSave
165
    }
166

            
167
    @discardableResult
168
    func updateDevice(
Bogdan Timofte authored a month ago
169
        id: UUID,
170
        name: String,
171
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
172
        templateID: String?,
Bogdan Timofte authored a month ago
173
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
174
        supportsWiredCharging: Bool,
175
        supportsWirelessCharging: Bool,
176
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
177
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
178
        notes: String?
179
    ) -> Bool {
Bogdan Timofte authored a month ago
180
        guard deviceClass.kind == .device else { return false }
Bogdan Timofte authored a month ago
181
        let normalizedName = normalizedText(name)
Bogdan Timofte authored a month ago
182
        let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability)
183
        let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
184
            supportsWiredCharging: supportsWiredCharging,
185
            supportsWirelessCharging: supportsWirelessCharging
186
        )
187
        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device)
Bogdan Timofte authored a month ago
188
        guard !normalizedName.isEmpty else { return false }
Bogdan Timofte authored a month ago
189
        guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
Bogdan Timofte authored a month ago
190

            
191
        var didSave = false
192
        context.performAndWait {
193
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
194
                return
195
            }
Bogdan Timofte authored a month ago
196
            guard isChargerObject(object) == false else {
197
                return
198
            }
Bogdan Timofte authored a month ago
199

            
200
            let previousSupportsChargingWhileOff = boolValue(object, key: "supportsChargingWhileOff")
Bogdan Timofte authored a month ago
201
            let previousChargingStateAvailability = self.chargingStateAvailability(for: object)
Bogdan Timofte authored a month ago
202
            let previousSupportsWiredCharging = self.supportsWiredCharging(for: object)
203
            let previousSupportsWirelessCharging = self.supportsWirelessCharging(for: object)
204
            let now = Date()
205

            
206
            object.setValue(normalizedName, forKey: "name")
207
            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
208
            object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
209
            object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
210
            object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
211
            object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging")
212
            object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging")
Bogdan Timofte authored a month ago
213
            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
214
            object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
215
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
216
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
217
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
218
            object.setValue(now, forKey: "updatedAt")
219

            
Bogdan Timofte authored a month ago
220
            let supportsChargingWhileOff = normalizedChargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
221
            let shouldRecalculateSessionCapacity = previousSupportsChargingWhileOff != supportsChargingWhileOff
222
            let shouldRefreshActiveSessions = shouldRecalculateSessionCapacity
Bogdan Timofte authored a month ago
223
                || previousChargingStateAvailability != normalizedChargingStateAvailability
224
                || previousSupportsWiredCharging != normalizedChargingSupport.wired
225
                || previousSupportsWirelessCharging != normalizedChargingSupport.wireless
Bogdan Timofte authored a month ago
226

            
227
            if shouldRecalculateSessionCapacity || shouldRefreshActiveSessions {
228
                let sessions = fetchSessions(forChargedDeviceID: id.uuidString)
229
                for session in sessions {
Bogdan Timofte authored a month ago
230
                    let isOpen = statusValue(session, key: "statusRawValue")?.isOpen == true
Bogdan Timofte authored a month ago
231

            
232
                    if shouldRecalculateSessionCapacity {
233
                        session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
234
                        updateCapacityEstimate(for: session)
235
                        session.setValue(now, forKey: "updatedAt")
236
                    }
237

            
Bogdan Timofte authored a month ago
238
                    guard isOpen, shouldRefreshActiveSessions else {
Bogdan Timofte authored a month ago
239
                        continue
240
                    }
241

            
242
                    let resolvedSessionChargingTransportMode = resolvedPreferredChargingTransportMode(
243
                        chargingTransportMode(for: session),
Bogdan Timofte authored a month ago
244
                        supportsWiredCharging: normalizedChargingSupport.wired,
245
                        supportsWirelessCharging: normalizedChargingSupport.wireless
Bogdan Timofte authored a month ago
246
                    )
Bogdan Timofte authored a month ago
247
                    let resolvedSessionChargingStateMode = resolvedChargingStateMode(
248
                        chargingStateMode(for: session),
Bogdan Timofte authored a month ago
249
                        availability: normalizedChargingStateAvailability
Bogdan Timofte authored a month ago
250
                    )
251
                    let charger = stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
252

            
253
                    session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
254
                    session.setValue(resolvedSessionChargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
255
                    session.setValue(resolvedSessionChargingStateMode.rawValue, forKey: "chargingStateRawValue")
Bogdan Timofte authored a month ago
256
                    session.setValue(
257
                        resolvedStopThreshold(
258
                            for: object,
259
                            chargingTransportMode: resolvedSessionChargingTransportMode,
Bogdan Timofte authored a month ago
260
                            chargingStateMode: resolvedSessionChargingStateMode,
261
                            charger: charger,
262
                            fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
263
                        ) ?? 0,
Bogdan Timofte authored a month ago
264
                        forKey: "stopThresholdAmps"
265
                    )
266
                    session.setValue(now, forKey: "updatedAt")
267
                    updateCapacityEstimate(for: session)
268
                }
269
            }
270

            
271
            refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
272
            didSave = saveContext()
273
        }
274
        return didSave
275
    }
276

            
Bogdan Timofte authored a month ago
277
    @discardableResult
278
    func updateCharger(
279
        id: UUID,
280
        name: String,
Bogdan Timofte authored a month ago
281
        templateID: String?,
Bogdan Timofte authored a month ago
282
        notes: String?
283
    ) -> Bool {
284
        let normalizedName = normalizedText(name)
Bogdan Timofte authored a month ago
285
        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .charger)
286
        let chargerTemplateConfiguration = resolvedChargerTemplateConfiguration(templateID: normalizedTemplateID)
Bogdan Timofte authored a month ago
287
        guard !normalizedName.isEmpty else { return false }
288

            
289
        var didSave = false
290
        context.performAndWait {
291
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
292
                return
293
            }
294
            guard isChargerObject(object) else {
295
                return
296
            }
297

            
298
            object.setValue(normalizedName, forKey: "name")
Bogdan Timofte authored a month ago
299
            object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
300
            object.setValue(chargerTemplateConfiguration.chargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
301
            object.setValue(chargerTemplateConfiguration.chargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
302
            object.setValue(chargerTemplateConfiguration.supportsWiredCharging, forKey: "supportsWiredCharging")
303
            object.setValue(chargerTemplateConfiguration.supportsWirelessCharging, forKey: "supportsWirelessCharging")
304
            object.setValue(chargerTemplateConfiguration.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
305
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
306
            object.setValue(Date(), forKey: "updatedAt")
Bogdan Timofte authored a month ago
307
            refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
Bogdan Timofte authored a month ago
308
            didSave = saveContext()
309
        }
310

            
311
        return didSave
312
    }
313

            
Bogdan Timofte authored a month ago
314
    @discardableResult
315
    func assignChargedDevice(id: UUID, to meterMACAddress: String) -> Bool {
316
        assign(itemWithID: id, to: meterMACAddress, kind: .chargedDevice)
317
    }
318

            
319
    @discardableResult
320
    func assignCharger(id: UUID, to meterMACAddress: String) -> Bool {
321
        assign(itemWithID: id, to: meterMACAddress, kind: .charger)
322
    }
323

            
324
    @discardableResult
325
    private func assign(
326
        itemWithID id: UUID,
327
        to meterMACAddress: String,
328
        kind: MeterAssignmentKind
329
    ) -> Bool {
330
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
331
        guard !normalizedMAC.isEmpty else { return false }
332

            
333
        var didSave = false
334
        context.performAndWait {
335
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
336
                return
337
            }
338

            
339
            let isCharger = ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
340
            guard isCharger == kind.expectsChargerClass else {
341
                return
342
            }
343

            
344
            let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
345
            request.predicate = NSPredicate(
346
                format: "lastAssociatedMeterMAC == %@ AND id != %@",
347
                normalizedMAC,
348
                id.uuidString
349
            )
350
            let previouslyAssignedDevices = (try? context.fetch(request)) ?? []
351
            for previousDevice in previouslyAssignedDevices {
352
                let previousIsCharger = ChargedDeviceClass(rawValue: stringValue(previousDevice, key: "deviceClassRawValue") ?? "") == .charger
353
                guard previousIsCharger == kind.expectsChargerClass else {
354
                    continue
355
                }
356
                previousDevice.setValue(nil, forKey: "lastAssociatedMeterMAC")
357
                previousDevice.setValue(Date(), forKey: "updatedAt")
358
            }
359

            
360
            object.setValue(normalizedMAC, forKey: "lastAssociatedMeterMAC")
361
            object.setValue(Date(), forKey: "updatedAt")
362

            
363
            if kind == .charger,
Bogdan Timofte authored a month ago
364
               let openSession = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
365
               chargingTransportMode(for: openSession) == .wireless {
366
                openSession.setValue(id.uuidString, forKey: "chargerID")
367
                openSession.setValue(Date(), forKey: "updatedAt")
Bogdan Timofte authored a month ago
368
            }
369

            
370
            didSave = saveContext()
371
        }
372
        return didSave
373
    }
374

            
375
    @discardableResult
Bogdan Timofte authored a month ago
376
    func startSession(
377
        for snapshot: ChargingMonitorSnapshot,
378
        chargedDeviceID: UUID,
379
        chargerID: UUID?,
380
        chargingTransportMode: ChargingTransportMode,
381
        chargingStateMode: ChargingStateMode,
382
        autoStopEnabled: Bool,
Bogdan Timofte authored a month ago
383
        initialBatteryPercent: Double?,
384
        startsFromFlatBattery: Bool
Bogdan Timofte authored a month ago
385
    ) -> Bool {
Bogdan Timofte authored a month ago
386
        if let initialBatteryPercent,
387
           (initialBatteryPercent.isFinite == false || initialBatteryPercent < 0 || initialBatteryPercent > 100) {
Bogdan Timofte authored a month ago
388
            return false
389
        }
390

            
Bogdan Timofte authored a month ago
391
        var didSave = false
392
        context.performAndWait {
Bogdan Timofte authored a month ago
393
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
Bogdan Timofte authored a month ago
394
                return
395
            }
Bogdan Timofte authored a month ago
396
            guard isChargerObject(chargedDevice) == false else {
397
                return
398
            }
Bogdan Timofte authored a month ago
399

            
Bogdan Timofte authored a month ago
400
            guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
Bogdan Timofte authored a month ago
401
                return
402
            }
403

            
Bogdan Timofte authored a month ago
404
            let resolvedChargingTransportMode = resolvedPreferredChargingTransportMode(
405
                chargingTransportMode,
406
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
407
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
Bogdan Timofte authored a month ago
408
            )
Bogdan Timofte authored a month ago
409
            let resolvedChargingStateMode = resolvedChargingStateMode(
410
                chargingStateMode,
411
                availability: chargingStateAvailability(for: chargedDevice)
412
            )
413
            let charger = resolvedChargingTransportMode == .wireless
414
                ? chargerID.flatMap { fetchChargedDeviceObject(id: $0.uuidString) }
415
                : nil
Bogdan Timofte authored a month ago
416
            if let charger, isChargerObject(charger) == false {
417
                return
418
            }
Bogdan Timofte authored a month ago
419
            guard resolvedChargingTransportMode == .wired || charger != nil else {
Bogdan Timofte authored a month ago
420
                return
421
            }
Bogdan Timofte authored a month ago
422
            let stopThreshold = resolvedStopThreshold(
Bogdan Timofte authored a month ago
423
                for: chargedDevice,
424
                chargingTransportMode: resolvedChargingTransportMode,
425
                chargingStateMode: resolvedChargingStateMode,
426
                charger: charger,
Bogdan Timofte authored a month ago
427
                fallback: snapshot.fallbackStopThresholdAmps > 0 ? snapshot.fallbackStopThresholdAmps : nil
428
            )
Bogdan Timofte authored a month ago
429
            guard let session = createSessionObject(
430
                for: chargedDevice,
Bogdan Timofte authored a month ago
431
                charger: charger,
432
                snapshot: snapshot,
433
                stopThreshold: stopThreshold,
Bogdan Timofte authored a month ago
434
                chargingTransportMode: resolvedChargingTransportMode,
435
                chargingStateMode: resolvedChargingStateMode,
436
                autoStopEnabled: autoStopEnabled
437
            ) else {
438
                return
439
            }
440

            
Bogdan Timofte authored a month ago
441
            if startsFromFlatBattery {
442
                session.setValue(unresolvedFlatBatteryPercent, forKey: "startBatteryPercent")
443
                session.setValue(nil, forKey: "endBatteryPercent")
444
            } else if let initialBatteryPercent {
445
                guard insertBatteryCheckpoint(
446
                    percent: initialBatteryPercent,
Bogdan Timofte authored a month ago
447
                    flag: .initial,
Bogdan Timofte authored a month ago
448
                    timestamp: snapshot.observedAt,
449
                    to: session
450
                ) != nil else {
451
                    return
452
                }
Bogdan Timofte authored a month ago
453
            }
Bogdan Timofte authored a month ago
454
            didSave = saveContext()
455
        }
456
        return didSave
457
    }
458

            
Bogdan Timofte authored a month ago
459
    @discardableResult
460
    func pauseSession(id sessionID: UUID, observedAt: Date) -> Bool {
461
        var didSave = false
462
        context.performAndWait {
463
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
464
                return
465
            }
466

            
467
            guard statusValue(session, key: "statusRawValue") == .active else {
468
                return
469
            }
470

            
471
            session.setValue(ChargeSessionStatus.paused.rawValue, forKey: "statusRawValue")
472
            session.setValue(observedAt, forKey: "pausedAt")
473
            session.setValue(nil, forKey: "belowThresholdSince")
474
            clearCompletionConfirmationState(for: session)
475
            session.setValue(observedAt, forKey: "updatedAt")
476
            didSave = saveContext()
477
        }
478
        return didSave
479
    }
480

            
481
    @discardableResult
482
    func resumeSession(id sessionID: UUID, snapshot: ChargingMonitorSnapshot?) -> Bool {
483
        var didSave = false
484
        context.performAndWait {
485
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
486
                return
487
            }
488

            
489
            guard statusValue(session, key: "statusRawValue") == .paused else {
490
                return
491
            }
492

            
493
            let pausedAt = dateValue(session, key: "pausedAt") ?? Date()
494
            let resumedAt = snapshot?.observedAt ?? Date()
495
            if resumedAt.timeIntervalSince(pausedAt) >= pausedSessionTimeout {
496
                finishSession(
497
                    session,
498
                    observedAt: pausedAt.addingTimeInterval(pausedSessionTimeout),
499
                    finalBatteryPercent: nil,
500
                    status: .completed
501
                )
502
                guard saveContext() else {
503
                    return
504
                }
505
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
506
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
507
                    didSave = saveContext()
508
                } else {
509
                    didSave = true
510
                }
511
                return
512
            }
513

            
514
            session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
515
            session.setValue(nil, forKey: "pausedAt")
516
            session.setValue(nil, forKey: "belowThresholdSince")
517
            clearCompletionConfirmationState(for: session)
518
            session.setValue(resumedAt, forKey: "lastObservedAt")
519
            if let snapshot {
520
                session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
521
                session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
522
                session.setValue(
523
                    chargingTransportMode(for: session) == .wired ? snapshot.voltageVolts : nil,
524
                    forKey: "lastObservedVoltageVolts"
525
                )
526
            } else {
527
                session.setValue(0, forKey: "lastObservedCurrentAmps")
528
                session.setValue(0, forKey: "lastObservedPowerWatts")
529
                session.setValue(nil, forKey: "lastObservedVoltageVolts")
530
            }
531
            session.setValue(resumedAt, forKey: "updatedAt")
532
            didSave = saveContext()
533
        }
534
        return didSave
535
    }
536

            
537
    @discardableResult
538
    func stopSession(
539
        id sessionID: UUID,
Bogdan Timofte authored a month ago
540
        finalBatteryPercent: Double? = nil
Bogdan Timofte authored a month ago
541
    ) -> Bool {
Bogdan Timofte authored a month ago
542
        if let finalBatteryPercent {
543
            guard finalBatteryPercent.isFinite, finalBatteryPercent >= 0, finalBatteryPercent <= 100 else {
544
                return false
545
            }
Bogdan Timofte authored a month ago
546
        }
547

            
548
        var didSave = false
549
        context.performAndWait {
550
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
551
                return
552
            }
553

            
554
            guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
555
                return
556
            }
557

            
558
            let observedAt = snapshotDateForManualStop(session)
559
            finishSession(
560
                session,
561
                observedAt: observedAt,
562
                finalBatteryPercent: finalBatteryPercent,
563
                status: .completed
564
            )
565

            
566
            guard saveContext() else {
567
                return
568
            }
569

            
570
            if let deviceID = stringValue(session, key: "chargedDeviceID") {
571
                refreshDerivedMetrics(forChargedDeviceID: deviceID)
572
                didSave = saveContext()
573
            } else {
574
                didSave = true
575
            }
576
        }
577
        return didSave
578
    }
579

            
Bogdan Timofte authored a month ago
580
    @discardableResult
581
    func addBatteryCheckpoint(
582
        percent: Double,
Bogdan Timofte authored a month ago
583
        for meterMACAddress: String,
584
        measuredEnergyWh: Double? = nil,
585
        measuredChargeAh: Double? = nil
Bogdan Timofte authored a month ago
586
    ) -> Bool {
587
        guard percent.isFinite, percent >= 0, percent <= 100 else {
588
            return false
589
        }
590

            
591
        var didSave = false
592
        context.performAndWait {
Bogdan Timofte authored a month ago
593
            guard let session = fetchOpenSessionObject(forMeterMACAddress: meterMACAddress) else {
Bogdan Timofte authored a month ago
594
                return
595
            }
596

            
Bogdan Timofte authored a month ago
597
            didSave = addBatteryCheckpoint(
598
                percent: percent,
599
                measuredEnergyWh: measuredEnergyWh,
600
                measuredChargeAh: measuredChargeAh,
Bogdan Timofte authored a month ago
601
                flag: .intermediate,
Bogdan Timofte authored a month ago
602
                to: session
603
            )
Bogdan Timofte authored a month ago
604
        }
605
        return didSave
606
    }
607

            
608
    @discardableResult
609
    func addBatteryCheckpoint(
610
        percent: Double,
Bogdan Timofte authored a month ago
611
        for sessionID: UUID,
612
        measuredEnergyWh: Double? = nil,
613
        measuredChargeAh: Double? = nil
Bogdan Timofte authored a month ago
614
    ) -> Bool {
615
        guard percent.isFinite, percent >= 0, percent <= 100 else {
616
            return false
617
        }
618

            
619
        var didSave = false
620
        context.performAndWait {
621
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
622
                return
623
            }
624

            
Bogdan Timofte authored a month ago
625
            didSave = addBatteryCheckpoint(
626
                percent: percent,
627
                measuredEnergyWh: measuredEnergyWh,
628
                measuredChargeAh: measuredChargeAh,
Bogdan Timofte authored a month ago
629
                flag: .intermediate,
Bogdan Timofte authored a month ago
630
                to: session
631
            )
Bogdan Timofte authored a month ago
632
        }
633
        return didSave
634
    }
635

            
Bogdan Timofte authored a month ago
636
    @discardableResult
637
    func deleteBatteryCheckpoint(
638
        id checkpointID: UUID,
639
        from sessionID: UUID
640
    ) -> Bool {
641
        var didSave = false
642
        context.performAndWait {
643
            guard let session = fetchSessionObject(id: sessionID.uuidString),
644
                  let checkpoint = fetchCheckpointObject(
645
                    id: checkpointID.uuidString,
646
                    sessionID: sessionID.uuidString
647
                  ) else {
648
                return
649
            }
650

            
651
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
652
            context.delete(checkpoint)
653
            refreshCheckpointDerivedValues(for: session)
654

            
655
            if let chargedDeviceID {
656
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
657
            }
Bogdan Timofte authored a month ago
658

            
659
            didSave = saveContext()
Bogdan Timofte authored a month ago
660
        }
661
        return didSave
662
    }
663

            
Bogdan Timofte authored a month ago
664
    @discardableResult
665
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
666
        if let percent, (!percent.isFinite || percent <= 0 || percent > 100) {
667
            return false
668
        }
669

            
670
        var didSave = false
671
        context.performAndWait {
672
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
673
                return
674
            }
675

            
676
            session.setValue(percent, forKey: "targetBatteryPercent")
677
            session.setValue(nil, forKey: "targetBatteryAlertTriggeredAt")
678
            session.setValue(Date(), forKey: "updatedAt")
679
            didSave = saveContext()
680
        }
681
        return didSave
682
    }
683

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

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

            
Bogdan Timofte authored a month ago
696
            finishSession(
697
                session,
698
                observedAt: dateValue(session, key: "lastObservedAt") ?? Date(),
699
                finalBatteryPercent: nil,
700
                status: .completed
701
            )
Bogdan Timofte authored a month ago
702

            
703
            if saveContext() {
704
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
705
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
706
                    didSave = saveContext()
707
                } else {
708
                    didSave = true
709
                }
710
            }
711
        }
712
        return didSave
713
    }
714

            
715
    @discardableResult
716
    func continueMonitoringDespiteCompletionContradiction(for sessionID: UUID) -> Bool {
717
        var didSave = false
718
        context.performAndWait {
719
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
720
                return
721
            }
722

            
723
            guard statusValue(session, key: "statusRawValue") == .active else {
724
                return
725
            }
726

            
727
            clearCompletionConfirmationState(for: session)
Bogdan Timofte authored a month ago
728
            session.setValue(nil, forKey: "belowThresholdSince")
Bogdan Timofte authored a month ago
729
            session.setValue(Date().addingTimeInterval(completionConfirmationCooldown), forKey: "completionConfirmationCooldownUntil")
730
            session.setValue(Date(), forKey: "updatedAt")
731
            didSave = saveContext()
732
        }
733
        return didSave
734
    }
735

            
736
    @discardableResult
737
    func deleteChargeSession(id sessionID: UUID) -> Bool {
738
        var didSave = false
739
        context.performAndWait {
740
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
741
                return
742
            }
743

            
744
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
745

            
746
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
747
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
748
            context.delete(session)
749

            
750
            guard saveContext() else {
751
                return
752
            }
753

            
754
            if let chargedDeviceID {
755
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
756
                didSave = saveContext()
757
            } else {
758
                didSave = true
759
            }
760
        }
761
        return didSave
762
    }
763

            
764
    @discardableResult
765
    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
766
        var didSave = false
767

            
768
        context.performAndWait {
769
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
770
                return
771
            }
772

            
773
            let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
774
            let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
775
            let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
776

            
777
            var impactedChargedDeviceIDs = Set<String>()
778

            
779
            for session in deviceSessions {
780
                if let impactedID = stringValue(session, key: "chargedDeviceID") {
781
                    impactedChargedDeviceIDs.insert(impactedID)
782
                }
783
                if let impactedChargerID = stringValue(session, key: "chargerID") {
784
                    impactedChargedDeviceIDs.insert(impactedChargerID)
785
                }
786
                if let sessionID = stringValue(session, key: "id") {
787
                    fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete)
788
                    fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete)
789
                }
790
                context.delete(session)
791
            }
792

            
793
            if deviceClass == .charger {
794
                for session in linkedWirelessSessions {
795
                    guard stringValue(session, key: "chargedDeviceID") != chargedDeviceID.uuidString else {
796
                        continue
797
                    }
798
                    if let impactedID = stringValue(session, key: "chargedDeviceID") {
799
                        impactedChargedDeviceIDs.insert(impactedID)
800
                    }
801
                    session.setValue(nil, forKey: "chargerID")
802
                    session.setValue(Date(), forKey: "updatedAt")
803
                }
804
            }
805

            
806
            context.delete(chargedDevice)
807

            
808
            guard saveContext() else {
809
                return
810
            }
811

            
812
            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
813
            for impactedID in impactedChargedDeviceIDs {
814
                refreshDerivedMetrics(forChargedDeviceID: impactedID)
815
            }
816
            didSave = saveContext()
817
        }
818

            
819
        return didSave
820
    }
821

            
822
    @discardableResult
823
    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
824
        var didSave = false
825

            
826
        context.performAndWait {
Bogdan Timofte authored a month ago
827
            guard let session = fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress),
828
                  let resolvedDevice = stringValue(session, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:)) else {
829
                return
830
            }
Bogdan Timofte authored a month ago
831

            
Bogdan Timofte authored a month ago
832
            if statusValue(session, key: "statusRawValue") == .paused {
833
                if maybeCompletePausedSession(session, observedAt: snapshot.observedAt) {
834
                    didSave = true
835
                }
Bogdan Timofte authored a month ago
836
                return
837
            }
838

            
Bogdan Timofte authored a month ago
839
            let chargingTransportMode = self.chargingTransportMode(for: session)
840
            let chargingStateMode = self.chargingStateMode(for: session)
Bogdan Timofte authored a month ago
841
            let charger = chargingTransportMode == .wireless
Bogdan Timofte authored a month ago
842
                ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
843
                : nil
844
            guard chargingTransportMode == .wired || charger != nil else {
845
                return
846
            }
847
            let stopThreshold = resolvedStopThreshold(
848
                for: resolvedDevice,
849
                chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
850
                chargingStateMode: chargingStateMode,
851
                charger: charger,
852
                fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
Bogdan Timofte authored a month ago
853
            )
854

            
Bogdan Timofte authored a month ago
855
            update(session: session, with: snapshot, stopThreshold: stopThreshold, charger: charger)
Bogdan Timofte authored a month ago
856
            let aggregatedSample = updateAggregatedSample(session: session, with: snapshot)
Bogdan Timofte authored a month ago
857

            
858
            let saveReason = saveReason(for: session, observedAt: snapshot.observedAt)
Bogdan Timofte authored a month ago
859
            let shouldPersistAggregatedCurve = aggregatedSample.map {
860
                shouldPersistAggregatedSample($0, observedAt: snapshot.observedAt)
861
            } ?? false
862

            
863
            guard saveReason != .none || shouldPersistAggregatedCurve else {
Bogdan Timofte authored a month ago
864
                return
865
            }
866

            
867
            session.setValue(snapshot.observedAt, forKey: "updatedAt")
868

            
869
            if saveContext() {
870
                if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
871
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
872
                    didSave = saveContext()
873
                } else {
874
                    didSave = true
875
                }
876
            }
877
        }
878

            
879
        return didSave
880
    }
881

            
882
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
883
        var summaries: [ChargedDeviceSummary] = []
884

            
885
        context.performAndWait {
886
            let devices = fetchObjects(entityName: EntityName.chargedDevice)
887
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
888
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
889

            
890
            let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
891
            let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
892
            let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
Bogdan Timofte authored a month ago
893
            let sampleBackedSessionIDs = sampleBackedSessionIDs(
894
                devices: devices,
895
                sessionsByDeviceID: sessionsByDeviceID,
896
                sessionsByChargerID: sessionsByChargerID
897
            )
898
            let samplesBySessionID = Dictionary(
899
                grouping: fetchSessionSampleObjects(forSessionIDs: Array(sampleBackedSessionIDs))
900
            ) { stringValue($0, key: "sessionID") ?? "" }
Bogdan Timofte authored a month ago
901

            
902
            summaries = devices.compactMap { device in
903
                guard
904
                    let id = uuidValue(device, key: "id"),
905
                    let name = stringValue(device, key: "name"),
906
                    let qrIdentifier = stringValue(device, key: "qrIdentifier"),
907
                    let rawClass = stringValue(device, key: "deviceClassRawValue"),
908
                    let deviceClass = ChargedDeviceClass(rawValue: rawClass)
909
                else {
910
                    return nil
911
                }
912

            
Bogdan Timofte authored a month ago
913
                let chargingStateAvailability = chargingStateAvailability(for: device)
914
                let supportsWiredCharging = supportsWiredCharging(for: device)
915
                let supportsWirelessCharging = supportsWirelessCharging(for: device)
916
                let templateDefinition = templateDefinition(for: device)
917

            
Bogdan Timofte authored a month ago
918
                let sessionObjects = relevantSessionObjects(
919
                    for: id.uuidString,
920
                    deviceClass: deviceClass,
921
                    sessionsByDeviceID: sessionsByDeviceID,
922
                    sessionsByChargerID: sessionsByChargerID
923
                )
924
                let sessionSummaries = sessionObjects
925
                    .compactMap { session in
926
                        makeSessionSummary(
927
                            from: session,
928
                            checkpoints: checkpointsBySessionID[stringValue(session, key: "id") ?? ""] ?? [],
929
                            samples: samplesBySessionID[stringValue(session, key: "id") ?? ""] ?? []
930
                        )
931
                    }
932
                    .sorted { lhs, rhs in
Bogdan Timofte authored a month ago
933
                        if lhs.status.isOpen && !rhs.status.isOpen {
Bogdan Timofte authored a month ago
934
                            return true
935
                        }
Bogdan Timofte authored a month ago
936
                        if !lhs.status.isOpen && rhs.status.isOpen {
937
                            return false
938
                        }
939
                        if lhs.status == .active && rhs.status == .paused {
940
                            return true
941
                        }
942
                        if lhs.status == .paused && rhs.status == .active {
Bogdan Timofte authored a month ago
943
                            return false
944
                        }
945
                        return lhs.startedAt > rhs.startedAt
946
                    }
947

            
948
                return ChargedDeviceSummary(
949
                    id: id,
950
                    qrIdentifier: qrIdentifier,
951
                    name: name,
952
                    deviceClass: deviceClass,
Bogdan Timofte authored a month ago
953
                    deviceTemplateID: stringValue(device, key: "deviceTemplateID"),
954
                    templateDefinition: templateDefinition,
955
                    supportsChargingWhileOff: chargingStateAvailability.supportsChargingWhileOff,
956
                    chargingStateAvailability: chargingStateAvailability,
957
                    supportsWiredCharging: supportsWiredCharging,
958
                    supportsWirelessCharging: supportsWirelessCharging,
Bogdan Timofte authored a month ago
959
                    wirelessChargingProfile: wirelessChargingProfile(for: device),
Bogdan Timofte authored a month ago
960
                    configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"),
961
                    learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"),
Bogdan Timofte authored a month ago
962
                    wirelessChargerEfficiencyFactor: optionalDoubleValue(device, key: "wirelessChargerEfficiencyFactor"),
963
                    wiredChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wiredChargeCompletionCurrentAmps"),
964
                    wirelessChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wirelessChargeCompletionCurrentAmps"),
965
                    chargerObservedVoltageSelections: decodedObservedVoltageSelections(from: device),
966
                    chargerIdleCurrentAmps: optionalDoubleValue(device, key: "chargerIdleCurrentAmps"),
967
                    chargerEfficiencyFactor: optionalDoubleValue(device, key: "chargerEfficiencyFactor"),
968
                    chargerMaximumPowerWatts: optionalDoubleValue(device, key: "chargerMaximumPowerWatts"),
969
                    notes: stringValue(device, key: "notes"),
970
                    minimumCurrentAmps: optionalDoubleValue(device, key: "minimumCurrentAmps"),
971
                    estimatedBatteryCapacityWh: optionalDoubleValue(device, key: "estimatedBatteryCapacityWh"),
972
                    wiredMinimumCurrentAmps: optionalDoubleValue(device, key: "wiredMinimumCurrentAmps"),
973
                    wirelessMinimumCurrentAmps: optionalDoubleValue(device, key: "wirelessMinimumCurrentAmps"),
974
                    wiredEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wiredEstimatedBatteryCapacityWh"),
975
                    wirelessEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wirelessEstimatedBatteryCapacityWh"),
976
                    lastAssociatedMeterMAC: stringValue(device, key: "lastAssociatedMeterMAC"),
977
                    createdAt: dateValue(device, key: "createdAt") ?? .distantPast,
978
                    updatedAt: dateValue(device, key: "updatedAt") ?? .distantPast,
979
                    sessions: sessionSummaries,
980
                    capacityHistory: buildCapacityHistory(from: sessionSummaries),
Bogdan Timofte authored a month ago
981
                    typicalCurve: buildTypicalCurve(from: sessionSummaries),
982
                    standbyPowerMeasurements: []
Bogdan Timofte authored a month ago
983
                )
984
            }
985
            .sorted { lhs, rhs in
986
                if lhs.activeSession != nil && rhs.activeSession == nil {
987
                    return true
988
                }
989
                if lhs.activeSession == nil && rhs.activeSession != nil {
990
                    return false
991
                }
992
                if lhs.updatedAt != rhs.updatedAt {
993
                    return lhs.updatedAt > rhs.updatedAt
994
                }
995
                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
996
            }
997
        }
998

            
999
        return summaries
1000
    }
1001

            
1002
    func resolvedChargedDeviceSummary(forMeterMACAddress meterMACAddress: String) -> ChargedDeviceSummary? {
1003
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1004
        guard !normalizedMAC.isEmpty else { return nil }
1005

            
1006
        let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
1007

            
1008
        if let activeMatch = summaries.first(where: { summary in
1009
            summary.activeSession?.meterMACAddress == normalizedMAC
1010
        }) {
1011
            return activeMatch
1012
        }
1013

            
1014
        return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
1015
    }
1016

            
1017
    func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
1018
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1019
        guard !normalizedMAC.isEmpty else { return nil }
1020

            
Bogdan Timofte authored a month ago
1021
        var summary: ChargeSessionSummary?
1022

            
1023
        context.performAndWait {
Bogdan Timofte authored a month ago
1024
            guard let session = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
Bogdan Timofte authored a month ago
1025
                  let sessionID = stringValue(session, key: "id") else {
1026
                return
1027
            }
1028

            
1029
            summary = makeSessionSummary(
1030
                from: session,
1031
                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
1032
                samples: fetchSessionSampleObjects(forSessionID: sessionID)
1033
            )
1034
        }
1035

            
1036
        return summary
Bogdan Timofte authored a month ago
1037
    }
1038

            
1039
    private func createSessionObject(
1040
        for chargedDevice: NSManagedObject,
1041
        charger: NSManagedObject?,
1042
        snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1043
        stopThreshold: Double?,
1044
        chargingTransportMode: ChargingTransportMode,
1045
        chargingStateMode: ChargingStateMode,
1046
        autoStopEnabled: Bool
Bogdan Timofte authored a month ago
1047
    ) -> NSManagedObject? {
1048
        guard
1049
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
1050
            let chargedDeviceID = stringValue(chargedDevice, key: "id")
1051
        else {
1052
            return nil
1053
        }
1054

            
1055
        let session = NSManagedObject(entity: entity, insertInto: context)
1056
        let now = snapshot.observedAt
1057
        session.setValue(UUID().uuidString, forKey: "id")
1058
        session.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1059
        session.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
1060
        session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress")
1061
        session.setValue(snapshot.meterName, forKey: "meterName")
1062
        session.setValue(snapshot.meterModel, forKey: "meterModel")
1063
        session.setValue(now, forKey: "startedAt")
1064
        session.setValue(now, forKey: "lastObservedAt")
1065
        session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
Bogdan Timofte authored a month ago
1066
        let usesMeterCounters = snapshot.meterEnergyCounterWh != nil || snapshot.meterChargeCounterAh != nil
1067
        session.setValue(
1068
            (usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue,
1069
            forKey: "sourceModeRawValue"
1070
        )
Bogdan Timofte authored a month ago
1071
        session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
1072
        session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue")
1073
        session.setValue(autoStopEnabled, forKey: "autoStopEnabled")
1074
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1075
        session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
1076
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1077
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1078
        session.setValue(
1079
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1080
            forKey: "lastObservedVoltageVolts"
1081
        )
Bogdan Timofte authored a month ago
1082
        session.setValue(
1083
            hasObservedChargeFlow(
1084
                currentAmps: snapshot.currentAmps,
1085
                chargingTransportMode: chargingTransportMode,
1086
                charger: charger,
1087
                stopThreshold: stopThreshold
1088
            ),
1089
            forKey: "hasObservedChargeFlow"
1090
        )
Bogdan Timofte authored a month ago
1091
        session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
1092
        session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
1093
        session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
1094
        session.setValue(
1095
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1096
            forKey: "maximumObservedVoltageVolts"
1097
        )
1098
        session.setValue(boolValue(chargedDevice, key: "supportsChargingWhileOff"), forKey: "supportsChargingWhileOff")
1099
        if let selectedDataGroup = snapshot.selectedDataGroup {
1100
            session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup")
1101
        }
1102
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1103
            session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1104
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1105
        }
1106
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1107
            session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1108
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1109
        }
Bogdan Timofte authored a month ago
1110
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1111
            setValue(meterRecordingDurationSeconds, on: session, key: "meterDurationBaselineSeconds")
1112
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1113
        }
Bogdan Timofte authored a month ago
1114
        session.setValue(now, forKey: "createdAt")
1115
        session.setValue(now, forKey: "updatedAt")
1116

            
1117
        chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
1118
        chargedDevice.setValue(now, forKey: "updatedAt")
1119
        return session
1120
    }
1121

            
1122
    private func update(
1123
        session: NSManagedObject,
1124
        with snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1125
        stopThreshold: Double?,
1126
        charger: NSManagedObject?
Bogdan Timofte authored a month ago
1127
    ) {
1128
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1129
        let lastObservedAt = dateValue(session, key: "lastObservedAt")
1130
        let previousPower = doubleValue(session, key: "lastObservedPowerWatts")
1131
        let previousCurrent = doubleValue(session, key: "lastObservedCurrentAmps")
1132
        var measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1133
        var measuredChargeAh = doubleValue(session, key: "measuredChargeAh")
1134
        var sourceMode = ChargeSessionSourceMode(rawValue: stringValue(session, key: "sourceModeRawValue") ?? "") ?? .live
1135
        var usedOfflineMeterCounters = boolValue(session, key: "usedOfflineMeterCounters")
1136

            
1137
        if let lastObservedAt {
1138
            let deltaSeconds = max(snapshot.observedAt.timeIntervalSince(lastObservedAt), 0)
1139
            if deltaSeconds > 0, deltaSeconds <= maximumLiveIntegrationGap {
1140
                measuredEnergyWh += max(previousPower, 0) * deltaSeconds / 3600
1141
                measuredChargeAh += max(previousCurrent, 0) * deltaSeconds / 3600
1142
                if sourceMode == .offline {
1143
                    sourceMode = .blended
1144
                }
1145
            }
1146
        }
1147

            
1148
        if let counterGroup = snapshot.selectedDataGroup,
1149
           let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
1150
           UInt8(storedGroup) != counterGroup {
1151
            session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
1152
            session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1153
            session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1154
        }
1155

            
1156
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1157
            let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
1158
            let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
1159
            if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
1160
                session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
1161
            }
1162

            
1163
            if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
1164
                let offlineEnergy = meterEnergyCounterWh - baselineEnergy
Bogdan Timofte authored a month ago
1165
                measuredEnergyWh = max(offlineEnergy, 0)
Bogdan Timofte authored a month ago
1166
                usedOfflineMeterCounters = true
Bogdan Timofte authored a month ago
1167
                sourceMode = .offline
Bogdan Timofte authored a month ago
1168
            } else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
1169
                let delta = meterEnergyCounterWh - lastEnergy
1170
                if delta > 0 {
1171
                    measuredEnergyWh += delta
1172
                    usedOfflineMeterCounters = true
1173
                    sourceMode = .blended
1174
                }
1175
            }
1176
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1177
        }
1178

            
1179
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1180
            let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
1181
            let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
1182
            if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
1183
                session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
1184
            }
1185

            
1186
            if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
1187
                let offlineCharge = meterChargeCounterAh - baselineCharge
Bogdan Timofte authored a month ago
1188
                measuredChargeAh = max(offlineCharge, 0)
Bogdan Timofte authored a month ago
1189
                usedOfflineMeterCounters = true
1190
            } else if let lastCharge, meterChargeCounterAh > lastCharge {
1191
                let delta = meterChargeCounterAh - lastCharge
1192
                if delta > 0 {
1193
                    measuredChargeAh += delta
1194
                    usedOfflineMeterCounters = true
1195
                }
1196
            }
1197
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1198
        }
1199

            
Bogdan Timofte authored a month ago
1200
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1201
            let baselineDuration = optionalDoubleValue(session, key: "meterDurationBaselineSeconds") ?? meterRecordingDurationSeconds
1202
            if optionalDoubleValue(session, key: "meterDurationBaselineSeconds") == nil {
1203
                setValue(baselineDuration, on: session, key: "meterDurationBaselineSeconds")
1204
            }
1205
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1206
        }
1207

            
Bogdan Timofte authored a month ago
1208
        let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps")
1209
        let updatedMinimum: Double
1210
        if snapshot.currentAmps > 0 {
1211
            updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps)
1212
        } else {
1213
            updatedMinimum = existingMinimum ?? 0
1214
        }
1215

            
Bogdan Timofte authored a month ago
1216
        let effectiveCurrent = effectiveCurrentAmps(
1217
            fromMeasuredCurrent: snapshot.currentAmps,
1218
            chargingTransportMode: sessionChargingTransportMode,
1219
            charger: charger
1220
        )
1221
        let observedChargeFlow = boolValue(session, key: "hasObservedChargeFlow")
1222
            || hasObservedChargeFlow(
1223
                currentAmps: snapshot.currentAmps,
1224
                chargingTransportMode: sessionChargingTransportMode,
1225
                charger: charger,
1226
                stopThreshold: stopThreshold
1227
            )
1228

            
Bogdan Timofte authored a month ago
1229
        session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh")
1230
        session.setValue(measuredChargeAh, forKey: "measuredChargeAh")
1231
        session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps")
Bogdan Timofte authored a month ago
1232
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1233
        session.setValue(snapshot.observedAt, forKey: "lastObservedAt")
1234
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1235
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1236
        session.setValue(
1237
            sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1238
            forKey: "lastObservedVoltageVolts"
1239
        )
Bogdan Timofte authored a month ago
1240
        session.setValue(observedChargeFlow, forKey: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
1241
        session.setValue(
1242
            max(optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? snapshot.currentAmps, snapshot.currentAmps),
1243
            forKey: "maximumObservedCurrentAmps"
1244
        )
1245
        session.setValue(
1246
            max(optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? snapshot.powerWatts, snapshot.powerWatts),
1247
            forKey: "maximumObservedPowerWatts"
1248
        )
1249
        session.setValue(
1250
            sessionChargingTransportMode == .wired
1251
                ? max(optionalDoubleValue(session, key: "maximumObservedVoltageVolts") ?? snapshot.voltageVolts, snapshot.voltageVolts)
1252
                : nil,
1253
            forKey: "maximumObservedVoltageVolts"
1254
        )
1255
        session.setValue(usedOfflineMeterCounters, forKey: "usedOfflineMeterCounters")
1256
        session.setValue(sourceMode.rawValue, forKey: "sourceModeRawValue")
1257
        maybeTriggerTargetBatteryAlert(for: session, observedAt: snapshot.observedAt)
1258

            
Bogdan Timofte authored a month ago
1259
        guard boolValue(session, key: "autoStopEnabled"), let stopThreshold, stopThreshold > 0, observedChargeFlow else {
1260
            session.setValue(nil, forKey: "belowThresholdSince")
1261
            clearCompletionConfirmationState(for: session)
1262
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1263
            return
1264
        }
1265

            
1266
        if effectiveCurrent <= stopThreshold {
Bogdan Timofte authored a month ago
1267
            let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt
1268
            session.setValue(belowThresholdSince, forKey: "belowThresholdSince")
1269
            if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
1270
                if boolValue(session, key: "requiresCompletionConfirmation") {
1271
                    // Leave the session active until the user explicitly confirms or charging resumes.
1272
                    return
1273
                }
1274

            
1275
                if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
1276
                    requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt)
1277
                } else {
Bogdan Timofte authored a month ago
1278
                    finishSession(
1279
                        session,
1280
                        observedAt: snapshot.observedAt,
1281
                        finalBatteryPercent: nil,
1282
                        status: .completed
1283
                    )
Bogdan Timofte authored a month ago
1284
                }
1285
            }
1286
        } else {
1287
            session.setValue(nil, forKey: "belowThresholdSince")
1288
            clearCompletionConfirmationState(for: session)
1289
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1290
        }
1291
    }
1292

            
1293
    private func updateAggregatedSample(
1294
        session: NSManagedObject,
1295
        with snapshot: ChargingMonitorSnapshot
Bogdan Timofte authored a month ago
1296
    ) -> NSManagedObject? {
Bogdan Timofte authored a month ago
1297
        guard
1298
            let sessionID = stringValue(session, key: "id"),
1299
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1300
            let startedAt = dateValue(session, key: "startedAt"),
1301
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context)
1302
        else {
Bogdan Timofte authored a month ago
1303
            return nil
Bogdan Timofte authored a month ago
1304
        }
1305

            
1306
        let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0)
1307
        let bucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
1308
        let bucketStart = startedAt.addingTimeInterval(Double(bucketIndex) * Self.aggregatedSampleBucketDuration)
1309
        let bucketIdentifier = "\(sessionID)-\(bucketIndex)"
1310
        let sample = fetchSessionSampleObject(sessionID: sessionID, bucketIndex: bucketIndex)
1311
            ?? NSManagedObject(entity: entity, insertInto: context)
1312
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1313
        let sampleVoltage = sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil
1314

            
1315
        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
1316
        let updatedCount = existingCount + 1
1317

            
1318
        sample.setValue(bucketIdentifier, forKey: "id")
1319
        sample.setValue(sessionID, forKey: "sessionID")
1320
        sample.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1321
        sample.setValue(bucketIndex, forKey: "bucketIndex")
1322
        sample.setValue(max(snapshot.observedAt, bucketStart), forKey: "timestamp")
1323
        sample.setValue(
1324
            runningAverage(
1325
                currentAverage: optionalDoubleValue(sample, key: "averageCurrentAmps") ?? snapshot.currentAmps,
1326
                currentCount: Int(existingCount),
1327
                newValue: snapshot.currentAmps
1328
            ),
1329
            forKey: "averageCurrentAmps"
1330
        )
1331
        sample.setValue(
1332
            sampleVoltage.flatMap { voltage in
1333
                runningAverage(
1334
                    currentAverage: optionalDoubleValue(sample, key: "averageVoltageVolts") ?? voltage,
1335
                    currentCount: Int(existingCount),
1336
                    newValue: voltage
1337
                )
1338
            },
1339
            forKey: "averageVoltageVolts"
1340
        )
1341
        sample.setValue(
1342
            runningAverage(
1343
                currentAverage: optionalDoubleValue(sample, key: "averagePowerWatts") ?? snapshot.powerWatts,
1344
                currentCount: Int(existingCount),
1345
                newValue: snapshot.powerWatts
1346
            ),
1347
            forKey: "averagePowerWatts"
1348
        )
1349
        sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh")
1350
        sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
1351
        sample.setValue(Int16(updatedCount), forKey: "sampleCount")
1352
        sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
1353
        sample.setValue(snapshot.observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1354
        return sample
Bogdan Timofte authored a month ago
1355
    }
1356

            
Bogdan Timofte authored a month ago
1357
    private func maybeTriggerTargetBatteryAlert(
1358
        for session: NSManagedObject,
1359
        observedAt: Date,
1360
        completionFallbackPercent: Double? = nil
1361
    ) {
Bogdan Timofte authored a month ago
1362
        guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
1363
            return
1364
        }
1365

            
1366
        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
1367
            return
1368
        }
1369

            
1370
        let predictedBatteryPercent = predictedBatteryPercent(for: session)
1371
            ?? optionalDoubleValue(session, key: "endBatteryPercent")
Bogdan Timofte authored a month ago
1372
            ?? completionFallbackPercent
Bogdan Timofte authored a month ago
1373

            
1374
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
1375
            return
1376
        }
1377

            
1378
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
1379
    }
1380

            
1381
    private func shouldRequireCompletionConfirmation(
1382
        for session: NSManagedObject,
1383
        observedAt: Date
1384
    ) -> Bool {
1385
        if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"),
1386
           cooldownUntil > observedAt {
1387
            return false
1388
        }
1389

            
1390
        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
1391
            return false
1392
        }
1393

            
1394
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
1395
            ?? defaultCompletionPercentThreshold
1396

            
1397
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1398
    }
1399

            
1400
    private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
1401
        guard !boolValue(session, key: "requiresCompletionConfirmation") else {
1402
            return
1403
        }
1404

            
1405
        session.setValue(true, forKey: "requiresCompletionConfirmation")
1406
        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
1407
        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
1408
    }
1409

            
1410
    private func clearCompletionConfirmationState(for session: NSManagedObject) {
1411
        session.setValue(false, forKey: "requiresCompletionConfirmation")
1412
        session.setValue(nil, forKey: "completionConfirmationRequestedAt")
1413
        session.setValue(nil, forKey: "completionContradictionPercent")
1414
    }
1415

            
Bogdan Timofte authored a month ago
1416
    private func snapshotDateForManualStop(_ session: NSManagedObject) -> Date {
1417
        if statusValue(session, key: "statusRawValue") == .paused {
1418
            return dateValue(session, key: "pausedAt")
1419
                ?? dateValue(session, key: "lastObservedAt")
1420
                ?? Date()
1421
        }
1422
        return dateValue(session, key: "lastObservedAt") ?? Date()
1423
    }
1424

            
1425
    @discardableResult
1426
    private func maybeCompletePausedSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
1427
        guard statusValue(session, key: "statusRawValue") == .paused else {
1428
            return false
1429
        }
1430

            
1431
        guard let pausedAt = dateValue(session, key: "pausedAt"),
1432
              observedAt.timeIntervalSince(pausedAt) >= pausedSessionTimeout else {
1433
            return false
1434
        }
1435

            
1436
        finishSession(
1437
            session,
1438
            observedAt: pausedAt.addingTimeInterval(pausedSessionTimeout),
1439
            finalBatteryPercent: nil,
1440
            status: .completed
1441
        )
1442

            
1443
        guard saveContext() else {
1444
            return false
1445
        }
1446

            
1447
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1448
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1449
            return saveContext()
1450
        }
1451

            
1452
        return true
1453
    }
1454

            
1455
    private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
1456
        let chargingTransportMode = chargingTransportMode(for: session)
1457
        let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps")
1458
            ?? doubleValue(session, key: "lastObservedCurrentAmps")
1459

            
1460
        guard measuredCurrent > 0 else {
1461
            return nil
1462
        }
1463

            
1464
        let charger = chargingTransportMode == .wireless
1465
            ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
1466
            : nil
1467

            
1468
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
1469
            return nil
1470
        }
1471

            
1472
        let effectiveCurrent = effectiveCurrentAmps(
1473
            fromMeasuredCurrent: measuredCurrent,
1474
            chargingTransportMode: chargingTransportMode,
1475
            charger: charger
1476
        )
1477
        guard effectiveCurrent > 0 else {
1478
            return nil
1479
        }
1480
        return effectiveCurrent
1481
    }
1482

            
1483
    private func finishSession(
1484
        _ session: NSManagedObject,
1485
        observedAt: Date,
1486
        finalBatteryPercent: Double?,
1487
        status: ChargeSessionStatus
1488
    ) {
1489
        if let finalBatteryPercent {
1490
            _ = insertBatteryCheckpoint(
1491
                percent: finalBatteryPercent,
Bogdan Timofte authored a month ago
1492
                flag: .final,
Bogdan Timofte authored a month ago
1493
                timestamp: observedAt,
1494
                to: session
1495
            )
1496
        }
1497

            
1498
        session.setValue(status.rawValue, forKey: "statusRawValue")
1499
        session.setValue(nil, forKey: "pausedAt")
1500
        session.setValue(nil, forKey: "belowThresholdSince")
1501
        session.setValue(observedAt, forKey: "endedAt")
1502
        session.setValue(observedAt, forKey: "lastObservedAt")
1503
        session.setValue(completionCurrentForSessionEnd(session), forKey: "completionCurrentAmps")
1504
        clearCompletionConfirmationState(for: session)
1505
        session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1506
        updateCapacityEstimate(for: session)
1507
        session.setValue(observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1508

            
1509
        if status == .completed {
1510
            maybeTriggerTargetBatteryAlert(
1511
                for: session,
1512
                observedAt: observedAt,
1513
                completionFallbackPercent: defaultCompletionPercentThreshold
1514
            )
1515
        }
Bogdan Timofte authored a month ago
1516
    }
1517

            
Bogdan Timofte authored a month ago
1518
    private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
1519
        guard
1520
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1521
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
1522
            let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice),
1523
            estimatedCapacityWh > 0
1524
        else {
1525
            return nil
1526
        }
1527

            
Bogdan Timofte authored a month ago
1528
        // Compute effective battery energy dynamically so the prediction uses the
1529
        // most current measuredEnergyWh rather than the stale effectiveBatteryEnergyWh
1530
        // (which is only refreshed at session start, checkpoint insertion, and finish).
1531
        let rawMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1532
        let measuredEnergyWh: Double
1533
        switch chargingTransportMode(for: session) {
1534
        case .wired:
1535
            measuredEnergyWh = rawMeasuredEnergyWh
1536
        case .wireless:
1537
            if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 {
1538
                measuredEnergyWh = rawMeasuredEnergyWh * factor
1539
            } else {
1540
                measuredEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1541
                    ?? rawMeasuredEnergyWh
1542
            }
1543
        }
Bogdan Timofte authored a month ago
1544
        let sessionID = stringValue(session, key: "id") ?? ""
1545

            
1546
        struct Anchor {
1547
            let percent: Double
1548
            let energyWh: Double
Bogdan Timofte authored a month ago
1549
            let timestamp: Date
1550
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1551
        }
1552

            
1553
        var anchors: [Anchor] = []
Bogdan Timofte authored a month ago
1554
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1555
           startBatteryPercent >= 0 {
1556
            anchors.append(
1557
                Anchor(
1558
                    percent: startBatteryPercent,
1559
                    energyWh: 0,
1560
                    timestamp: dateValue(session, key: "startedAt") ?? Date.distantPast,
1561
                    isCheckpoint: false
1562
                )
1563
            )
Bogdan Timofte authored a month ago
1564
        }
1565

            
1566
        let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
1567
            .compactMap(makeCheckpointSummary(from:))
1568
            .sorted { lhs, rhs in
1569
                if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1570
                    return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1571
                }
1572
                return lhs.timestamp < rhs.timestamp
1573
            }
Bogdan Timofte authored a month ago
1574
            .filter { $0.batteryPercent >= 0 }
1575
            .map {
1576
                Anchor(
1577
                    percent: $0.batteryPercent,
1578
                    energyWh: $0.measuredEnergyWh,
1579
                    timestamp: $0.timestamp,
1580
                    isCheckpoint: true
1581
                )
1582
            }
Bogdan Timofte authored a month ago
1583
        anchors.append(contentsOf: checkpointAnchors)
1584

            
1585
        guard !anchors.isEmpty else {
1586
            return optionalDoubleValue(session, key: "endBatteryPercent")
1587
        }
1588

            
1589
        let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
Bogdan Timofte authored a month ago
1590
        return BatteryLevelPredictionTuning.predictedPercent(
1591
            anchorPercent: anchor.percent,
1592
            anchorEnergyWh: anchor.energyWh,
1593
            anchorTimestamp: anchor.timestamp,
1594
            anchorIsCheckpoint: anchor.isCheckpoint,
1595
            effectiveEnergyWh: measuredEnergyWh,
1596
            referenceTimestamp: dateValue(session, key: "lastObservedAt") ?? anchor.timestamp,
1597
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1598
        )
1599
    }
1600

            
1601
    private func resolvedEstimatedBatteryCapacityWh(
1602
        for session: NSManagedObject,
1603
        chargedDevice: NSManagedObject
1604
    ) -> Double? {
1605
        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
1606
           sessionCapacityEstimate > 0 {
1607
            return sessionCapacityEstimate
1608
        }
1609

            
1610
        switch chargingTransportMode(for: session) {
1611
        case .wired:
1612
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1613
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1614
        case .wireless:
1615
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1616
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1617
        }
1618
    }
1619

            
1620
    private func updateCapacityEstimate(for session: NSManagedObject) {
1621
        guard
1622
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1623
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
1624
        else {
1625
            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
1626
            session.setValue(nil, forKey: "capacityEstimateWh")
1627
            return
1628
        }
1629

            
1630
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1631
        let chargingMode = chargingTransportMode(for: session)
1632
        let wirelessResolution = chargingMode == .wireless
1633
            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
1634
            : nil
1635
        let effectiveBatteryEnergyWh = chargingMode == .wired
1636
            ? measuredEnergyWh
1637
            : wirelessResolution.map { measuredEnergyWh * $0.factor }
1638

            
1639
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
1640
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
1641
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
1642
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
1643

            
1644
        guard
1645
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1646
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1647
        else {
1648
            session.setValue(nil, forKey: "capacityEstimateWh")
1649
            return
1650
        }
1651

            
Bogdan Timofte authored a month ago
1652
        guard startBatteryPercent >= 0, endBatteryPercent >= 0 else {
1653
            session.setValue(nil, forKey: "capacityEstimateWh")
1654
            return
1655
        }
1656

            
Bogdan Timofte authored a month ago
1657
        let percentDelta = endBatteryPercent - startBatteryPercent
1658
        let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
1659

            
1660
        guard percentDelta >= 20, let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
1661
            session.setValue(nil, forKey: "capacityEstimateWh")
1662
            return
1663
        }
1664

            
1665
        if !supportsChargingWhileOff && endBatteryPercent >= 99.5 {
1666
            session.setValue(nil, forKey: "capacityEstimateWh")
1667
            return
1668
        }
1669

            
1670
        let capacityEstimateWh = effectiveBatteryEnergyWh / (percentDelta / 100)
1671
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
1672
    }
1673

            
1674
    @discardableResult
Bogdan Timofte authored a month ago
1675
    private func insertBatteryCheckpoint(
Bogdan Timofte authored a month ago
1676
        percent: Double,
Bogdan Timofte authored a month ago
1677
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
1678
        timestamp: Date = Date(),
Bogdan Timofte authored a month ago
1679
        measuredEnergyWhOverride: Double? = nil,
1680
        measuredChargeAhOverride: Double? = nil,
Bogdan Timofte authored a month ago
1681
        to session: NSManagedObject
Bogdan Timofte authored a month ago
1682
    ) -> String? {
Bogdan Timofte authored a month ago
1683
        guard
1684
            let sessionID = stringValue(session, key: "id"),
1685
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1686
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
1687
        else {
Bogdan Timofte authored a month ago
1688
            return nil
Bogdan Timofte authored a month ago
1689
        }
1690

            
1691
        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
Bogdan Timofte authored a month ago
1692
        let checkpointEnergyWh = measuredEnergyWhOverride
1693
            ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
Bogdan Timofte authored a month ago
1694
            ?? doubleValue(session, key: "measuredEnergyWh")
Bogdan Timofte authored a month ago
1695
        let checkpointChargeAh = measuredChargeAhOverride
1696
            ?? doubleValue(session, key: "measuredChargeAh")
Bogdan Timofte authored a month ago
1697
        checkpoint.setValue(UUID().uuidString, forKey: "id")
1698
        checkpoint.setValue(sessionID, forKey: "sessionID")
1699
        checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
Bogdan Timofte authored a month ago
1700
        checkpoint.setValue(timestamp, forKey: "timestamp")
Bogdan Timofte authored a month ago
1701
        checkpoint.setValue(percent, forKey: "batteryPercent")
1702
        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
Bogdan Timofte authored a month ago
1703
        checkpoint.setValue(checkpointChargeAh, forKey: "measuredChargeAh")
Bogdan Timofte authored a month ago
1704
        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
1705
        checkpoint.setValue(
1706
            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
1707
            forKey: "voltageVolts"
1708
        )
Bogdan Timofte authored a month ago
1709
        checkpoint.setValue(flag.rawValue, forKey: "label")
Bogdan Timofte authored a month ago
1710
        checkpoint.setValue(timestamp, forKey: "createdAt")
Bogdan Timofte authored a month ago
1711

            
Bogdan Timofte authored a month ago
1712
        let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
1713
        if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
Bogdan Timofte authored a month ago
1714
            session.setValue(percent, forKey: "startBatteryPercent")
1715
        }
Bogdan Timofte authored a month ago
1716
        if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
1717
            session.setValue(percent, forKey: "endBatteryPercent")
1718
        }
Bogdan Timofte authored a month ago
1719
        session.setValue(timestamp, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1720
        updateCapacityEstimate(for: session)
1721

            
Bogdan Timofte authored a month ago
1722
        return chargedDeviceID
1723
    }
1724

            
Bogdan Timofte authored a month ago
1725
    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
1726
        guard let sessionID = stringValue(session, key: "id") else {
1727
            return
1728
        }
1729

            
1730
        let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
1731
        if let latestCheckpoint = remainingCheckpoints.last {
1732
            session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
1733
        } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1734
                  startBatteryPercent >= 0 {
1735
            session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
1736
        } else {
1737
            session.setValue(nil, forKey: "endBatteryPercent")
1738
        }
1739

            
1740
        session.setValue(Date(), forKey: "updatedAt")
1741
        updateCapacityEstimate(for: session)
1742
    }
1743

            
Bogdan Timofte authored a month ago
1744
    @discardableResult
1745
    private func addBatteryCheckpoint(
1746
        percent: Double,
Bogdan Timofte authored a month ago
1747
        measuredEnergyWh: Double? = nil,
1748
        measuredChargeAh: Double? = nil,
Bogdan Timofte authored a month ago
1749
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
1750
        to session: NSManagedObject,
1751
        timestamp: Date = Date()
1752
    ) -> Bool {
Bogdan Timofte authored a month ago
1753
        if let measuredEnergyWh, measuredEnergyWh.isFinite {
1754
            session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
1755
        }
1756
        if let measuredChargeAh, measuredChargeAh.isFinite {
1757
            session.setValue(max(measuredChargeAh, 0), forKey: "measuredChargeAh")
1758
        }
1759

            
Bogdan Timofte authored a month ago
1760
        guard let chargedDeviceID = insertBatteryCheckpoint(
1761
            percent: percent,
Bogdan Timofte authored a month ago
1762
            flag: flag,
Bogdan Timofte authored a month ago
1763
            timestamp: timestamp,
Bogdan Timofte authored a month ago
1764
            measuredEnergyWhOverride: measuredEnergyWh,
1765
            measuredChargeAhOverride: measuredChargeAh,
Bogdan Timofte authored a month ago
1766
            to: session
1767
        ) else {
1768
            return false
1769
        }
1770

            
Bogdan Timofte authored a month ago
1771
        guard saveContext() else {
1772
            return false
1773
        }
1774

            
1775
        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1776
        return saveContext()
1777
    }
1778

            
1779
    private func resolvedWirelessEfficiency(
1780
        for session: NSManagedObject,
1781
        chargedDevice: NSManagedObject
1782
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
1783
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
1784
           storedFactor > 0 {
1785
            return (
1786
                factor: storedFactor,
1787
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
1788
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
1789
            )
1790
        }
1791

            
1792
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
1793
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1794
        guard measuredEnergyWh > 0 else {
1795
            return nil
1796
        }
1797

            
1798
        if chargingProfile == .magsafe,
1799
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
1800
           calibratedFactor > 0 {
1801
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
1802
        }
1803

            
1804
        guard
1805
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1806
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1807
        else {
1808
            return nil
1809
        }
1810

            
1811
        let percentDelta = endBatteryPercent - startBatteryPercent
1812
        guard percentDelta >= 20 else {
1813
            return nil
1814
        }
1815

            
1816
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
Bogdan Timofte authored a month ago
1817
            ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
Bogdan Timofte authored a month ago
1818
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1819
                : nil),
1820
              wiredCapacityWh > 0
1821
        else {
1822
            return nil
1823
        }
1824

            
1825
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
1826
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
1827
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
1828
        let usesEstimated = chargingProfile != .magsafe
1829
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
1830

            
1831
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
1832
    }
1833

            
1834
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
1835
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
1836
            return
1837
        }
1838

            
Bogdan Timofte authored a month ago
1839
        let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
Bogdan Timofte authored a month ago
1840
        let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
1841
        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
1842
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
1843
        let sessions = relevantSessionObjects(
1844
            for: chargedDeviceID,
1845
            deviceClass: deviceClass,
1846
            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
1847
            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
1848
        )
Bogdan Timofte authored a month ago
1849
        let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
Bogdan Timofte authored a month ago
1850
        let wiredMinimumCurrent = derivedMinimumCurrent(
1851
            from: sessions,
1852
            chargingTransportMode: .wired
1853
        )
1854
        let wirelessMinimumCurrent = derivedMinimumCurrent(
1855
            from: sessions,
1856
            chargingTransportMode: .wireless
1857
        )
1858

            
1859
        let wiredCapacity = derivedCapacity(
1860
            from: sessions,
1861
            chargingTransportMode: .wired,
1862
            supportsChargingWhileOff: supportsChargingWhileOff
1863
        )
1864
        let wirelessCapacity = derivedCapacity(
1865
            from: sessions,
1866
            chargingTransportMode: .wireless,
1867
            supportsChargingWhileOff: supportsChargingWhileOff
1868
        )
1869
        let wirelessEfficiency = derivedWirelessEfficiency(
1870
            from: sessions,
1871
            chargingProfile: wirelessProfile
1872
        )
Bogdan Timofte authored a month ago
1873
        let configuredCompletionCurrents = decodedCompletionCurrents(
1874
            from: chargedDevice,
1875
            key: "configuredCompletionCurrentsRawValue"
1876
        )
Bogdan Timofte authored a month ago
1877
        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
1878
        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
1879
        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
1880
        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
1881
        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
1882
        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
1883

            
Bogdan Timofte authored a month ago
1884
        let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
1885
        let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
Bogdan Timofte authored a month ago
1886
        let preferredMinimumCurrent: Double?
1887
        let preferredCapacity: Double?
1888
        switch preferredChargingTransportMode {
1889
        case .wired:
Bogdan Timofte authored a month ago
1890
            preferredMinimumCurrent = configuredCompletionCurrents[
1891
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
1892
            ] ?? learnedCompletionCurrents[
1893
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
1894
            ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
Bogdan Timofte authored a month ago
1895
            preferredCapacity = wiredCapacity ?? wirelessCapacity
1896
        case .wireless:
Bogdan Timofte authored a month ago
1897
            preferredMinimumCurrent = configuredCompletionCurrents[
1898
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
1899
            ] ?? learnedCompletionCurrents[
1900
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
1901
            ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
Bogdan Timofte authored a month ago
1902
            preferredCapacity = wirelessCapacity ?? wiredCapacity
1903
        }
1904

            
Bogdan Timofte authored a month ago
1905
        setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
1906
        setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
1907
        setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
1908
        setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1909
        setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1910
        setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
1911
        setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
1912
        setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
1913
        setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
1914
        setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
1915
        setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
1916
        setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
1917
        setValue(Date(), on: chargedDevice, key: "updatedAt")
Bogdan Timofte authored a month ago
1918
    }
1919

            
1920
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
1921
        sessions
1922
            .filter { $0.status == .completed }
1923
            .compactMap { session in
1924
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
1925
                let timestamp = session.endedAt ?? session.lastObservedAt
1926
                return CapacityTrendPoint(
1927
                    sessionID: session.id,
1928
                    timestamp: timestamp,
1929
                    capacityWh: capacityEstimateWh,
1930
                    chargingTransportMode: session.chargingTransportMode
1931
                )
1932
            }
1933
            .sorted { $0.timestamp < $1.timestamp }
1934
    }
1935

            
1936
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
1937
        var groupedEnergyByBin: [Int: [Double]] = [:]
1938
        var groupedChargeByBin: [Int: [Double]] = [:]
1939

            
1940
        for session in sessions where session.status == .completed {
Bogdan Timofte authored a month ago
1941
            let anchors = normalizedTypicalCurveAnchors(for: session)
1942
            guard anchors.count >= 2 else {
1943
                continue
Bogdan Timofte authored a month ago
1944
            }
1945

            
Bogdan Timofte authored a month ago
1946
            for percentBin in stride(from: 0, through: 100, by: 10) {
1947
                guard let interpolatedPoint = interpolatedTypicalCurvePoint(
1948
                    for: Double(percentBin),
1949
                    anchors: anchors
1950
                ) else {
1951
                    continue
1952
                }
Bogdan Timofte authored a month ago
1953

            
Bogdan Timofte authored a month ago
1954
                groupedEnergyByBin[percentBin, default: []].append(interpolatedPoint.energyWh)
1955
                groupedChargeByBin[percentBin, default: []].append(interpolatedPoint.chargeAh)
Bogdan Timofte authored a month ago
1956
            }
1957
        }
1958

            
Bogdan Timofte authored a month ago
1959
        let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
Bogdan Timofte authored a month ago
1960
            guard
1961
                let energies = groupedEnergyByBin[percentBin],
1962
                let charges = groupedChargeByBin[percentBin],
1963
                !energies.isEmpty,
1964
                !charges.isEmpty
1965
            else {
1966
                return nil
1967
            }
1968

            
1969
            return TypicalChargeCurvePoint(
1970
                percentBin: percentBin,
1971
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
1972
                averageChargeAh: charges.reduce(0, +) / Double(charges.count),
1973
                sampleCount: min(energies.count, charges.count)
1974
            )
1975
        }
Bogdan Timofte authored a month ago
1976

            
1977
        var runningMaximumEnergyWh = 0.0
1978
        var runningMaximumChargeAh = 0.0
1979

            
1980
        return averagedPoints.map { point in
1981
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
1982
            runningMaximumChargeAh = max(runningMaximumChargeAh, point.averageChargeAh)
1983
            return TypicalChargeCurvePoint(
1984
                percentBin: point.percentBin,
1985
                averageEnergyWh: runningMaximumEnergyWh,
1986
                averageChargeAh: runningMaximumChargeAh,
1987
                sampleCount: point.sampleCount
1988
            )
1989
        }
1990
    }
1991

            
1992
    private func normalizedTypicalCurveAnchors(
1993
        for session: ChargeSessionSummary
1994
    ) -> [(percent: Double, energyWh: Double, chargeAh: Double)] {
1995
        struct Anchor {
1996
            let percent: Double
1997
            let energyWh: Double
1998
            let chargeAh: Double
1999
            let timestamp: Date
2000
        }
2001

            
2002
        var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
2003
            guard checkpoint.batteryPercent.isFinite,
2004
                  checkpoint.measuredEnergyWh.isFinite,
2005
                  checkpoint.measuredChargeAh.isFinite,
2006
                  checkpoint.batteryPercent >= 0,
2007
                  checkpoint.batteryPercent <= 100,
2008
                  checkpoint.measuredEnergyWh >= 0,
2009
                  checkpoint.measuredChargeAh >= 0 else {
2010
                return nil
2011
            }
2012

            
2013
            return Anchor(
2014
                percent: checkpoint.batteryPercent,
2015
                energyWh: checkpoint.measuredEnergyWh,
2016
                chargeAh: checkpoint.measuredChargeAh,
2017
                timestamp: checkpoint.timestamp
2018
            )
2019
        }
2020

            
2021
        if let startBatteryPercent = session.startBatteryPercent,
2022
           startBatteryPercent.isFinite,
2023
           startBatteryPercent >= 0,
2024
           startBatteryPercent <= 100 {
2025
            anchors.append(
2026
                Anchor(
2027
                    percent: startBatteryPercent,
2028
                    energyWh: 0,
2029
                    chargeAh: 0,
2030
                    timestamp: session.startedAt
2031
                )
2032
            )
2033
        }
2034

            
2035
        if let endBatteryPercent = session.endBatteryPercent,
2036
           endBatteryPercent.isFinite,
2037
           endBatteryPercent >= 0,
2038
           endBatteryPercent <= 100 {
2039
            anchors.append(
2040
                Anchor(
2041
                    percent: endBatteryPercent,
2042
                    energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
2043
                    chargeAh: session.measuredChargeAh,
2044
                    timestamp: session.endedAt ?? session.lastObservedAt
2045
                )
2046
            )
2047
        }
2048

            
2049
        let sortedAnchors = anchors.sorted { lhs, rhs in
2050
            if lhs.percent != rhs.percent {
2051
                return lhs.percent < rhs.percent
2052
            }
2053
            if lhs.energyWh != rhs.energyWh {
2054
                return lhs.energyWh < rhs.energyWh
2055
            }
2056
            return lhs.timestamp < rhs.timestamp
2057
        }
2058

            
2059
        var collapsedAnchors: [(percent: Double, energyWh: Double, chargeAh: Double)] = []
2060

            
2061
        for anchor in sortedAnchors {
2062
            if let lastIndex = collapsedAnchors.indices.last,
2063
               abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
2064
                collapsedAnchors[lastIndex] = (
2065
                    percent: collapsedAnchors[lastIndex].percent,
2066
                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh),
2067
                    chargeAh: max(collapsedAnchors[lastIndex].chargeAh, anchor.chargeAh)
2068
                )
2069
            } else {
2070
                collapsedAnchors.append(
2071
                    (percent: anchor.percent, energyWh: anchor.energyWh, chargeAh: anchor.chargeAh)
2072
                )
2073
            }
2074
        }
2075

            
2076
        var runningMaximumEnergyWh = 0.0
2077
        var runningMaximumChargeAh = 0.0
2078

            
2079
        return collapsedAnchors.map { anchor in
2080
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
2081
            runningMaximumChargeAh = max(runningMaximumChargeAh, anchor.chargeAh)
2082
            return (
2083
                percent: anchor.percent,
2084
                energyWh: runningMaximumEnergyWh,
2085
                chargeAh: runningMaximumChargeAh
2086
            )
2087
        }
2088
    }
2089

            
2090
    private func interpolatedTypicalCurvePoint(
2091
        for percent: Double,
2092
        anchors: [(percent: Double, energyWh: Double, chargeAh: Double)]
2093
    ) -> (energyWh: Double, chargeAh: Double)? {
2094
        guard
2095
            let firstAnchor = anchors.first,
2096
            let lastAnchor = anchors.last,
2097
            percent >= firstAnchor.percent,
2098
            percent <= lastAnchor.percent
2099
        else {
2100
            return nil
2101
        }
2102

            
2103
        if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
2104
            return (energyWh: exactAnchor.energyWh, chargeAh: exactAnchor.chargeAh)
2105
        }
2106

            
2107
        guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
2108
              upperIndex > 0 else {
2109
            return nil
2110
        }
2111

            
2112
        let lowerAnchor = anchors[upperIndex - 1]
2113
        let upperAnchor = anchors[upperIndex]
2114
        let span = upperAnchor.percent - lowerAnchor.percent
2115
        guard span > 0.000_1 else {
2116
            return nil
2117
        }
2118

            
2119
        let ratio = (percent - lowerAnchor.percent) / span
2120
        let energyWh = lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
2121
        let chargeAh = lowerAnchor.chargeAh + ((upperAnchor.chargeAh - lowerAnchor.chargeAh) * ratio)
2122
        return (energyWh: energyWh, chargeAh: chargeAh)
Bogdan Timofte authored a month ago
2123
    }
2124

            
2125
    private func makeSessionSummary(
2126
        from object: NSManagedObject,
2127
        checkpoints: [NSManagedObject],
2128
        samples: [NSManagedObject]
2129
    ) -> ChargeSessionSummary? {
2130
        let chargingTransportMode = chargingTransportMode(for: object)
2131

            
2132
        guard
2133
            let id = uuidValue(object, key: "id"),
2134
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2135
            let startedAt = dateValue(object, key: "startedAt"),
2136
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
2137
            let status = statusValue(object, key: "statusRawValue"),
2138
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
2139
        else {
2140
            return nil
2141
        }
2142

            
2143
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
2144
            .sorted { $0.timestamp < $1.timestamp }
2145
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
2146
            .sorted { lhs, rhs in
2147
                if lhs.bucketIndex != rhs.bucketIndex {
2148
                    return lhs.bucketIndex < rhs.bucketIndex
2149
                }
2150
                return lhs.timestamp < rhs.timestamp
2151
            }
2152

            
2153
        return ChargeSessionSummary(
2154
            id: id,
2155
            chargedDeviceID: chargedDeviceID,
2156
            chargerID: uuidValue(object, key: "chargerID"),
2157
            meterMACAddress: stringValue(object, key: "meterMACAddress"),
2158
            meterName: stringValue(object, key: "meterName"),
2159
            meterModel: stringValue(object, key: "meterModel"),
2160
            startedAt: startedAt,
2161
            endedAt: dateValue(object, key: "endedAt"),
2162
            lastObservedAt: lastObservedAt,
Bogdan Timofte authored a month ago
2163
            pausedAt: dateValue(object, key: "pausedAt"),
Bogdan Timofte authored a month ago
2164
            status: status,
2165
            sourceMode: sourceMode,
2166
            chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
2167
            chargingStateMode: chargingStateMode(for: object),
2168
            autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
Bogdan Timofte authored a month ago
2169
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2170
            effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
2171
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
Bogdan Timofte authored a month ago
2172
            meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
2173
            meterChargeBaselineAh: optionalDoubleValue(object, key: "meterChargeBaselineAh"),
Bogdan Timofte authored a month ago
2174
            meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"),
2175
            meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"),
Bogdan Timofte authored a month ago
2176
            minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
2177
            maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
2178
            maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
2179
            maximumObservedVoltageVolts: chargingTransportMode == .wired
2180
                ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
2181
                : nil,
2182
            selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
2183
            completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
2184
            stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
2185
            startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
2186
            endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
2187
            capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
2188
            wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
2189
            usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
2190
            shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
2191
            supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
2192
            usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
2193
            targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
2194
            targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
2195
            requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
2196
            completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
2197
            completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
2198
            selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
2199
            checkpoints: checkpointSummaries,
2200
            aggregatedSamples: sampleSummaries
2201
        )
2202
    }
2203

            
2204
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
2205
        guard
2206
            let id = uuidValue(object, key: "id"),
2207
            let sessionID = uuidValue(object, key: "sessionID"),
2208
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2209
            let timestamp = dateValue(object, key: "timestamp")
2210
        else {
2211
            return nil
2212
        }
2213

            
2214
        return ChargeCheckpointSummary(
2215
            id: id,
2216
            sessionID: sessionID,
2217
            chargedDeviceID: chargedDeviceID,
2218
            timestamp: timestamp,
2219
            batteryPercent: doubleValue(object, key: "batteryPercent"),
2220
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2221
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2222
            currentAmps: doubleValue(object, key: "currentAmps"),
2223
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
2224
            label: stringValue(object, key: "label")
2225
        )
2226
    }
2227

            
2228
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
2229
        guard
2230
            let sessionID = uuidValue(object, key: "sessionID"),
2231
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2232
            let timestamp = dateValue(object, key: "timestamp")
2233
        else {
2234
            return nil
2235
        }
2236

            
2237
        return ChargeSessionSampleSummary(
2238
            sessionID: sessionID,
2239
            chargedDeviceID: chargedDeviceID,
2240
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
2241
            timestamp: timestamp,
2242
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
2243
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
2244
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
2245
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2246
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2247
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
2248
        )
2249
    }
2250

            
Bogdan Timofte authored a month ago
2251
    private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2252
        fetchSessionObject(
2253
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2254
                format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
Bogdan Timofte authored a month ago
2255
                normalizedMACAddress(meterMACAddress),
Bogdan Timofte authored a month ago
2256
                ChargeSessionStatus.active.rawValue,
2257
                ChargeSessionStatus.paused.rawValue
Bogdan Timofte authored a month ago
2258
            )
2259
        )
2260
    }
2261

            
Bogdan Timofte authored a month ago
2262
    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2263
        fetchSessionObject(
2264
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2265
                format: "meterMACAddress == %@ AND statusRawValue == %@",
2266
                normalizedMACAddress(meterMACAddress),
2267
                ChargeSessionStatus.active.rawValue
Bogdan Timofte authored a month ago
2268
            )
2269
        )
2270
    }
2271

            
2272
    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
2273
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2274
        request.predicate = predicate
2275
        request.fetchLimit = 1
2276
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
2277
        return (try? context.fetch(request))?.first
2278
    }
2279

            
2280
    private func fetchSessionObject(id: String) -> NSManagedObject? {
2281
        fetchSessionObject(
2282
            predicate: NSPredicate(format: "id == %@", id)
2283
        )
2284
    }
2285

            
2286
    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
2287
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2288
        request.predicate = NSPredicate(
2289
            format: "sessionID == %@ AND bucketIndex == %d",
2290
            sessionID,
2291
            bucketIndex
2292
        )
2293
        request.fetchLimit = 1
2294
        return (try? context.fetch(request))?.first
2295
    }
2296

            
2297
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2298
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2299
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2300
        return (try? context.fetch(request)) ?? []
2301
    }
2302

            
Bogdan Timofte authored a month ago
2303
    private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
2304
        guard !sessionIDs.isEmpty else {
2305
            return []
2306
        }
2307

            
2308
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2309
        request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs)
2310
        return (try? context.fetch(request)) ?? []
2311
    }
2312

            
Bogdan Timofte authored a month ago
2313
    private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
2314
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2315
        request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
2316
        request.fetchLimit = 1
2317
        return (try? context.fetch(request))?.first
2318
    }
2319

            
Bogdan Timofte authored a month ago
2320
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2321
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2322
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2323
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
2324
        return (try? context.fetch(request)) ?? []
2325
    }
2326

            
2327
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
2328
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2329
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
2330
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2331
        return (try? context.fetch(request)) ?? []
2332
    }
2333

            
2334
    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
2335
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2336
        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
2337
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2338
        return (try? context.fetch(request)) ?? []
2339
    }
2340

            
Bogdan Timofte authored a month ago
2341
    private func sampleBackedSessionIDs(
2342
        devices: [NSManagedObject],
2343
        sessionsByDeviceID: [String: [NSManagedObject]],
2344
        sessionsByChargerID: [String: [NSManagedObject]]
2345
    ) -> Set<String> {
2346
        var sessionIDs: Set<String> = []
2347

            
2348
        for device in devices {
2349
            guard
2350
                let deviceID = stringValue(device, key: "id"),
2351
                let rawClass = stringValue(device, key: "deviceClassRawValue"),
2352
                let deviceClass = ChargedDeviceClass(rawValue: rawClass)
2353
            else {
2354
                continue
2355
            }
2356

            
2357
            let relevantSessions = relevantSessionObjects(
2358
                for: deviceID,
2359
                deviceClass: deviceClass,
2360
                sessionsByDeviceID: sessionsByDeviceID,
2361
                sessionsByChargerID: sessionsByChargerID
2362
            )
2363
            .sorted { lhs, rhs in
2364
                let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed
2365
                let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed
2366

            
2367
                if lhsStatus.isOpen && !rhsStatus.isOpen {
2368
                    return true
2369
                }
2370
                if !lhsStatus.isOpen && rhsStatus.isOpen {
2371
                    return false
2372
                }
2373

            
2374
                return (dateValue(lhs, key: "startedAt") ?? .distantPast)
2375
                    > (dateValue(rhs, key: "startedAt") ?? .distantPast)
2376
            }
2377

            
2378
            var recentCompletedSamplesIncluded = 0
2379

            
2380
            for session in relevantSessions {
2381
                guard let sessionID = stringValue(session, key: "id"),
2382
                      let status = statusValue(session, key: "statusRawValue") else {
2383
                    continue
2384
                }
2385

            
2386
                if status.isOpen {
2387
                    sessionIDs.insert(sessionID)
2388
                    continue
2389
                }
2390

            
2391
                guard recentCompletedSamplesIncluded < 2 else {
2392
                    continue
2393
                }
2394

            
2395
                sessionIDs.insert(sessionID)
2396
                recentCompletedSamplesIncluded += 1
2397
            }
2398
        }
2399

            
2400
        return sessionIDs
2401
    }
2402

            
Bogdan Timofte authored a month ago
2403
    private func relevantSessionObjects(
2404
        for chargedDeviceID: String,
2405
        deviceClass: ChargedDeviceClass,
2406
        sessionsByDeviceID: [String: [NSManagedObject]],
2407
        sessionsByChargerID: [String: [NSManagedObject]]
2408
    ) -> [NSManagedObject] {
2409
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
2410
        guard deviceClass == .charger else {
2411
            return directSessions
2412
        }
2413

            
2414
        var seenSessionIDs = Set<String>()
2415
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
2416
            .filter { session in
2417
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
2418
                return seenSessionIDs.insert(sessionID).inserted
2419
            }
2420
            .sorted {
2421
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
2422
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
2423
                return lhsDate < rhsDate
2424
            }
2425
    }
2426

            
2427
    private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
2428
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false)
2429
    }
2430

            
2431
    private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
2432
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true)
2433
    }
2434

            
2435
    private func resolvedAssignedObject(
2436
        for meterMACAddress: String,
2437
        expectsChargerClass: Bool
2438
    ) -> NSManagedObject? {
2439
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
2440
        guard !normalizedMAC.isEmpty else { return nil }
2441

            
2442
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2443
        request.predicate = NSPredicate(format: "lastAssociatedMeterMAC == %@", normalizedMAC)
2444
        request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
2445
        let matches = (try? context.fetch(request)) ?? []
2446
        return matches.first { object in
Bogdan Timofte authored a month ago
2447
            isChargerObject(object) == expectsChargerClass
Bogdan Timofte authored a month ago
2448
        }
2449
    }
2450

            
Bogdan Timofte authored a month ago
2451
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
2452
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2453
    }
2454

            
Bogdan Timofte authored a month ago
2455
    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
2456
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2457
        request.predicate = NSPredicate(format: "id == %@", id)
2458
        request.fetchLimit = 1
2459
        return (try? context.fetch(request))?.first
2460
    }
2461

            
2462
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
2463
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
2464
        return (try? context.fetch(request)) ?? []
2465
    }
2466

            
2467
    private func resolvedStopThreshold(
2468
        for chargedDevice: NSManagedObject,
2469
        chargingTransportMode: ChargingTransportMode,
Bogdan Timofte authored a month ago
2470
        chargingStateMode: ChargingStateMode,
2471
        charger: NSManagedObject?,
2472
        fallback: Double?
2473
    ) -> Double? {
2474
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
2475
            return nil
2476
        }
2477

            
2478
        let sessionKind = ChargeSessionKind(
2479
            chargingTransportMode: chargingTransportMode,
2480
            chargingStateMode: chargingStateMode
2481
        )
2482
        let configuredCurrents = decodedCompletionCurrents(
2483
            from: chargedDevice,
2484
            key: "configuredCompletionCurrentsRawValue"
2485
        )
2486
        let learnedCurrents = decodedCompletionCurrents(
2487
            from: chargedDevice,
2488
            key: "learnedCompletionCurrentsRawValue"
2489
        )
2490
        let legacyCurrent: Double?
Bogdan Timofte authored a month ago
2491
        switch chargingTransportMode {
2492
        case .wired:
Bogdan Timofte authored a month ago
2493
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2494
                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
2495
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2496
        case .wireless:
Bogdan Timofte authored a month ago
2497
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2498
                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
2499
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2500
        }
Bogdan Timofte authored a month ago
2501

            
2502
        let resolvedCurrent = configuredCurrents[sessionKind]
2503
            ?? learnedCurrents[sessionKind]
2504
            ?? legacyCurrent
2505
            ?? fallback
2506
        guard let resolvedCurrent, resolvedCurrent > 0 else {
2507
            return nil
2508
        }
2509
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
2510
    }
2511

            
Bogdan Timofte authored a month ago
2512
    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
Bogdan Timofte authored a month ago
2513
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
2514
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
2515
        return resolvedPreferredChargingTransportMode(
Bogdan Timofte authored a month ago
2516
            .wired,
Bogdan Timofte authored a month ago
2517
            supportsWiredCharging: supportsWiredCharging,
2518
            supportsWirelessCharging: supportsWirelessCharging
2519
        )
2520
    }
2521

            
Bogdan Timofte authored a month ago
2522
    private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
2523
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other
2524
    }
2525

            
2526
    private func normalizedTemplateID(
2527
        _ templateID: String?,
2528
        kind: ChargedDeviceKind
2529
    ) -> String? {
2530
        guard let templateID,
2531
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2532
              templateDefinition.kind == kind else {
2533
            return nil
Bogdan Timofte authored a month ago
2534
        }
Bogdan Timofte authored a month ago
2535
        return templateDefinition.id
Bogdan Timofte authored a month ago
2536
    }
2537

            
Bogdan Timofte authored a month ago
2538
    private func resolvedChargerTemplateConfiguration(
2539
        templateID: String?
2540
    ) -> (
2541
        chargingStateAvailability: ChargingStateAvailability,
2542
        supportsWiredCharging: Bool,
2543
        supportsWirelessCharging: Bool,
2544
        wirelessChargingProfile: WirelessChargingProfile
2545
    ) {
2546
        guard let templateID,
2547
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2548
              templateDefinition.kind == .charger else {
2549
            return (
2550
                chargingStateAvailability: .onOnly,
2551
                supportsWiredCharging: false,
2552
                supportsWirelessCharging: true,
2553
                wirelessChargingProfile: .genericQi
2554
            )
Bogdan Timofte authored a month ago
2555
        }
Bogdan Timofte authored a month ago
2556

            
2557
        let normalizedChargingStateAvailability = templateDefinition.deviceClass.normalizedChargingStateAvailability(
2558
            templateDefinition.chargingStateAvailability
2559
        )
2560
        let normalizedChargingSupport = templateDefinition.deviceClass.normalizedChargingSupport(
2561
            supportsWiredCharging: templateDefinition.supportsWiredCharging,
2562
            supportsWirelessCharging: templateDefinition.supportsWirelessCharging
2563
        )
2564

            
2565
        return (
2566
            chargingStateAvailability: normalizedChargingStateAvailability,
2567
            supportsWiredCharging: normalizedChargingSupport.wired,
2568
            supportsWirelessCharging: normalizedChargingSupport.wireless,
2569
            wirelessChargingProfile: templateDefinition.wirelessChargingProfile
2570
        )
Bogdan Timofte authored a month ago
2571
    }
2572

            
Bogdan Timofte authored a month ago
2573
    private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
2574
        guard let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
2575
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2576
              templateDefinition.kind == deviceClass(for: chargedDevice).kind else {
2577
            return nil
Bogdan Timofte authored a month ago
2578
        }
Bogdan Timofte authored a month ago
2579
        return templateDefinition
2580
    }
2581

            
2582
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
2583
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2584
            ? true
2585
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2586
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2587
            ? false
2588
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2589
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2590
            supportsWiredCharging: persistedWiredCharging,
2591
            supportsWirelessCharging: persistedWirelessCharging
2592
        ).wired
2593
    }
2594

            
2595
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
2596
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2597
            ? true
2598
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2599
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2600
            ? false
2601
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2602
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2603
            supportsWiredCharging: persistedWiredCharging,
2604
            supportsWirelessCharging: persistedWirelessCharging
2605
        ).wireless
2606
    }
2607

            
2608
    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
2609
        let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue")
2610
            .flatMap(ChargingStateAvailability.init(rawValue:))
2611
            ?? ChargingStateAvailability.fallback(
Bogdan Timofte authored a month ago
2612
            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
2613
        )
Bogdan Timofte authored a month ago
2614
        return deviceClass(for: chargedDevice).normalizedChargingStateAvailability(persistedAvailability)
Bogdan Timofte authored a month ago
2615
    }
2616

            
2617
    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
Bogdan Timofte authored a month ago
2618
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2619
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2620
            let persistedChargingStateMode = stringValue(session, key: "chargingStateRawValue")
2621
                .flatMap(ChargingStateMode.init(rawValue:))
2622
                ?? .on
2623
            return resolvedChargingStateMode(
2624
                persistedChargingStateMode,
2625
                availability: chargingStateAvailability(for: chargedDevice)
2626
            )
2627
        }
2628

            
Bogdan Timofte authored a month ago
2629
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
2630
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
2631
            return chargingStateMode
2632
        }
2633

            
2634
        return .on
2635
    }
2636

            
2637
    private func resolvedChargingStateMode(
2638
        _ chargingStateMode: ChargingStateMode,
2639
        availability: ChargingStateAvailability
2640
    ) -> ChargingStateMode {
2641
        if availability.supportedModes.contains(chargingStateMode) {
2642
            return chargingStateMode
2643
        }
2644
        return availability.supportedModes.first ?? .on
2645
    }
2646

            
Bogdan Timofte authored a month ago
2647
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
2648
        guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2649
              let profile = WirelessChargingProfile(rawValue: rawValue) else {
2650
            return .genericQi
2651
        }
2652
        return profile
2653
    }
2654

            
2655
    private func resolvedPreferredChargingTransportMode(
2656
        _ preferredChargingTransportMode: ChargingTransportMode,
2657
        supportsWiredCharging: Bool,
2658
        supportsWirelessCharging: Bool
2659
    ) -> ChargingTransportMode {
2660
        switch preferredChargingTransportMode {
2661
        case .wired where supportsWiredCharging:
2662
            return .wired
2663
        case .wireless where supportsWirelessCharging:
2664
            return .wireless
2665
        default:
2666
            if supportsWiredCharging {
2667
                return .wired
2668
            }
2669
            if supportsWirelessCharging {
2670
                return .wireless
2671
            }
2672
            return .wired
2673
        }
2674
    }
2675

            
Bogdan Timofte authored a month ago
2676
    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
2677
        let payload = Dictionary(
2678
            uniqueKeysWithValues: currents.map { key, value in
2679
                (key.rawValue, value)
2680
            }
2681
        )
2682
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
2683
            return nil
2684
        }
2685
        return String(data: data, encoding: .utf8)
2686
    }
2687

            
2688
    private func decodedCompletionCurrents(
2689
        from object: NSManagedObject,
2690
        key: String
2691
    ) -> [ChargeSessionKind: Double] {
2692
        guard let rawValue = stringValue(object, key: key),
2693
              let data = rawValue.data(using: .utf8),
2694
              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
2695
            return [:]
2696
        }
2697

            
2698
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2699
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
2700
                return
2701
            }
2702
            result[sessionKind] = entry.value
2703
        }
2704
    }
2705

            
2706
    private func legacyConfiguredCompletionCurrent(
2707
        for currents: [ChargeSessionKind: Double],
2708
        chargingTransportMode: ChargingTransportMode
2709
    ) -> Double? {
2710
        let candidates = currents
2711
            .filter { $0.key.chargingTransportMode == chargingTransportMode }
2712
            .sorted { lhs, rhs in
2713
                lhs.key.rawValue < rhs.key.rawValue
2714
            }
2715
            .map(\.value)
2716
        return candidates.first
2717
    }
2718

            
2719
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
2720
        guard let charger else {
2721
            return nil
2722
        }
2723
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
2724
        guard let idleCurrent, idleCurrent >= 0 else {
2725
            return nil
2726
        }
2727
        return idleCurrent
2728
    }
2729

            
2730
    private func effectiveCurrentAmps(
2731
        fromMeasuredCurrent currentAmps: Double,
2732
        chargingTransportMode: ChargingTransportMode,
2733
        charger: NSManagedObject?
2734
    ) -> Double {
2735
        switch chargingTransportMode {
2736
        case .wired:
2737
            return max(currentAmps, 0)
2738
        case .wireless:
2739
            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
2740
                return max(currentAmps, 0)
2741
            }
2742
            return max(currentAmps - idleCurrent, 0)
2743
        }
2744
    }
2745

            
2746
    private func hasObservedChargeFlow(
2747
        currentAmps: Double,
2748
        chargingTransportMode: ChargingTransportMode,
2749
        charger: NSManagedObject?,
2750
        stopThreshold: Double?
2751
    ) -> Bool {
2752
        let effectiveCurrent = effectiveCurrentAmps(
2753
            fromMeasuredCurrent: currentAmps,
2754
            chargingTransportMode: chargingTransportMode,
2755
            charger: charger
2756
        )
2757
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
2758
    }
2759

            
Bogdan Timofte authored a month ago
2760
    private func derivedMinimumCurrent(
2761
        from sessions: [NSManagedObject],
2762
        chargingTransportMode: ChargingTransportMode
2763
    ) -> Double? {
2764
        let completionCurrents = sessions.compactMap { session -> Double? in
2765
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2766
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
2767
                return nil
2768
            }
Bogdan Timofte authored a month ago
2769
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2770
                return nil
2771
            }
Bogdan Timofte authored a month ago
2772
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
2773
                return nil
2774
            }
2775
            return completionCurrent
2776
        }
2777

            
2778
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
2779
        guard !recentCompletionCurrents.isEmpty else { return nil }
2780
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
2781
    }
2782

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

            
2786
        for session in sessions {
2787
            guard statusValue(session, key: "statusRawValue") == .completed else {
2788
                continue
2789
            }
2790
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2791
                continue
2792
            }
2793
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
2794
                  completionCurrent > 0 else {
2795
                continue
2796
            }
2797

            
2798
            let sessionKind = ChargeSessionKind(
2799
                chargingTransportMode: chargingTransportMode(for: session),
2800
                chargingStateMode: chargingStateMode(for: session)
2801
            )
2802
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
2803
        }
2804

            
2805
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2806
            let recentCurrents = Array(entry.value.suffix(5))
2807
            guard !recentCurrents.isEmpty else {
2808
                return
2809
            }
2810
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
2811
        }
2812
    }
2813

            
Bogdan Timofte authored a month ago
2814
    private func derivedCapacity(
2815
        from sessions: [NSManagedObject],
2816
        chargingTransportMode: ChargingTransportMode,
2817
        supportsChargingWhileOff: Bool
2818
    ) -> Double? {
2819
        let capacityCandidates = sessions.compactMap { session -> Double? in
2820
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2821
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
2822
                return nil
2823
            }
2824
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
2825
                return nil
2826
            }
2827
            if supportsChargingWhileOff {
2828
                return capacityEstimate
2829
            }
2830
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
2831
                return nil
2832
            }
2833
            return capacityEstimate
2834
        }
2835

            
2836
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
2837
        guard !recentCapacityCandidates.isEmpty else { return nil }
2838
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
2839
    }
2840

            
2841
    private func derivedWirelessEfficiency(
2842
        from sessions: [NSManagedObject],
2843
        chargingProfile: WirelessChargingProfile
2844
    ) -> Double? {
2845
        guard chargingProfile == .magsafe else {
2846
            return nil
2847
        }
2848

            
2849
        let candidates = sessions.compactMap { session -> Double? in
2850
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2851
            guard chargingTransportMode(for: session) == .wireless else { return nil }
2852
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
2853
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
2854
                return nil
2855
            }
2856
            return factor
2857
        }
2858

            
2859
        let recentCandidates = Array(candidates.suffix(6))
2860
        guard !recentCandidates.isEmpty else { return nil }
2861
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2862
    }
2863

            
2864
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
2865
        let candidates = sessions.compactMap { session -> Double? in
2866
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2867
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
2868
                return nil
2869
            }
2870
            return (sourceVoltage * 10).rounded() / 10
2871
        }
2872

            
2873
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
2874
        return counts.keys.sorted()
2875
    }
2876

            
2877
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
2878
        let candidates = sessions.compactMap { session -> Double? in
2879
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2880
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
2881
                return nil
2882
            }
2883
            return minimumObservedCurrent
2884
        }
2885

            
2886
        let recentCandidates = Array(candidates.suffix(6))
2887
        guard !recentCandidates.isEmpty else { return nil }
2888
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2889
    }
2890

            
2891
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
2892
        let candidates = sessions.compactMap { session -> Double? in
2893
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2894
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
2895
                return nil
2896
            }
2897
            return factor
2898
        }
2899

            
2900
        let recentCandidates = Array(candidates.suffix(6))
2901
        guard !recentCandidates.isEmpty else { return nil }
2902
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2903
    }
2904

            
2905
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
2906
        sessions.compactMap { session -> Double? in
2907
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2908
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
2909
                return nil
2910
            }
2911
            return maximumObservedPower
2912
        }
2913
        .max()
2914
    }
2915

            
2916
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
2917
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2918
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
2919
            return resolvedPreferredChargingTransportMode(
2920
                chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired,
2921
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
2922
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
2923
            )
2924
        }
2925

            
2926
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
2927
            return persistedChargingTransportMode
Bogdan Timofte authored a month ago
2928
        }
2929

            
2930
        return .wired
2931
    }
2932

            
2933
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
2934
        if session.isInserted {
2935
            return .created
2936
        }
2937

            
2938
        let committedValues = session.committedValues(
2939
            forKeys: [
2940
                "statusRawValue",
2941
                "updatedAt",
2942
                "targetBatteryAlertTriggeredAt",
2943
                "requiresCompletionConfirmation"
2944
            ]
2945
        )
2946
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
2947
        let currentStatus = statusValue(session, key: "statusRawValue")
2948
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
2949
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
2950
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
2951
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
2952
            ?? false
2953
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
2954

            
2955
        if currentStatus == .completed, committedStatus != .completed {
2956
            return .completed
2957
        }
2958

            
Bogdan Timofte authored a month ago
2959
        if currentStatus != committedStatus {
2960
            return .event
2961
        }
2962

            
Bogdan Timofte authored a month ago
2963
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
2964
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
2965
            return .event
2966
        }
2967

            
2968
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
2969
            ?? dateValue(session, key: "createdAt")
2970
            ?? observedAt
2971

            
2972
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
2973
            return .periodic
2974
        }
2975

            
2976
        return .none
2977
    }
2978

            
Bogdan Timofte authored a month ago
2979
    private func shouldPersistAggregatedSample(
2980
        _ sample: NSManagedObject,
2981
        observedAt: Date
2982
    ) -> Bool {
2983
        if sample.isInserted {
2984
            return true
2985
        }
2986

            
2987
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
2988
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
2989
            ?? dateValue(sample, key: "createdAt")
2990
            ?? observedAt
2991

            
2992
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
2993
    }
2994

            
Bogdan Timofte authored a month ago
2995
    private func generateQRIdentifier() -> String {
2996
        "device:\(UUID().uuidString)"
2997
    }
2998

            
2999
    @discardableResult
3000
    private func saveContext() -> Bool {
3001
        guard context.hasChanges else { return true }
3002
        do {
3003
            try context.save()
3004
            return true
3005
        } catch {
3006
            track("Failed saving charge insights context: \(error)")
3007
            context.rollback()
3008
            return false
3009
        }
3010
    }
3011

            
3012
    private func normalizedText(_ text: String) -> String {
3013
        text.trimmingCharacters(in: .whitespacesAndNewlines)
3014
    }
3015

            
3016
    private func normalizedOptionalText(_ text: String?) -> String? {
3017
        guard let text else { return nil }
3018
        let normalized = normalizedText(text)
3019
        return normalized.isEmpty ? nil : normalized
3020
    }
3021

            
3022
    private func normalizedMACAddress(_ macAddress: String) -> String {
3023
        normalizedText(macAddress).uppercased()
3024
    }
3025

            
Bogdan Timofte authored a month ago
3026
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
3027
        guard object.entity.propertiesByName[key] != nil else {
3028
            return nil
3029
        }
3030
        return object.value(forKey: key)
3031
    }
3032

            
3033
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
3034
        guard object.entity.propertiesByName[key] != nil else {
3035
            return
3036
        }
3037
        object.setValue(value, forKey: key)
3038
    }
3039

            
Bogdan Timofte authored a month ago
3040
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
3041
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
3042
        let normalized = normalizedOptionalText(value)
3043
        return normalized
3044
    }
3045

            
3046
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
3047
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
3048
    }
3049

            
3050
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
3051
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
3052
            return value
3053
        }
Bogdan Timofte authored a month ago
3054
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3055
            return value.doubleValue
3056
        }
3057
        return 0
3058
    }
3059

            
3060
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
3061
        let value = rawValue(object, key: key)
3062
        if value == nil {
Bogdan Timofte authored a month ago
3063
            return nil
3064
        }
3065
        return doubleValue(object, key: key)
3066
    }
3067

            
3068
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
Bogdan Timofte authored a month ago
3069
        if let value = rawValue(object, key: key) as? Int16 {
Bogdan Timofte authored a month ago
3070
            return value
3071
        }
Bogdan Timofte authored a month ago
3072
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3073
            return value.int16Value
3074
        }
3075
        return nil
3076
    }
3077

            
3078
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
3079
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
3080
            return value
3081
        }
Bogdan Timofte authored a month ago
3082
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3083
            return value.int32Value
3084
        }
3085
        return nil
3086
    }
3087

            
3088
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
Bogdan Timofte authored a month ago
3089
        if let value = rawValue(object, key: key) as? Bool {
Bogdan Timofte authored a month ago
3090
            return value
3091
        }
Bogdan Timofte authored a month ago
3092
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3093
            return value.boolValue
3094
        }
3095
        return false
3096
    }
3097

            
3098
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
3099
        guard let value = stringValue(object, key: key) else { return nil }
3100
        return UUID(uuidString: value)
3101
    }
3102

            
3103
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
3104
        guard let value = stringValue(object, key: key) else { return nil }
3105
        return ChargeSessionStatus(rawValue: value)
3106
    }
3107

            
3108
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
3109
        guard let value = stringValue(object, key: key) else { return nil }
3110
        return ChargingTransportMode(rawValue: value)
3111
    }
3112

            
3113
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
3114
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
3115
            return []
3116
        }
3117
        return rawValue
3118
            .split(separator: ",")
3119
            .compactMap { Double($0) }
3120
            .sorted()
3121
    }
3122

            
3123
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
3124
        let uniqueVoltages = Array(Set(voltages)).sorted()
3125
        guard !uniqueVoltages.isEmpty else {
3126
            return nil
3127
        }
3128
        return uniqueVoltages
3129
            .map { String(format: "%.1f", $0) }
3130
            .joined(separator: ",")
3131
    }
3132

            
3133
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
3134
        guard currentCount > 0 else {
3135
            return newValue
3136
        }
3137
        let total = (currentAverage * Double(currentCount)) + newValue
3138
        return total / Double(currentCount + 1)
3139
    }
3140
}
3141

            
3142
private enum ObservationSaveReason {
3143
    case none
3144
    case created
3145
    case periodic
3146
    case completed
3147
    case event
3148
}