USB-Meter / USB Meter / Model / ChargeInsightsStore.swift
Newer Older
3118 lines | 137.099kb
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
Bogdan Timofte authored a month ago
541
    ) -> Bool {
542
        guard finalBatteryPercent.isFinite, finalBatteryPercent >= 0, finalBatteryPercent <= 100 else {
543
            return false
544
        }
545

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

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

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

            
564
            guard saveContext() else {
565
                return
566
            }
567

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
725
            clearCompletionConfirmationState(for: session)
726
            session.setValue(Date().addingTimeInterval(completionConfirmationCooldown), forKey: "completionConfirmationCooldownUntil")
727
            session.setValue(Date(), forKey: "updatedAt")
728
            didSave = saveContext()
729
        }
730
        return didSave
731
    }
732

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

            
741
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
742

            
743
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
744
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
745
            context.delete(session)
746

            
747
            guard saveContext() else {
748
                return
749
            }
750

            
751
            if let chargedDeviceID {
752
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
753
                didSave = saveContext()
754
            } else {
755
                didSave = true
756
            }
757
        }
758
        return didSave
759
    }
760

            
761
    @discardableResult
762
    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
763
        var didSave = false
764

            
765
        context.performAndWait {
766
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
767
                return
768
            }
769

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

            
774
            var impactedChargedDeviceIDs = Set<String>()
775

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

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

            
803
            context.delete(chargedDevice)
804

            
805
            guard saveContext() else {
806
                return
807
            }
808

            
809
            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
810
            for impactedID in impactedChargedDeviceIDs {
811
                refreshDerivedMetrics(forChargedDeviceID: impactedID)
812
            }
813
            didSave = saveContext()
814
        }
815

            
816
        return didSave
817
    }
818

            
819
    @discardableResult
820
    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
821
        var didSave = false
822

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

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

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

            
Bogdan Timofte authored a month ago
852
            update(session: session, with: snapshot, stopThreshold: stopThreshold, charger: charger)
Bogdan Timofte authored a month ago
853
            let aggregatedSample = updateAggregatedSample(session: session, with: snapshot)
Bogdan Timofte authored a month ago
854

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

            
860
            guard saveReason != .none || shouldPersistAggregatedCurve else {
Bogdan Timofte authored a month ago
861
                return
862
            }
863

            
864
            session.setValue(snapshot.observedAt, forKey: "updatedAt")
865

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

            
876
        return didSave
877
    }
878

            
879
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
880
        var summaries: [ChargedDeviceSummary] = []
881

            
882
        context.performAndWait {
883
            let devices = fetchObjects(entityName: EntityName.chargedDevice)
884
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
885
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
886

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

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

            
Bogdan Timofte authored a month ago
910
                let chargingStateAvailability = chargingStateAvailability(for: device)
911
                let supportsWiredCharging = supportsWiredCharging(for: device)
912
                let supportsWirelessCharging = supportsWirelessCharging(for: device)
913
                let templateDefinition = templateDefinition(for: device)
914

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

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

            
996
        return summaries
997
    }
998

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

            
1003
        let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
1004

            
1005
        if let activeMatch = summaries.first(where: { summary in
1006
            summary.activeSession?.meterMACAddress == normalizedMAC
1007
        }) {
1008
            return activeMatch
1009
        }
1010

            
1011
        return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
1012
    }
1013

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

            
Bogdan Timofte authored a month ago
1018
        var summary: ChargeSessionSummary?
1019

            
1020
        context.performAndWait {
1021
            guard let session = fetchActiveSessionObject(forMeterMACAddress: normalizedMAC),
1022
                  let sessionID = stringValue(session, key: "id") else {
1023
                return
1024
            }
1025

            
1026
            summary = makeSessionSummary(
1027
                from: session,
1028
                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
1029
                samples: fetchSessionSampleObjects(forSessionID: sessionID)
1030
            )
1031
        }
1032

            
1033
        return summary
Bogdan Timofte authored a month ago
1034
    }
1035

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

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

            
1114
        chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
1115
        chargedDevice.setValue(now, forKey: "updatedAt")
1116
        return session
1117
    }
1118

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1312
        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
1313
        let updatedCount = existingCount + 1
1314

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

            
1354
    private func maybeTriggerTargetBatteryAlert(for session: NSManagedObject, observedAt: Date) {
1355
        guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
1356
            return
1357
        }
1358

            
1359
        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
1360
            return
1361
        }
1362

            
1363
        let predictedBatteryPercent = predictedBatteryPercent(for: session)
1364
            ?? optionalDoubleValue(session, key: "endBatteryPercent")
1365

            
1366
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
1367
            return
1368
        }
1369

            
1370
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
1371
    }
1372

            
1373
    private func shouldRequireCompletionConfirmation(
1374
        for session: NSManagedObject,
1375
        observedAt: Date
1376
    ) -> Bool {
1377
        if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"),
1378
           cooldownUntil > observedAt {
1379
            return false
1380
        }
1381

            
1382
        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
1383
            return false
1384
        }
1385

            
1386
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
1387
            ?? defaultCompletionPercentThreshold
1388

            
1389
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1390
    }
1391

            
1392
    private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
1393
        guard !boolValue(session, key: "requiresCompletionConfirmation") else {
1394
            return
1395
        }
1396

            
1397
        session.setValue(true, forKey: "requiresCompletionConfirmation")
1398
        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
1399
        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
1400
    }
1401

            
1402
    private func clearCompletionConfirmationState(for session: NSManagedObject) {
1403
        session.setValue(false, forKey: "requiresCompletionConfirmation")
1404
        session.setValue(nil, forKey: "completionConfirmationRequestedAt")
1405
        session.setValue(nil, forKey: "completionContradictionPercent")
1406
    }
1407

            
Bogdan Timofte authored a month ago
1408
    private func snapshotDateForManualStop(_ session: NSManagedObject) -> Date {
1409
        if statusValue(session, key: "statusRawValue") == .paused {
1410
            return dateValue(session, key: "pausedAt")
1411
                ?? dateValue(session, key: "lastObservedAt")
1412
                ?? Date()
1413
        }
1414
        return dateValue(session, key: "lastObservedAt") ?? Date()
1415
    }
1416

            
1417
    @discardableResult
1418
    private func maybeCompletePausedSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
1419
        guard statusValue(session, key: "statusRawValue") == .paused else {
1420
            return false
1421
        }
1422

            
1423
        guard let pausedAt = dateValue(session, key: "pausedAt"),
1424
              observedAt.timeIntervalSince(pausedAt) >= pausedSessionTimeout else {
1425
            return false
1426
        }
1427

            
1428
        finishSession(
1429
            session,
1430
            observedAt: pausedAt.addingTimeInterval(pausedSessionTimeout),
1431
            finalBatteryPercent: nil,
1432
            status: .completed
1433
        )
1434

            
1435
        guard saveContext() else {
1436
            return false
1437
        }
1438

            
1439
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1440
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1441
            return saveContext()
1442
        }
1443

            
1444
        return true
1445
    }
1446

            
1447
    private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
1448
        let chargingTransportMode = chargingTransportMode(for: session)
1449
        let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps")
1450
            ?? doubleValue(session, key: "lastObservedCurrentAmps")
1451

            
1452
        guard measuredCurrent > 0 else {
1453
            return nil
1454
        }
1455

            
1456
        let charger = chargingTransportMode == .wireless
1457
            ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
1458
            : nil
1459

            
1460
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
1461
            return nil
1462
        }
1463

            
1464
        let effectiveCurrent = effectiveCurrentAmps(
1465
            fromMeasuredCurrent: measuredCurrent,
1466
            chargingTransportMode: chargingTransportMode,
1467
            charger: charger
1468
        )
1469
        guard effectiveCurrent > 0 else {
1470
            return nil
1471
        }
1472
        return effectiveCurrent
1473
    }
1474

            
1475
    private func finishSession(
1476
        _ session: NSManagedObject,
1477
        observedAt: Date,
1478
        finalBatteryPercent: Double?,
1479
        status: ChargeSessionStatus
1480
    ) {
1481
        if let finalBatteryPercent {
1482
            _ = insertBatteryCheckpoint(
1483
                percent: finalBatteryPercent,
Bogdan Timofte authored a month ago
1484
                flag: .final,
Bogdan Timofte authored a month ago
1485
                timestamp: observedAt,
1486
                to: session
1487
            )
1488
        }
1489

            
1490
        session.setValue(status.rawValue, forKey: "statusRawValue")
1491
        session.setValue(nil, forKey: "pausedAt")
1492
        session.setValue(nil, forKey: "belowThresholdSince")
1493
        session.setValue(observedAt, forKey: "endedAt")
1494
        session.setValue(observedAt, forKey: "lastObservedAt")
1495
        session.setValue(completionCurrentForSessionEnd(session), forKey: "completionCurrentAmps")
1496
        clearCompletionConfirmationState(for: session)
1497
        session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1498
        updateCapacityEstimate(for: session)
1499
        session.setValue(observedAt, forKey: "updatedAt")
1500
    }
1501

            
Bogdan Timofte authored a month ago
1502
    private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
1503
        guard
1504
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1505
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
1506
            let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice),
1507
            estimatedCapacityWh > 0
1508
        else {
1509
            return nil
1510
        }
1511

            
1512
        let measuredEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1513
            ?? doubleValue(session, key: "measuredEnergyWh")
1514
        let sessionID = stringValue(session, key: "id") ?? ""
1515

            
1516
        struct Anchor {
1517
            let percent: Double
1518
            let energyWh: Double
Bogdan Timofte authored a month ago
1519
            let timestamp: Date
1520
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1521
        }
1522

            
1523
        var anchors: [Anchor] = []
Bogdan Timofte authored a month ago
1524
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1525
           startBatteryPercent >= 0 {
1526
            anchors.append(
1527
                Anchor(
1528
                    percent: startBatteryPercent,
1529
                    energyWh: 0,
1530
                    timestamp: dateValue(session, key: "startedAt") ?? Date.distantPast,
1531
                    isCheckpoint: false
1532
                )
1533
            )
Bogdan Timofte authored a month ago
1534
        }
1535

            
1536
        let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
1537
            .compactMap(makeCheckpointSummary(from:))
1538
            .sorted { lhs, rhs in
1539
                if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1540
                    return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1541
                }
1542
                return lhs.timestamp < rhs.timestamp
1543
            }
Bogdan Timofte authored a month ago
1544
            .filter { $0.batteryPercent >= 0 }
1545
            .map {
1546
                Anchor(
1547
                    percent: $0.batteryPercent,
1548
                    energyWh: $0.measuredEnergyWh,
1549
                    timestamp: $0.timestamp,
1550
                    isCheckpoint: true
1551
                )
1552
            }
Bogdan Timofte authored a month ago
1553
        anchors.append(contentsOf: checkpointAnchors)
1554

            
1555
        guard !anchors.isEmpty else {
1556
            return optionalDoubleValue(session, key: "endBatteryPercent")
1557
        }
1558

            
1559
        let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
Bogdan Timofte authored a month ago
1560
        return BatteryLevelPredictionTuning.predictedPercent(
1561
            anchorPercent: anchor.percent,
1562
            anchorEnergyWh: anchor.energyWh,
1563
            anchorTimestamp: anchor.timestamp,
1564
            anchorIsCheckpoint: anchor.isCheckpoint,
1565
            effectiveEnergyWh: measuredEnergyWh,
1566
            referenceTimestamp: dateValue(session, key: "lastObservedAt") ?? anchor.timestamp,
1567
            estimatedCapacityWh: estimatedCapacityWh
Bogdan Timofte authored a month ago
1568
        )
1569
    }
1570

            
1571
    private func resolvedEstimatedBatteryCapacityWh(
1572
        for session: NSManagedObject,
1573
        chargedDevice: NSManagedObject
1574
    ) -> Double? {
1575
        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
1576
           sessionCapacityEstimate > 0 {
1577
            return sessionCapacityEstimate
1578
        }
1579

            
1580
        switch chargingTransportMode(for: session) {
1581
        case .wired:
1582
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1583
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1584
        case .wireless:
1585
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1586
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1587
        }
1588
    }
1589

            
1590
    private func updateCapacityEstimate(for session: NSManagedObject) {
1591
        guard
1592
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1593
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
1594
        else {
1595
            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
1596
            session.setValue(nil, forKey: "capacityEstimateWh")
1597
            return
1598
        }
1599

            
1600
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1601
        let chargingMode = chargingTransportMode(for: session)
1602
        let wirelessResolution = chargingMode == .wireless
1603
            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
1604
            : nil
1605
        let effectiveBatteryEnergyWh = chargingMode == .wired
1606
            ? measuredEnergyWh
1607
            : wirelessResolution.map { measuredEnergyWh * $0.factor }
1608

            
1609
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
1610
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
1611
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
1612
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
1613

            
1614
        guard
1615
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1616
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1617
        else {
1618
            session.setValue(nil, forKey: "capacityEstimateWh")
1619
            return
1620
        }
1621

            
Bogdan Timofte authored a month ago
1622
        guard startBatteryPercent >= 0, endBatteryPercent >= 0 else {
1623
            session.setValue(nil, forKey: "capacityEstimateWh")
1624
            return
1625
        }
1626

            
Bogdan Timofte authored a month ago
1627
        let percentDelta = endBatteryPercent - startBatteryPercent
1628
        let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
1629

            
1630
        guard percentDelta >= 20, let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
1631
            session.setValue(nil, forKey: "capacityEstimateWh")
1632
            return
1633
        }
1634

            
1635
        if !supportsChargingWhileOff && endBatteryPercent >= 99.5 {
1636
            session.setValue(nil, forKey: "capacityEstimateWh")
1637
            return
1638
        }
1639

            
1640
        let capacityEstimateWh = effectiveBatteryEnergyWh / (percentDelta / 100)
1641
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
1642
    }
1643

            
1644
    @discardableResult
Bogdan Timofte authored a month ago
1645
    private func insertBatteryCheckpoint(
Bogdan Timofte authored a month ago
1646
        percent: Double,
Bogdan Timofte authored a month ago
1647
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
1648
        timestamp: Date = Date(),
Bogdan Timofte authored a month ago
1649
        measuredEnergyWhOverride: Double? = nil,
1650
        measuredChargeAhOverride: Double? = nil,
Bogdan Timofte authored a month ago
1651
        to session: NSManagedObject
Bogdan Timofte authored a month ago
1652
    ) -> String? {
Bogdan Timofte authored a month ago
1653
        guard
1654
            let sessionID = stringValue(session, key: "id"),
1655
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1656
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
1657
        else {
Bogdan Timofte authored a month ago
1658
            return nil
Bogdan Timofte authored a month ago
1659
        }
1660

            
1661
        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
Bogdan Timofte authored a month ago
1662
        let checkpointEnergyWh = measuredEnergyWhOverride
1663
            ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
Bogdan Timofte authored a month ago
1664
            ?? doubleValue(session, key: "measuredEnergyWh")
Bogdan Timofte authored a month ago
1665
        let checkpointChargeAh = measuredChargeAhOverride
1666
            ?? doubleValue(session, key: "measuredChargeAh")
Bogdan Timofte authored a month ago
1667
        checkpoint.setValue(UUID().uuidString, forKey: "id")
1668
        checkpoint.setValue(sessionID, forKey: "sessionID")
1669
        checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
Bogdan Timofte authored a month ago
1670
        checkpoint.setValue(timestamp, forKey: "timestamp")
Bogdan Timofte authored a month ago
1671
        checkpoint.setValue(percent, forKey: "batteryPercent")
1672
        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
Bogdan Timofte authored a month ago
1673
        checkpoint.setValue(checkpointChargeAh, forKey: "measuredChargeAh")
Bogdan Timofte authored a month ago
1674
        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
1675
        checkpoint.setValue(
1676
            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
1677
            forKey: "voltageVolts"
1678
        )
Bogdan Timofte authored a month ago
1679
        checkpoint.setValue(flag.rawValue, forKey: "label")
Bogdan Timofte authored a month ago
1680
        checkpoint.setValue(timestamp, forKey: "createdAt")
Bogdan Timofte authored a month ago
1681

            
Bogdan Timofte authored a month ago
1682
        let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
1683
        if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
Bogdan Timofte authored a month ago
1684
            session.setValue(percent, forKey: "startBatteryPercent")
1685
        }
Bogdan Timofte authored a month ago
1686
        if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
1687
            session.setValue(percent, forKey: "endBatteryPercent")
1688
        }
Bogdan Timofte authored a month ago
1689
        session.setValue(timestamp, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1690
        updateCapacityEstimate(for: session)
1691

            
Bogdan Timofte authored a month ago
1692
        return chargedDeviceID
1693
    }
1694

            
Bogdan Timofte authored a month ago
1695
    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
1696
        guard let sessionID = stringValue(session, key: "id") else {
1697
            return
1698
        }
1699

            
1700
        let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
1701
        if let latestCheckpoint = remainingCheckpoints.last {
1702
            session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
1703
        } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1704
                  startBatteryPercent >= 0 {
1705
            session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
1706
        } else {
1707
            session.setValue(nil, forKey: "endBatteryPercent")
1708
        }
1709

            
1710
        session.setValue(Date(), forKey: "updatedAt")
1711
        updateCapacityEstimate(for: session)
1712
    }
1713

            
Bogdan Timofte authored a month ago
1714
    @discardableResult
1715
    private func addBatteryCheckpoint(
1716
        percent: Double,
Bogdan Timofte authored a month ago
1717
        measuredEnergyWh: Double? = nil,
1718
        measuredChargeAh: Double? = nil,
Bogdan Timofte authored a month ago
1719
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
1720
        to session: NSManagedObject,
1721
        timestamp: Date = Date()
1722
    ) -> Bool {
Bogdan Timofte authored a month ago
1723
        if let measuredEnergyWh, measuredEnergyWh.isFinite {
1724
            session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
1725
        }
1726
        if let measuredChargeAh, measuredChargeAh.isFinite {
1727
            session.setValue(max(measuredChargeAh, 0), forKey: "measuredChargeAh")
1728
        }
1729

            
Bogdan Timofte authored a month ago
1730
        guard let chargedDeviceID = insertBatteryCheckpoint(
1731
            percent: percent,
Bogdan Timofte authored a month ago
1732
            flag: flag,
Bogdan Timofte authored a month ago
1733
            timestamp: timestamp,
Bogdan Timofte authored a month ago
1734
            measuredEnergyWhOverride: measuredEnergyWh,
1735
            measuredChargeAhOverride: measuredChargeAh,
Bogdan Timofte authored a month ago
1736
            to: session
1737
        ) else {
1738
            return false
1739
        }
1740

            
Bogdan Timofte authored a month ago
1741
        guard saveContext() else {
1742
            return false
1743
        }
1744

            
1745
        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1746
        return saveContext()
1747
    }
1748

            
1749
    private func resolvedWirelessEfficiency(
1750
        for session: NSManagedObject,
1751
        chargedDevice: NSManagedObject
1752
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
1753
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
1754
           storedFactor > 0 {
1755
            return (
1756
                factor: storedFactor,
1757
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
1758
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
1759
            )
1760
        }
1761

            
1762
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
1763
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1764
        guard measuredEnergyWh > 0 else {
1765
            return nil
1766
        }
1767

            
1768
        if chargingProfile == .magsafe,
1769
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
1770
           calibratedFactor > 0 {
1771
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
1772
        }
1773

            
1774
        guard
1775
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1776
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1777
        else {
1778
            return nil
1779
        }
1780

            
1781
        let percentDelta = endBatteryPercent - startBatteryPercent
1782
        guard percentDelta >= 20 else {
1783
            return nil
1784
        }
1785

            
1786
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
Bogdan Timofte authored a month ago
1787
            ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
Bogdan Timofte authored a month ago
1788
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1789
                : nil),
1790
              wiredCapacityWh > 0
1791
        else {
1792
            return nil
1793
        }
1794

            
1795
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
1796
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
1797
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
1798
        let usesEstimated = chargingProfile != .magsafe
1799
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
1800

            
1801
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
1802
    }
1803

            
1804
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
1805
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
1806
            return
1807
        }
1808

            
Bogdan Timofte authored a month ago
1809
        let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
Bogdan Timofte authored a month ago
1810
        let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
1811
        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
1812
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
1813
        let sessions = relevantSessionObjects(
1814
            for: chargedDeviceID,
1815
            deviceClass: deviceClass,
1816
            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
1817
            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
1818
        )
Bogdan Timofte authored a month ago
1819
        let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
Bogdan Timofte authored a month ago
1820
        let wiredMinimumCurrent = derivedMinimumCurrent(
1821
            from: sessions,
1822
            chargingTransportMode: .wired
1823
        )
1824
        let wirelessMinimumCurrent = derivedMinimumCurrent(
1825
            from: sessions,
1826
            chargingTransportMode: .wireless
1827
        )
1828

            
1829
        let wiredCapacity = derivedCapacity(
1830
            from: sessions,
1831
            chargingTransportMode: .wired,
1832
            supportsChargingWhileOff: supportsChargingWhileOff
1833
        )
1834
        let wirelessCapacity = derivedCapacity(
1835
            from: sessions,
1836
            chargingTransportMode: .wireless,
1837
            supportsChargingWhileOff: supportsChargingWhileOff
1838
        )
1839
        let wirelessEfficiency = derivedWirelessEfficiency(
1840
            from: sessions,
1841
            chargingProfile: wirelessProfile
1842
        )
Bogdan Timofte authored a month ago
1843
        let configuredCompletionCurrents = decodedCompletionCurrents(
1844
            from: chargedDevice,
1845
            key: "configuredCompletionCurrentsRawValue"
1846
        )
Bogdan Timofte authored a month ago
1847
        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
1848
        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
1849
        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
1850
        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
1851
        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
1852
        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
1853

            
Bogdan Timofte authored a month ago
1854
        let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
1855
        let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
Bogdan Timofte authored a month ago
1856
        let preferredMinimumCurrent: Double?
1857
        let preferredCapacity: Double?
1858
        switch preferredChargingTransportMode {
1859
        case .wired:
Bogdan Timofte authored a month ago
1860
            preferredMinimumCurrent = configuredCompletionCurrents[
1861
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
1862
            ] ?? learnedCompletionCurrents[
1863
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
1864
            ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
Bogdan Timofte authored a month ago
1865
            preferredCapacity = wiredCapacity ?? wirelessCapacity
1866
        case .wireless:
Bogdan Timofte authored a month ago
1867
            preferredMinimumCurrent = configuredCompletionCurrents[
1868
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
1869
            ] ?? learnedCompletionCurrents[
1870
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
1871
            ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
Bogdan Timofte authored a month ago
1872
            preferredCapacity = wirelessCapacity ?? wiredCapacity
1873
        }
1874

            
Bogdan Timofte authored a month ago
1875
        setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
1876
        setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
1877
        setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
1878
        setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1879
        setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1880
        setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
1881
        setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
1882
        setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
1883
        setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
1884
        setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
1885
        setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
1886
        setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
1887
        setValue(Date(), on: chargedDevice, key: "updatedAt")
Bogdan Timofte authored a month ago
1888
    }
1889

            
1890
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
1891
        sessions
1892
            .filter { $0.status == .completed }
1893
            .compactMap { session in
1894
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
1895
                let timestamp = session.endedAt ?? session.lastObservedAt
1896
                return CapacityTrendPoint(
1897
                    sessionID: session.id,
1898
                    timestamp: timestamp,
1899
                    capacityWh: capacityEstimateWh,
1900
                    chargingTransportMode: session.chargingTransportMode
1901
                )
1902
            }
1903
            .sorted { $0.timestamp < $1.timestamp }
1904
    }
1905

            
1906
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
1907
        var groupedEnergyByBin: [Int: [Double]] = [:]
1908
        var groupedChargeByBin: [Int: [Double]] = [:]
1909

            
1910
        for session in sessions where session.status == .completed {
Bogdan Timofte authored a month ago
1911
            let anchors = normalizedTypicalCurveAnchors(for: session)
1912
            guard anchors.count >= 2 else {
1913
                continue
Bogdan Timofte authored a month ago
1914
            }
1915

            
Bogdan Timofte authored a month ago
1916
            for percentBin in stride(from: 0, through: 100, by: 10) {
1917
                guard let interpolatedPoint = interpolatedTypicalCurvePoint(
1918
                    for: Double(percentBin),
1919
                    anchors: anchors
1920
                ) else {
1921
                    continue
1922
                }
Bogdan Timofte authored a month ago
1923

            
Bogdan Timofte authored a month ago
1924
                groupedEnergyByBin[percentBin, default: []].append(interpolatedPoint.energyWh)
1925
                groupedChargeByBin[percentBin, default: []].append(interpolatedPoint.chargeAh)
Bogdan Timofte authored a month ago
1926
            }
1927
        }
1928

            
Bogdan Timofte authored a month ago
1929
        let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
Bogdan Timofte authored a month ago
1930
            guard
1931
                let energies = groupedEnergyByBin[percentBin],
1932
                let charges = groupedChargeByBin[percentBin],
1933
                !energies.isEmpty,
1934
                !charges.isEmpty
1935
            else {
1936
                return nil
1937
            }
1938

            
1939
            return TypicalChargeCurvePoint(
1940
                percentBin: percentBin,
1941
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
1942
                averageChargeAh: charges.reduce(0, +) / Double(charges.count),
1943
                sampleCount: min(energies.count, charges.count)
1944
            )
1945
        }
Bogdan Timofte authored a month ago
1946

            
1947
        var runningMaximumEnergyWh = 0.0
1948
        var runningMaximumChargeAh = 0.0
1949

            
1950
        return averagedPoints.map { point in
1951
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
1952
            runningMaximumChargeAh = max(runningMaximumChargeAh, point.averageChargeAh)
1953
            return TypicalChargeCurvePoint(
1954
                percentBin: point.percentBin,
1955
                averageEnergyWh: runningMaximumEnergyWh,
1956
                averageChargeAh: runningMaximumChargeAh,
1957
                sampleCount: point.sampleCount
1958
            )
1959
        }
1960
    }
1961

            
1962
    private func normalizedTypicalCurveAnchors(
1963
        for session: ChargeSessionSummary
1964
    ) -> [(percent: Double, energyWh: Double, chargeAh: Double)] {
1965
        struct Anchor {
1966
            let percent: Double
1967
            let energyWh: Double
1968
            let chargeAh: Double
1969
            let timestamp: Date
1970
        }
1971

            
1972
        var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
1973
            guard checkpoint.batteryPercent.isFinite,
1974
                  checkpoint.measuredEnergyWh.isFinite,
1975
                  checkpoint.measuredChargeAh.isFinite,
1976
                  checkpoint.batteryPercent >= 0,
1977
                  checkpoint.batteryPercent <= 100,
1978
                  checkpoint.measuredEnergyWh >= 0,
1979
                  checkpoint.measuredChargeAh >= 0 else {
1980
                return nil
1981
            }
1982

            
1983
            return Anchor(
1984
                percent: checkpoint.batteryPercent,
1985
                energyWh: checkpoint.measuredEnergyWh,
1986
                chargeAh: checkpoint.measuredChargeAh,
1987
                timestamp: checkpoint.timestamp
1988
            )
1989
        }
1990

            
1991
        if let startBatteryPercent = session.startBatteryPercent,
1992
           startBatteryPercent.isFinite,
1993
           startBatteryPercent >= 0,
1994
           startBatteryPercent <= 100 {
1995
            anchors.append(
1996
                Anchor(
1997
                    percent: startBatteryPercent,
1998
                    energyWh: 0,
1999
                    chargeAh: 0,
2000
                    timestamp: session.startedAt
2001
                )
2002
            )
2003
        }
2004

            
2005
        if let endBatteryPercent = session.endBatteryPercent,
2006
           endBatteryPercent.isFinite,
2007
           endBatteryPercent >= 0,
2008
           endBatteryPercent <= 100 {
2009
            anchors.append(
2010
                Anchor(
2011
                    percent: endBatteryPercent,
2012
                    energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
2013
                    chargeAh: session.measuredChargeAh,
2014
                    timestamp: session.endedAt ?? session.lastObservedAt
2015
                )
2016
            )
2017
        }
2018

            
2019
        let sortedAnchors = anchors.sorted { lhs, rhs in
2020
            if lhs.percent != rhs.percent {
2021
                return lhs.percent < rhs.percent
2022
            }
2023
            if lhs.energyWh != rhs.energyWh {
2024
                return lhs.energyWh < rhs.energyWh
2025
            }
2026
            return lhs.timestamp < rhs.timestamp
2027
        }
2028

            
2029
        var collapsedAnchors: [(percent: Double, energyWh: Double, chargeAh: Double)] = []
2030

            
2031
        for anchor in sortedAnchors {
2032
            if let lastIndex = collapsedAnchors.indices.last,
2033
               abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
2034
                collapsedAnchors[lastIndex] = (
2035
                    percent: collapsedAnchors[lastIndex].percent,
2036
                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh),
2037
                    chargeAh: max(collapsedAnchors[lastIndex].chargeAh, anchor.chargeAh)
2038
                )
2039
            } else {
2040
                collapsedAnchors.append(
2041
                    (percent: anchor.percent, energyWh: anchor.energyWh, chargeAh: anchor.chargeAh)
2042
                )
2043
            }
2044
        }
2045

            
2046
        var runningMaximumEnergyWh = 0.0
2047
        var runningMaximumChargeAh = 0.0
2048

            
2049
        return collapsedAnchors.map { anchor in
2050
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
2051
            runningMaximumChargeAh = max(runningMaximumChargeAh, anchor.chargeAh)
2052
            return (
2053
                percent: anchor.percent,
2054
                energyWh: runningMaximumEnergyWh,
2055
                chargeAh: runningMaximumChargeAh
2056
            )
2057
        }
2058
    }
2059

            
2060
    private func interpolatedTypicalCurvePoint(
2061
        for percent: Double,
2062
        anchors: [(percent: Double, energyWh: Double, chargeAh: Double)]
2063
    ) -> (energyWh: Double, chargeAh: Double)? {
2064
        guard
2065
            let firstAnchor = anchors.first,
2066
            let lastAnchor = anchors.last,
2067
            percent >= firstAnchor.percent,
2068
            percent <= lastAnchor.percent
2069
        else {
2070
            return nil
2071
        }
2072

            
2073
        if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
2074
            return (energyWh: exactAnchor.energyWh, chargeAh: exactAnchor.chargeAh)
2075
        }
2076

            
2077
        guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
2078
              upperIndex > 0 else {
2079
            return nil
2080
        }
2081

            
2082
        let lowerAnchor = anchors[upperIndex - 1]
2083
        let upperAnchor = anchors[upperIndex]
2084
        let span = upperAnchor.percent - lowerAnchor.percent
2085
        guard span > 0.000_1 else {
2086
            return nil
2087
        }
2088

            
2089
        let ratio = (percent - lowerAnchor.percent) / span
2090
        let energyWh = lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
2091
        let chargeAh = lowerAnchor.chargeAh + ((upperAnchor.chargeAh - lowerAnchor.chargeAh) * ratio)
2092
        return (energyWh: energyWh, chargeAh: chargeAh)
Bogdan Timofte authored a month ago
2093
    }
2094

            
2095
    private func makeSessionSummary(
2096
        from object: NSManagedObject,
2097
        checkpoints: [NSManagedObject],
2098
        samples: [NSManagedObject]
2099
    ) -> ChargeSessionSummary? {
2100
        let chargingTransportMode = chargingTransportMode(for: object)
2101

            
2102
        guard
2103
            let id = uuidValue(object, key: "id"),
2104
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2105
            let startedAt = dateValue(object, key: "startedAt"),
2106
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
2107
            let status = statusValue(object, key: "statusRawValue"),
2108
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
2109
        else {
2110
            return nil
2111
        }
2112

            
2113
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
2114
            .sorted { $0.timestamp < $1.timestamp }
2115
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
2116
            .sorted { lhs, rhs in
2117
                if lhs.bucketIndex != rhs.bucketIndex {
2118
                    return lhs.bucketIndex < rhs.bucketIndex
2119
                }
2120
                return lhs.timestamp < rhs.timestamp
2121
            }
2122

            
2123
        return ChargeSessionSummary(
2124
            id: id,
2125
            chargedDeviceID: chargedDeviceID,
2126
            chargerID: uuidValue(object, key: "chargerID"),
2127
            meterMACAddress: stringValue(object, key: "meterMACAddress"),
2128
            meterName: stringValue(object, key: "meterName"),
2129
            meterModel: stringValue(object, key: "meterModel"),
2130
            startedAt: startedAt,
2131
            endedAt: dateValue(object, key: "endedAt"),
2132
            lastObservedAt: lastObservedAt,
Bogdan Timofte authored a month ago
2133
            pausedAt: dateValue(object, key: "pausedAt"),
Bogdan Timofte authored a month ago
2134
            status: status,
2135
            sourceMode: sourceMode,
2136
            chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
2137
            chargingStateMode: chargingStateMode(for: object),
2138
            autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
Bogdan Timofte authored a month ago
2139
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2140
            effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
2141
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
Bogdan Timofte authored a month ago
2142
            meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
2143
            meterChargeBaselineAh: optionalDoubleValue(object, key: "meterChargeBaselineAh"),
Bogdan Timofte authored a month ago
2144
            meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"),
2145
            meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"),
Bogdan Timofte authored a month ago
2146
            minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
2147
            maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
2148
            maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
2149
            maximumObservedVoltageVolts: chargingTransportMode == .wired
2150
                ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
2151
                : nil,
2152
            selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
2153
            completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
2154
            stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
2155
            startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
2156
            endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
2157
            capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
2158
            wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
2159
            usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
2160
            shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
2161
            supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
2162
            usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
2163
            targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
2164
            targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
2165
            requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
2166
            completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
2167
            completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
2168
            selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
2169
            checkpoints: checkpointSummaries,
2170
            aggregatedSamples: sampleSummaries
2171
        )
2172
    }
2173

            
2174
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
2175
        guard
2176
            let id = uuidValue(object, key: "id"),
2177
            let sessionID = uuidValue(object, key: "sessionID"),
2178
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2179
            let timestamp = dateValue(object, key: "timestamp")
2180
        else {
2181
            return nil
2182
        }
2183

            
2184
        return ChargeCheckpointSummary(
2185
            id: id,
2186
            sessionID: sessionID,
2187
            chargedDeviceID: chargedDeviceID,
2188
            timestamp: timestamp,
2189
            batteryPercent: doubleValue(object, key: "batteryPercent"),
2190
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2191
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2192
            currentAmps: doubleValue(object, key: "currentAmps"),
2193
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
2194
            label: stringValue(object, key: "label")
2195
        )
2196
    }
2197

            
2198
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
2199
        guard
2200
            let sessionID = uuidValue(object, key: "sessionID"),
2201
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
2202
            let timestamp = dateValue(object, key: "timestamp")
2203
        else {
2204
            return nil
2205
        }
2206

            
2207
        return ChargeSessionSampleSummary(
2208
            sessionID: sessionID,
2209
            chargedDeviceID: chargedDeviceID,
2210
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
2211
            timestamp: timestamp,
2212
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
2213
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
2214
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
2215
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
2216
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
2217
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
2218
        )
2219
    }
2220

            
Bogdan Timofte authored a month ago
2221
    private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2222
        fetchSessionObject(
2223
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2224
                format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
Bogdan Timofte authored a month ago
2225
                normalizedMACAddress(meterMACAddress),
Bogdan Timofte authored a month ago
2226
                ChargeSessionStatus.active.rawValue,
2227
                ChargeSessionStatus.paused.rawValue
Bogdan Timofte authored a month ago
2228
            )
2229
        )
2230
    }
2231

            
Bogdan Timofte authored a month ago
2232
    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2233
        fetchSessionObject(
2234
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
2235
                format: "meterMACAddress == %@ AND statusRawValue == %@",
2236
                normalizedMACAddress(meterMACAddress),
2237
                ChargeSessionStatus.active.rawValue
Bogdan Timofte authored a month ago
2238
            )
2239
        )
2240
    }
2241

            
2242
    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
2243
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2244
        request.predicate = predicate
2245
        request.fetchLimit = 1
2246
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
2247
        return (try? context.fetch(request))?.first
2248
    }
2249

            
2250
    private func fetchSessionObject(id: String) -> NSManagedObject? {
2251
        fetchSessionObject(
2252
            predicate: NSPredicate(format: "id == %@", id)
2253
        )
2254
    }
2255

            
2256
    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
2257
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2258
        request.predicate = NSPredicate(
2259
            format: "sessionID == %@ AND bucketIndex == %d",
2260
            sessionID,
2261
            bucketIndex
2262
        )
2263
        request.fetchLimit = 1
2264
        return (try? context.fetch(request))?.first
2265
    }
2266

            
2267
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2268
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2269
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2270
        return (try? context.fetch(request)) ?? []
2271
    }
2272

            
Bogdan Timofte authored a month ago
2273
    private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
2274
        guard !sessionIDs.isEmpty else {
2275
            return []
2276
        }
2277

            
2278
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
2279
        request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs)
2280
        return (try? context.fetch(request)) ?? []
2281
    }
2282

            
Bogdan Timofte authored a month ago
2283
    private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
2284
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2285
        request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
2286
        request.fetchLimit = 1
2287
        return (try? context.fetch(request))?.first
2288
    }
2289

            
Bogdan Timofte authored a month ago
2290
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
2291
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2292
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
2293
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
2294
        return (try? context.fetch(request)) ?? []
2295
    }
2296

            
2297
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
2298
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2299
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
2300
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2301
        return (try? context.fetch(request)) ?? []
2302
    }
2303

            
2304
    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
2305
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2306
        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
2307
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2308
        return (try? context.fetch(request)) ?? []
2309
    }
2310

            
Bogdan Timofte authored a month ago
2311
    private func sampleBackedSessionIDs(
2312
        devices: [NSManagedObject],
2313
        sessionsByDeviceID: [String: [NSManagedObject]],
2314
        sessionsByChargerID: [String: [NSManagedObject]]
2315
    ) -> Set<String> {
2316
        var sessionIDs: Set<String> = []
2317

            
2318
        for device in devices {
2319
            guard
2320
                let deviceID = stringValue(device, key: "id"),
2321
                let rawClass = stringValue(device, key: "deviceClassRawValue"),
2322
                let deviceClass = ChargedDeviceClass(rawValue: rawClass)
2323
            else {
2324
                continue
2325
            }
2326

            
2327
            let relevantSessions = relevantSessionObjects(
2328
                for: deviceID,
2329
                deviceClass: deviceClass,
2330
                sessionsByDeviceID: sessionsByDeviceID,
2331
                sessionsByChargerID: sessionsByChargerID
2332
            )
2333
            .sorted { lhs, rhs in
2334
                let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed
2335
                let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed
2336

            
2337
                if lhsStatus.isOpen && !rhsStatus.isOpen {
2338
                    return true
2339
                }
2340
                if !lhsStatus.isOpen && rhsStatus.isOpen {
2341
                    return false
2342
                }
2343

            
2344
                return (dateValue(lhs, key: "startedAt") ?? .distantPast)
2345
                    > (dateValue(rhs, key: "startedAt") ?? .distantPast)
2346
            }
2347

            
2348
            var recentCompletedSamplesIncluded = 0
2349

            
2350
            for session in relevantSessions {
2351
                guard let sessionID = stringValue(session, key: "id"),
2352
                      let status = statusValue(session, key: "statusRawValue") else {
2353
                    continue
2354
                }
2355

            
2356
                if status.isOpen {
2357
                    sessionIDs.insert(sessionID)
2358
                    continue
2359
                }
2360

            
2361
                guard recentCompletedSamplesIncluded < 2 else {
2362
                    continue
2363
                }
2364

            
2365
                sessionIDs.insert(sessionID)
2366
                recentCompletedSamplesIncluded += 1
2367
            }
2368
        }
2369

            
2370
        return sessionIDs
2371
    }
2372

            
Bogdan Timofte authored a month ago
2373
    private func relevantSessionObjects(
2374
        for chargedDeviceID: String,
2375
        deviceClass: ChargedDeviceClass,
2376
        sessionsByDeviceID: [String: [NSManagedObject]],
2377
        sessionsByChargerID: [String: [NSManagedObject]]
2378
    ) -> [NSManagedObject] {
2379
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
2380
        guard deviceClass == .charger else {
2381
            return directSessions
2382
        }
2383

            
2384
        var seenSessionIDs = Set<String>()
2385
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
2386
            .filter { session in
2387
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
2388
                return seenSessionIDs.insert(sessionID).inserted
2389
            }
2390
            .sorted {
2391
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
2392
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
2393
                return lhsDate < rhsDate
2394
            }
2395
    }
2396

            
2397
    private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
2398
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false)
2399
    }
2400

            
2401
    private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
2402
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true)
2403
    }
2404

            
2405
    private func resolvedAssignedObject(
2406
        for meterMACAddress: String,
2407
        expectsChargerClass: Bool
2408
    ) -> NSManagedObject? {
2409
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
2410
        guard !normalizedMAC.isEmpty else { return nil }
2411

            
2412
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2413
        request.predicate = NSPredicate(format: "lastAssociatedMeterMAC == %@", normalizedMAC)
2414
        request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
2415
        let matches = (try? context.fetch(request)) ?? []
2416
        return matches.first { object in
Bogdan Timofte authored a month ago
2417
            isChargerObject(object) == expectsChargerClass
Bogdan Timofte authored a month ago
2418
        }
2419
    }
2420

            
Bogdan Timofte authored a month ago
2421
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
2422
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2423
    }
2424

            
Bogdan Timofte authored a month ago
2425
    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
2426
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2427
        request.predicate = NSPredicate(format: "id == %@", id)
2428
        request.fetchLimit = 1
2429
        return (try? context.fetch(request))?.first
2430
    }
2431

            
2432
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
2433
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
2434
        return (try? context.fetch(request)) ?? []
2435
    }
2436

            
2437
    private func resolvedStopThreshold(
2438
        for chargedDevice: NSManagedObject,
2439
        chargingTransportMode: ChargingTransportMode,
Bogdan Timofte authored a month ago
2440
        chargingStateMode: ChargingStateMode,
2441
        charger: NSManagedObject?,
2442
        fallback: Double?
2443
    ) -> Double? {
2444
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
2445
            return nil
2446
        }
2447

            
2448
        let sessionKind = ChargeSessionKind(
2449
            chargingTransportMode: chargingTransportMode,
2450
            chargingStateMode: chargingStateMode
2451
        )
2452
        let configuredCurrents = decodedCompletionCurrents(
2453
            from: chargedDevice,
2454
            key: "configuredCompletionCurrentsRawValue"
2455
        )
2456
        let learnedCurrents = decodedCompletionCurrents(
2457
            from: chargedDevice,
2458
            key: "learnedCompletionCurrentsRawValue"
2459
        )
2460
        let legacyCurrent: Double?
Bogdan Timofte authored a month ago
2461
        switch chargingTransportMode {
2462
        case .wired:
Bogdan Timofte authored a month ago
2463
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2464
                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
2465
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2466
        case .wireless:
Bogdan Timofte authored a month ago
2467
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2468
                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
2469
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2470
        }
Bogdan Timofte authored a month ago
2471

            
2472
        let resolvedCurrent = configuredCurrents[sessionKind]
2473
            ?? learnedCurrents[sessionKind]
2474
            ?? legacyCurrent
2475
            ?? fallback
2476
        guard let resolvedCurrent, resolvedCurrent > 0 else {
2477
            return nil
2478
        }
2479
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
2480
    }
2481

            
Bogdan Timofte authored a month ago
2482
    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
Bogdan Timofte authored a month ago
2483
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
2484
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
2485
        return resolvedPreferredChargingTransportMode(
Bogdan Timofte authored a month ago
2486
            .wired,
Bogdan Timofte authored a month ago
2487
            supportsWiredCharging: supportsWiredCharging,
2488
            supportsWirelessCharging: supportsWirelessCharging
2489
        )
2490
    }
2491

            
Bogdan Timofte authored a month ago
2492
    private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
2493
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other
2494
    }
2495

            
2496
    private func normalizedTemplateID(
2497
        _ templateID: String?,
2498
        kind: ChargedDeviceKind
2499
    ) -> String? {
2500
        guard let templateID,
2501
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2502
              templateDefinition.kind == kind else {
2503
            return nil
Bogdan Timofte authored a month ago
2504
        }
Bogdan Timofte authored a month ago
2505
        return templateDefinition.id
Bogdan Timofte authored a month ago
2506
    }
2507

            
Bogdan Timofte authored a month ago
2508
    private func resolvedChargerTemplateConfiguration(
2509
        templateID: String?
2510
    ) -> (
2511
        chargingStateAvailability: ChargingStateAvailability,
2512
        supportsWiredCharging: Bool,
2513
        supportsWirelessCharging: Bool,
2514
        wirelessChargingProfile: WirelessChargingProfile
2515
    ) {
2516
        guard let templateID,
2517
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2518
              templateDefinition.kind == .charger else {
2519
            return (
2520
                chargingStateAvailability: .onOnly,
2521
                supportsWiredCharging: false,
2522
                supportsWirelessCharging: true,
2523
                wirelessChargingProfile: .genericQi
2524
            )
Bogdan Timofte authored a month ago
2525
        }
Bogdan Timofte authored a month ago
2526

            
2527
        let normalizedChargingStateAvailability = templateDefinition.deviceClass.normalizedChargingStateAvailability(
2528
            templateDefinition.chargingStateAvailability
2529
        )
2530
        let normalizedChargingSupport = templateDefinition.deviceClass.normalizedChargingSupport(
2531
            supportsWiredCharging: templateDefinition.supportsWiredCharging,
2532
            supportsWirelessCharging: templateDefinition.supportsWirelessCharging
2533
        )
2534

            
2535
        return (
2536
            chargingStateAvailability: normalizedChargingStateAvailability,
2537
            supportsWiredCharging: normalizedChargingSupport.wired,
2538
            supportsWirelessCharging: normalizedChargingSupport.wireless,
2539
            wirelessChargingProfile: templateDefinition.wirelessChargingProfile
2540
        )
Bogdan Timofte authored a month ago
2541
    }
2542

            
Bogdan Timofte authored a month ago
2543
    private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
2544
        guard let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
2545
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2546
              templateDefinition.kind == deviceClass(for: chargedDevice).kind else {
2547
            return nil
Bogdan Timofte authored a month ago
2548
        }
Bogdan Timofte authored a month ago
2549
        return templateDefinition
2550
    }
2551

            
2552
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
2553
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2554
            ? true
2555
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2556
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2557
            ? false
2558
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2559
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2560
            supportsWiredCharging: persistedWiredCharging,
2561
            supportsWirelessCharging: persistedWirelessCharging
2562
        ).wired
2563
    }
2564

            
2565
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
2566
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2567
            ? true
2568
            : boolValue(chargedDevice, key: "supportsWiredCharging")
2569
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2570
            ? false
2571
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2572
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2573
            supportsWiredCharging: persistedWiredCharging,
2574
            supportsWirelessCharging: persistedWirelessCharging
2575
        ).wireless
2576
    }
2577

            
2578
    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
2579
        let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue")
2580
            .flatMap(ChargingStateAvailability.init(rawValue:))
2581
            ?? ChargingStateAvailability.fallback(
Bogdan Timofte authored a month ago
2582
            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
2583
        )
Bogdan Timofte authored a month ago
2584
        return deviceClass(for: chargedDevice).normalizedChargingStateAvailability(persistedAvailability)
Bogdan Timofte authored a month ago
2585
    }
2586

            
2587
    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
Bogdan Timofte authored a month ago
2588
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2589
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2590
            let persistedChargingStateMode = stringValue(session, key: "chargingStateRawValue")
2591
                .flatMap(ChargingStateMode.init(rawValue:))
2592
                ?? .on
2593
            return resolvedChargingStateMode(
2594
                persistedChargingStateMode,
2595
                availability: chargingStateAvailability(for: chargedDevice)
2596
            )
2597
        }
2598

            
Bogdan Timofte authored a month ago
2599
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
2600
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
2601
            return chargingStateMode
2602
        }
2603

            
2604
        return .on
2605
    }
2606

            
2607
    private func resolvedChargingStateMode(
2608
        _ chargingStateMode: ChargingStateMode,
2609
        availability: ChargingStateAvailability
2610
    ) -> ChargingStateMode {
2611
        if availability.supportedModes.contains(chargingStateMode) {
2612
            return chargingStateMode
2613
        }
2614
        return availability.supportedModes.first ?? .on
2615
    }
2616

            
Bogdan Timofte authored a month ago
2617
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
2618
        guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2619
              let profile = WirelessChargingProfile(rawValue: rawValue) else {
2620
            return .genericQi
2621
        }
2622
        return profile
2623
    }
2624

            
2625
    private func resolvedPreferredChargingTransportMode(
2626
        _ preferredChargingTransportMode: ChargingTransportMode,
2627
        supportsWiredCharging: Bool,
2628
        supportsWirelessCharging: Bool
2629
    ) -> ChargingTransportMode {
2630
        switch preferredChargingTransportMode {
2631
        case .wired where supportsWiredCharging:
2632
            return .wired
2633
        case .wireless where supportsWirelessCharging:
2634
            return .wireless
2635
        default:
2636
            if supportsWiredCharging {
2637
                return .wired
2638
            }
2639
            if supportsWirelessCharging {
2640
                return .wireless
2641
            }
2642
            return .wired
2643
        }
2644
    }
2645

            
Bogdan Timofte authored a month ago
2646
    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
2647
        let payload = Dictionary(
2648
            uniqueKeysWithValues: currents.map { key, value in
2649
                (key.rawValue, value)
2650
            }
2651
        )
2652
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
2653
            return nil
2654
        }
2655
        return String(data: data, encoding: .utf8)
2656
    }
2657

            
2658
    private func decodedCompletionCurrents(
2659
        from object: NSManagedObject,
2660
        key: String
2661
    ) -> [ChargeSessionKind: Double] {
2662
        guard let rawValue = stringValue(object, key: key),
2663
              let data = rawValue.data(using: .utf8),
2664
              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
2665
            return [:]
2666
        }
2667

            
2668
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2669
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
2670
                return
2671
            }
2672
            result[sessionKind] = entry.value
2673
        }
2674
    }
2675

            
2676
    private func legacyConfiguredCompletionCurrent(
2677
        for currents: [ChargeSessionKind: Double],
2678
        chargingTransportMode: ChargingTransportMode
2679
    ) -> Double? {
2680
        let candidates = currents
2681
            .filter { $0.key.chargingTransportMode == chargingTransportMode }
2682
            .sorted { lhs, rhs in
2683
                lhs.key.rawValue < rhs.key.rawValue
2684
            }
2685
            .map(\.value)
2686
        return candidates.first
2687
    }
2688

            
2689
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
2690
        guard let charger else {
2691
            return nil
2692
        }
2693
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
2694
        guard let idleCurrent, idleCurrent >= 0 else {
2695
            return nil
2696
        }
2697
        return idleCurrent
2698
    }
2699

            
2700
    private func effectiveCurrentAmps(
2701
        fromMeasuredCurrent currentAmps: Double,
2702
        chargingTransportMode: ChargingTransportMode,
2703
        charger: NSManagedObject?
2704
    ) -> Double {
2705
        switch chargingTransportMode {
2706
        case .wired:
2707
            return max(currentAmps, 0)
2708
        case .wireless:
2709
            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
2710
                return max(currentAmps, 0)
2711
            }
2712
            return max(currentAmps - idleCurrent, 0)
2713
        }
2714
    }
2715

            
2716
    private func hasObservedChargeFlow(
2717
        currentAmps: Double,
2718
        chargingTransportMode: ChargingTransportMode,
2719
        charger: NSManagedObject?,
2720
        stopThreshold: Double?
2721
    ) -> Bool {
2722
        let effectiveCurrent = effectiveCurrentAmps(
2723
            fromMeasuredCurrent: currentAmps,
2724
            chargingTransportMode: chargingTransportMode,
2725
            charger: charger
2726
        )
2727
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
2728
    }
2729

            
Bogdan Timofte authored a month ago
2730
    private func derivedMinimumCurrent(
2731
        from sessions: [NSManagedObject],
2732
        chargingTransportMode: ChargingTransportMode
2733
    ) -> Double? {
2734
        let completionCurrents = sessions.compactMap { session -> Double? in
2735
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2736
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
2737
                return nil
2738
            }
Bogdan Timofte authored a month ago
2739
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2740
                return nil
2741
            }
Bogdan Timofte authored a month ago
2742
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
2743
                return nil
2744
            }
2745
            return completionCurrent
2746
        }
2747

            
2748
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
2749
        guard !recentCompletionCurrents.isEmpty else { return nil }
2750
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
2751
    }
2752

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

            
2756
        for session in sessions {
2757
            guard statusValue(session, key: "statusRawValue") == .completed else {
2758
                continue
2759
            }
2760
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2761
                continue
2762
            }
2763
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
2764
                  completionCurrent > 0 else {
2765
                continue
2766
            }
2767

            
2768
            let sessionKind = ChargeSessionKind(
2769
                chargingTransportMode: chargingTransportMode(for: session),
2770
                chargingStateMode: chargingStateMode(for: session)
2771
            )
2772
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
2773
        }
2774

            
2775
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2776
            let recentCurrents = Array(entry.value.suffix(5))
2777
            guard !recentCurrents.isEmpty else {
2778
                return
2779
            }
2780
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
2781
        }
2782
    }
2783

            
Bogdan Timofte authored a month ago
2784
    private func derivedCapacity(
2785
        from sessions: [NSManagedObject],
2786
        chargingTransportMode: ChargingTransportMode,
2787
        supportsChargingWhileOff: Bool
2788
    ) -> Double? {
2789
        let capacityCandidates = sessions.compactMap { session -> Double? in
2790
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2791
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
2792
                return nil
2793
            }
2794
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
2795
                return nil
2796
            }
2797
            if supportsChargingWhileOff {
2798
                return capacityEstimate
2799
            }
2800
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
2801
                return nil
2802
            }
2803
            return capacityEstimate
2804
        }
2805

            
2806
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
2807
        guard !recentCapacityCandidates.isEmpty else { return nil }
2808
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
2809
    }
2810

            
2811
    private func derivedWirelessEfficiency(
2812
        from sessions: [NSManagedObject],
2813
        chargingProfile: WirelessChargingProfile
2814
    ) -> Double? {
2815
        guard chargingProfile == .magsafe else {
2816
            return nil
2817
        }
2818

            
2819
        let candidates = sessions.compactMap { session -> Double? in
2820
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2821
            guard chargingTransportMode(for: session) == .wireless else { return nil }
2822
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
2823
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
2824
                return nil
2825
            }
2826
            return factor
2827
        }
2828

            
2829
        let recentCandidates = Array(candidates.suffix(6))
2830
        guard !recentCandidates.isEmpty else { return nil }
2831
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2832
    }
2833

            
2834
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
2835
        let candidates = sessions.compactMap { session -> Double? in
2836
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2837
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
2838
                return nil
2839
            }
2840
            return (sourceVoltage * 10).rounded() / 10
2841
        }
2842

            
2843
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
2844
        return counts.keys.sorted()
2845
    }
2846

            
2847
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
2848
        let candidates = sessions.compactMap { session -> Double? in
2849
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2850
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
2851
                return nil
2852
            }
2853
            return minimumObservedCurrent
2854
        }
2855

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

            
2861
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
2862
        let candidates = sessions.compactMap { session -> Double? in
2863
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2864
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
2865
                return nil
2866
            }
2867
            return factor
2868
        }
2869

            
2870
        let recentCandidates = Array(candidates.suffix(6))
2871
        guard !recentCandidates.isEmpty else { return nil }
2872
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2873
    }
2874

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

            
2886
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
2887
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2888
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
2889
            return resolvedPreferredChargingTransportMode(
2890
                chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired,
2891
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
2892
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
2893
            )
2894
        }
2895

            
2896
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
2897
            return persistedChargingTransportMode
Bogdan Timofte authored a month ago
2898
        }
2899

            
2900
        return .wired
2901
    }
2902

            
2903
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
2904
        if session.isInserted {
2905
            return .created
2906
        }
2907

            
2908
        let committedValues = session.committedValues(
2909
            forKeys: [
2910
                "statusRawValue",
2911
                "updatedAt",
2912
                "targetBatteryAlertTriggeredAt",
2913
                "requiresCompletionConfirmation"
2914
            ]
2915
        )
2916
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
2917
        let currentStatus = statusValue(session, key: "statusRawValue")
2918
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
2919
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
2920
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
2921
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
2922
            ?? false
2923
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
2924

            
2925
        if currentStatus == .completed, committedStatus != .completed {
2926
            return .completed
2927
        }
2928

            
Bogdan Timofte authored a month ago
2929
        if currentStatus != committedStatus {
2930
            return .event
2931
        }
2932

            
Bogdan Timofte authored a month ago
2933
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
2934
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
2935
            return .event
2936
        }
2937

            
2938
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
2939
            ?? dateValue(session, key: "createdAt")
2940
            ?? observedAt
2941

            
2942
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
2943
            return .periodic
2944
        }
2945

            
2946
        return .none
2947
    }
2948

            
Bogdan Timofte authored a month ago
2949
    private func shouldPersistAggregatedSample(
2950
        _ sample: NSManagedObject,
2951
        observedAt: Date
2952
    ) -> Bool {
2953
        if sample.isInserted {
2954
            return true
2955
        }
2956

            
2957
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
2958
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
2959
            ?? dateValue(sample, key: "createdAt")
2960
            ?? observedAt
2961

            
2962
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
2963
    }
2964

            
Bogdan Timofte authored a month ago
2965
    private func generateQRIdentifier() -> String {
2966
        "device:\(UUID().uuidString)"
2967
    }
2968

            
2969
    @discardableResult
2970
    private func saveContext() -> Bool {
2971
        guard context.hasChanges else { return true }
2972
        do {
2973
            try context.save()
2974
            return true
2975
        } catch {
2976
            track("Failed saving charge insights context: \(error)")
2977
            context.rollback()
2978
            return false
2979
        }
2980
    }
2981

            
2982
    private func normalizedText(_ text: String) -> String {
2983
        text.trimmingCharacters(in: .whitespacesAndNewlines)
2984
    }
2985

            
2986
    private func normalizedOptionalText(_ text: String?) -> String? {
2987
        guard let text else { return nil }
2988
        let normalized = normalizedText(text)
2989
        return normalized.isEmpty ? nil : normalized
2990
    }
2991

            
2992
    private func normalizedMACAddress(_ macAddress: String) -> String {
2993
        normalizedText(macAddress).uppercased()
2994
    }
2995

            
Bogdan Timofte authored a month ago
2996
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
2997
        guard object.entity.propertiesByName[key] != nil else {
2998
            return nil
2999
        }
3000
        return object.value(forKey: key)
3001
    }
3002

            
3003
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
3004
        guard object.entity.propertiesByName[key] != nil else {
3005
            return
3006
        }
3007
        object.setValue(value, forKey: key)
3008
    }
3009

            
Bogdan Timofte authored a month ago
3010
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
3011
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
3012
        let normalized = normalizedOptionalText(value)
3013
        return normalized
3014
    }
3015

            
3016
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
3017
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
3018
    }
3019

            
3020
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
3021
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
3022
            return value
3023
        }
Bogdan Timofte authored a month ago
3024
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3025
            return value.doubleValue
3026
        }
3027
        return 0
3028
    }
3029

            
3030
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
3031
        let value = rawValue(object, key: key)
3032
        if value == nil {
Bogdan Timofte authored a month ago
3033
            return nil
3034
        }
3035
        return doubleValue(object, key: key)
3036
    }
3037

            
3038
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
Bogdan Timofte authored a month ago
3039
        if let value = rawValue(object, key: key) as? Int16 {
Bogdan Timofte authored a month ago
3040
            return value
3041
        }
Bogdan Timofte authored a month ago
3042
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3043
            return value.int16Value
3044
        }
3045
        return nil
3046
    }
3047

            
3048
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
3049
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
3050
            return value
3051
        }
Bogdan Timofte authored a month ago
3052
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3053
            return value.int32Value
3054
        }
3055
        return nil
3056
    }
3057

            
3058
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
Bogdan Timofte authored a month ago
3059
        if let value = rawValue(object, key: key) as? Bool {
Bogdan Timofte authored a month ago
3060
            return value
3061
        }
Bogdan Timofte authored a month ago
3062
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
3063
            return value.boolValue
3064
        }
3065
        return false
3066
    }
3067

            
3068
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
3069
        guard let value = stringValue(object, key: key) else { return nil }
3070
        return UUID(uuidString: value)
3071
    }
3072

            
3073
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
3074
        guard let value = stringValue(object, key: key) else { return nil }
3075
        return ChargeSessionStatus(rawValue: value)
3076
    }
3077

            
3078
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
3079
        guard let value = stringValue(object, key: key) else { return nil }
3080
        return ChargingTransportMode(rawValue: value)
3081
    }
3082

            
3083
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
3084
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
3085
            return []
3086
        }
3087
        return rawValue
3088
            .split(separator: ",")
3089
            .compactMap { Double($0) }
3090
            .sorted()
3091
    }
3092

            
3093
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
3094
        let uniqueVoltages = Array(Set(voltages)).sorted()
3095
        guard !uniqueVoltages.isEmpty else {
3096
            return nil
3097
        }
3098
        return uniqueVoltages
3099
            .map { String(format: "%.1f", $0) }
3100
            .joined(separator: ",")
3101
    }
3102

            
3103
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
3104
        guard currentCount > 0 else {
3105
            return newValue
3106
        }
3107
        let total = (currentAverage * Double(currentCount)) + newValue
3108
        return total / Double(currentCount + 1)
3109
    }
3110
}
3111

            
3112
private enum ObservationSaveReason {
3113
    case none
3114
    case created
3115
    case periodic
3116
    case completed
3117
    case event
3118
}