USB-Meter / USB Meter / Model / ChargeInsightsStore.swift
Newer Older
2619 lines | 115.613kb
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
40
    private let counterDecreaseTolerance = 0.002
41
    private let completionConfirmationCooldown: TimeInterval = 15 * 60
Bogdan Timofte authored a month ago
42
    private let pausedSessionTimeout: TimeInterval = 10 * 60
Bogdan Timofte authored a month ago
43
    private let defaultCompletionPercentThreshold = 95.0
44
    private let completionContradictionTolerancePercent = 2.0
45
    private let minimumWirelessEfficiencyFactor = 0.35
46
    private let maximumWirelessEfficiencyFactor = 0.95
47
    private let lowWirelessEfficiencyThreshold = 0.72
48

            
49
    init(context: NSManagedObjectContext) {
50
        self.context = context
51
    }
52

            
53
    func refreshContext() {
54
        context.performAndWait {
55
            context.processPendingChanges()
56
        }
57
    }
58

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

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

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

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

            
123
    @discardableResult
124
    func updateChargedDevice(
125
        id: UUID,
126
        name: String,
127
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
128
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
129
        supportsWiredCharging: Bool,
130
        supportsWirelessCharging: Bool,
131
        preferredChargingTransportMode: ChargingTransportMode,
132
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
133
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
134
        notes: String?
135
    ) -> Bool {
136
        let normalizedName = normalizedText(name)
137
        guard !normalizedName.isEmpty else { return false }
138
        guard supportsWiredCharging || supportsWirelessCharging else { return false }
139

            
140
        var didSave = false
141
        context.performAndWait {
142
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
143
                return
144
            }
145

            
146
            let previousSupportsChargingWhileOff = boolValue(object, key: "supportsChargingWhileOff")
Bogdan Timofte authored a month ago
147
            let previousChargingStateAvailability = self.chargingStateAvailability(for: object)
Bogdan Timofte authored a month ago
148
            let previousSupportsWiredCharging = self.supportsWiredCharging(for: object)
149
            let previousSupportsWirelessCharging = self.supportsWirelessCharging(for: object)
150
            let previousPreferredChargingTransportMode = self.preferredChargingTransportMode(for: object)
151
            let resolvedPreferredTransportMode = resolvedPreferredChargingTransportMode(
152
                preferredChargingTransportMode,
153
                supportsWiredCharging: supportsWiredCharging,
154
                supportsWirelessCharging: supportsWirelessCharging
155
            )
156
            let now = Date()
157

            
158
            object.setValue(normalizedName, forKey: "name")
159
            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
160
            object.setValue(chargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
161
            object.setValue(chargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
Bogdan Timofte authored a month ago
162
            object.setValue(supportsWiredCharging, forKey: "supportsWiredCharging")
163
            object.setValue(supportsWirelessCharging, forKey: "supportsWirelessCharging")
164
            object.setValue(resolvedPreferredTransportMode.rawValue, forKey: "preferredChargingTransportRawValue")
165
            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
166
            object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
167
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
168
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
169
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
170
            object.setValue(now, forKey: "updatedAt")
171

            
Bogdan Timofte authored a month ago
172
            let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
173
            let shouldRecalculateSessionCapacity = previousSupportsChargingWhileOff != supportsChargingWhileOff
174
            let shouldRefreshActiveSessions = shouldRecalculateSessionCapacity
Bogdan Timofte authored a month ago
175
                || previousChargingStateAvailability != chargingStateAvailability
Bogdan Timofte authored a month ago
176
                || previousSupportsWiredCharging != supportsWiredCharging
177
                || previousSupportsWirelessCharging != supportsWirelessCharging
178
                || previousPreferredChargingTransportMode != resolvedPreferredTransportMode
179

            
180
            if shouldRecalculateSessionCapacity || shouldRefreshActiveSessions {
181
                let sessions = fetchSessions(forChargedDeviceID: id.uuidString)
182
                for session in sessions {
Bogdan Timofte authored a month ago
183
                    let isOpen = statusValue(session, key: "statusRawValue")?.isOpen == true
Bogdan Timofte authored a month ago
184

            
185
                    if shouldRecalculateSessionCapacity {
186
                        session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
187
                        updateCapacityEstimate(for: session)
188
                        session.setValue(now, forKey: "updatedAt")
189
                    }
190

            
Bogdan Timofte authored a month ago
191
                    guard isOpen, shouldRefreshActiveSessions else {
Bogdan Timofte authored a month ago
192
                        continue
193
                    }
194

            
195
                    let resolvedSessionChargingTransportMode = resolvedPreferredChargingTransportMode(
196
                        chargingTransportMode(for: session),
197
                        supportsWiredCharging: supportsWiredCharging,
198
                        supportsWirelessCharging: supportsWirelessCharging
199
                    )
Bogdan Timofte authored a month ago
200
                    let resolvedSessionChargingStateMode = resolvedChargingStateMode(
201
                        chargingStateMode(for: session),
202
                        availability: chargingStateAvailability
203
                    )
204
                    let charger = stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
205

            
206
                    session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
207
                    session.setValue(resolvedSessionChargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
208
                    session.setValue(resolvedSessionChargingStateMode.rawValue, forKey: "chargingStateRawValue")
Bogdan Timofte authored a month ago
209
                    session.setValue(
210
                        resolvedStopThreshold(
211
                            for: object,
212
                            chargingTransportMode: resolvedSessionChargingTransportMode,
Bogdan Timofte authored a month ago
213
                            chargingStateMode: resolvedSessionChargingStateMode,
214
                            charger: charger,
215
                            fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
216
                        ) ?? 0,
Bogdan Timofte authored a month ago
217
                        forKey: "stopThresholdAmps"
218
                    )
219
                    session.setValue(now, forKey: "updatedAt")
220
                    updateCapacityEstimate(for: session)
221
                }
222
            }
223

            
224
            refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
225
            didSave = saveContext()
226
        }
227
        return didSave
228
    }
229

            
230
    @discardableResult
231
    func assignChargedDevice(id: UUID, to meterMACAddress: String) -> Bool {
232
        assign(itemWithID: id, to: meterMACAddress, kind: .chargedDevice)
233
    }
234

            
235
    @discardableResult
236
    func assignCharger(id: UUID, to meterMACAddress: String) -> Bool {
237
        assign(itemWithID: id, to: meterMACAddress, kind: .charger)
238
    }
239

            
240
    @discardableResult
241
    private func assign(
242
        itemWithID id: UUID,
243
        to meterMACAddress: String,
244
        kind: MeterAssignmentKind
245
    ) -> Bool {
246
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
247
        guard !normalizedMAC.isEmpty else { return false }
248

            
249
        var didSave = false
250
        context.performAndWait {
251
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
252
                return
253
            }
254

            
255
            let isCharger = ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
256
            guard isCharger == kind.expectsChargerClass else {
257
                return
258
            }
259

            
260
            let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
261
            request.predicate = NSPredicate(
262
                format: "lastAssociatedMeterMAC == %@ AND id != %@",
263
                normalizedMAC,
264
                id.uuidString
265
            )
266
            let previouslyAssignedDevices = (try? context.fetch(request)) ?? []
267
            for previousDevice in previouslyAssignedDevices {
268
                let previousIsCharger = ChargedDeviceClass(rawValue: stringValue(previousDevice, key: "deviceClassRawValue") ?? "") == .charger
269
                guard previousIsCharger == kind.expectsChargerClass else {
270
                    continue
271
                }
272
                previousDevice.setValue(nil, forKey: "lastAssociatedMeterMAC")
273
                previousDevice.setValue(Date(), forKey: "updatedAt")
274
            }
275

            
276
            object.setValue(normalizedMAC, forKey: "lastAssociatedMeterMAC")
277
            object.setValue(Date(), forKey: "updatedAt")
278

            
279
            if kind == .charger,
Bogdan Timofte authored a month ago
280
               let openSession = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
281
               chargingTransportMode(for: openSession) == .wireless {
282
                openSession.setValue(id.uuidString, forKey: "chargerID")
283
                openSession.setValue(Date(), forKey: "updatedAt")
Bogdan Timofte authored a month ago
284
            }
285

            
286
            didSave = saveContext()
287
        }
288
        return didSave
289
    }
290

            
291
    @discardableResult
292
    func setChargingTransportMode(_ chargingTransportMode: ChargingTransportMode, for meterMACAddress: String) -> Bool {
293
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
294
        guard !normalizedMAC.isEmpty else { return false }
295

            
296
        var didSave = false
297
        context.performAndWait {
Bogdan Timofte authored a month ago
298
            let openSession = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC)
299
            let device = (openSession.flatMap { stringValue($0, key: "chargedDeviceID") }.flatMap(fetchChargedDeviceObject(id:)))
Bogdan Timofte authored a month ago
300
                ?? resolvedDeviceObject(for: normalizedMAC)
301

            
302
            guard let device else {
303
                return
304
            }
305

            
306
            let resolvedMode = resolvedPreferredChargingTransportMode(
307
                chargingTransportMode,
308
                supportsWiredCharging: supportsWiredCharging(for: device),
309
                supportsWirelessCharging: supportsWirelessCharging(for: device)
310
            )
311
            let charger = resolvedMode == .wireless ? resolvedChargerObject(for: normalizedMAC) : nil
312
            guard resolvedMode == .wired || charger != nil else {
313
                return
314
            }
315

            
316
            device.setValue(resolvedMode.rawValue, forKey: "preferredChargingTransportRawValue")
317
            device.setValue(Date(), forKey: "updatedAt")
318

            
Bogdan Timofte authored a month ago
319
            if let openSession {
320
                let chargingStateMode = resolvedChargingStateMode(
321
                    chargingStateMode(for: openSession),
322
                    availability: chargingStateAvailability(for: device)
323
                )
324
                openSession.setValue(resolvedMode.rawValue, forKey: "chargingTransportRawValue")
325
                openSession.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
326
                openSession.setValue(
327
                    resolvedStopThreshold(
328
                        for: device,
329
                        chargingTransportMode: resolvedMode,
330
                        chargingStateMode: chargingStateMode,
331
                        charger: charger,
332
                        fallback: optionalDoubleValue(openSession, key: "stopThresholdAmps")
333
                    ) ?? 0,
334
                    forKey: "stopThresholdAmps"
335
                )
336
                openSession.setValue(Date(), forKey: "updatedAt")
Bogdan Timofte authored a month ago
337
            }
338

            
339
            didSave = saveContext()
340
        }
341

            
342
        return didSave
343
    }
344

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

            
Bogdan Timofte authored a month ago
359
        var didSave = false
360
        context.performAndWait {
Bogdan Timofte authored a month ago
361
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
Bogdan Timofte authored a month ago
362
                return
363
            }
364

            
Bogdan Timofte authored a month ago
365
            guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
Bogdan Timofte authored a month ago
366
                return
367
            }
368

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

            
403
            guard insertBatteryCheckpoint(
404
                percent: initialBatteryPercent,
405
                label: "Start",
406
                timestamp: snapshot.observedAt,
407
                to: session
408
            ) != nil else {
409
                return
410
            }
Bogdan Timofte authored a month ago
411
            didSave = saveContext()
412
        }
413
        return didSave
414
    }
415

            
Bogdan Timofte authored a month ago
416
    @discardableResult
417
    func pauseSession(id sessionID: UUID, observedAt: Date) -> Bool {
418
        var didSave = false
419
        context.performAndWait {
420
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
421
                return
422
            }
423

            
424
            guard statusValue(session, key: "statusRawValue") == .active else {
425
                return
426
            }
427

            
428
            session.setValue(ChargeSessionStatus.paused.rawValue, forKey: "statusRawValue")
429
            session.setValue(observedAt, forKey: "pausedAt")
430
            session.setValue(nil, forKey: "belowThresholdSince")
431
            clearCompletionConfirmationState(for: session)
432
            session.setValue(observedAt, forKey: "updatedAt")
433
            didSave = saveContext()
434
        }
435
        return didSave
436
    }
437

            
438
    @discardableResult
439
    func resumeSession(id sessionID: UUID, snapshot: ChargingMonitorSnapshot?) -> Bool {
440
        var didSave = false
441
        context.performAndWait {
442
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
443
                return
444
            }
445

            
446
            guard statusValue(session, key: "statusRawValue") == .paused else {
447
                return
448
            }
449

            
450
            let pausedAt = dateValue(session, key: "pausedAt") ?? Date()
451
            let resumedAt = snapshot?.observedAt ?? Date()
452
            if resumedAt.timeIntervalSince(pausedAt) >= pausedSessionTimeout {
453
                finishSession(
454
                    session,
455
                    observedAt: pausedAt.addingTimeInterval(pausedSessionTimeout),
456
                    finalBatteryPercent: nil,
457
                    label: nil,
458
                    status: .completed
459
                )
460
                guard saveContext() else {
461
                    return
462
                }
463
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
464
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
465
                    didSave = saveContext()
466
                } else {
467
                    didSave = true
468
                }
469
                return
470
            }
471

            
472
            session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
473
            session.setValue(nil, forKey: "pausedAt")
474
            session.setValue(nil, forKey: "belowThresholdSince")
475
            clearCompletionConfirmationState(for: session)
476
            session.setValue(resumedAt, forKey: "lastObservedAt")
477
            if let snapshot {
478
                session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
479
                session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
480
                session.setValue(
481
                    chargingTransportMode(for: session) == .wired ? snapshot.voltageVolts : nil,
482
                    forKey: "lastObservedVoltageVolts"
483
                )
484
            } else {
485
                session.setValue(0, forKey: "lastObservedCurrentAmps")
486
                session.setValue(0, forKey: "lastObservedPowerWatts")
487
                session.setValue(nil, forKey: "lastObservedVoltageVolts")
488
            }
489
            session.setValue(resumedAt, forKey: "updatedAt")
490
            didSave = saveContext()
491
        }
492
        return didSave
493
    }
494

            
495
    @discardableResult
496
    func stopSession(
497
        id sessionID: UUID,
498
        finalBatteryPercent: Double,
499
        label: String?
500
    ) -> Bool {
501
        guard finalBatteryPercent.isFinite, finalBatteryPercent >= 0, finalBatteryPercent <= 100 else {
502
            return false
503
        }
504

            
505
        var didSave = false
506
        context.performAndWait {
507
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
508
                return
509
            }
510

            
511
            guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
512
                return
513
            }
514

            
515
            let observedAt = snapshotDateForManualStop(session)
516
            finishSession(
517
                session,
518
                observedAt: observedAt,
519
                finalBatteryPercent: finalBatteryPercent,
520
                label: label,
521
                status: .completed
522
            )
523

            
524
            guard saveContext() else {
525
                return
526
            }
527

            
528
            if let deviceID = stringValue(session, key: "chargedDeviceID") {
529
                refreshDerivedMetrics(forChargedDeviceID: deviceID)
530
                didSave = saveContext()
531
            } else {
532
                didSave = true
533
            }
534
        }
535
        return didSave
536
    }
537

            
Bogdan Timofte authored a month ago
538
    @discardableResult
539
    func addBatteryCheckpoint(
540
        percent: Double,
541
        label: String?,
542
        for meterMACAddress: String
543
    ) -> Bool {
544
        guard percent.isFinite, percent >= 0, percent <= 100 else {
545
            return false
546
        }
547

            
548
        var didSave = false
549
        context.performAndWait {
Bogdan Timofte authored a month ago
550
            guard let session = fetchOpenSessionObject(forMeterMACAddress: meterMACAddress) else {
Bogdan Timofte authored a month ago
551
                return
552
            }
553

            
554
            didSave = addBatteryCheckpoint(percent: percent, label: label, to: session)
555
        }
556
        return didSave
557
    }
558

            
559
    @discardableResult
560
    func addBatteryCheckpoint(
561
        percent: Double,
562
        label: String?,
563
        for sessionID: UUID
564
    ) -> Bool {
565
        guard percent.isFinite, percent >= 0, percent <= 100 else {
566
            return false
567
        }
568

            
569
        var didSave = false
570
        context.performAndWait {
571
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
572
                return
573
            }
574

            
575
            didSave = addBatteryCheckpoint(percent: percent, label: label, to: session)
576
        }
577
        return didSave
578
    }
579

            
580
    @discardableResult
581
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
582
        if let percent, (!percent.isFinite || percent <= 0 || percent > 100) {
583
            return false
584
        }
585

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

            
592
            session.setValue(percent, forKey: "targetBatteryPercent")
593
            session.setValue(nil, forKey: "targetBatteryAlertTriggeredAt")
594
            session.setValue(Date(), forKey: "updatedAt")
595
            didSave = saveContext()
596
        }
597
        return didSave
598
    }
599

            
600
    @discardableResult
601
    func confirmCompletion(for sessionID: UUID) -> Bool {
602
        var didSave = false
603
        context.performAndWait {
604
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
605
                return
606
            }
607

            
608
            guard statusValue(session, key: "statusRawValue") == .active else {
609
                return
610
            }
611

            
Bogdan Timofte authored a month ago
612
            finishSession(
613
                session,
614
                observedAt: dateValue(session, key: "lastObservedAt") ?? Date(),
615
                finalBatteryPercent: nil,
616
                label: nil,
617
                status: .completed
618
            )
Bogdan Timofte authored a month ago
619

            
620
            if saveContext() {
621
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
622
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
623
                    didSave = saveContext()
624
                } else {
625
                    didSave = true
626
                }
627
            }
628
        }
629
        return didSave
630
    }
631

            
632
    @discardableResult
633
    func continueMonitoringDespiteCompletionContradiction(for sessionID: UUID) -> Bool {
634
        var didSave = false
635
        context.performAndWait {
636
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
637
                return
638
            }
639

            
640
            guard statusValue(session, key: "statusRawValue") == .active else {
641
                return
642
            }
643

            
644
            clearCompletionConfirmationState(for: session)
645
            session.setValue(Date().addingTimeInterval(completionConfirmationCooldown), forKey: "completionConfirmationCooldownUntil")
646
            session.setValue(Date(), forKey: "updatedAt")
647
            didSave = saveContext()
648
        }
649
        return didSave
650
    }
651

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

            
660
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
661

            
662
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
663
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
664
            context.delete(session)
665

            
666
            guard saveContext() else {
667
                return
668
            }
669

            
670
            if let chargedDeviceID {
671
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
672
                didSave = saveContext()
673
            } else {
674
                didSave = true
675
            }
676
        }
677
        return didSave
678
    }
679

            
680
    @discardableResult
681
    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
682
        var didSave = false
683

            
684
        context.performAndWait {
685
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
686
                return
687
            }
688

            
689
            let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
690
            let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
691
            let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
692

            
693
            var impactedChargedDeviceIDs = Set<String>()
694

            
695
            for session in deviceSessions {
696
                if let impactedID = stringValue(session, key: "chargedDeviceID") {
697
                    impactedChargedDeviceIDs.insert(impactedID)
698
                }
699
                if let impactedChargerID = stringValue(session, key: "chargerID") {
700
                    impactedChargedDeviceIDs.insert(impactedChargerID)
701
                }
702
                if let sessionID = stringValue(session, key: "id") {
703
                    fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete)
704
                    fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete)
705
                }
706
                context.delete(session)
707
            }
708

            
709
            if deviceClass == .charger {
710
                for session in linkedWirelessSessions {
711
                    guard stringValue(session, key: "chargedDeviceID") != chargedDeviceID.uuidString else {
712
                        continue
713
                    }
714
                    if let impactedID = stringValue(session, key: "chargedDeviceID") {
715
                        impactedChargedDeviceIDs.insert(impactedID)
716
                    }
717
                    session.setValue(nil, forKey: "chargerID")
718
                    session.setValue(Date(), forKey: "updatedAt")
719
                }
720
            }
721

            
722
            context.delete(chargedDevice)
723

            
724
            guard saveContext() else {
725
                return
726
            }
727

            
728
            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
729
            for impactedID in impactedChargedDeviceIDs {
730
                refreshDerivedMetrics(forChargedDeviceID: impactedID)
731
            }
732
            didSave = saveContext()
733
        }
734

            
735
        return didSave
736
    }
737

            
738
    @discardableResult
739
    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
740
        var didSave = false
741

            
742
        context.performAndWait {
Bogdan Timofte authored a month ago
743
            guard let session = fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress),
744
                  let resolvedDevice = stringValue(session, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:)) else {
745
                return
746
            }
Bogdan Timofte authored a month ago
747

            
Bogdan Timofte authored a month ago
748
            if statusValue(session, key: "statusRawValue") == .paused {
749
                if maybeCompletePausedSession(session, observedAt: snapshot.observedAt) {
750
                    didSave = true
751
                }
Bogdan Timofte authored a month ago
752
                return
753
            }
754

            
Bogdan Timofte authored a month ago
755
            let chargingTransportMode = self.chargingTransportMode(for: session)
756
            let chargingStateMode = self.chargingStateMode(for: session)
Bogdan Timofte authored a month ago
757
            let charger = chargingTransportMode == .wireless
Bogdan Timofte authored a month ago
758
                ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
759
                : nil
760
            guard chargingTransportMode == .wired || charger != nil else {
761
                return
762
            }
763
            let stopThreshold = resolvedStopThreshold(
764
                for: resolvedDevice,
765
                chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
766
                chargingStateMode: chargingStateMode,
767
                charger: charger,
768
                fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
Bogdan Timofte authored a month ago
769
            )
770

            
Bogdan Timofte authored a month ago
771
            update(session: session, with: snapshot, stopThreshold: stopThreshold, charger: charger)
Bogdan Timofte authored a month ago
772
            updateAggregatedSample(session: session, with: snapshot)
773

            
774
            let saveReason = saveReason(for: session, observedAt: snapshot.observedAt)
775
            guard saveReason != .none else {
776
                return
777
            }
778

            
779
            session.setValue(snapshot.observedAt, forKey: "updatedAt")
780

            
781
            if saveContext() {
782
                if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
783
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
784
                    didSave = saveContext()
785
                } else {
786
                    didSave = true
787
                }
788
            }
789
        }
790

            
791
        return didSave
792
    }
793

            
794
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
795
        var summaries: [ChargedDeviceSummary] = []
796

            
797
        context.performAndWait {
798
            let devices = fetchObjects(entityName: EntityName.chargedDevice)
799
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
800
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
801
            let sessionSamples = fetchObjects(entityName: EntityName.chargeSessionSample)
802

            
803
            let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
804
            let samplesBySessionID = Dictionary(grouping: sessionSamples) { stringValue($0, key: "sessionID") ?? "" }
805
            let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
806
            let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
807

            
808
            summaries = devices.compactMap { device in
809
                guard
810
                    let id = uuidValue(device, key: "id"),
811
                    let name = stringValue(device, key: "name"),
812
                    let qrIdentifier = stringValue(device, key: "qrIdentifier"),
813
                    let rawClass = stringValue(device, key: "deviceClassRawValue"),
814
                    let deviceClass = ChargedDeviceClass(rawValue: rawClass)
815
                else {
816
                    return nil
817
                }
818

            
819
                let sessionObjects = relevantSessionObjects(
820
                    for: id.uuidString,
821
                    deviceClass: deviceClass,
822
                    sessionsByDeviceID: sessionsByDeviceID,
823
                    sessionsByChargerID: sessionsByChargerID
824
                )
825
                let sessionSummaries = sessionObjects
826
                    .compactMap { session in
827
                        makeSessionSummary(
828
                            from: session,
829
                            checkpoints: checkpointsBySessionID[stringValue(session, key: "id") ?? ""] ?? [],
830
                            samples: samplesBySessionID[stringValue(session, key: "id") ?? ""] ?? []
831
                        )
832
                    }
833
                    .sorted { lhs, rhs in
Bogdan Timofte authored a month ago
834
                        if lhs.status.isOpen && !rhs.status.isOpen {
Bogdan Timofte authored a month ago
835
                            return true
836
                        }
Bogdan Timofte authored a month ago
837
                        if !lhs.status.isOpen && rhs.status.isOpen {
838
                            return false
839
                        }
840
                        if lhs.status == .active && rhs.status == .paused {
841
                            return true
842
                        }
843
                        if lhs.status == .paused && rhs.status == .active {
Bogdan Timofte authored a month ago
844
                            return false
845
                        }
846
                        return lhs.startedAt > rhs.startedAt
847
                    }
848

            
849
                return ChargedDeviceSummary(
850
                    id: id,
851
                    qrIdentifier: qrIdentifier,
852
                    name: name,
853
                    deviceClass: deviceClass,
854
                    supportsChargingWhileOff: boolValue(device, key: "supportsChargingWhileOff"),
Bogdan Timofte authored a month ago
855
                    chargingStateAvailability: chargingStateAvailability(for: device),
Bogdan Timofte authored a month ago
856
                    supportsWiredCharging: supportsWiredCharging(for: device),
857
                    supportsWirelessCharging: supportsWirelessCharging(for: device),
858
                    preferredChargingTransportMode: preferredChargingTransportMode(for: device),
859
                    wirelessChargingProfile: wirelessChargingProfile(for: device),
Bogdan Timofte authored a month ago
860
                    configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"),
861
                    learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"),
Bogdan Timofte authored a month ago
862
                    wirelessChargerEfficiencyFactor: optionalDoubleValue(device, key: "wirelessChargerEfficiencyFactor"),
863
                    wiredChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wiredChargeCompletionCurrentAmps"),
864
                    wirelessChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wirelessChargeCompletionCurrentAmps"),
865
                    chargerObservedVoltageSelections: decodedObservedVoltageSelections(from: device),
866
                    chargerIdleCurrentAmps: optionalDoubleValue(device, key: "chargerIdleCurrentAmps"),
867
                    chargerEfficiencyFactor: optionalDoubleValue(device, key: "chargerEfficiencyFactor"),
868
                    chargerMaximumPowerWatts: optionalDoubleValue(device, key: "chargerMaximumPowerWatts"),
869
                    notes: stringValue(device, key: "notes"),
870
                    minimumCurrentAmps: optionalDoubleValue(device, key: "minimumCurrentAmps"),
871
                    estimatedBatteryCapacityWh: optionalDoubleValue(device, key: "estimatedBatteryCapacityWh"),
872
                    wiredMinimumCurrentAmps: optionalDoubleValue(device, key: "wiredMinimumCurrentAmps"),
873
                    wirelessMinimumCurrentAmps: optionalDoubleValue(device, key: "wirelessMinimumCurrentAmps"),
874
                    wiredEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wiredEstimatedBatteryCapacityWh"),
875
                    wirelessEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wirelessEstimatedBatteryCapacityWh"),
876
                    lastAssociatedMeterMAC: stringValue(device, key: "lastAssociatedMeterMAC"),
877
                    createdAt: dateValue(device, key: "createdAt") ?? .distantPast,
878
                    updatedAt: dateValue(device, key: "updatedAt") ?? .distantPast,
879
                    sessions: sessionSummaries,
880
                    capacityHistory: buildCapacityHistory(from: sessionSummaries),
881
                    typicalCurve: buildTypicalCurve(from: sessionSummaries)
882
                )
883
            }
884
            .sorted { lhs, rhs in
885
                if lhs.activeSession != nil && rhs.activeSession == nil {
886
                    return true
887
                }
888
                if lhs.activeSession == nil && rhs.activeSession != nil {
889
                    return false
890
                }
891
                if lhs.updatedAt != rhs.updatedAt {
892
                    return lhs.updatedAt > rhs.updatedAt
893
                }
894
                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
895
            }
896
        }
897

            
898
        return summaries
899
    }
900

            
901
    func resolvedChargedDeviceSummary(forMeterMACAddress meterMACAddress: String) -> ChargedDeviceSummary? {
902
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
903
        guard !normalizedMAC.isEmpty else { return nil }
904

            
905
        let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
906

            
907
        if let activeMatch = summaries.first(where: { summary in
908
            summary.activeSession?.meterMACAddress == normalizedMAC
909
        }) {
910
            return activeMatch
911
        }
912

            
913
        return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
914
    }
915

            
916
    func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
917
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
918
        guard !normalizedMAC.isEmpty else { return nil }
919

            
920
        return fetchChargedDeviceSummaries()
921
            .flatMap(\.sessions)
922
            .first(where: {
Bogdan Timofte authored a month ago
923
                $0.status.isOpen && $0.meterMACAddress == normalizedMAC
Bogdan Timofte authored a month ago
924
            })
925
    }
926

            
927
    private func createSessionObject(
928
        for chargedDevice: NSManagedObject,
929
        charger: NSManagedObject?,
930
        snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
931
        stopThreshold: Double?,
932
        chargingTransportMode: ChargingTransportMode,
933
        chargingStateMode: ChargingStateMode,
934
        autoStopEnabled: Bool
Bogdan Timofte authored a month ago
935
    ) -> NSManagedObject? {
936
        guard
937
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
938
            let chargedDeviceID = stringValue(chargedDevice, key: "id")
939
        else {
940
            return nil
941
        }
942

            
943
        let session = NSManagedObject(entity: entity, insertInto: context)
944
        let now = snapshot.observedAt
945
        session.setValue(UUID().uuidString, forKey: "id")
946
        session.setValue(chargedDeviceID, forKey: "chargedDeviceID")
947
        session.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
948
        session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress")
949
        session.setValue(snapshot.meterName, forKey: "meterName")
950
        session.setValue(snapshot.meterModel, forKey: "meterModel")
951
        session.setValue(now, forKey: "startedAt")
952
        session.setValue(now, forKey: "lastObservedAt")
953
        session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
954
        session.setValue(ChargeSessionSourceMode.live.rawValue, forKey: "sourceModeRawValue")
955
        session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
956
        session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue")
957
        session.setValue(autoStopEnabled, forKey: "autoStopEnabled")
958
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
959
        session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
960
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
961
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
962
        session.setValue(
963
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
964
            forKey: "lastObservedVoltageVolts"
965
        )
Bogdan Timofte authored a month ago
966
        session.setValue(
967
            hasObservedChargeFlow(
968
                currentAmps: snapshot.currentAmps,
969
                chargingTransportMode: chargingTransportMode,
970
                charger: charger,
971
                stopThreshold: stopThreshold
972
            ),
973
            forKey: "hasObservedChargeFlow"
974
        )
Bogdan Timofte authored a month ago
975
        session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
976
        session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
977
        session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
978
        session.setValue(
979
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
980
            forKey: "maximumObservedVoltageVolts"
981
        )
982
        session.setValue(boolValue(chargedDevice, key: "supportsChargingWhileOff"), forKey: "supportsChargingWhileOff")
983
        if let selectedDataGroup = snapshot.selectedDataGroup {
984
            session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup")
985
        }
986
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
987
            session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
988
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
989
        }
990
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
991
            session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh")
992
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
993
        }
994
        session.setValue(now, forKey: "createdAt")
995
        session.setValue(now, forKey: "updatedAt")
996

            
997
        chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
998
        chargedDevice.setValue(chargingTransportMode.rawValue, forKey: "preferredChargingTransportRawValue")
999
        chargedDevice.setValue(now, forKey: "updatedAt")
1000
        return session
1001
    }
1002

            
1003
    private func update(
1004
        session: NSManagedObject,
1005
        with snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1006
        stopThreshold: Double?,
1007
        charger: NSManagedObject?
Bogdan Timofte authored a month ago
1008
    ) {
1009
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1010
        let lastObservedAt = dateValue(session, key: "lastObservedAt")
1011
        let previousPower = doubleValue(session, key: "lastObservedPowerWatts")
1012
        let previousCurrent = doubleValue(session, key: "lastObservedCurrentAmps")
1013
        var measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1014
        var measuredChargeAh = doubleValue(session, key: "measuredChargeAh")
1015
        var sourceMode = ChargeSessionSourceMode(rawValue: stringValue(session, key: "sourceModeRawValue") ?? "") ?? .live
1016
        var usedOfflineMeterCounters = boolValue(session, key: "usedOfflineMeterCounters")
1017

            
1018
        if let lastObservedAt {
1019
            let deltaSeconds = max(snapshot.observedAt.timeIntervalSince(lastObservedAt), 0)
1020
            if deltaSeconds > 0, deltaSeconds <= maximumLiveIntegrationGap {
1021
                measuredEnergyWh += max(previousPower, 0) * deltaSeconds / 3600
1022
                measuredChargeAh += max(previousCurrent, 0) * deltaSeconds / 3600
1023
                if sourceMode == .offline {
1024
                    sourceMode = .blended
1025
                }
1026
            }
1027
        }
1028

            
1029
        if let counterGroup = snapshot.selectedDataGroup,
1030
           let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
1031
           UInt8(storedGroup) != counterGroup {
1032
            session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
1033
            session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1034
            session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1035
        }
1036

            
1037
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1038
            let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
1039
            let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
1040
            if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
1041
                session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
1042
            }
1043

            
1044
            if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
1045
                let offlineEnergy = meterEnergyCounterWh - baselineEnergy
1046
                if offlineEnergy > measuredEnergyWh {
1047
                    measuredEnergyWh = offlineEnergy
1048
                }
1049
                usedOfflineMeterCounters = true
1050
                sourceMode = sourceMode == .live && measuredEnergyWh > 0 ? .blended : .offline
1051
            } else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
1052
                let delta = meterEnergyCounterWh - lastEnergy
1053
                if delta > 0 {
1054
                    measuredEnergyWh += delta
1055
                    usedOfflineMeterCounters = true
1056
                    sourceMode = .blended
1057
                }
1058
            }
1059
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1060
        }
1061

            
1062
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1063
            let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
1064
            let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
1065
            if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
1066
                session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
1067
            }
1068

            
1069
            if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
1070
                let offlineCharge = meterChargeCounterAh - baselineCharge
1071
                if offlineCharge > measuredChargeAh {
1072
                    measuredChargeAh = offlineCharge
1073
                }
1074
                usedOfflineMeterCounters = true
1075
            } else if let lastCharge, meterChargeCounterAh > lastCharge {
1076
                let delta = meterChargeCounterAh - lastCharge
1077
                if delta > 0 {
1078
                    measuredChargeAh += delta
1079
                    usedOfflineMeterCounters = true
1080
                }
1081
            }
1082
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1083
        }
1084

            
1085
        let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps")
1086
        let updatedMinimum: Double
1087
        if snapshot.currentAmps > 0 {
1088
            updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps)
1089
        } else {
1090
            updatedMinimum = existingMinimum ?? 0
1091
        }
1092

            
Bogdan Timofte authored a month ago
1093
        let effectiveCurrent = effectiveCurrentAmps(
1094
            fromMeasuredCurrent: snapshot.currentAmps,
1095
            chargingTransportMode: sessionChargingTransportMode,
1096
            charger: charger
1097
        )
1098
        let observedChargeFlow = boolValue(session, key: "hasObservedChargeFlow")
1099
            || hasObservedChargeFlow(
1100
                currentAmps: snapshot.currentAmps,
1101
                chargingTransportMode: sessionChargingTransportMode,
1102
                charger: charger,
1103
                stopThreshold: stopThreshold
1104
            )
1105

            
Bogdan Timofte authored a month ago
1106
        session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh")
1107
        session.setValue(measuredChargeAh, forKey: "measuredChargeAh")
1108
        session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps")
Bogdan Timofte authored a month ago
1109
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1110
        session.setValue(snapshot.observedAt, forKey: "lastObservedAt")
1111
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1112
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1113
        session.setValue(
1114
            sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1115
            forKey: "lastObservedVoltageVolts"
1116
        )
Bogdan Timofte authored a month ago
1117
        session.setValue(observedChargeFlow, forKey: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
1118
        session.setValue(
1119
            max(optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? snapshot.currentAmps, snapshot.currentAmps),
1120
            forKey: "maximumObservedCurrentAmps"
1121
        )
1122
        session.setValue(
1123
            max(optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? snapshot.powerWatts, snapshot.powerWatts),
1124
            forKey: "maximumObservedPowerWatts"
1125
        )
1126
        session.setValue(
1127
            sessionChargingTransportMode == .wired
1128
                ? max(optionalDoubleValue(session, key: "maximumObservedVoltageVolts") ?? snapshot.voltageVolts, snapshot.voltageVolts)
1129
                : nil,
1130
            forKey: "maximumObservedVoltageVolts"
1131
        )
1132
        session.setValue(usedOfflineMeterCounters, forKey: "usedOfflineMeterCounters")
1133
        session.setValue(sourceMode.rawValue, forKey: "sourceModeRawValue")
1134
        maybeTriggerTargetBatteryAlert(for: session, observedAt: snapshot.observedAt)
1135

            
Bogdan Timofte authored a month ago
1136
        guard boolValue(session, key: "autoStopEnabled"), let stopThreshold, stopThreshold > 0, observedChargeFlow else {
1137
            session.setValue(nil, forKey: "belowThresholdSince")
1138
            clearCompletionConfirmationState(for: session)
1139
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1140
            return
1141
        }
1142

            
1143
        if effectiveCurrent <= stopThreshold {
Bogdan Timofte authored a month ago
1144
            let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt
1145
            session.setValue(belowThresholdSince, forKey: "belowThresholdSince")
1146
            if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
1147
                if boolValue(session, key: "requiresCompletionConfirmation") {
1148
                    // Leave the session active until the user explicitly confirms or charging resumes.
1149
                    return
1150
                }
1151

            
1152
                if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
1153
                    requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt)
1154
                } else {
Bogdan Timofte authored a month ago
1155
                    finishSession(
1156
                        session,
1157
                        observedAt: snapshot.observedAt,
1158
                        finalBatteryPercent: nil,
1159
                        label: nil,
1160
                        status: .completed
1161
                    )
Bogdan Timofte authored a month ago
1162
                }
1163
            }
1164
        } else {
1165
            session.setValue(nil, forKey: "belowThresholdSince")
1166
            clearCompletionConfirmationState(for: session)
1167
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1168
        }
1169
    }
1170

            
1171
    private func updateAggregatedSample(
1172
        session: NSManagedObject,
1173
        with snapshot: ChargingMonitorSnapshot
1174
    ) {
1175
        guard
1176
            let sessionID = stringValue(session, key: "id"),
1177
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1178
            let startedAt = dateValue(session, key: "startedAt"),
1179
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context)
1180
        else {
1181
            return
1182
        }
1183

            
1184
        let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0)
1185
        let bucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
1186
        let bucketStart = startedAt.addingTimeInterval(Double(bucketIndex) * Self.aggregatedSampleBucketDuration)
1187
        let bucketIdentifier = "\(sessionID)-\(bucketIndex)"
1188
        let sample = fetchSessionSampleObject(sessionID: sessionID, bucketIndex: bucketIndex)
1189
            ?? NSManagedObject(entity: entity, insertInto: context)
1190
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1191
        let sampleVoltage = sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil
1192

            
1193
        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
1194
        let updatedCount = existingCount + 1
1195

            
1196
        sample.setValue(bucketIdentifier, forKey: "id")
1197
        sample.setValue(sessionID, forKey: "sessionID")
1198
        sample.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1199
        sample.setValue(bucketIndex, forKey: "bucketIndex")
1200
        sample.setValue(max(snapshot.observedAt, bucketStart), forKey: "timestamp")
1201
        sample.setValue(
1202
            runningAverage(
1203
                currentAverage: optionalDoubleValue(sample, key: "averageCurrentAmps") ?? snapshot.currentAmps,
1204
                currentCount: Int(existingCount),
1205
                newValue: snapshot.currentAmps
1206
            ),
1207
            forKey: "averageCurrentAmps"
1208
        )
1209
        sample.setValue(
1210
            sampleVoltage.flatMap { voltage in
1211
                runningAverage(
1212
                    currentAverage: optionalDoubleValue(sample, key: "averageVoltageVolts") ?? voltage,
1213
                    currentCount: Int(existingCount),
1214
                    newValue: voltage
1215
                )
1216
            },
1217
            forKey: "averageVoltageVolts"
1218
        )
1219
        sample.setValue(
1220
            runningAverage(
1221
                currentAverage: optionalDoubleValue(sample, key: "averagePowerWatts") ?? snapshot.powerWatts,
1222
                currentCount: Int(existingCount),
1223
                newValue: snapshot.powerWatts
1224
            ),
1225
            forKey: "averagePowerWatts"
1226
        )
1227
        sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh")
1228
        sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
1229
        sample.setValue(Int16(updatedCount), forKey: "sampleCount")
1230
        sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
1231
        sample.setValue(snapshot.observedAt, forKey: "updatedAt")
1232
    }
1233

            
1234
    private func maybeTriggerTargetBatteryAlert(for session: NSManagedObject, observedAt: Date) {
1235
        guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
1236
            return
1237
        }
1238

            
1239
        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
1240
            return
1241
        }
1242

            
1243
        let predictedBatteryPercent = predictedBatteryPercent(for: session)
1244
            ?? optionalDoubleValue(session, key: "endBatteryPercent")
1245

            
1246
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
1247
            return
1248
        }
1249

            
1250
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
1251
    }
1252

            
1253
    private func shouldRequireCompletionConfirmation(
1254
        for session: NSManagedObject,
1255
        observedAt: Date
1256
    ) -> Bool {
1257
        if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"),
1258
           cooldownUntil > observedAt {
1259
            return false
1260
        }
1261

            
1262
        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
1263
            return false
1264
        }
1265

            
1266
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
1267
            ?? defaultCompletionPercentThreshold
1268

            
1269
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1270
    }
1271

            
1272
    private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
1273
        guard !boolValue(session, key: "requiresCompletionConfirmation") else {
1274
            return
1275
        }
1276

            
1277
        session.setValue(true, forKey: "requiresCompletionConfirmation")
1278
        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
1279
        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
1280
    }
1281

            
1282
    private func clearCompletionConfirmationState(for session: NSManagedObject) {
1283
        session.setValue(false, forKey: "requiresCompletionConfirmation")
1284
        session.setValue(nil, forKey: "completionConfirmationRequestedAt")
1285
        session.setValue(nil, forKey: "completionContradictionPercent")
1286
    }
1287

            
Bogdan Timofte authored a month ago
1288
    private func snapshotDateForManualStop(_ session: NSManagedObject) -> Date {
1289
        if statusValue(session, key: "statusRawValue") == .paused {
1290
            return dateValue(session, key: "pausedAt")
1291
                ?? dateValue(session, key: "lastObservedAt")
1292
                ?? Date()
1293
        }
1294
        return dateValue(session, key: "lastObservedAt") ?? Date()
1295
    }
1296

            
1297
    @discardableResult
1298
    private func maybeCompletePausedSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
1299
        guard statusValue(session, key: "statusRawValue") == .paused else {
1300
            return false
1301
        }
1302

            
1303
        guard let pausedAt = dateValue(session, key: "pausedAt"),
1304
              observedAt.timeIntervalSince(pausedAt) >= pausedSessionTimeout else {
1305
            return false
1306
        }
1307

            
1308
        finishSession(
1309
            session,
1310
            observedAt: pausedAt.addingTimeInterval(pausedSessionTimeout),
1311
            finalBatteryPercent: nil,
1312
            label: nil,
1313
            status: .completed
1314
        )
1315

            
1316
        guard saveContext() else {
1317
            return false
1318
        }
1319

            
1320
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1321
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1322
            return saveContext()
1323
        }
1324

            
1325
        return true
1326
    }
1327

            
1328
    private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
1329
        let chargingTransportMode = chargingTransportMode(for: session)
1330
        let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps")
1331
            ?? doubleValue(session, key: "lastObservedCurrentAmps")
1332

            
1333
        guard measuredCurrent > 0 else {
1334
            return nil
1335
        }
1336

            
1337
        let charger = chargingTransportMode == .wireless
1338
            ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
1339
            : nil
1340

            
1341
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
1342
            return nil
1343
        }
1344

            
1345
        let effectiveCurrent = effectiveCurrentAmps(
1346
            fromMeasuredCurrent: measuredCurrent,
1347
            chargingTransportMode: chargingTransportMode,
1348
            charger: charger
1349
        )
1350
        guard effectiveCurrent > 0 else {
1351
            return nil
1352
        }
1353
        return effectiveCurrent
1354
    }
1355

            
1356
    private func finishSession(
1357
        _ session: NSManagedObject,
1358
        observedAt: Date,
1359
        finalBatteryPercent: Double?,
1360
        label: String?,
1361
        status: ChargeSessionStatus
1362
    ) {
1363
        if let finalBatteryPercent {
1364
            _ = insertBatteryCheckpoint(
1365
                percent: finalBatteryPercent,
1366
                label: label,
1367
                timestamp: observedAt,
1368
                to: session
1369
            )
1370
        }
1371

            
1372
        session.setValue(status.rawValue, forKey: "statusRawValue")
1373
        session.setValue(nil, forKey: "pausedAt")
1374
        session.setValue(nil, forKey: "belowThresholdSince")
1375
        session.setValue(observedAt, forKey: "endedAt")
1376
        session.setValue(observedAt, forKey: "lastObservedAt")
1377
        session.setValue(completionCurrentForSessionEnd(session), forKey: "completionCurrentAmps")
1378
        clearCompletionConfirmationState(for: session)
1379
        session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1380
        updateCapacityEstimate(for: session)
1381
        session.setValue(observedAt, forKey: "updatedAt")
1382
    }
1383

            
Bogdan Timofte authored a month ago
1384
    private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
1385
        guard
1386
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1387
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
1388
            let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice),
1389
            estimatedCapacityWh > 0
1390
        else {
1391
            return nil
1392
        }
1393

            
1394
        let measuredEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1395
            ?? doubleValue(session, key: "measuredEnergyWh")
1396
        let sessionID = stringValue(session, key: "id") ?? ""
1397

            
1398
        struct Anchor {
1399
            let percent: Double
1400
            let energyWh: Double
1401
        }
1402

            
1403
        var anchors: [Anchor] = []
1404
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent") {
1405
            anchors.append(Anchor(percent: startBatteryPercent, energyWh: 0))
1406
        }
1407

            
1408
        let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
1409
            .compactMap(makeCheckpointSummary(from:))
1410
            .sorted { lhs, rhs in
1411
                if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1412
                    return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1413
                }
1414
                return lhs.timestamp < rhs.timestamp
1415
            }
1416
            .map { Anchor(percent: $0.batteryPercent, energyWh: $0.measuredEnergyWh) }
1417
        anchors.append(contentsOf: checkpointAnchors)
1418

            
1419
        guard !anchors.isEmpty else {
1420
            return optionalDoubleValue(session, key: "endBatteryPercent")
1421
        }
1422

            
1423
        let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
1424
        return min(
1425
            100,
1426
            max(
1427
                0,
1428
                anchor.percent + (((measuredEnergyWh - anchor.energyWh) / estimatedCapacityWh) * 100)
1429
            )
1430
        )
1431
    }
1432

            
1433
    private func resolvedEstimatedBatteryCapacityWh(
1434
        for session: NSManagedObject,
1435
        chargedDevice: NSManagedObject
1436
    ) -> Double? {
1437
        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
1438
           sessionCapacityEstimate > 0 {
1439
            return sessionCapacityEstimate
1440
        }
1441

            
1442
        switch chargingTransportMode(for: session) {
1443
        case .wired:
1444
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1445
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1446
        case .wireless:
1447
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1448
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1449
        }
1450
    }
1451

            
1452
    private func updateCapacityEstimate(for session: NSManagedObject) {
1453
        guard
1454
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1455
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
1456
        else {
1457
            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
1458
            session.setValue(nil, forKey: "capacityEstimateWh")
1459
            return
1460
        }
1461

            
1462
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1463
        let chargingMode = chargingTransportMode(for: session)
1464
        let wirelessResolution = chargingMode == .wireless
1465
            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
1466
            : nil
1467
        let effectiveBatteryEnergyWh = chargingMode == .wired
1468
            ? measuredEnergyWh
1469
            : wirelessResolution.map { measuredEnergyWh * $0.factor }
1470

            
1471
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
1472
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
1473
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
1474
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
1475

            
1476
        guard
1477
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1478
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1479
        else {
1480
            session.setValue(nil, forKey: "capacityEstimateWh")
1481
            return
1482
        }
1483

            
1484
        let percentDelta = endBatteryPercent - startBatteryPercent
1485
        let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
1486

            
1487
        guard percentDelta >= 20, let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
1488
            session.setValue(nil, forKey: "capacityEstimateWh")
1489
            return
1490
        }
1491

            
1492
        if !supportsChargingWhileOff && endBatteryPercent >= 99.5 {
1493
            session.setValue(nil, forKey: "capacityEstimateWh")
1494
            return
1495
        }
1496

            
1497
        let capacityEstimateWh = effectiveBatteryEnergyWh / (percentDelta / 100)
1498
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
1499
    }
1500

            
1501
    @discardableResult
Bogdan Timofte authored a month ago
1502
    private func insertBatteryCheckpoint(
Bogdan Timofte authored a month ago
1503
        percent: Double,
1504
        label: String?,
Bogdan Timofte authored a month ago
1505
        timestamp: Date = Date(),
Bogdan Timofte authored a month ago
1506
        to session: NSManagedObject
Bogdan Timofte authored a month ago
1507
    ) -> String? {
Bogdan Timofte authored a month ago
1508
        guard
1509
            let sessionID = stringValue(session, key: "id"),
1510
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1511
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
1512
        else {
Bogdan Timofte authored a month ago
1513
            return nil
Bogdan Timofte authored a month ago
1514
        }
1515

            
1516
        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
1517
        let checkpointEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1518
            ?? doubleValue(session, key: "measuredEnergyWh")
1519
        checkpoint.setValue(UUID().uuidString, forKey: "id")
1520
        checkpoint.setValue(sessionID, forKey: "sessionID")
1521
        checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
Bogdan Timofte authored a month ago
1522
        checkpoint.setValue(timestamp, forKey: "timestamp")
Bogdan Timofte authored a month ago
1523
        checkpoint.setValue(percent, forKey: "batteryPercent")
1524
        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
1525
        checkpoint.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
1526
        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
1527
        checkpoint.setValue(
1528
            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
1529
            forKey: "voltageVolts"
1530
        )
1531
        checkpoint.setValue(normalizedOptionalText(label), forKey: "label")
Bogdan Timofte authored a month ago
1532
        checkpoint.setValue(timestamp, forKey: "createdAt")
Bogdan Timofte authored a month ago
1533

            
1534
        if session.value(forKey: "startBatteryPercent") == nil {
1535
            session.setValue(percent, forKey: "startBatteryPercent")
1536
        }
1537
        session.setValue(percent, forKey: "endBatteryPercent")
Bogdan Timofte authored a month ago
1538
        session.setValue(timestamp, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1539
        updateCapacityEstimate(for: session)
1540

            
Bogdan Timofte authored a month ago
1541
        return chargedDeviceID
1542
    }
1543

            
1544
    @discardableResult
1545
    private func addBatteryCheckpoint(
1546
        percent: Double,
1547
        label: String?,
1548
        to session: NSManagedObject,
1549
        timestamp: Date = Date()
1550
    ) -> Bool {
1551
        guard let chargedDeviceID = insertBatteryCheckpoint(
1552
            percent: percent,
1553
            label: label,
1554
            timestamp: timestamp,
1555
            to: session
1556
        ) else {
1557
            return false
1558
        }
1559

            
Bogdan Timofte authored a month ago
1560
        guard saveContext() else {
1561
            return false
1562
        }
1563

            
1564
        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1565
        return saveContext()
1566
    }
1567

            
1568
    private func resolvedWirelessEfficiency(
1569
        for session: NSManagedObject,
1570
        chargedDevice: NSManagedObject
1571
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
1572
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
1573
           storedFactor > 0 {
1574
            return (
1575
                factor: storedFactor,
1576
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
1577
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
1578
            )
1579
        }
1580

            
1581
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
1582
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1583
        guard measuredEnergyWh > 0 else {
1584
            return nil
1585
        }
1586

            
1587
        if chargingProfile == .magsafe,
1588
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
1589
           calibratedFactor > 0 {
1590
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
1591
        }
1592

            
1593
        guard
1594
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1595
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1596
        else {
1597
            return nil
1598
        }
1599

            
1600
        let percentDelta = endBatteryPercent - startBatteryPercent
1601
        guard percentDelta >= 20 else {
1602
            return nil
1603
        }
1604

            
1605
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1606
            ?? ((preferredChargingTransportMode(for: chargedDevice) == .wired)
1607
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1608
                : nil),
1609
              wiredCapacityWh > 0
1610
        else {
1611
            return nil
1612
        }
1613

            
1614
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
1615
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
1616
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
1617
        let usesEstimated = chargingProfile != .magsafe
1618
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
1619

            
1620
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
1621
    }
1622

            
1623
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
1624
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
1625
            return
1626
        }
1627

            
1628
        let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
Bogdan Timofte authored a month ago
1629
        let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
Bogdan Timofte authored a month ago
1630
        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
1631
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
1632
        let sessions = relevantSessionObjects(
1633
            for: chargedDeviceID,
1634
            deviceClass: deviceClass,
1635
            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
1636
            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
1637
        )
Bogdan Timofte authored a month ago
1638
        let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
Bogdan Timofte authored a month ago
1639
        let wiredMinimumCurrent = derivedMinimumCurrent(
1640
            from: sessions,
1641
            chargingTransportMode: .wired
1642
        )
1643
        let wirelessMinimumCurrent = derivedMinimumCurrent(
1644
            from: sessions,
1645
            chargingTransportMode: .wireless
1646
        )
1647

            
1648
        let wiredCapacity = derivedCapacity(
1649
            from: sessions,
1650
            chargingTransportMode: .wired,
1651
            supportsChargingWhileOff: supportsChargingWhileOff
1652
        )
1653
        let wirelessCapacity = derivedCapacity(
1654
            from: sessions,
1655
            chargingTransportMode: .wireless,
1656
            supportsChargingWhileOff: supportsChargingWhileOff
1657
        )
1658
        let wirelessEfficiency = derivedWirelessEfficiency(
1659
            from: sessions,
1660
            chargingProfile: wirelessProfile
1661
        )
Bogdan Timofte authored a month ago
1662
        let configuredCompletionCurrents = decodedCompletionCurrents(
1663
            from: chargedDevice,
1664
            key: "configuredCompletionCurrentsRawValue"
1665
        )
Bogdan Timofte authored a month ago
1666
        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
1667
        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
1668
        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
1669
        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
1670
        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
1671
        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
1672

            
1673
        let preferredChargingTransportMode = preferredChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
1674
        let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
Bogdan Timofte authored a month ago
1675
        let preferredMinimumCurrent: Double?
1676
        let preferredCapacity: Double?
1677
        switch preferredChargingTransportMode {
1678
        case .wired:
Bogdan Timofte authored a month ago
1679
            preferredMinimumCurrent = configuredCompletionCurrents[
1680
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
1681
            ] ?? learnedCompletionCurrents[
1682
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
1683
            ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
Bogdan Timofte authored a month ago
1684
            preferredCapacity = wiredCapacity ?? wirelessCapacity
1685
        case .wireless:
Bogdan Timofte authored a month ago
1686
            preferredMinimumCurrent = configuredCompletionCurrents[
1687
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
1688
            ] ?? learnedCompletionCurrents[
1689
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
1690
            ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
Bogdan Timofte authored a month ago
1691
            preferredCapacity = wirelessCapacity ?? wiredCapacity
1692
        }
1693

            
1694
        chargedDevice.setValue(wiredMinimumCurrent, forKey: "wiredMinimumCurrentAmps")
1695
        chargedDevice.setValue(wirelessMinimumCurrent, forKey: "wirelessMinimumCurrentAmps")
Bogdan Timofte authored a month ago
1696
        chargedDevice.setValue(encodedCompletionCurrents(learnedCompletionCurrents), forKey: "learnedCompletionCurrentsRawValue")
Bogdan Timofte authored a month ago
1697
        chargedDevice.setValue(wiredCapacity, forKey: "wiredEstimatedBatteryCapacityWh")
1698
        chargedDevice.setValue(wirelessCapacity, forKey: "wirelessEstimatedBatteryCapacityWh")
1699
        chargedDevice.setValue(wirelessEfficiency, forKey: "wirelessChargerEfficiencyFactor")
1700
        chargedDevice.setValue(encodedObservedVoltageSelections(chargerObservedVoltages), forKey: "chargerObservedVoltageSelectionsRawValue")
1701
        chargedDevice.setValue(chargerIdleCurrent, forKey: "chargerIdleCurrentAmps")
1702
        chargedDevice.setValue(chargerEfficiency, forKey: "chargerEfficiencyFactor")
1703
        chargedDevice.setValue(chargerMaximumPower, forKey: "chargerMaximumPowerWatts")
1704
        chargedDevice.setValue(preferredMinimumCurrent, forKey: "minimumCurrentAmps")
1705
        chargedDevice.setValue(preferredCapacity, forKey: "estimatedBatteryCapacityWh")
1706
        chargedDevice.setValue(Date(), forKey: "updatedAt")
1707
    }
1708

            
1709
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
1710
        sessions
1711
            .filter { $0.status == .completed }
1712
            .compactMap { session in
1713
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
1714
                let timestamp = session.endedAt ?? session.lastObservedAt
1715
                return CapacityTrendPoint(
1716
                    sessionID: session.id,
1717
                    timestamp: timestamp,
1718
                    capacityWh: capacityEstimateWh,
1719
                    chargingTransportMode: session.chargingTransportMode
1720
                )
1721
            }
1722
            .sorted { $0.timestamp < $1.timestamp }
1723
    }
1724

            
1725
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
1726
        var groupedEnergyByBin: [Int: [Double]] = [:]
1727
        var groupedChargeByBin: [Int: [Double]] = [:]
1728

            
1729
        for session in sessions where session.status == .completed {
1730
            var points = session.checkpoints
1731

            
1732
            if let startBatteryPercent = session.startBatteryPercent {
1733
                points.append(
1734
                    ChargeCheckpointSummary(
1735
                        id: UUID(),
1736
                        sessionID: session.id,
1737
                        chargedDeviceID: session.chargedDeviceID,
1738
                        timestamp: session.startedAt,
1739
                        batteryPercent: startBatteryPercent,
1740
                        measuredEnergyWh: 0,
1741
                        measuredChargeAh: 0,
1742
                        currentAmps: 0,
1743
                        voltageVolts: nil,
1744
                        label: "Start"
1745
                    )
1746
                )
1747
            }
1748

            
1749
            if let endBatteryPercent = session.endBatteryPercent {
1750
                points.append(
1751
                    ChargeCheckpointSummary(
1752
                        id: UUID(),
1753
                        sessionID: session.id,
1754
                        chargedDeviceID: session.chargedDeviceID,
1755
                        timestamp: session.endedAt ?? session.lastObservedAt,
1756
                        batteryPercent: endBatteryPercent,
1757
                        measuredEnergyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
1758
                        measuredChargeAh: session.measuredChargeAh,
1759
                        currentAmps: 0,
1760
                        voltageVolts: nil,
1761
                        label: "End"
1762
                    )
1763
                )
1764
            }
1765

            
1766
            for point in points {
1767
                let percentBin = Int((point.batteryPercent / 10).rounded(.toNearestOrEven)) * 10
1768
                groupedEnergyByBin[percentBin, default: []].append(point.measuredEnergyWh)
1769
                groupedChargeByBin[percentBin, default: []].append(point.measuredChargeAh)
1770
            }
1771
        }
1772

            
1773
        return groupedEnergyByBin.keys.sorted().compactMap { percentBin in
1774
            guard
1775
                let energies = groupedEnergyByBin[percentBin],
1776
                let charges = groupedChargeByBin[percentBin],
1777
                !energies.isEmpty,
1778
                !charges.isEmpty
1779
            else {
1780
                return nil
1781
            }
1782

            
1783
            return TypicalChargeCurvePoint(
1784
                percentBin: percentBin,
1785
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
1786
                averageChargeAh: charges.reduce(0, +) / Double(charges.count),
1787
                sampleCount: min(energies.count, charges.count)
1788
            )
1789
        }
1790
    }
1791

            
1792
    private func makeSessionSummary(
1793
        from object: NSManagedObject,
1794
        checkpoints: [NSManagedObject],
1795
        samples: [NSManagedObject]
1796
    ) -> ChargeSessionSummary? {
1797
        let chargingTransportMode = chargingTransportMode(for: object)
1798

            
1799
        guard
1800
            let id = uuidValue(object, key: "id"),
1801
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
1802
            let startedAt = dateValue(object, key: "startedAt"),
1803
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
1804
            let status = statusValue(object, key: "statusRawValue"),
1805
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
1806
        else {
1807
            return nil
1808
        }
1809

            
1810
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
1811
            .sorted { $0.timestamp < $1.timestamp }
1812
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
1813
            .sorted { lhs, rhs in
1814
                if lhs.bucketIndex != rhs.bucketIndex {
1815
                    return lhs.bucketIndex < rhs.bucketIndex
1816
                }
1817
                return lhs.timestamp < rhs.timestamp
1818
            }
1819

            
1820
        return ChargeSessionSummary(
1821
            id: id,
1822
            chargedDeviceID: chargedDeviceID,
1823
            chargerID: uuidValue(object, key: "chargerID"),
1824
            meterMACAddress: stringValue(object, key: "meterMACAddress"),
1825
            meterName: stringValue(object, key: "meterName"),
1826
            meterModel: stringValue(object, key: "meterModel"),
1827
            startedAt: startedAt,
1828
            endedAt: dateValue(object, key: "endedAt"),
1829
            lastObservedAt: lastObservedAt,
Bogdan Timofte authored a month ago
1830
            pausedAt: dateValue(object, key: "pausedAt"),
Bogdan Timofte authored a month ago
1831
            status: status,
1832
            sourceMode: sourceMode,
1833
            chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
1834
            chargingStateMode: chargingStateMode(for: object),
1835
            autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
Bogdan Timofte authored a month ago
1836
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
1837
            effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
1838
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
1839
            minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
1840
            maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
1841
            maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
1842
            maximumObservedVoltageVolts: chargingTransportMode == .wired
1843
                ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
1844
                : nil,
1845
            selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
1846
            completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
1847
            stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
1848
            startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
1849
            endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
1850
            capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
1851
            wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
1852
            usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
1853
            shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
1854
            supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
1855
            usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
1856
            targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
1857
            targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
1858
            requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
1859
            completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
1860
            completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
1861
            selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
1862
            checkpoints: checkpointSummaries,
1863
            aggregatedSamples: sampleSummaries
1864
        )
1865
    }
1866

            
1867
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
1868
        guard
1869
            let id = uuidValue(object, key: "id"),
1870
            let sessionID = uuidValue(object, key: "sessionID"),
1871
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
1872
            let timestamp = dateValue(object, key: "timestamp")
1873
        else {
1874
            return nil
1875
        }
1876

            
1877
        return ChargeCheckpointSummary(
1878
            id: id,
1879
            sessionID: sessionID,
1880
            chargedDeviceID: chargedDeviceID,
1881
            timestamp: timestamp,
1882
            batteryPercent: doubleValue(object, key: "batteryPercent"),
1883
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
1884
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
1885
            currentAmps: doubleValue(object, key: "currentAmps"),
1886
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
1887
            label: stringValue(object, key: "label")
1888
        )
1889
    }
1890

            
1891
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
1892
        guard
1893
            let sessionID = uuidValue(object, key: "sessionID"),
1894
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
1895
            let timestamp = dateValue(object, key: "timestamp")
1896
        else {
1897
            return nil
1898
        }
1899

            
1900
        return ChargeSessionSampleSummary(
1901
            sessionID: sessionID,
1902
            chargedDeviceID: chargedDeviceID,
1903
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
1904
            timestamp: timestamp,
1905
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
1906
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
1907
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
1908
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
1909
            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
1910
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
1911
        )
1912
    }
1913

            
Bogdan Timofte authored a month ago
1914
    private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
1915
        fetchSessionObject(
1916
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
1917
                format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
Bogdan Timofte authored a month ago
1918
                normalizedMACAddress(meterMACAddress),
Bogdan Timofte authored a month ago
1919
                ChargeSessionStatus.active.rawValue,
1920
                ChargeSessionStatus.paused.rawValue
Bogdan Timofte authored a month ago
1921
            )
1922
        )
1923
    }
1924

            
Bogdan Timofte authored a month ago
1925
    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
1926
        fetchSessionObject(
1927
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
1928
                format: "meterMACAddress == %@ AND statusRawValue == %@",
1929
                normalizedMACAddress(meterMACAddress),
1930
                ChargeSessionStatus.active.rawValue
Bogdan Timofte authored a month ago
1931
            )
1932
        )
1933
    }
1934

            
1935
    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
1936
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
1937
        request.predicate = predicate
1938
        request.fetchLimit = 1
1939
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
1940
        return (try? context.fetch(request))?.first
1941
    }
1942

            
1943
    private func fetchSessionObject(id: String) -> NSManagedObject? {
1944
        fetchSessionObject(
1945
            predicate: NSPredicate(format: "id == %@", id)
1946
        )
1947
    }
1948

            
1949
    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
1950
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
1951
        request.predicate = NSPredicate(
1952
            format: "sessionID == %@ AND bucketIndex == %d",
1953
            sessionID,
1954
            bucketIndex
1955
        )
1956
        request.fetchLimit = 1
1957
        return (try? context.fetch(request))?.first
1958
    }
1959

            
1960
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
1961
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
1962
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
1963
        return (try? context.fetch(request)) ?? []
1964
    }
1965

            
1966
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
1967
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
1968
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
1969
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
1970
        return (try? context.fetch(request)) ?? []
1971
    }
1972

            
1973
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
1974
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
1975
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
1976
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
1977
        return (try? context.fetch(request)) ?? []
1978
    }
1979

            
1980
    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
1981
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
1982
        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
1983
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
1984
        return (try? context.fetch(request)) ?? []
1985
    }
1986

            
1987
    private func relevantSessionObjects(
1988
        for chargedDeviceID: String,
1989
        deviceClass: ChargedDeviceClass,
1990
        sessionsByDeviceID: [String: [NSManagedObject]],
1991
        sessionsByChargerID: [String: [NSManagedObject]]
1992
    ) -> [NSManagedObject] {
1993
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
1994
        guard deviceClass == .charger else {
1995
            return directSessions
1996
        }
1997

            
1998
        var seenSessionIDs = Set<String>()
1999
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
2000
            .filter { session in
2001
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
2002
                return seenSessionIDs.insert(sessionID).inserted
2003
            }
2004
            .sorted {
2005
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
2006
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
2007
                return lhsDate < rhsDate
2008
            }
2009
    }
2010

            
2011
    private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
2012
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false)
2013
    }
2014

            
2015
    private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
2016
        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true)
2017
    }
2018

            
2019
    private func resolvedAssignedObject(
2020
        for meterMACAddress: String,
2021
        expectsChargerClass: Bool
2022
    ) -> NSManagedObject? {
2023
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
2024
        guard !normalizedMAC.isEmpty else { return nil }
2025

            
2026
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2027
        request.predicate = NSPredicate(format: "lastAssociatedMeterMAC == %@", normalizedMAC)
2028
        request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
2029
        let matches = (try? context.fetch(request)) ?? []
2030
        return matches.first { object in
2031
            let isCharger = ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2032
            return isCharger == expectsChargerClass
2033
        }
2034
    }
2035

            
2036
    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
2037
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2038
        request.predicate = NSPredicate(format: "id == %@", id)
2039
        request.fetchLimit = 1
2040
        return (try? context.fetch(request))?.first
2041
    }
2042

            
2043
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
2044
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
2045
        return (try? context.fetch(request)) ?? []
2046
    }
2047

            
2048
    private func resolvedStopThreshold(
2049
        for chargedDevice: NSManagedObject,
2050
        chargingTransportMode: ChargingTransportMode,
Bogdan Timofte authored a month ago
2051
        chargingStateMode: ChargingStateMode,
2052
        charger: NSManagedObject?,
2053
        fallback: Double?
2054
    ) -> Double? {
2055
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
2056
            return nil
2057
        }
2058

            
2059
        let sessionKind = ChargeSessionKind(
2060
            chargingTransportMode: chargingTransportMode,
2061
            chargingStateMode: chargingStateMode
2062
        )
2063
        let configuredCurrents = decodedCompletionCurrents(
2064
            from: chargedDevice,
2065
            key: "configuredCompletionCurrentsRawValue"
2066
        )
2067
        let learnedCurrents = decodedCompletionCurrents(
2068
            from: chargedDevice,
2069
            key: "learnedCompletionCurrentsRawValue"
2070
        )
2071
        let legacyCurrent: Double?
Bogdan Timofte authored a month ago
2072
        switch chargingTransportMode {
2073
        case .wired:
Bogdan Timofte authored a month ago
2074
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2075
                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
2076
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2077
        case .wireless:
Bogdan Timofte authored a month ago
2078
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
2079
                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
2080
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
2081
        }
Bogdan Timofte authored a month ago
2082

            
2083
        let resolvedCurrent = configuredCurrents[sessionKind]
2084
            ?? learnedCurrents[sessionKind]
2085
            ?? legacyCurrent
2086
            ?? fallback
2087
        guard let resolvedCurrent, resolvedCurrent > 0 else {
2088
            return nil
2089
        }
2090
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
2091
    }
2092

            
2093
    private func preferredChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
2094
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
2095
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
2096
        let persistedMode = chargingTransportModeValue(chargedDevice, key: "preferredChargingTransportRawValue") ?? .wired
2097
        return resolvedPreferredChargingTransportMode(
2098
            persistedMode,
2099
            supportsWiredCharging: supportsWiredCharging,
2100
            supportsWirelessCharging: supportsWirelessCharging
2101
        )
2102
    }
2103

            
2104
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
2105
        if chargedDevice.value(forKey: "supportsWiredCharging") == nil {
2106
            return true
2107
        }
2108
        return boolValue(chargedDevice, key: "supportsWiredCharging")
2109
    }
2110

            
2111
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
2112
        if chargedDevice.value(forKey: "supportsWirelessCharging") == nil {
2113
            return false
2114
        }
2115
        return boolValue(chargedDevice, key: "supportsWirelessCharging")
2116
    }
2117

            
Bogdan Timofte authored a month ago
2118
    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
2119
        if let rawValue = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue"),
2120
           let availability = ChargingStateAvailability(rawValue: rawValue) {
2121
            return availability
2122
        }
2123
        return ChargingStateAvailability.fallback(
2124
            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
2125
        )
2126
    }
2127

            
2128
    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
2129
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
2130
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
2131
            return chargingStateMode
2132
        }
2133

            
2134
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2135
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2136
            return chargingStateAvailability(for: chargedDevice).supportedModes.first ?? .on
2137
        }
2138

            
2139
        return .on
2140
    }
2141

            
2142
    private func resolvedChargingStateMode(
2143
        _ chargingStateMode: ChargingStateMode,
2144
        availability: ChargingStateAvailability
2145
    ) -> ChargingStateMode {
2146
        if availability.supportedModes.contains(chargingStateMode) {
2147
            return chargingStateMode
2148
        }
2149
        return availability.supportedModes.first ?? .on
2150
    }
2151

            
Bogdan Timofte authored a month ago
2152
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
2153
        guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2154
              let profile = WirelessChargingProfile(rawValue: rawValue) else {
2155
            return .genericQi
2156
        }
2157
        return profile
2158
    }
2159

            
2160
    private func resolvedPreferredChargingTransportMode(
2161
        _ preferredChargingTransportMode: ChargingTransportMode,
2162
        supportsWiredCharging: Bool,
2163
        supportsWirelessCharging: Bool
2164
    ) -> ChargingTransportMode {
2165
        switch preferredChargingTransportMode {
2166
        case .wired where supportsWiredCharging:
2167
            return .wired
2168
        case .wireless where supportsWirelessCharging:
2169
            return .wireless
2170
        default:
2171
            if supportsWiredCharging {
2172
                return .wired
2173
            }
2174
            if supportsWirelessCharging {
2175
                return .wireless
2176
            }
2177
            return .wired
2178
        }
2179
    }
2180

            
Bogdan Timofte authored a month ago
2181
    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
2182
        let payload = Dictionary(
2183
            uniqueKeysWithValues: currents.map { key, value in
2184
                (key.rawValue, value)
2185
            }
2186
        )
2187
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
2188
            return nil
2189
        }
2190
        return String(data: data, encoding: .utf8)
2191
    }
2192

            
2193
    private func decodedCompletionCurrents(
2194
        from object: NSManagedObject,
2195
        key: String
2196
    ) -> [ChargeSessionKind: Double] {
2197
        guard let rawValue = stringValue(object, key: key),
2198
              let data = rawValue.data(using: .utf8),
2199
              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
2200
            return [:]
2201
        }
2202

            
2203
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2204
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
2205
                return
2206
            }
2207
            result[sessionKind] = entry.value
2208
        }
2209
    }
2210

            
2211
    private func legacyConfiguredCompletionCurrent(
2212
        for currents: [ChargeSessionKind: Double],
2213
        chargingTransportMode: ChargingTransportMode
2214
    ) -> Double? {
2215
        let candidates = currents
2216
            .filter { $0.key.chargingTransportMode == chargingTransportMode }
2217
            .sorted { lhs, rhs in
2218
                lhs.key.rawValue < rhs.key.rawValue
2219
            }
2220
            .map(\.value)
2221
        return candidates.first
2222
    }
2223

            
2224
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
2225
        guard let charger else {
2226
            return nil
2227
        }
2228
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
2229
        guard let idleCurrent, idleCurrent >= 0 else {
2230
            return nil
2231
        }
2232
        return idleCurrent
2233
    }
2234

            
2235
    private func effectiveCurrentAmps(
2236
        fromMeasuredCurrent currentAmps: Double,
2237
        chargingTransportMode: ChargingTransportMode,
2238
        charger: NSManagedObject?
2239
    ) -> Double {
2240
        switch chargingTransportMode {
2241
        case .wired:
2242
            return max(currentAmps, 0)
2243
        case .wireless:
2244
            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
2245
                return max(currentAmps, 0)
2246
            }
2247
            return max(currentAmps - idleCurrent, 0)
2248
        }
2249
    }
2250

            
2251
    private func hasObservedChargeFlow(
2252
        currentAmps: Double,
2253
        chargingTransportMode: ChargingTransportMode,
2254
        charger: NSManagedObject?,
2255
        stopThreshold: Double?
2256
    ) -> Bool {
2257
        let effectiveCurrent = effectiveCurrentAmps(
2258
            fromMeasuredCurrent: currentAmps,
2259
            chargingTransportMode: chargingTransportMode,
2260
            charger: charger
2261
        )
2262
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
2263
    }
2264

            
Bogdan Timofte authored a month ago
2265
    private func derivedMinimumCurrent(
2266
        from sessions: [NSManagedObject],
2267
        chargingTransportMode: ChargingTransportMode
2268
    ) -> Double? {
2269
        let completionCurrents = sessions.compactMap { session -> Double? in
2270
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2271
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
2272
                return nil
2273
            }
Bogdan Timofte authored a month ago
2274
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2275
                return nil
2276
            }
Bogdan Timofte authored a month ago
2277
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
2278
                return nil
2279
            }
2280
            return completionCurrent
2281
        }
2282

            
2283
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
2284
        guard !recentCompletionCurrents.isEmpty else { return nil }
2285
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
2286
    }
2287

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

            
2291
        for session in sessions {
2292
            guard statusValue(session, key: "statusRawValue") == .completed else {
2293
                continue
2294
            }
2295
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
2296
                continue
2297
            }
2298
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
2299
                  completionCurrent > 0 else {
2300
                continue
2301
            }
2302

            
2303
            let sessionKind = ChargeSessionKind(
2304
                chargingTransportMode: chargingTransportMode(for: session),
2305
                chargingStateMode: chargingStateMode(for: session)
2306
            )
2307
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
2308
        }
2309

            
2310
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
2311
            let recentCurrents = Array(entry.value.suffix(5))
2312
            guard !recentCurrents.isEmpty else {
2313
                return
2314
            }
2315
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
2316
        }
2317
    }
2318

            
Bogdan Timofte authored a month ago
2319
    private func derivedCapacity(
2320
        from sessions: [NSManagedObject],
2321
        chargingTransportMode: ChargingTransportMode,
2322
        supportsChargingWhileOff: Bool
2323
    ) -> Double? {
2324
        let capacityCandidates = sessions.compactMap { session -> Double? in
2325
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2326
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
2327
                return nil
2328
            }
2329
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
2330
                return nil
2331
            }
2332
            if supportsChargingWhileOff {
2333
                return capacityEstimate
2334
            }
2335
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
2336
                return nil
2337
            }
2338
            return capacityEstimate
2339
        }
2340

            
2341
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
2342
        guard !recentCapacityCandidates.isEmpty else { return nil }
2343
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
2344
    }
2345

            
2346
    private func derivedWirelessEfficiency(
2347
        from sessions: [NSManagedObject],
2348
        chargingProfile: WirelessChargingProfile
2349
    ) -> Double? {
2350
        guard chargingProfile == .magsafe else {
2351
            return nil
2352
        }
2353

            
2354
        let candidates = sessions.compactMap { session -> Double? in
2355
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2356
            guard chargingTransportMode(for: session) == .wireless else { return nil }
2357
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
2358
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
2359
                return nil
2360
            }
2361
            return factor
2362
        }
2363

            
2364
        let recentCandidates = Array(candidates.suffix(6))
2365
        guard !recentCandidates.isEmpty else { return nil }
2366
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2367
    }
2368

            
2369
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
2370
        let candidates = sessions.compactMap { session -> Double? in
2371
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2372
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
2373
                return nil
2374
            }
2375
            return (sourceVoltage * 10).rounded() / 10
2376
        }
2377

            
2378
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
2379
        return counts.keys.sorted()
2380
    }
2381

            
2382
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
2383
        let candidates = sessions.compactMap { session -> Double? in
2384
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2385
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
2386
                return nil
2387
            }
2388
            return minimumObservedCurrent
2389
        }
2390

            
2391
        let recentCandidates = Array(candidates.suffix(6))
2392
        guard !recentCandidates.isEmpty else { return nil }
2393
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2394
    }
2395

            
2396
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
2397
        let candidates = sessions.compactMap { session -> Double? in
2398
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2399
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
2400
                return nil
2401
            }
2402
            return factor
2403
        }
2404

            
2405
        let recentCandidates = Array(candidates.suffix(6))
2406
        guard !recentCandidates.isEmpty else { return nil }
2407
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
2408
    }
2409

            
2410
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
2411
        sessions.compactMap { session -> Double? in
2412
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
2413
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
2414
                return nil
2415
            }
2416
            return maximumObservedPower
2417
        }
2418
        .max()
2419
    }
2420

            
2421
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
2422
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
2423
            return persistedChargingTransportMode
2424
        }
2425

            
2426
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2427
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2428
            return preferredChargingTransportMode(for: chargedDevice)
2429
        }
2430

            
2431
        return .wired
2432
    }
2433

            
2434
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
2435
        if session.isInserted {
2436
            return .created
2437
        }
2438

            
2439
        let committedValues = session.committedValues(
2440
            forKeys: [
2441
                "statusRawValue",
2442
                "updatedAt",
2443
                "targetBatteryAlertTriggeredAt",
2444
                "requiresCompletionConfirmation"
2445
            ]
2446
        )
2447
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
2448
        let currentStatus = statusValue(session, key: "statusRawValue")
2449
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
2450
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
2451
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
2452
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
2453
            ?? false
2454
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
2455

            
2456
        if currentStatus == .completed, committedStatus != .completed {
2457
            return .completed
2458
        }
2459

            
Bogdan Timofte authored a month ago
2460
        if currentStatus != committedStatus {
2461
            return .event
2462
        }
2463

            
Bogdan Timofte authored a month ago
2464
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
2465
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
2466
            return .event
2467
        }
2468

            
2469
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
2470
            ?? dateValue(session, key: "createdAt")
2471
            ?? observedAt
2472

            
2473
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
2474
            return .periodic
2475
        }
2476

            
2477
        return .none
2478
    }
2479

            
2480
    private func generateQRIdentifier() -> String {
2481
        "device:\(UUID().uuidString)"
2482
    }
2483

            
2484
    @discardableResult
2485
    private func saveContext() -> Bool {
2486
        guard context.hasChanges else { return true }
2487
        do {
2488
            try context.save()
2489
            return true
2490
        } catch {
2491
            track("Failed saving charge insights context: \(error)")
2492
            context.rollback()
2493
            return false
2494
        }
2495
    }
2496

            
2497
    private func normalizedText(_ text: String) -> String {
2498
        text.trimmingCharacters(in: .whitespacesAndNewlines)
2499
    }
2500

            
2501
    private func normalizedOptionalText(_ text: String?) -> String? {
2502
        guard let text else { return nil }
2503
        let normalized = normalizedText(text)
2504
        return normalized.isEmpty ? nil : normalized
2505
    }
2506

            
2507
    private func normalizedMACAddress(_ macAddress: String) -> String {
2508
        normalizedText(macAddress).uppercased()
2509
    }
2510

            
2511
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
2512
        guard let value = object.value(forKey: key) as? String else { return nil }
2513
        let normalized = normalizedOptionalText(value)
2514
        return normalized
2515
    }
2516

            
2517
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
2518
        object.value(forKey: key) as? Date
2519
    }
2520

            
2521
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
2522
        if let value = object.value(forKey: key) as? Double {
2523
            return value
2524
        }
2525
        if let value = object.value(forKey: key) as? NSNumber {
2526
            return value.doubleValue
2527
        }
2528
        return 0
2529
    }
2530

            
2531
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
2532
        let rawValue = object.value(forKey: key)
2533
        if rawValue == nil {
2534
            return nil
2535
        }
2536
        return doubleValue(object, key: key)
2537
    }
2538

            
2539
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
2540
        if let value = object.value(forKey: key) as? Int16 {
2541
            return value
2542
        }
2543
        if let value = object.value(forKey: key) as? NSNumber {
2544
            return value.int16Value
2545
        }
2546
        return nil
2547
    }
2548

            
2549
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
2550
        if let value = object.value(forKey: key) as? Int32 {
2551
            return value
2552
        }
2553
        if let value = object.value(forKey: key) as? NSNumber {
2554
            return value.int32Value
2555
        }
2556
        return nil
2557
    }
2558

            
2559
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
2560
        if let value = object.value(forKey: key) as? Bool {
2561
            return value
2562
        }
2563
        if let value = object.value(forKey: key) as? NSNumber {
2564
            return value.boolValue
2565
        }
2566
        return false
2567
    }
2568

            
2569
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
2570
        guard let value = stringValue(object, key: key) else { return nil }
2571
        return UUID(uuidString: value)
2572
    }
2573

            
2574
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
2575
        guard let value = stringValue(object, key: key) else { return nil }
2576
        return ChargeSessionStatus(rawValue: value)
2577
    }
2578

            
2579
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
2580
        guard let value = stringValue(object, key: key) else { return nil }
2581
        return ChargingTransportMode(rawValue: value)
2582
    }
2583

            
2584
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
2585
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
2586
            return []
2587
        }
2588
        return rawValue
2589
            .split(separator: ",")
2590
            .compactMap { Double($0) }
2591
            .sorted()
2592
    }
2593

            
2594
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
2595
        let uniqueVoltages = Array(Set(voltages)).sorted()
2596
        guard !uniqueVoltages.isEmpty else {
2597
            return nil
2598
        }
2599
        return uniqueVoltages
2600
            .map { String(format: "%.1f", $0) }
2601
            .joined(separator: ",")
2602
    }
2603

            
2604
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
2605
        guard currentCount > 0 else {
2606
            return newValue
2607
        }
2608
        let total = (currentAverage * Double(currentCount)) + newValue
2609
        return total / Double(currentCount + 1)
2610
    }
2611
}
2612

            
2613
private enum ObservationSaveReason {
2614
    case none
2615
    case created
2616
    case periodic
2617
    case completed
2618
    case event
2619
}