USB-Meter / USB Meter / Model / ChargeInsightsStore.swift
Newer Older
4603 lines | 207.056kb
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"
Bogdan Timofte authored a month ago
14
        static let powerbank = "Powerbank"
Bogdan Timofte authored a month ago
15
        static let chargeSession = "ChargeSession"
16
        static let chargeCheckpoint = "ChargeCheckpoint"
17
        static let chargeSessionSample = "ChargeSessionSample"
Bogdan Timofte authored a month ago
18
        static let deviceProfile = "DeviceProfile"
Bogdan Timofte authored a month ago
19
    }
20

            
Bogdan Timofte authored a month ago
21
    private static let maximumChargeSessionDuration: TimeInterval = 12 * 60 * 60
Bogdan Timofte authored a month ago
22
    private static let persistedSamplesPerHour = 300
Bogdan Timofte authored a month ago
23
    private static let aggregatedSampleBucketDuration = 3600.0 / Double(persistedSamplesPerHour)
24

            
25
    private let context: NSManagedObjectContext
26
    private let stopDetectionHoldDuration: TimeInterval = 20
Bogdan Timofte authored a month ago
27
    private let maximumLiveIntegrationGap: TimeInterval = 90
Bogdan Timofte authored a month ago
28
    private let activeSessionSaveInterval: TimeInterval = 60
29
    private let aggregatedSampleSaveInterval: TimeInterval = 30
Bogdan Timofte authored a month ago
30
    private let counterDecreaseTolerance = 0.002
31
    private let completionConfirmationCooldown: TimeInterval = 15 * 60
Bogdan Timofte authored a month ago
32
    private let pausedSessionTimeout: TimeInterval = 10 * 60
Bogdan Timofte authored a month ago
33
    private let defaultCompletionPercentThreshold = 95.0
34
    private let completionContradictionTolerancePercent = 2.0
35
    private let minimumWirelessEfficiencyFactor = 0.35
36
    private let maximumWirelessEfficiencyFactor = 0.95
37
    private let lowWirelessEfficiencyThreshold = 0.72
Bogdan Timofte authored a month ago
38
    private let unresolvedFlatBatteryPercent = -1.0
Bogdan Timofte authored a month ago
39

            
40
    init(context: NSManagedObjectContext) {
41
        self.context = context
42
    }
43

            
44
    func refreshContext() {
45
        context.performAndWait {
46
            context.processPendingChanges()
47
        }
48
    }
49

            
Bogdan Timofte authored a month ago
50
    func resetContext() {
51
        context.performAndWait {
52
            context.reset()
53
        }
54
    }
55

            
Bogdan Timofte authored a month ago
56
    @discardableResult
57
    func seedDeviceProfilesCatalog(_ profiles: [DeviceProfileDefinition]) -> Bool {
58
        var didSave = false
59
        context.performAndWait {
60
            let now = Date()
61
            let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.deviceProfile)
62
            request.predicate = NSPredicate(format: "isCustom == NO OR isCustom == nil")
63
            let existing = (try? context.fetch(request)) ?? []
64
            let existingByID: [String: NSManagedObject] = Dictionary(
65
                grouping: existing,
66
                by: { stringValue($0, key: "id") ?? "" }
67
            ).compactMapValues { $0.first }
68

            
69
            for profile in profiles {
70
                let target: NSManagedObject
71
                if let row = existingByID[profile.id] {
72
                    target = row
73
                } else {
74
                    target = NSEntityDescription.insertNewObject(
75
                        forEntityName: EntityName.deviceProfile,
76
                        into: context
77
                    )
78
                    setValue(profile.id, on: target, key: "id")
79
                    setValue(now, on: target, key: "createdAt")
80
                }
81
                applyCatalogProfile(profile, to: target, updatedAt: now)
82
            }
83

            
84
            didSave = saveContext()
85
        }
86
        return didSave
87
    }
88

            
89
    /// Backfills `profileID` on all ChargedDevice and Powerbank rows that lack one,
90
    /// and promotes legacy `ChargedDevice` rows with `deviceClass == "powerbank"` to
91
    /// the dedicated `Powerbank` entity. Idempotent — re-running has no effect once
92
    /// every row has a `profileID` and no legacy powerbank-class rows remain.
93
    @discardableResult
94
    func migrateDevicesToProfiles() -> Bool {
95
        var didSave = false
96
        context.performAndWait {
97
            let now = Date()
98
            var didChange = false
99

            
100
            // 1. ChargedDevices (including chargers): assign profileID where missing.
101
            let deviceRequest = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
102
            let devices = (try? context.fetch(deviceRequest)) ?? []
103

            
104
            // 1a. Promote legacy class-`.powerbank` rows to the Powerbank entity.
105
            for device in devices {
106
                guard stringValue(device, key: "deviceClassRawValue") == ChargedDeviceClass.powerbank.rawValue else {
107
                    continue
108
                }
109
                promoteLegacyPowerbankDevice(device, now: now)
110
                didChange = true
111
            }
112

            
113
            // 1b. Backfill profileID for the remaining (non-powerbank-class) devices.
114
            for device in devices where !device.isDeleted {
115
                guard stringValue(device, key: "profileID") == nil else { continue }
116
                guard stringValue(device, key: "deviceClassRawValue") != ChargedDeviceClass.powerbank.rawValue else {
117
                    // Already handled (and deleted) in 1a.
118
                    continue
119
                }
120

            
121
                let assignedProfileID = resolveProfileIDForLegacyDevice(device, now: now)
122
                setValue(assignedProfileID, on: device, key: "profileID")
123
                setValue(now, on: device, key: "updatedAt")
124
                didChange = true
125
            }
126

            
127
            // 2. Powerbanks: backfill profileID where missing.
128
            let powerbankRequest = NSFetchRequest<NSManagedObject>(entityName: EntityName.powerbank)
129
            let powerbanks = (try? context.fetch(powerbankRequest)) ?? []
130
            for powerbank in powerbanks where !powerbank.isDeleted {
131
                guard stringValue(powerbank, key: "profileID") == nil else { continue }
132

            
133
                let templateID = stringValue(powerbank, key: "deviceTemplateID")
134
                let assignedProfileID: String
135
                if let templateID,
136
                   let catalog = DeviceProfileCatalog.shared.profile(id: templateID),
137
                   catalog.category == .powerbank {
138
                    assignedProfileID = templateID
139
                } else {
140
                    assignedProfileID = "generic-powerbank"
141
                }
142
                setValue(assignedProfileID, on: powerbank, key: "profileID")
143
                setValue(now, on: powerbank, key: "updatedAt")
144
                didChange = true
145
            }
146

            
147
            if didChange {
148
                didSave = saveContext()
149
            } else {
150
                didSave = true // nothing to do counts as success
151
            }
152
        }
153
        return didSave
154
    }
155

            
156
    private func promoteLegacyPowerbankDevice(_ device: NSManagedObject, now: Date) {
157
        let legacyID = stringValue(device, key: "id")
158
        let powerbank = NSEntityDescription.insertNewObject(
159
            forEntityName: EntityName.powerbank,
160
            into: context
161
        )
162
        setValue(legacyID ?? UUID().uuidString, on: powerbank, key: "id")
163
        setValue(stringValue(device, key: "name"), on: powerbank, key: "name")
164
        setValue(stringValue(device, key: "qrIdentifier"), on: powerbank, key: "qrIdentifier")
165
        setValue(stringValue(device, key: "notes"), on: powerbank, key: "notes")
166
        setValue("none", on: powerbank, key: "batteryLevelReportingRawValue")
167
        setValue(Int16(0), on: powerbank, key: "batteryBarsCount")
168
        setValue(optionalDoubleValue(device, key: "estimatedBatteryCapacityWh") ?? 0, on: powerbank, key: "estimatedBatteryCapacityWh")
169
        setValue(optionalDoubleValue(device, key: "minimumCurrentAmps") ?? 0, on: powerbank, key: "minimumCurrentAmps")
170
        setValue("generic-powerbank", on: powerbank, key: "profileID")
171
        setValue(stringValue(device, key: "deviceTemplateID"), on: powerbank, key: "deviceTemplateID")
172
        setValue(dateValue(device, key: "createdAt") ?? now, on: powerbank, key: "createdAt")
173
        setValue(now, on: powerbank, key: "updatedAt")
174

            
175
        track("ChargeInsightsStore: promoted legacy class-.powerbank ChargedDevice (\(legacyID ?? "?")) to Powerbank entity")
176
        context.delete(device)
177
    }
178

            
179
    private func resolveProfileIDForLegacyDevice(_ device: NSManagedObject, now: Date) -> String {
180
        // 1. Direct catalog match by template ID (apple-iphone, apple-watch, etc.).
181
        if let templateID = stringValue(device, key: "deviceTemplateID"),
182
           DeviceProfileCatalog.shared.profile(id: templateID) != nil {
183
            return templateID
184
        }
185

            
186
        // 2. For chargers, map chargerType to a catalog charger profile.
187
        if stringValue(device, key: "deviceClassRawValue") == ChargedDeviceClass.charger.rawValue {
188
            if let chargerTypeRaw = stringValue(device, key: "chargerTypeRawValue"),
189
               let chargerType = ChargerType(rawValue: chargerTypeRaw) {
190
                switch chargerType {
191
                case .appleMagSafe: return "apple-magsafe-charger"
192
                case .appleWatch: return "apple-watch-charger"
193
                case .genericMagSafe: return "generic-magsafe-charger"
194
                case .genericQi: return "generic-qi-charger"
195
                }
196
            }
197
            return "generic-qi-charger"
198
        }
199

            
200
        // 3. Synthesize a custom DeviceProfile row matching the device's persisted state.
201
        return synthesizeCustomProfile(from: device, now: now)
202
    }
203

            
204
    private func synthesizeCustomProfile(from device: NSManagedObject, now: Date) -> String {
205
        let id = UUID().uuidString
206
        let deviceName = stringValue(device, key: "name") ?? "Untitled"
207
        let profileName = "\(deviceName) profile"
208

            
209
        let classRaw = stringValue(device, key: "deviceClassRawValue") ?? ChargedDeviceClass.other.rawValue
210
        let deviceClass = ChargedDeviceClass(rawValue: classRaw) ?? .other
211
        let category = ProfileCategory.fromLegacyDeviceClass(deviceClass)
212

            
213
        let supportsWired = (device.value(forKey: "supportsWiredCharging") as? Bool) ?? false
214
        let supportsWireless = (device.value(forKey: "supportsWirelessCharging") as? Bool) ?? false
215
        let chargingStateRaw = stringValue(device, key: "chargingStateAvailabilityRawValue") ?? ChargingStateAvailability.onOrOff.rawValue
216
        let chargingState = ChargingStateAvailability(rawValue: chargingStateRaw) ?? .onOrOff
217
        let wirelessProfileRaw = stringValue(device, key: "wirelessChargingProfileRawValue") ?? WirelessChargingProfile.genericQi.rawValue
218
        let wirelessProfile = WirelessChargingProfile(rawValue: wirelessProfileRaw) ?? .genericQi
219

            
220
        let allowedWirelessProfiles = supportsWireless ? [wirelessProfile] : []
221

            
222
        let profile = NSEntityDescription.insertNewObject(
223
            forEntityName: EntityName.deviceProfile,
224
            into: context
225
        )
226
        setValue(id, on: profile, key: "id")
227
        setValue(profileName, on: profile, key: "name")
228
        setValue(category.rawValue, on: profile, key: "categoryRawValue")
229
        setValue(category.symbolName, on: profile, key: "iconSymbolName")
230
        setValue(true, on: profile, key: "isCustom")
231
        setValue(Int16(1), on: profile, key: "schemaVersion")
232
        setValue(Int32(1000), on: profile, key: "sortOrder")
233
        setValue("Custom", on: profile, key: "group")
234
        setValue(supportsWired, on: profile, key: "capWiredCharging")
235
        setValue(supportsWireless, on: profile, key: "capWirelessCharging")
236
        setValue(allowedWirelessProfiles.map { $0.rawValue }.joined(separator: ","), on: profile, key: "capWirelessProfilesRawValue")
237
        setValue(chargingState.rawValue, on: profile, key: "capChargingStateAvailabilityRawValue")
238
        setValue(false, on: profile, key: "capHasInternalSubject")
239
        setValue(supportsWireless ? wirelessProfile.rawValue : nil, on: profile, key: "defaultWirelessChargingProfileRawValue")
240
        setValue(now, on: profile, key: "createdAt")
241
        setValue(now, on: profile, key: "updatedAt")
242

            
243
        track("ChargeInsightsStore: synthesized custom DeviceProfile \(id) for legacy device \(stringValue(device, key: "id") ?? "?")")
244
        return id
245
    }
246

            
247
    private func applyCatalogProfile(
248
        _ profile: DeviceProfileDefinition,
249
        to object: NSManagedObject,
250
        updatedAt: Date
251
    ) {
252
        setValue(profile.name, on: object, key: "name")
253
        setValue(profile.category.rawValue, on: object, key: "categoryRawValue")
254
        setValue(profile.icon.name, on: object, key: "iconSymbolName")
255
        setValue(profile.icon.fallbackSystemName, on: object, key: "iconFallbackSymbolName")
256
        setValue(false, on: object, key: "isCustom")
257
        setValue(Int16(1), on: object, key: "schemaVersion")
258
        setValue(Int32(profile.sortOrder), on: object, key: "sortOrder")
259
        setValue(profile.group, on: object, key: "group")
260
        setValue(profile.capWiredCharging, on: object, key: "capWiredCharging")
261
        setValue(profile.capWirelessCharging, on: object, key: "capWirelessCharging")
262
        setValue(profile.wirelessProfilesCSV, on: object, key: "capWirelessProfilesRawValue")
263
        setValue(profile.capChargingStateAvailability.rawValue, on: object, key: "capChargingStateAvailabilityRawValue")
264
        setValue(profile.capHasInternalSubject, on: object, key: "capHasInternalSubject")
265
        setValue(profile.defaultWirelessChargingProfile?.rawValue, on: object, key: "defaultWirelessChargingProfileRawValue")
266
        setValue(profile.defaultWiredMinimumCurrentAmps ?? 0, on: object, key: "defaultWiredMinimumCurrentAmps")
267
        setValue(profile.defaultWirelessMinimumCurrentAmps ?? 0, on: object, key: "defaultWirelessMinimumCurrentAmps")
268
        setValue(profile.defaultWiredEstimatedBatteryCapacityWh ?? 0, on: object, key: "defaultWiredEstimatedBatteryCapacityWh")
269
        setValue(profile.defaultWirelessEstimatedBatteryCapacityWh ?? 0, on: object, key: "defaultWirelessEstimatedBatteryCapacityWh")
270
        setValue(updatedAt, on: object, key: "updatedAt")
271
    }
272

            
Bogdan Timofte authored a month ago
273
    @discardableResult
274
    func flushPendingChanges() -> Bool {
275
        var didSave = false
276
        context.performAndWait {
277
            context.processPendingChanges()
278
            didSave = saveContext()
279
        }
280
        return didSave
281
    }
282

            
Bogdan Timofte authored a month ago
283
    @discardableResult
284
    func completeExpiredOpenSessions(referenceDate: Date = Date()) -> Bool {
285
        var didSave = false
286

            
287
        context.performAndWait {
288
            let expiredSessions = fetchOpenSessionObjects().compactMap { session -> NSManagedObject? in
289
                guard automaticCompletionDate(for: session, referenceDate: referenceDate) != nil else {
290
                    return nil
291
                }
292
                return session
293
            }
294
            guard expiredSessions.isEmpty == false else {
295
                return
296
            }
297

            
298
            var chargedDeviceIDsToRefresh = Set<String>()
299
            for session in expiredSessions {
300
                guard let completionDate = automaticCompletionDate(for: session, referenceDate: referenceDate) else {
301
                    continue
302
                }
303
                finishSession(
304
                    session,
305
                    observedAt: completionDate,
306
                    finalBatteryPercent: nil,
307
                    status: .completed
308
                )
309
                if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
310
                    chargedDeviceIDsToRefresh.insert(chargedDeviceID)
311
                }
312
            }
313

            
314
            guard saveContext() else {
315
                return
316
            }
317

            
318
            for chargedDeviceID in chargedDeviceIDsToRefresh {
319
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
320
            }
321
            didSave = saveContext()
322
        }
323

            
324
        return didSave
325
    }
326

            
Bogdan Timofte authored a month ago
327
    // Heals the invariant "at most one open session per meter MAC".
328
    // Called after every remote CloudKit sync import to resolve sessions that were started
329
    // independently on different devices while offline.
330
    //
331
    // Scenario: session A started on Device 1 and forgotten; user starts session B on Device 2
332
    // while offline. After sync both appear open for the same meter.
333
    //
334
    // Winner = session with the latest startedAt (represents the user's intentional new session).
335
    // Loser endedAt is set to winner's startedAt so there is no time overlap.
336
    @discardableResult
337
    func healDuplicateOpenSessions() -> Bool {
338
        var didSave = false
339

            
340
        context.performAndWait {
341
            let openSessions = fetchOpenSessionObjects()
342

            
343
            var sessionsByMAC: [String: [NSManagedObject]] = [:]
344
            for session in openSessions {
345
                guard let mac = stringValue(session, key: "meterMACAddress") else { continue }
346
                sessionsByMAC[mac, default: []].append(session)
347
            }
348

            
349
            let duplicatedMACs = sessionsByMAC.filter { $0.value.count > 1 }
350
            guard !duplicatedMACs.isEmpty else { return }
351

            
352
            var chargedDeviceIDsToRefresh = Set<String>()
353

            
354
            for (_, sessions) in duplicatedMACs {
355
                // Winner = most recently started (explicit user intent); tie-break by measuredEnergyWh
356
                let winner = sessions.max { a, b in
357
                    let aDate = (a.value(forKey: "startedAt") as? Date) ?? .distantPast
358
                    let bDate = (b.value(forKey: "startedAt") as? Date) ?? .distantPast
359
                    if aDate != bDate { return aDate < bDate }
360
                    let aEnergy = (a.value(forKey: "measuredEnergyWh") as? Double) ?? 0
361
                    let bEnergy = (b.value(forKey: "measuredEnergyWh") as? Double) ?? 0
362
                    return aEnergy < bEnergy
363
                }
364
                let winnerStartedAt = (winner?.value(forKey: "startedAt") as? Date) ?? Date()
365

            
366
                for loser in sessions where loser !== winner {
367
                    // End the loser exactly when the winner began — no overlap.
368
                    finishSession(loser, observedAt: winnerStartedAt, finalBatteryPercent: nil, status: .abandoned)
369
                    loser.setValue(true, forKey: "wasConflictHealed")
370
                    if let chargedDeviceID = stringValue(loser, key: "chargedDeviceID") {
371
                        chargedDeviceIDsToRefresh.insert(chargedDeviceID)
372
                    }
373
                    track("ChargeInsightsStore: healed duplicate open session \(stringValue(loser, key: "id") ?? "?") for meter \(stringValue(loser, key: "meterMACAddress") ?? "?")")
374
                }
375
            }
376

            
377
            guard saveContext() else { return }
378

            
379
            for chargedDeviceID in chargedDeviceIDsToRefresh {
380
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
381
            }
382
            didSave = saveContext()
383
        }
384

            
385
        return didSave
386
    }
387

            
Bogdan Timofte authored a month ago
388
    @discardableResult
Bogdan Timofte authored a month ago
389
    func createDevice(
Bogdan Timofte authored a month ago
390
        name: String,
391
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
392
        templateID: String?,
Bogdan Timofte authored a month ago
393
        profileID: String? = nil,
394
        hasInternalSubject: Bool = false,
Bogdan Timofte authored a month ago
395
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
396
        supportsWiredCharging: Bool,
397
        supportsWirelessCharging: Bool,
398
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
399
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
400
        notes: String?
Bogdan Timofte authored a month ago
401
    ) -> Bool {
Bogdan Timofte authored a month ago
402
        guard deviceClass.kind == .device else { return false }
Bogdan Timofte authored a month ago
403
        let normalizedName = normalizedText(name)
Bogdan Timofte authored a month ago
404
        let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability)
405
        let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
406
            supportsWiredCharging: supportsWiredCharging,
407
            supportsWirelessCharging: supportsWirelessCharging
408
        )
409
        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device)
Bogdan Timofte authored a month ago
410
        let normalizedProfileID = normalizedOptionalText(profileID)
Bogdan Timofte authored a month ago
411
        guard !normalizedName.isEmpty else { return false }
Bogdan Timofte authored a month ago
412
        guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
Bogdan Timofte authored a month ago
413

            
414
        var didSave = false
415
        context.performAndWait {
416
            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
417
                return
418
            }
419

            
420
            let object = NSManagedObject(entity: entity, insertInto: context)
421
            let now = Date()
422
            object.setValue(UUID().uuidString, forKey: "id")
423
            object.setValue(normalizedName, forKey: "name")
424
            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
425
            object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
Bogdan Timofte authored a month ago
426
            object.setValue(normalizedProfileID, forKey: "profileID")
427
            object.setValue(hasInternalSubject, forKey: "hasInternalSubject")
Bogdan Timofte authored a month ago
428
            object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
429
            object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
430
            object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging")
431
            object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging")
Bogdan Timofte authored a month ago
432
            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
433
            object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
434
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
435
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
436
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
437
            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
438
            object.setValue(now, forKey: "createdAt")
439
            object.setValue(now, forKey: "updatedAt")
440
            didSave = saveContext()
441
        }
442
        return didSave
443
    }
444

            
445
    @discardableResult
Bogdan Timofte authored a month ago
446
    func createPowerbank(
447
        name: String,
448
        templateID: String?,
449
        batteryLevelReporting: BatteryLevelReporting,
450
        batteryBarsCount: Int,
451
        notes: String?
452
    ) -> Bool {
453
        let normalizedName = normalizedText(name)
454
        guard !normalizedName.isEmpty else { return false }
455

            
456
        var didSave = false
457
        context.performAndWait {
458
            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.powerbank, in: context) else {
459
                return
460
            }
461

            
462
            let object = NSManagedObject(entity: entity, insertInto: context)
463
            let now = Date()
464
            object.setValue(UUID().uuidString, forKey: "id")
465
            object.setValue(normalizedName, forKey: "name")
466
            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
467
            object.setValue(normalizedTemplateID(templateID, kind: .device), forKey: "deviceTemplateID")
468
            object.setValue(batteryLevelReporting.rawValue, forKey: "batteryLevelReportingRawValue")
469
            object.setValue(Int16(batteryLevelReporting == .bars ? max(1, batteryBarsCount) : 0), forKey: "batteryBarsCount")
470
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
471
            object.setValue(now, forKey: "createdAt")
472
            object.setValue(now, forKey: "updatedAt")
473
            didSave = saveContext()
474
        }
475
        return didSave
476
    }
477

            
478
    @discardableResult
479
    func updatePowerbank(
480
        id: UUID,
481
        name: String,
482
        templateID: String?,
483
        batteryLevelReporting: BatteryLevelReporting,
484
        batteryBarsCount: Int,
485
        notes: String?
486
    ) -> Bool {
487
        let normalizedName = normalizedText(name)
488
        guard !normalizedName.isEmpty else { return false }
489

            
490
        var didSave = false
491
        context.performAndWait {
492
            guard let object = fetchPowerbankObject(id: id.uuidString) else {
493
                return
494
            }
495

            
496
            let now = Date()
497
            object.setValue(normalizedName, forKey: "name")
498
            object.setValue(normalizedTemplateID(templateID, kind: .device), forKey: "deviceTemplateID")
499
            object.setValue(batteryLevelReporting.rawValue, forKey: "batteryLevelReportingRawValue")
500
            object.setValue(Int16(batteryLevelReporting == .bars ? max(1, batteryBarsCount) : 0), forKey: "batteryBarsCount")
501
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
502
            object.setValue(now, forKey: "updatedAt")
503
            didSave = saveContext()
504
        }
505
        return didSave
506
    }
507

            
508
    @discardableResult
509
    func deletePowerbank(id powerbankID: UUID) -> Bool {
510
        var didSave = false
511

            
512
        context.performAndWait {
513
            guard let powerbank = fetchPowerbankObject(id: powerbankID.uuidString) else {
514
                return
515
            }
516

            
517
            // Detach references from any session that mentions this powerbank as subject or source.
518
            let subjectSessions = fetchSessions(forPowerbankSubjectID: powerbankID.uuidString)
519
            for session in subjectSessions {
520
                if let sessionID = stringValue(session, key: "id") {
521
                    fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete)
522
                    fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete)
523
                }
524
                context.delete(session)
525
            }
526

            
527
            let sourceSessions = fetchSessions(forPowerbankSourceID: powerbankID.uuidString)
528
            for session in sourceSessions {
529
                session.setValue(nil, forKey: "sourcePowerbankID")
530
                session.setValue(Date(), forKey: "updatedAt")
531
            }
532

            
533
            context.delete(powerbank)
534
            didSave = saveContext()
535
        }
536

            
537
        return didSave
538
    }
539

            
Bogdan Timofte authored a month ago
540
    func createCharger(
541
        name: String,
Bogdan Timofte authored a month ago
542
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
543
        notes: String?
Bogdan Timofte authored a month ago
544
    ) -> Bool {
545
        let normalizedName = normalizedText(name)
546
        guard !normalizedName.isEmpty else { return false }
547

            
548
        var didSave = false
549
        context.performAndWait {
550
            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
551
                return
552
            }
553

            
554
            let object = NSManagedObject(entity: entity, insertInto: context)
555
            let now = Date()
556
            object.setValue(UUID().uuidString, forKey: "id")
557
            object.setValue(normalizedName, forKey: "name")
558
            object.setValue(ChargedDeviceClass.charger.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
559
            object.setValue(nil, forKey: "deviceTemplateID")
560
            object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue")
561
            object.setValue(false, forKey: "supportsChargingWhileOff")
562
            object.setValue(false, forKey: "supportsWiredCharging")
563
            object.setValue(true, forKey: "supportsWirelessCharging")
564
            if object.entity.attributesByName["chargerTypeRawValue"] != nil {
565
                object.setValue(chargerType.rawValue, forKey: "chargerTypeRawValue")
566
            }
567
            object.setValue(chargerType.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
568
            object.setValue(encodedCompletionCurrents([:]), forKey: "configuredCompletionCurrentsRawValue")
569
            object.setValue(nil, forKey: "wiredChargeCompletionCurrentAmps")
570
            object.setValue(nil, forKey: "wirelessChargeCompletionCurrentAmps")
571
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
572
            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
573
            object.setValue(now, forKey: "createdAt")
574
            object.setValue(now, forKey: "updatedAt")
575
            didSave = saveContext()
576
        }
577
        return didSave
578
    }
579

            
580
    @discardableResult
581
    func updateDevice(
Bogdan Timofte authored a month ago
582
        id: UUID,
583
        name: String,
584
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
585
        templateID: String?,
Bogdan Timofte authored a month ago
586
        profileID: String? = nil,
587
        hasInternalSubject: Bool = false,
Bogdan Timofte authored a month ago
588
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
589
        supportsWiredCharging: Bool,
590
        supportsWirelessCharging: Bool,
591
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
592
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
593
        notes: String?
594
    ) -> Bool {
Bogdan Timofte authored a month ago
595
        guard deviceClass.kind == .device else { return false }
Bogdan Timofte authored a month ago
596
        let normalizedName = normalizedText(name)
Bogdan Timofte authored a month ago
597
        let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability)
598
        let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
599
            supportsWiredCharging: supportsWiredCharging,
600
            supportsWirelessCharging: supportsWirelessCharging
601
        )
602
        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device)
Bogdan Timofte authored a month ago
603
        let normalizedProfileID = normalizedOptionalText(profileID)
Bogdan Timofte authored a month ago
604
        guard !normalizedName.isEmpty else { return false }
Bogdan Timofte authored a month ago
605
        guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
Bogdan Timofte authored a month ago
606

            
607
        var didSave = false
608
        context.performAndWait {
609
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
610
                return
611
            }
Bogdan Timofte authored a month ago
612
            guard isChargerObject(object) == false else {
613
                return
614
            }
Bogdan Timofte authored a month ago
615

            
616
            let previousSupportsChargingWhileOff = boolValue(object, key: "supportsChargingWhileOff")
Bogdan Timofte authored a month ago
617
            let previousChargingStateAvailability = self.chargingStateAvailability(for: object)
Bogdan Timofte authored a month ago
618
            let previousSupportsWiredCharging = self.supportsWiredCharging(for: object)
619
            let previousSupportsWirelessCharging = self.supportsWirelessCharging(for: object)
620
            let now = Date()
621

            
622
            object.setValue(normalizedName, forKey: "name")
623
            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
Bogdan Timofte authored a month ago
624
            object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
Bogdan Timofte authored a month ago
625
            object.setValue(normalizedProfileID, forKey: "profileID")
626
            object.setValue(hasInternalSubject, forKey: "hasInternalSubject")
Bogdan Timofte authored a month ago
627
            object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
628
            object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
629
            object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging")
630
            object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging")
Bogdan Timofte authored a month ago
631
            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
632
            object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
633
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
634
            object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
635
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
636
            object.setValue(now, forKey: "updatedAt")
637

            
Bogdan Timofte authored a month ago
638
            let supportsChargingWhileOff = normalizedChargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
639
            let shouldRecalculateSessionCapacity = previousSupportsChargingWhileOff != supportsChargingWhileOff
640
            let shouldRefreshActiveSessions = shouldRecalculateSessionCapacity
Bogdan Timofte authored a month ago
641
                || previousChargingStateAvailability != normalizedChargingStateAvailability
642
                || previousSupportsWiredCharging != normalizedChargingSupport.wired
643
                || previousSupportsWirelessCharging != normalizedChargingSupport.wireless
Bogdan Timofte authored a month ago
644

            
645
            if shouldRecalculateSessionCapacity || shouldRefreshActiveSessions {
646
                let sessions = fetchSessions(forChargedDeviceID: id.uuidString)
647
                for session in sessions {
Bogdan Timofte authored a month ago
648
                    let isOpen = statusValue(session, key: "statusRawValue")?.isOpen == true
Bogdan Timofte authored a month ago
649

            
650
                    if shouldRecalculateSessionCapacity {
651
                        session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
652
                        updateCapacityEstimate(for: session)
653
                        session.setValue(now, forKey: "updatedAt")
654
                    }
655

            
Bogdan Timofte authored a month ago
656
                    guard isOpen, shouldRefreshActiveSessions else {
Bogdan Timofte authored a month ago
657
                        continue
658
                    }
659

            
660
                    let resolvedSessionChargingTransportMode = resolvedPreferredChargingTransportMode(
661
                        chargingTransportMode(for: session),
Bogdan Timofte authored a month ago
662
                        supportsWiredCharging: normalizedChargingSupport.wired,
663
                        supportsWirelessCharging: normalizedChargingSupport.wireless
Bogdan Timofte authored a month ago
664
                    )
Bogdan Timofte authored a month ago
665
                    let resolvedSessionChargingStateMode = resolvedChargingStateMode(
666
                        chargingStateMode(for: session),
Bogdan Timofte authored a month ago
667
                        availability: normalizedChargingStateAvailability
Bogdan Timofte authored a month ago
668
                    )
669
                    let charger = stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
670

            
671
                    session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
672
                    session.setValue(resolvedSessionChargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
673
                    session.setValue(resolvedSessionChargingStateMode.rawValue, forKey: "chargingStateRawValue")
Bogdan Timofte authored a month ago
674
                    session.setValue(
675
                        resolvedStopThreshold(
676
                            for: object,
677
                            chargingTransportMode: resolvedSessionChargingTransportMode,
Bogdan Timofte authored a month ago
678
                            chargingStateMode: resolvedSessionChargingStateMode,
679
                            charger: charger,
680
                            fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
681
                        ) ?? 0,
Bogdan Timofte authored a month ago
682
                        forKey: "stopThresholdAmps"
683
                    )
684
                    session.setValue(now, forKey: "updatedAt")
685
                    updateCapacityEstimate(for: session)
686
                }
687
            }
688

            
689
            refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
690
            didSave = saveContext()
691
        }
692
        return didSave
693
    }
694

            
Bogdan Timofte authored a month ago
695
    @discardableResult
696
    func updateCharger(
697
        id: UUID,
698
        name: String,
Bogdan Timofte authored a month ago
699
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
700
        notes: String?
701
    ) -> Bool {
702
        let normalizedName = normalizedText(name)
703
        guard !normalizedName.isEmpty else { return false }
704

            
705
        var didSave = false
706
        context.performAndWait {
707
            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
708
                return
709
            }
710
            guard isChargerObject(object) else {
711
                return
712
            }
713

            
714
            object.setValue(normalizedName, forKey: "name")
Bogdan Timofte authored a month ago
715
            object.setValue(nil, forKey: "deviceTemplateID")
716
            object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue")
717
            object.setValue(false, forKey: "supportsChargingWhileOff")
718
            object.setValue(false, forKey: "supportsWiredCharging")
719
            object.setValue(true, forKey: "supportsWirelessCharging")
720
            if object.entity.attributesByName["chargerTypeRawValue"] != nil {
721
                object.setValue(chargerType.rawValue, forKey: "chargerTypeRawValue")
722
            }
723
            object.setValue(chargerType.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
Bogdan Timofte authored a month ago
724
            object.setValue(normalizedOptionalText(notes), forKey: "notes")
725
            object.setValue(Date(), forKey: "updatedAt")
Bogdan Timofte authored a month ago
726
            refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
Bogdan Timofte authored a month ago
727
            didSave = saveContext()
728
        }
729

            
730
        return didSave
731
    }
732

            
Bogdan Timofte authored a month ago
733
    @discardableResult
Bogdan Timofte authored a month ago
734
    func startSession(
735
        for snapshot: ChargingMonitorSnapshot,
736
        chargedDeviceID: UUID,
737
        chargerID: UUID?,
Bogdan Timofte authored a month ago
738
        sourcePowerbankID: UUID? = nil,
Bogdan Timofte authored a month ago
739
        chargingTransportMode: ChargingTransportMode,
740
        chargingStateMode: ChargingStateMode,
741
        autoStopEnabled: Bool,
Bogdan Timofte authored a month ago
742
        initialBatteryPercent: Double?,
743
        startsFromFlatBattery: Bool
Bogdan Timofte authored a month ago
744
    ) -> Bool {
Bogdan Timofte authored a month ago
745
        if let initialBatteryPercent,
746
           (initialBatteryPercent.isFinite == false || initialBatteryPercent < 0 || initialBatteryPercent > 100) {
Bogdan Timofte authored a month ago
747
            return false
748
        }
749

            
Bogdan Timofte authored a month ago
750
        var didSave = false
751
        context.performAndWait {
Bogdan Timofte authored a month ago
752
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
Bogdan Timofte authored a month ago
753
                return
754
            }
Bogdan Timofte authored a month ago
755
            guard isChargerObject(chargedDevice) == false else {
756
                return
757
            }
Bogdan Timofte authored a month ago
758

            
Bogdan Timofte authored a month ago
759
            guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
Bogdan Timofte authored a month ago
760
                return
761
            }
762

            
Bogdan Timofte authored a month ago
763
            let resolvedChargingTransportMode = resolvedPreferredChargingTransportMode(
764
                chargingTransportMode,
765
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
766
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
Bogdan Timofte authored a month ago
767
            )
Bogdan Timofte authored a month ago
768
            let resolvedChargingStateMode = resolvedChargingStateMode(
769
                chargingStateMode,
770
                availability: chargingStateAvailability(for: chargedDevice)
771
            )
772
            let charger = resolvedChargingTransportMode == .wireless
773
                ? chargerID.flatMap { fetchChargedDeviceObject(id: $0.uuidString) }
774
                : nil
Bogdan Timofte authored a month ago
775
            if let charger, isChargerObject(charger) == false {
776
                return
777
            }
Bogdan Timofte authored a month ago
778
            let powerbankSource = sourcePowerbankID.flatMap { fetchPowerbankObject(id: $0.uuidString) }
779
            // Wireless transport historically required a charger; accept a powerbank in its place.
780
            guard resolvedChargingTransportMode == .wired || charger != nil || powerbankSource != nil else {
Bogdan Timofte authored a month ago
781
                return
782
            }
Bogdan Timofte authored a month ago
783
            let stopThreshold = resolvedStopThreshold(
Bogdan Timofte authored a month ago
784
                for: chargedDevice,
785
                chargingTransportMode: resolvedChargingTransportMode,
786
                chargingStateMode: resolvedChargingStateMode,
787
                charger: charger,
Bogdan Timofte authored a month ago
788
                fallback: snapshot.fallbackStopThresholdAmps > 0 ? snapshot.fallbackStopThresholdAmps : nil
789
            )
Bogdan Timofte authored a month ago
790
            guard let session = createSessionObject(
791
                for: chargedDevice,
Bogdan Timofte authored a month ago
792
                charger: charger,
793
                snapshot: snapshot,
794
                stopThreshold: stopThreshold,
Bogdan Timofte authored a month ago
795
                chargingTransportMode: resolvedChargingTransportMode,
796
                chargingStateMode: resolvedChargingStateMode,
797
                autoStopEnabled: autoStopEnabled
798
            ) else {
799
                return
800
            }
Bogdan Timofte authored a month ago
801
            if let powerbankSource, let powerbankIDString = stringValue(powerbankSource, key: "id") {
802
                session.setValue(powerbankIDString, forKey: "sourcePowerbankID")
803
            }
Bogdan Timofte authored a month ago
804

            
Bogdan Timofte authored a month ago
805
            if startsFromFlatBattery {
806
                session.setValue(unresolvedFlatBatteryPercent, forKey: "startBatteryPercent")
807
                session.setValue(nil, forKey: "endBatteryPercent")
808
            } else if let initialBatteryPercent {
809
                guard insertBatteryCheckpoint(
810
                    percent: initialBatteryPercent,
Bogdan Timofte authored a month ago
811
                    flag: .initial,
Bogdan Timofte authored a month ago
812
                    timestamp: snapshot.observedAt,
813
                    to: session
814
                ) != nil else {
815
                    return
816
                }
Bogdan Timofte authored a month ago
817
            }
Bogdan Timofte authored a month ago
818
            didSave = saveContext()
819
        }
820
        return didSave
821
    }
822

            
Bogdan Timofte authored a month ago
823
    @discardableResult
824
    func startPowerbankSession(
825
        for snapshot: ChargingMonitorSnapshot,
826
        powerbankID: UUID,
827
        sourcePowerbankID: UUID? = nil,
828
        autoStopEnabled: Bool,
829
        initialBatteryPercent: Double?,
830
        startsFromFlatBattery: Bool
831
    ) -> Bool {
832
        if let initialBatteryPercent,
833
           (initialBatteryPercent.isFinite == false || initialBatteryPercent < 0 || initialBatteryPercent > 100) {
834
            return false
835
        }
836

            
837
        var didSave = false
838
        context.performAndWait {
839
            guard let powerbank = fetchPowerbankObject(id: powerbankID.uuidString) else {
840
                return
841
            }
842

            
843
            guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
844
                return
845
            }
846

            
847
            let powerbankSource = sourcePowerbankID == powerbankID
848
                ? nil
849
                : sourcePowerbankID.flatMap { fetchPowerbankObject(id: $0.uuidString) }
850
            let stopThreshold = optionalDoubleValue(powerbank, key: "configuredCompletionCurrentAmps")
851
                ?? optionalDoubleValue(powerbank, key: "learnedCompletionCurrentAmps")
852
                ?? (snapshot.fallbackStopThresholdAmps > 0 ? snapshot.fallbackStopThresholdAmps : nil)
853

            
854
            guard let session = createPowerbankSubjectSessionObject(
855
                for: powerbank,
856
                snapshot: snapshot,
857
                stopThreshold: stopThreshold,
858
                autoStopEnabled: autoStopEnabled
859
            ) else {
860
                return
861
            }
862
            if let powerbankSource, let powerbankIDString = stringValue(powerbankSource, key: "id") {
863
                session.setValue(powerbankIDString, forKey: "sourcePowerbankID")
864
            }
865

            
866
            if startsFromFlatBattery {
867
                session.setValue(unresolvedFlatBatteryPercent, forKey: "startBatteryPercent")
868
                session.setValue(nil, forKey: "endBatteryPercent")
869
            } else if let initialBatteryPercent {
870
                guard insertBatteryCheckpoint(
871
                    percent: initialBatteryPercent,
872
                    flag: .initial,
873
                    timestamp: snapshot.observedAt,
874
                    subject: .powerbank,
875
                    to: session
876
                ) != nil else {
877
                    return
878
                }
879
            }
880
            didSave = saveContext()
881
        }
882
        return didSave
883
    }
884

            
Bogdan Timofte authored a month ago
885
    @discardableResult
886
    func pauseSession(id sessionID: UUID, observedAt: Date) -> Bool {
887
        var didSave = false
888
        context.performAndWait {
889
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
890
                return
891
            }
892

            
893
            guard statusValue(session, key: "statusRawValue") == .active else {
894
                return
895
            }
896

            
897
            session.setValue(ChargeSessionStatus.paused.rawValue, forKey: "statusRawValue")
898
            session.setValue(observedAt, forKey: "pausedAt")
899
            session.setValue(nil, forKey: "belowThresholdSince")
900
            clearCompletionConfirmationState(for: session)
901
            session.setValue(observedAt, forKey: "updatedAt")
902
            didSave = saveContext()
903
        }
904
        return didSave
905
    }
906

            
907
    @discardableResult
908
    func resumeSession(id sessionID: UUID, snapshot: ChargingMonitorSnapshot?) -> Bool {
909
        var didSave = false
910
        context.performAndWait {
911
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
912
                return
913
            }
914

            
915
            guard statusValue(session, key: "statusRawValue") == .paused else {
916
                return
917
            }
918

            
919
            let resumedAt = snapshot?.observedAt ?? Date()
Bogdan Timofte authored a month ago
920
            if let completionDate = automaticCompletionDate(for: session, referenceDate: resumedAt) {
Bogdan Timofte authored a month ago
921
                finishSession(
922
                    session,
Bogdan Timofte authored a month ago
923
                    observedAt: completionDate,
Bogdan Timofte authored a month ago
924
                    finalBatteryPercent: nil,
925
                    status: .completed
926
                )
927
                guard saveContext() else {
928
                    return
929
                }
930
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
931
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
932
                    didSave = saveContext()
933
                } else {
934
                    didSave = true
935
                }
936
                return
937
            }
938

            
939
            session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
940
            session.setValue(nil, forKey: "pausedAt")
941
            session.setValue(nil, forKey: "belowThresholdSince")
942
            clearCompletionConfirmationState(for: session)
943
            session.setValue(resumedAt, forKey: "lastObservedAt")
944
            if let snapshot {
945
                session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
946
                session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
947
                session.setValue(
948
                    chargingTransportMode(for: session) == .wired ? snapshot.voltageVolts : nil,
949
                    forKey: "lastObservedVoltageVolts"
950
                )
951
            } else {
952
                session.setValue(0, forKey: "lastObservedCurrentAmps")
953
                session.setValue(0, forKey: "lastObservedPowerWatts")
954
                session.setValue(nil, forKey: "lastObservedVoltageVolts")
955
            }
956
            session.setValue(resumedAt, forKey: "updatedAt")
957
            didSave = saveContext()
958
        }
959
        return didSave
960
    }
961

            
962
    @discardableResult
963
    func stopSession(
964
        id sessionID: UUID,
Bogdan Timofte authored a month ago
965
        finalBatteryPercent: Double? = nil
Bogdan Timofte authored a month ago
966
    ) -> Bool {
Bogdan Timofte authored a month ago
967
        if let finalBatteryPercent {
968
            guard finalBatteryPercent.isFinite, finalBatteryPercent >= 0, finalBatteryPercent <= 100 else {
969
                return false
970
            }
Bogdan Timofte authored a month ago
971
        }
972

            
973
        var didSave = false
Bogdan Timofte authored a month ago
974
        var deviceIDToRefresh: String?
975

            
Bogdan Timofte authored a month ago
976
        context.performAndWait {
977
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
978
                return
979
            }
980

            
981
            guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
982
                return
983
            }
984

            
Bogdan Timofte authored a month ago
985
            restoreMeasuredTotalsFromLatestSampleIfNeeded(session)
986

            
Bogdan Timofte authored a month ago
987
            guard hasSavableChargeData(session) else {
988
                return
989
            }
990

            
Bogdan Timofte authored a month ago
991
            let observedAt = snapshotDateForManualStop(session)
992
            finishSession(
993
                session,
994
                observedAt: observedAt,
995
                finalBatteryPercent: finalBatteryPercent,
996
                status: .completed
997
            )
998

            
999
            guard saveContext() else {
1000
                return
1001
            }
1002

            
Bogdan Timofte authored a month ago
1003
            didSave = true
1004
            deviceIDToRefresh = stringValue(session, key: "chargedDeviceID")
1005
        }
1006

            
1007
        if let deviceID = deviceIDToRefresh {
1008
            context.perform { [weak self] in
1009
                guard let self else { return }
1010
                self.refreshDerivedMetrics(forChargedDeviceID: deviceID)
1011
                self.saveContext()
Bogdan Timofte authored a month ago
1012
            }
1013
        }
Bogdan Timofte authored a month ago
1014

            
Bogdan Timofte authored a month ago
1015
        return didSave
1016
    }
1017

            
Bogdan Timofte authored a month ago
1018
    @discardableResult
1019
    func addBatteryCheckpoint(
1020
        percent: Double,
Bogdan Timofte authored a month ago
1021
        for meterMACAddress: String,
Bogdan Timofte authored a month ago
1022
        measuredEnergyWh: Double? = nil
Bogdan Timofte authored a month ago
1023
    ) -> Bool {
1024
        guard percent.isFinite, percent >= 0, percent <= 100 else {
1025
            return false
1026
        }
1027

            
1028
        var didSave = false
1029
        context.performAndWait {
Bogdan Timofte authored a month ago
1030
            guard let session = fetchOpenSessionObject(forMeterMACAddress: meterMACAddress) else {
Bogdan Timofte authored a month ago
1031
                return
1032
            }
1033

            
Bogdan Timofte authored a month ago
1034
            didSave = addBatteryCheckpoint(
1035
                percent: percent,
1036
                measuredEnergyWh: measuredEnergyWh,
Bogdan Timofte authored a month ago
1037
                flag: .intermediate,
Bogdan Timofte authored a month ago
1038
                to: session
1039
            )
Bogdan Timofte authored a month ago
1040
        }
1041
        return didSave
1042
    }
1043

            
1044
    @discardableResult
1045
    func addBatteryCheckpoint(
1046
        percent: Double,
Bogdan Timofte authored a month ago
1047
        for sessionID: UUID,
Bogdan Timofte authored a month ago
1048
        measuredEnergyWh: Double? = nil,
1049
        subject: CheckpointSubject = .chargedDevice,
1050
        barsValue: Int = 0
Bogdan Timofte authored a month ago
1051
    ) -> Bool {
1052
        guard percent.isFinite, percent >= 0, percent <= 100 else {
1053
            return false
1054
        }
1055

            
1056
        var didSave = false
1057
        context.performAndWait {
1058
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
1059
                return
1060
            }
1061

            
Bogdan Timofte authored a month ago
1062
            didSave = addBatteryCheckpoint(
1063
                percent: percent,
1064
                measuredEnergyWh: measuredEnergyWh,
Bogdan Timofte authored a month ago
1065
                flag: .intermediate,
Bogdan Timofte authored a month ago
1066
                subject: subject,
1067
                barsValue: barsValue,
Bogdan Timofte authored a month ago
1068
                to: session
1069
            )
Bogdan Timofte authored a month ago
1070
        }
1071
        return didSave
1072
    }
1073

            
Bogdan Timofte authored a month ago
1074
    @discardableResult
1075
    func deleteBatteryCheckpoint(
1076
        id checkpointID: UUID,
1077
        from sessionID: UUID
1078
    ) -> Bool {
1079
        var didSave = false
1080
        context.performAndWait {
1081
            guard let session = fetchSessionObject(id: sessionID.uuidString),
1082
                  let checkpoint = fetchCheckpointObject(
1083
                    id: checkpointID.uuidString,
1084
                    sessionID: sessionID.uuidString
1085
                  ) else {
1086
                return
1087
            }
1088

            
1089
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
1090
            context.delete(checkpoint)
1091
            refreshCheckpointDerivedValues(for: session)
1092

            
1093
            if let chargedDeviceID {
1094
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1095
            }
Bogdan Timofte authored a month ago
1096

            
1097
            didSave = saveContext()
Bogdan Timofte authored a month ago
1098
        }
1099
        return didSave
1100
    }
1101

            
Bogdan Timofte authored a month ago
1102
    @discardableResult
1103
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
1104
        if let percent, (!percent.isFinite || percent <= 0 || percent > 100) {
1105
            return false
1106
        }
1107

            
1108
        var didSave = false
1109
        context.performAndWait {
1110
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
1111
                return
1112
            }
1113

            
1114
            session.setValue(percent, forKey: "targetBatteryPercent")
1115
            session.setValue(nil, forKey: "targetBatteryAlertTriggeredAt")
1116
            session.setValue(Date(), forKey: "updatedAt")
1117
            didSave = saveContext()
1118
        }
1119
        return didSave
1120
    }
1121

            
1122
    @discardableResult
1123
    func confirmCompletion(for sessionID: UUID) -> Bool {
1124
        var didSave = false
1125
        context.performAndWait {
1126
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
1127
                return
1128
            }
1129

            
1130
            guard statusValue(session, key: "statusRawValue") == .active else {
1131
                return
1132
            }
1133

            
Bogdan Timofte authored a month ago
1134
            finishSession(
1135
                session,
1136
                observedAt: dateValue(session, key: "lastObservedAt") ?? Date(),
1137
                finalBatteryPercent: nil,
1138
                status: .completed
1139
            )
Bogdan Timofte authored a month ago
1140

            
1141
            if saveContext() {
1142
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
1143
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
1144
                    didSave = saveContext()
1145
                } else {
1146
                    didSave = true
1147
                }
1148
            }
1149
        }
1150
        return didSave
1151
    }
1152

            
1153
    @discardableResult
1154
    func continueMonitoringDespiteCompletionContradiction(for sessionID: UUID) -> Bool {
1155
        var didSave = false
1156
        context.performAndWait {
1157
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
1158
                return
1159
            }
1160

            
1161
            guard statusValue(session, key: "statusRawValue") == .active else {
1162
                return
1163
            }
1164

            
1165
            clearCompletionConfirmationState(for: session)
Bogdan Timofte authored a month ago
1166
            session.setValue(nil, forKey: "belowThresholdSince")
Bogdan Timofte authored a month ago
1167
            session.setValue(Date().addingTimeInterval(completionConfirmationCooldown), forKey: "completionConfirmationCooldownUntil")
1168
            session.setValue(Date(), forKey: "updatedAt")
1169
            didSave = saveContext()
1170
        }
1171
        return didSave
1172
    }
1173

            
Bogdan Timofte authored a month ago
1174
    @discardableResult
1175
    func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
1176
        var didSave = false
1177
        context.performAndWait {
1178
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
1179
                return
1180
            }
1181

            
1182
            let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast
1183
            let sessionEnd   = dateValue(session, key: "endedAt")
1184
                ?? dateValue(session, key: "lastObservedAt")
1185
                ?? Date.distantFuture
1186

            
1187
            let effectiveStart = min(max(start ?? sessionStart, sessionStart), sessionEnd)
1188
            let effectiveEnd   = max(min(end ?? sessionEnd, sessionEnd), effectiveStart)
1189
            let persistedStart = effectiveStart == sessionStart ? nil : effectiveStart
1190
            let persistedEnd   = effectiveEnd == sessionEnd ? nil : effectiveEnd
1191

            
1192
            let allSamples = fetchSessionSampleObjects(forSessionID: sessionID.uuidString)
1193
                .compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in
1194
                    guard let ts = dateValue(obj, key: "timestamp") else { return nil }
1195
                    return (
1196
                        timestamp: ts,
1197
                        energy: doubleValue(obj, key: "measuredEnergyWh"),
1198
                        charge: doubleValue(obj, key: "measuredChargeAh")
1199
                    )
1200
                }
1201
                .sorted { $0.timestamp < $1.timestamp }
1202

            
1203
            // Each sample stores cumulative energy since session start.
1204
            // Trimmed energy = value at trimEnd  -  value just before trimStart.
1205
            let baselineSample = allSamples.last { $0.timestamp <= effectiveStart }
1206
            let endSample      = allSamples.last { $0.timestamp <= effectiveEnd }
1207
            let baselineEnergy = baselineSample?.energy ?? 0
1208
            let baselineCharge = baselineSample?.charge ?? 0
1209

            
1210
            if let endSample {
1211
                let trimmedEnergy  = max(endSample.energy - baselineEnergy, 0)
1212
                let trimmedCharge  = max(endSample.charge - baselineCharge, 0)
1213
                session.setValue(trimmedEnergy, forKey: "measuredEnergyWh")
1214
                session.setValue(trimmedCharge, forKey: "measuredChargeAh")
1215
            } else {
1216
                session.setValue(0, forKey: "measuredEnergyWh")
1217
                session.setValue(0, forKey: "measuredChargeAh")
1218
            }
1219

            
1220
            session.setValue(persistedStart, forKey: "trimStart")
1221
            session.setValue(persistedEnd,   forKey: "trimEnd")
1222
            session.setValue(Date(), forKey: "updatedAt")
1223

            
1224
            let checkpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
1225
            for checkpoint in checkpoints {
1226
                guard let timestamp = dateValue(checkpoint, key: "timestamp") else { continue }
1227

            
1228
                if timestamp < effectiveStart || timestamp > effectiveEnd {
1229
                    context.delete(checkpoint)
1230
                    continue
1231
                }
1232

            
1233
                let checkpointSample = allSamples.last { $0.timestamp <= timestamp }
1234
                let rebasedEnergy = max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0)
1235
                let rebasedCharge = max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0)
1236
                checkpoint.setValue(rebasedEnergy, forKey: "measuredEnergyWh")
1237
                checkpoint.setValue(rebasedCharge, forKey: "measuredChargeAh")
1238
            }
1239

            
1240
            let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
1241
                .sorted {
1242
                    (dateValue($0, key: "timestamp") ?? .distantPast)
1243
                        < (dateValue($1, key: "timestamp") ?? .distantPast)
1244
                }
1245
            let restoredInitialCheckpoint = remainingCheckpoints.first { checkpoint in
1246
                let label = stringValue(checkpoint, key: "label")
1247
                let timestamp = dateValue(checkpoint, key: "timestamp") ?? .distantFuture
1248
                return ChargeCheckpointFlag.fromStoredLabel(label) == .initial || timestamp <= effectiveStart
1249
            }
1250

            
1251
            if persistedStart == nil {
1252
                if let restoredInitialCheckpoint,
1253
                   let percent = optionalDoubleValue(restoredInitialCheckpoint, key: "batteryPercent"),
1254
                   percent >= 0 {
1255
                    session.setValue(percent, forKey: "startBatteryPercent")
1256
                }
1257
            } else {
1258
                session.setValue(nil, forKey: "startBatteryPercent")
1259
            }
1260

            
1261
            refreshCheckpointDerivedValues(for: session)
1262

            
1263
            if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1264
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1265
            }
1266

            
1267
            didSave = saveContext()
1268
        }
1269
        return didSave
1270
    }
1271

            
Bogdan Timofte authored a month ago
1272
    @discardableResult
1273
    func commitSessionTrim(sessionID: UUID) -> Bool {
1274
        var didSave = false
1275
        context.performAndWait {
1276
            guard let session = fetchSessionObject(id: sessionID.uuidString),
1277
                  statusValue(session, key: "statusRawValue")?.isOpen == false else {
1278
                return
1279
            }
1280

            
1281
            guard dateValue(session, key: "trimStart") != nil
1282
                    || dateValue(session, key: "trimEnd") != nil else {
1283
                return
1284
            }
1285

            
1286
            let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast
1287
            let sessionEnd = dateValue(session, key: "endedAt")
1288
                ?? dateValue(session, key: "lastObservedAt")
1289
                ?? sessionStart
1290

            
1291
            let effectiveStart = min(max(dateValue(session, key: "trimStart") ?? sessionStart, sessionStart), sessionEnd)
1292
            let effectiveEnd = max(
1293
                min(dateValue(session, key: "trimEnd") ?? sessionEnd, sessionEnd),
1294
                effectiveStart
1295
            )
1296

            
1297
            let sampleObjects = fetchSessionSampleObjects(forSessionID: sessionID.uuidString)
1298
            let allSamples = sampleObjects
1299
                .compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in
1300
                    guard let timestamp = dateValue(obj, key: "timestamp") else { return nil }
1301
                    return (
1302
                        timestamp: timestamp,
1303
                        energy: doubleValue(obj, key: "measuredEnergyWh"),
1304
                        charge: doubleValue(obj, key: "measuredChargeAh")
1305
                    )
1306
                }
1307
                .sorted { $0.timestamp < $1.timestamp }
1308

            
1309
            let baselineSample = allSamples.last { $0.timestamp <= effectiveStart }
1310
            let endSample = allSamples.last { $0.timestamp <= effectiveEnd }
1311
            let baselineEnergy = baselineSample?.energy ?? 0
1312
            let baselineCharge = baselineSample?.charge ?? 0
1313
            let committedEnergy = endSample.map { max($0.energy - baselineEnergy, 0) }
1314
                ?? doubleValue(session, key: "measuredEnergyWh")
1315
            let committedCharge = endSample.map { max($0.charge - baselineCharge, 0) }
1316
                ?? doubleValue(session, key: "measuredChargeAh")
1317

            
1318
            var retainedSamples: [(current: Double, power: Double, voltage: Double?)] = []
1319
            for sample in sampleObjects {
1320
                guard let timestamp = dateValue(sample, key: "timestamp") else {
1321
                    context.delete(sample)
1322
                    continue
1323
                }
1324

            
1325
                guard timestamp >= effectiveStart && timestamp <= effectiveEnd else {
1326
                    context.delete(sample)
1327
                    continue
1328
                }
1329

            
1330
                let rebasedEnergy = max(doubleValue(sample, key: "measuredEnergyWh") - baselineEnergy, 0)
1331
                let rebasedCharge = max(doubleValue(sample, key: "measuredChargeAh") - baselineCharge, 0)
1332
                let elapsed = max(timestamp.timeIntervalSince(effectiveStart), 0)
1333
                let rebasedBucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
1334

            
1335
                sample.setValue("\(sessionID.uuidString)-\(rebasedBucketIndex)", forKey: "id")
1336
                sample.setValue(rebasedBucketIndex, forKey: "bucketIndex")
1337
                sample.setValue(rebasedEnergy, forKey: "measuredEnergyWh")
1338
                sample.setValue(rebasedCharge, forKey: "measuredChargeAh")
1339
                sample.setValue(Date(), forKey: "updatedAt")
1340

            
1341
                retainedSamples.append(
1342
                    (
1343
                        current: doubleValue(sample, key: "averageCurrentAmps"),
1344
                        power: doubleValue(sample, key: "averagePowerWatts"),
1345
                        voltage: optionalDoubleValue(sample, key: "averageVoltageVolts")
1346
                    )
1347
                )
1348
            }
1349

            
1350
            for checkpoint in fetchCheckpointObjects(forSessionID: sessionID.uuidString) {
1351
                guard let timestamp = dateValue(checkpoint, key: "timestamp") else {
1352
                    context.delete(checkpoint)
1353
                    continue
1354
                }
1355

            
1356
                guard timestamp >= effectiveStart && timestamp <= effectiveEnd else {
1357
                    context.delete(checkpoint)
1358
                    continue
1359
                }
1360

            
1361
                let checkpointSample = allSamples.last { $0.timestamp <= timestamp }
1362
                checkpoint.setValue(
1363
                    max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0),
1364
                    forKey: "measuredEnergyWh"
1365
                )
1366
                checkpoint.setValue(
1367
                    max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0),
1368
                    forKey: "measuredChargeAh"
1369
                )
1370
            }
1371

            
1372
            if !retainedSamples.isEmpty {
1373
                let positiveCurrents = retainedSamples.map { $0.current }.filter { $0 > 0 }
1374
                session.setValue(positiveCurrents.min(), forKey: "minimumObservedCurrentAmps")
1375
                session.setValue(retainedSamples.map { $0.current }.max(), forKey: "maximumObservedCurrentAmps")
1376
                session.setValue(retainedSamples.map { $0.power }.max(), forKey: "maximumObservedPowerWatts")
1377
                session.setValue(retainedSamples.compactMap { $0.voltage }.max(), forKey: "maximumObservedVoltageVolts")
1378
                session.setValue(
1379
                    retainedSamples.contains { $0.power > 0.05 || $0.current > 0.01 },
1380
                    forKey: "hasObservedChargeFlow"
1381
                )
1382
            } else {
1383
                session.setValue(nil, forKey: "minimumObservedCurrentAmps")
1384
                session.setValue(nil, forKey: "maximumObservedCurrentAmps")
1385
                session.setValue(nil, forKey: "maximumObservedPowerWatts")
1386
                session.setValue(nil, forKey: "maximumObservedVoltageVolts")
1387
                session.setValue(committedEnergy > 0 || committedCharge > 0, forKey: "hasObservedChargeFlow")
1388
            }
1389

            
1390
            session.setValue(effectiveStart, forKey: "startedAt")
1391
            session.setValue(effectiveEnd, forKey: "lastObservedAt")
1392
            if dateValue(session, key: "endedAt") != nil {
1393
                session.setValue(effectiveEnd, forKey: "endedAt")
1394
            }
1395
            session.setValue(committedEnergy, forKey: "measuredEnergyWh")
1396
            session.setValue(committedCharge, forKey: "measuredChargeAh")
1397
            session.setValue(nil, forKey: "trimStart")
1398
            session.setValue(nil, forKey: "trimEnd")
1399
            session.setValue(Date(), forKey: "updatedAt")
1400

            
1401
            refreshCheckpointDerivedValues(for: session)
1402

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

            
1407
            didSave = saveContext()
1408
        }
1409
        return didSave
1410
    }
1411

            
Bogdan Timofte authored a month ago
1412
    @discardableResult
1413
    func deleteChargeSession(id sessionID: UUID) -> Bool {
1414
        var didSave = false
1415
        context.performAndWait {
1416
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
1417
                return
1418
            }
1419

            
1420
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
1421

            
1422
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
1423
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
1424
            context.delete(session)
1425

            
1426
            guard saveContext() else {
1427
                return
1428
            }
1429

            
1430
            if let chargedDeviceID {
1431
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1432
                didSave = saveContext()
1433
            } else {
1434
                didSave = true
1435
            }
1436
        }
1437
        return didSave
1438
    }
1439

            
1440
    @discardableResult
1441
    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
1442
        var didSave = false
1443

            
1444
        context.performAndWait {
1445
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
1446
                return
1447
            }
1448

            
1449
            let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
1450
            let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
1451
            let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
1452

            
1453
            var impactedChargedDeviceIDs = Set<String>()
1454

            
1455
            for session in deviceSessions {
1456
                if let impactedID = stringValue(session, key: "chargedDeviceID") {
1457
                    impactedChargedDeviceIDs.insert(impactedID)
1458
                }
1459
                if let impactedChargerID = stringValue(session, key: "chargerID") {
1460
                    impactedChargedDeviceIDs.insert(impactedChargerID)
1461
                }
1462
                if let sessionID = stringValue(session, key: "id") {
1463
                    fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete)
1464
                    fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete)
1465
                }
1466
                context.delete(session)
1467
            }
1468

            
1469
            if deviceClass == .charger {
1470
                for session in linkedWirelessSessions {
1471
                    guard stringValue(session, key: "chargedDeviceID") != chargedDeviceID.uuidString else {
1472
                        continue
1473
                    }
1474
                    if let impactedID = stringValue(session, key: "chargedDeviceID") {
1475
                        impactedChargedDeviceIDs.insert(impactedID)
1476
                    }
1477
                    session.setValue(nil, forKey: "chargerID")
1478
                    session.setValue(Date(), forKey: "updatedAt")
1479
                }
1480
            }
1481

            
1482
            context.delete(chargedDevice)
1483

            
1484
            guard saveContext() else {
1485
                return
1486
            }
1487

            
1488
            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
1489
            for impactedID in impactedChargedDeviceIDs {
1490
                refreshDerivedMetrics(forChargedDeviceID: impactedID)
1491
            }
1492
            didSave = saveContext()
1493
        }
1494

            
1495
        return didSave
1496
    }
1497

            
1498
    @discardableResult
1499
    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
1500
        var didSave = false
1501

            
1502
        context.performAndWait {
Bogdan Timofte authored a month ago
1503
            guard let session = fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress),
1504
                  let resolvedDevice = stringValue(session, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:)) else {
1505
                return
1506
            }
Bogdan Timofte authored a month ago
1507

            
Bogdan Timofte authored a month ago
1508
            if statusValue(session, key: "statusRawValue") == .paused {
Bogdan Timofte authored a month ago
1509
                if maybeCompleteOpenSession(session, observedAt: snapshot.observedAt) {
Bogdan Timofte authored a month ago
1510
                    didSave = true
1511
                }
Bogdan Timofte authored a month ago
1512
                return
1513
            }
1514

            
Bogdan Timofte authored a month ago
1515
            let chargingTransportMode = self.chargingTransportMode(for: session)
1516
            let chargingStateMode = self.chargingStateMode(for: session)
Bogdan Timofte authored a month ago
1517
            let charger = chargingTransportMode == .wireless
Bogdan Timofte authored a month ago
1518
                ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
1519
                : nil
1520
            guard chargingTransportMode == .wired || charger != nil else {
1521
                return
1522
            }
1523
            let stopThreshold = resolvedStopThreshold(
1524
                for: resolvedDevice,
1525
                chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
1526
                chargingStateMode: chargingStateMode,
1527
                charger: charger,
1528
                fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1529
            )
1530

            
Bogdan Timofte authored a month ago
1531
            let sessionSnapshot = snapshotClampedToMaximumDuration(snapshot, for: session)
1532
            update(session: session, with: sessionSnapshot, stopThreshold: stopThreshold, charger: charger)
1533
            let aggregatedSample = updateAggregatedSample(session: session, with: sessionSnapshot)
1534
            if let completionDate = automaticCompletionDate(for: session, referenceDate: snapshot.observedAt),
1535
               statusValue(session, key: "statusRawValue")?.isOpen == true {
1536
                finishSession(
1537
                    session,
1538
                    observedAt: completionDate,
1539
                    finalBatteryPercent: nil,
1540
                    status: .completed
1541
                )
1542
            }
Bogdan Timofte authored a month ago
1543

            
Bogdan Timofte authored a month ago
1544
            let saveReason = saveReason(for: session, observedAt: sessionSnapshot.observedAt)
Bogdan Timofte authored a month ago
1545
            let shouldPersistAggregatedCurve = aggregatedSample.map {
Bogdan Timofte authored a month ago
1546
                shouldPersistAggregatedSample($0, observedAt: sessionSnapshot.observedAt)
Bogdan Timofte authored a month ago
1547
            } ?? false
1548

            
1549
            guard saveReason != .none || shouldPersistAggregatedCurve else {
Bogdan Timofte authored a month ago
1550
                return
1551
            }
1552

            
Bogdan Timofte authored a month ago
1553
            session.setValue(sessionSnapshot.observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
1554

            
1555
            if saveContext() {
1556
                if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
1557
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
1558
                    didSave = saveContext()
1559
                } else {
1560
                    didSave = true
1561
                }
1562
            }
1563
        }
1564

            
1565
        return didSave
1566
    }
1567

            
1568
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
1569
        var summaries: [ChargedDeviceSummary] = []
1570

            
1571
        context.performAndWait {
1572
            let devices = fetchObjects(entityName: EntityName.chargedDevice)
1573
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
1574
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
1575

            
1576
            let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
1577
            let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
1578
            let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
Bogdan Timofte authored a month ago
1579
            let sampleBackedSessionIDs = sampleBackedSessionIDs(
1580
                devices: devices,
1581
                sessionsByDeviceID: sessionsByDeviceID,
1582
                sessionsByChargerID: sessionsByChargerID
1583
            )
1584
            let samplesBySessionID = Dictionary(
1585
                grouping: fetchSessionSampleObjects(forSessionIDs: Array(sampleBackedSessionIDs))
1586
            ) { stringValue($0, key: "sessionID") ?? "" }
Bogdan Timofte authored a month ago
1587

            
1588
            summaries = devices.compactMap { device in
1589
                guard
1590
                    let id = uuidValue(device, key: "id"),
1591
                    let name = stringValue(device, key: "name"),
1592
                    let qrIdentifier = stringValue(device, key: "qrIdentifier"),
1593
                    let rawClass = stringValue(device, key: "deviceClassRawValue"),
1594
                    let deviceClass = ChargedDeviceClass(rawValue: rawClass)
1595
                else {
1596
                    return nil
1597
                }
1598

            
Bogdan Timofte authored a month ago
1599
                let chargingStateAvailability = chargingStateAvailability(for: device)
1600
                let supportsWiredCharging = supportsWiredCharging(for: device)
1601
                let supportsWirelessCharging = supportsWirelessCharging(for: device)
1602
                let templateDefinition = templateDefinition(for: device)
1603

            
Bogdan Timofte authored a month ago
1604
                let sessionObjects = relevantSessionObjects(
1605
                    for: id.uuidString,
1606
                    deviceClass: deviceClass,
1607
                    sessionsByDeviceID: sessionsByDeviceID,
1608
                    sessionsByChargerID: sessionsByChargerID
1609
                )
1610
                let sessionSummaries = sessionObjects
1611
                    .compactMap { session in
1612
                        makeSessionSummary(
1613
                            from: session,
1614
                            checkpoints: checkpointsBySessionID[stringValue(session, key: "id") ?? ""] ?? [],
1615
                            samples: samplesBySessionID[stringValue(session, key: "id") ?? ""] ?? []
1616
                        )
1617
                    }
1618
                    .sorted { lhs, rhs in
Bogdan Timofte authored a month ago
1619
                        if lhs.status.isOpen && !rhs.status.isOpen {
Bogdan Timofte authored a month ago
1620
                            return true
1621
                        }
Bogdan Timofte authored a month ago
1622
                        if !lhs.status.isOpen && rhs.status.isOpen {
1623
                            return false
1624
                        }
1625
                        if lhs.status == .active && rhs.status == .paused {
1626
                            return true
1627
                        }
1628
                        if lhs.status == .paused && rhs.status == .active {
Bogdan Timofte authored a month ago
1629
                            return false
1630
                        }
1631
                        return lhs.startedAt > rhs.startedAt
1632
                    }
1633

            
1634
                return ChargedDeviceSummary(
1635
                    id: id,
1636
                    qrIdentifier: qrIdentifier,
1637
                    name: name,
1638
                    deviceClass: deviceClass,
Bogdan Timofte authored a month ago
1639
                    deviceTemplateID: stringValue(device, key: "deviceTemplateID"),
1640
                    templateDefinition: templateDefinition,
Bogdan Timofte authored a month ago
1641
                    profileID: stringValue(device, key: "profileID"),
1642
                    hasInternalSubject: (device.value(forKey: "hasInternalSubject") as? Bool) ?? false,
Bogdan Timofte authored a month ago
1643
                    supportsChargingWhileOff: chargingStateAvailability.supportsChargingWhileOff,
1644
                    chargingStateAvailability: chargingStateAvailability,
1645
                    supportsWiredCharging: supportsWiredCharging,
1646
                    supportsWirelessCharging: supportsWirelessCharging,
Bogdan Timofte authored a month ago
1647
                    chargerType: chargerType(for: device),
Bogdan Timofte authored a month ago
1648
                    wirelessChargingProfile: wirelessChargingProfile(for: device),
Bogdan Timofte authored a month ago
1649
                    configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"),
1650
                    learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"),
Bogdan Timofte authored a month ago
1651
                    wirelessChargerEfficiencyFactor: optionalDoubleValue(device, key: "wirelessChargerEfficiencyFactor"),
1652
                    wiredChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wiredChargeCompletionCurrentAmps"),
1653
                    wirelessChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wirelessChargeCompletionCurrentAmps"),
1654
                    chargerObservedVoltageSelections: decodedObservedVoltageSelections(from: device),
1655
                    chargerIdleCurrentAmps: optionalDoubleValue(device, key: "chargerIdleCurrentAmps"),
1656
                    chargerEfficiencyFactor: optionalDoubleValue(device, key: "chargerEfficiencyFactor"),
1657
                    chargerMaximumPowerWatts: optionalDoubleValue(device, key: "chargerMaximumPowerWatts"),
1658
                    notes: stringValue(device, key: "notes"),
1659
                    minimumCurrentAmps: optionalDoubleValue(device, key: "minimumCurrentAmps"),
1660
                    estimatedBatteryCapacityWh: optionalDoubleValue(device, key: "estimatedBatteryCapacityWh"),
1661
                    wiredMinimumCurrentAmps: optionalDoubleValue(device, key: "wiredMinimumCurrentAmps"),
1662
                    wirelessMinimumCurrentAmps: optionalDoubleValue(device, key: "wirelessMinimumCurrentAmps"),
1663
                    wiredEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wiredEstimatedBatteryCapacityWh"),
1664
                    wirelessEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wirelessEstimatedBatteryCapacityWh"),
1665
                    createdAt: dateValue(device, key: "createdAt") ?? .distantPast,
1666
                    updatedAt: dateValue(device, key: "updatedAt") ?? .distantPast,
1667
                    sessions: sessionSummaries,
1668
                    capacityHistory: buildCapacityHistory(from: sessionSummaries),
Bogdan Timofte authored a month ago
1669
                    typicalCurve: buildTypicalCurve(from: sessionSummaries),
Bogdan Timofte authored a month ago
1670
                    standbyPowerMeasurements: [],
1671
                    consumptionSessions: []
Bogdan Timofte authored a month ago
1672
                )
1673
            }
1674
            .sorted { lhs, rhs in
1675
                if lhs.activeSession != nil && rhs.activeSession == nil {
1676
                    return true
1677
                }
1678
                if lhs.activeSession == nil && rhs.activeSession != nil {
1679
                    return false
1680
                }
1681
                if lhs.updatedAt != rhs.updatedAt {
1682
                    return lhs.updatedAt > rhs.updatedAt
1683
                }
1684
                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
1685
            }
1686
        }
1687

            
1688
        return summaries
1689
    }
1690

            
1691
    func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
1692
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1693
        guard !normalizedMAC.isEmpty else { return nil }
1694

            
Bogdan Timofte authored a month ago
1695
        var summary: ChargeSessionSummary?
1696

            
1697
        context.performAndWait {
Bogdan Timofte authored a month ago
1698
            guard let session = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
Bogdan Timofte authored a month ago
1699
                  let sessionID = stringValue(session, key: "id") else {
1700
                return
1701
            }
1702

            
1703
            summary = makeSessionSummary(
1704
                from: session,
1705
                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
1706
                samples: fetchSessionSampleObjects(forSessionID: sessionID)
1707
            )
1708
        }
1709

            
1710
        return summary
Bogdan Timofte authored a month ago
1711
    }
1712

            
Bogdan Timofte authored a month ago
1713
    /// Materialize all Powerbank entities into Swift summaries, with sessions in which the
1714
    /// powerbank participates either as the charged subject (`chargedPowerbankID`) or as the
1715
    /// supplying source (`sourcePowerbankID`). Discharge curve aggregation across multiple
1716
    /// concurrent device-side sessions is derived view-side from `sessionsAsSource`.
1717
    func fetchPowerbankSummaries() -> [PowerbankSummary] {
1718
        var summaries: [PowerbankSummary] = []
1719

            
1720
        context.performAndWait {
1721
            let powerbanks = fetchObjects(entityName: EntityName.powerbank)
1722
            guard !powerbanks.isEmpty else {
1723
                return
1724
            }
1725

            
1726
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
1727
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
1728
            let checkpointsBySessionID = Dictionary(grouping: checkpoints) {
1729
                stringValue($0, key: "sessionID") ?? ""
1730
            }
1731
            let sessionsAsSubject = Dictionary(grouping: sessions) {
1732
                stringValue($0, key: "chargedPowerbankID") ?? ""
1733
            }
1734
            let sessionsAsSource = Dictionary(grouping: sessions) {
1735
                stringValue($0, key: "sourcePowerbankID") ?? ""
1736
            }
1737

            
1738
            summaries = powerbanks.compactMap { powerbank in
1739
                guard
1740
                    let id = uuidValue(powerbank, key: "id"),
1741
                    let name = stringValue(powerbank, key: "name"),
1742
                    let qrIdentifier = stringValue(powerbank, key: "qrIdentifier")
1743
                else {
1744
                    return nil
1745
                }
1746

            
1747
                let templateID = stringValue(powerbank, key: "deviceTemplateID")
1748
                let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID)
1749

            
1750
                let reportingRaw = stringValue(powerbank, key: "batteryLevelReportingRawValue")
1751
                let reporting = reportingRaw.flatMap(BatteryLevelReporting.init(rawValue:)) ?? .percent
1752
                let barsCount = Int(optionalInt16Value(powerbank, key: "batteryBarsCount") ?? 0)
1753

            
1754
                let sessionsAsSubjectRaw = sessionsAsSubject[id.uuidString] ?? []
1755
                let sessionsAsSourceRaw = sessionsAsSource[id.uuidString] ?? []
1756

            
1757
                let subjectSessions = sessionsAsSubjectRaw
1758
                    .compactMap { session -> ChargeSessionSummary? in
1759
                        let sessionID = stringValue(session, key: "id") ?? ""
1760
                        return makeSessionSummary(
1761
                            from: session,
1762
                            checkpoints: checkpointsBySessionID[sessionID] ?? [],
1763
                            samples: []
1764
                        )
1765
                    }
1766
                    .sorted { $0.startedAt > $1.startedAt }
1767

            
1768
                let sourceSessions = sessionsAsSourceRaw
1769
                    .compactMap { session -> ChargeSessionSummary? in
1770
                        let sessionID = stringValue(session, key: "id") ?? ""
1771
                        return makeSessionSummary(
1772
                            from: session,
1773
                            checkpoints: checkpointsBySessionID[sessionID] ?? [],
1774
                            samples: []
1775
                        )
1776
                    }
1777
                    .sorted { $0.startedAt > $1.startedAt }
1778

            
1779
                let observedVoltages: [Double] = (stringValue(powerbank, key: "sourceObservedVoltageSelectionsRawValue") ?? "")
1780
                    .split(separator: ",")
1781
                    .compactMap { Double($0) }
1782
                    .sorted()
1783

            
1784
                let derived = derivedPowerbankMetrics(
1785
                    sessionsAsSubject: subjectSessions,
1786
                    sessionsAsSource: sourceSessions,
1787
                    reporting: reporting
1788
                )
1789

            
1790
                return PowerbankSummary(
1791
                    id: id,
1792
                    qrIdentifier: qrIdentifier,
1793
                    name: name,
1794
                    deviceTemplateID: templateID,
1795
                    templateDefinition: templateDefinition,
1796
                    batteryLevelReporting: reporting,
1797
                    batteryBarsCount: barsCount,
1798
                    estimatedBatteryCapacityWh: optionalDoubleValue(powerbank, key: "estimatedBatteryCapacityWh"),
1799
                    apparentCapacityWh: derived.apparentCapacityWh
1800
                        ?? optionalDoubleValue(powerbank, key: "apparentCapacityWh"),
1801
                    configuredCompletionCurrentAmps: optionalDoubleValue(powerbank, key: "configuredCompletionCurrentAmps"),
1802
                    learnedCompletionCurrentAmps: optionalDoubleValue(powerbank, key: "learnedCompletionCurrentAmps"),
1803
                    minimumCurrentAmps: optionalDoubleValue(powerbank, key: "minimumCurrentAmps"),
1804
                    sourceObservedVoltageSelections: derived.voltageMaxCurrents.keys.sorted().isEmpty
1805
                        ? observedVoltages
1806
                        : derived.voltageMaxCurrents.keys.sorted(),
1807
                    sourceVoltageMaxCurrents: derived.voltageMaxCurrents,
1808
                    sourceIdleCurrentAmps: optionalDoubleValue(powerbank, key: "sourceIdleCurrentAmps"),
1809
                    sourceMaximumPowerWatts: derived.maxPowerWatts
1810
                        ?? optionalDoubleValue(powerbank, key: "sourceMaximumPowerWatts"),
1811
                    sourceEfficiencyFactor: derived.efficiencyFactor
1812
                        ?? optionalDoubleValue(powerbank, key: "sourceEfficiencyFactor"),
1813
                    notes: stringValue(powerbank, key: "notes"),
1814
                    createdAt: dateValue(powerbank, key: "createdAt") ?? Date(),
1815
                    updatedAt: dateValue(powerbank, key: "updatedAt") ?? Date(),
1816
                    sessionsAsSubject: subjectSessions,
1817
                    sessionsAsSource: sourceSessions
1818
                )
1819
            }
1820
        }
1821

            
1822
        return summaries
1823
    }
1824

            
Bogdan Timofte authored a month ago
1825
    private func createSessionObject(
1826
        for chargedDevice: NSManagedObject,
1827
        charger: NSManagedObject?,
1828
        snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1829
        stopThreshold: Double?,
1830
        chargingTransportMode: ChargingTransportMode,
1831
        chargingStateMode: ChargingStateMode,
1832
        autoStopEnabled: Bool
Bogdan Timofte authored a month ago
1833
    ) -> NSManagedObject? {
1834
        guard
1835
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
1836
            let chargedDeviceID = stringValue(chargedDevice, key: "id")
1837
        else {
1838
            return nil
1839
        }
1840

            
1841
        let session = NSManagedObject(entity: entity, insertInto: context)
1842
        let now = snapshot.observedAt
1843
        session.setValue(UUID().uuidString, forKey: "id")
1844
        session.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1845
        session.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
1846
        session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress")
1847
        session.setValue(snapshot.meterName, forKey: "meterName")
1848
        session.setValue(snapshot.meterModel, forKey: "meterModel")
1849
        session.setValue(now, forKey: "startedAt")
1850
        session.setValue(now, forKey: "lastObservedAt")
1851
        session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
Bogdan Timofte authored a month ago
1852
        let usesMeterCounters = snapshot.meterEnergyCounterWh != nil || snapshot.meterChargeCounterAh != nil
1853
        session.setValue(
1854
            (usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue,
1855
            forKey: "sourceModeRawValue"
1856
        )
Bogdan Timofte authored a month ago
1857
        session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
1858
        session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue")
1859
        session.setValue(autoStopEnabled, forKey: "autoStopEnabled")
1860
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1861
        session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
1862
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1863
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1864
        session.setValue(
1865
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1866
            forKey: "lastObservedVoltageVolts"
1867
        )
Bogdan Timofte authored a month ago
1868
        session.setValue(
1869
            hasObservedChargeFlow(
1870
                currentAmps: snapshot.currentAmps,
1871
                chargingTransportMode: chargingTransportMode,
1872
                charger: charger,
1873
                stopThreshold: stopThreshold
1874
            ),
1875
            forKey: "hasObservedChargeFlow"
1876
        )
Bogdan Timofte authored a month ago
1877
        session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
1878
        session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
1879
        session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
1880
        session.setValue(
1881
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1882
            forKey: "maximumObservedVoltageVolts"
1883
        )
1884
        session.setValue(boolValue(chargedDevice, key: "supportsChargingWhileOff"), forKey: "supportsChargingWhileOff")
1885
        if let selectedDataGroup = snapshot.selectedDataGroup {
1886
            session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup")
1887
        }
1888
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1889
            session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1890
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1891
        }
1892
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1893
            session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1894
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1895
        }
Bogdan Timofte authored a month ago
1896
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1897
            setValue(meterRecordingDurationSeconds, on: session, key: "meterDurationBaselineSeconds")
1898
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1899
        }
Bogdan Timofte authored a month ago
1900
        session.setValue(now, forKey: "createdAt")
1901
        session.setValue(now, forKey: "updatedAt")
1902

            
1903
        return session
1904
    }
1905

            
Bogdan Timofte authored a month ago
1906
    private func createPowerbankSubjectSessionObject(
1907
        for powerbank: NSManagedObject,
1908
        snapshot: ChargingMonitorSnapshot,
1909
        stopThreshold: Double?,
1910
        autoStopEnabled: Bool
1911
    ) -> NSManagedObject? {
1912
        guard
1913
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
1914
            let powerbankID = stringValue(powerbank, key: "id")
1915
        else {
1916
            return nil
1917
        }
1918

            
1919
        let session = NSManagedObject(entity: entity, insertInto: context)
1920
        let now = snapshot.observedAt
1921
        session.setValue(UUID().uuidString, forKey: "id")
1922
        session.setValue(powerbankID, forKey: "chargedDeviceID")
1923
        session.setValue(powerbankID, forKey: "chargedPowerbankID")
1924
        session.setValue(nil, forKey: "chargerID")
1925
        session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress")
1926
        session.setValue(snapshot.meterName, forKey: "meterName")
1927
        session.setValue(snapshot.meterModel, forKey: "meterModel")
1928
        session.setValue(now, forKey: "startedAt")
1929
        session.setValue(now, forKey: "lastObservedAt")
1930
        session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
1931
        let usesMeterCounters = snapshot.meterEnergyCounterWh != nil || snapshot.meterChargeCounterAh != nil
1932
        session.setValue(
1933
            (usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue,
1934
            forKey: "sourceModeRawValue"
1935
        )
1936
        session.setValue(ChargingTransportMode.wired.rawValue, forKey: "chargingTransportRawValue")
1937
        session.setValue(ChargingStateMode.on.rawValue, forKey: "chargingStateRawValue")
1938
        session.setValue(autoStopEnabled, forKey: "autoStopEnabled")
1939
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
1940
        session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
1941
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1942
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1943
        session.setValue(snapshot.voltageVolts, forKey: "lastObservedVoltageVolts")
1944
        session.setValue(
1945
            hasObservedChargeFlow(
1946
                currentAmps: snapshot.currentAmps,
1947
                chargingTransportMode: .wired,
1948
                charger: nil,
1949
                stopThreshold: stopThreshold
1950
            ),
1951
            forKey: "hasObservedChargeFlow"
1952
        )
1953
        session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
1954
        session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
1955
        session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
1956
        session.setValue(snapshot.voltageVolts, forKey: "maximumObservedVoltageVolts")
1957
        session.setValue(false, forKey: "supportsChargingWhileOff")
1958
        if let selectedDataGroup = snapshot.selectedDataGroup {
1959
            session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup")
1960
        }
1961
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1962
            session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1963
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1964
        }
1965
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1966
            session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1967
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1968
        }
1969
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1970
            setValue(meterRecordingDurationSeconds, on: session, key: "meterDurationBaselineSeconds")
1971
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1972
        }
1973
        session.setValue(now, forKey: "createdAt")
1974
        session.setValue(now, forKey: "updatedAt")
1975

            
1976
        return session
1977
    }
1978

            
Bogdan Timofte authored a month ago
1979
    private func update(
1980
        session: NSManagedObject,
1981
        with snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1982
        stopThreshold: Double?,
1983
        charger: NSManagedObject?
Bogdan Timofte authored a month ago
1984
    ) {
1985
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1986
        let lastObservedAt = dateValue(session, key: "lastObservedAt")
1987
        let previousPower = doubleValue(session, key: "lastObservedPowerWatts")
1988
        let previousCurrent = doubleValue(session, key: "lastObservedCurrentAmps")
1989
        var measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1990
        var measuredChargeAh = doubleValue(session, key: "measuredChargeAh")
1991
        var sourceMode = ChargeSessionSourceMode(rawValue: stringValue(session, key: "sourceModeRawValue") ?? "") ?? .live
1992
        var usedOfflineMeterCounters = boolValue(session, key: "usedOfflineMeterCounters")
1993

            
1994
        if let lastObservedAt {
1995
            let deltaSeconds = max(snapshot.observedAt.timeIntervalSince(lastObservedAt), 0)
1996
            if deltaSeconds > 0, deltaSeconds <= maximumLiveIntegrationGap {
1997
                measuredEnergyWh += max(previousPower, 0) * deltaSeconds / 3600
1998
                measuredChargeAh += max(previousCurrent, 0) * deltaSeconds / 3600
1999
                if sourceMode == .offline {
2000
                    sourceMode = .blended
2001
                }
2002
            }
2003
        }
2004

            
2005
        if let counterGroup = snapshot.selectedDataGroup,
2006
           let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
2007
           UInt8(storedGroup) != counterGroup {
2008
            session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
2009
            session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
2010
            session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
2011
        }
2012

            
2013
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
2014
            let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
2015
            let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
2016
            if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
2017
                session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
2018
            }
2019

            
2020
            if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
2021
                let offlineEnergy = meterEnergyCounterWh - baselineEnergy
Bogdan Timofte authored a month ago
2022
                measuredEnergyWh = max(offlineEnergy, 0)
Bogdan Timofte authored a month ago
2023
                usedOfflineMeterCounters = true
Bogdan Timofte authored a month ago
2024
                sourceMode = .offline
Bogdan Timofte authored a month ago
2025
            } else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
2026
                let delta = meterEnergyCounterWh - lastEnergy
2027
                if delta > 0 {
2028
                    measuredEnergyWh += delta
2029
                    usedOfflineMeterCounters = true
2030
                    sourceMode = .blended
2031
                }
2032
            }
2033
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
2034
        }
2035

            
2036
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
2037
            let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
2038
            let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
2039
            if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
2040
                session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
2041
            }
2042

            
2043
            if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
2044
                let offlineCharge = meterChargeCounterAh - baselineCharge
Bogdan Timofte authored a month ago
2045
                measuredChargeAh = max(offlineCharge, 0)
Bogdan Timofte authored a month ago
2046
                usedOfflineMeterCounters = true
2047
            } else if let lastCharge, meterChargeCounterAh > lastCharge {
2048
                let delta = meterChargeCounterAh - lastCharge
2049
                if delta > 0 {
2050
                    measuredChargeAh += delta
2051
                    usedOfflineMeterCounters = true
2052
                }
2053
            }
2054
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
2055
        }
2056

            
Bogdan Timofte authored a month ago
2057
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
2058
            let baselineDuration = optionalDoubleValue(session, key: "meterDurationBaselineSeconds") ?? meterRecordingDurationSeconds
2059
            if optionalDoubleValue(session, key: "meterDurationBaselineSeconds") == nil {
2060
                setValue(baselineDuration, on: session, key: "meterDurationBaselineSeconds")
2061
            }
2062
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
2063
        }
2064

            
Bogdan Timofte authored a month ago
2065
        let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps")
2066
        let updatedMinimum: Double
2067
        if snapshot.currentAmps > 0 {
2068
            updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps)
2069
        } else {
2070
            updatedMinimum = existingMinimum ?? 0
2071
        }
2072

            
Bogdan Timofte authored a month ago
2073
        let effectiveCurrent = effectiveCurrentAmps(
2074
            fromMeasuredCurrent: snapshot.currentAmps,
2075
            chargingTransportMode: sessionChargingTransportMode,
2076
            charger: charger
2077
        )
2078
        let observedChargeFlow = boolValue(session, key: "hasObservedChargeFlow")
2079
            || hasObservedChargeFlow(
2080
                currentAmps: snapshot.currentAmps,
2081
                chargingTransportMode: sessionChargingTransportMode,
2082
                charger: charger,
2083
                stopThreshold: stopThreshold
2084
            )
2085

            
Bogdan Timofte authored a month ago
2086
        session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh")
2087
        session.setValue(measuredChargeAh, forKey: "measuredChargeAh")
2088
        session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps")
Bogdan Timofte authored a month ago
2089
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
2090
        session.setValue(snapshot.observedAt, forKey: "lastObservedAt")
2091
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
2092
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
2093
        session.setValue(
2094
            sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil,
2095
            forKey: "lastObservedVoltageVolts"
2096
        )
Bogdan Timofte authored a month ago
2097
        session.setValue(observedChargeFlow, forKey: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
2098
        session.setValue(
2099
            max(optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? snapshot.currentAmps, snapshot.currentAmps),
2100
            forKey: "maximumObservedCurrentAmps"
2101
        )
2102
        session.setValue(
2103
            max(optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? snapshot.powerWatts, snapshot.powerWatts),
2104
            forKey: "maximumObservedPowerWatts"
2105
        )
2106
        session.setValue(
2107
            sessionChargingTransportMode == .wired
2108
                ? max(optionalDoubleValue(session, key: "maximumObservedVoltageVolts") ?? snapshot.voltageVolts, snapshot.voltageVolts)
2109
                : nil,
2110
            forKey: "maximumObservedVoltageVolts"
2111
        )
2112
        session.setValue(usedOfflineMeterCounters, forKey: "usedOfflineMeterCounters")
2113
        session.setValue(sourceMode.rawValue, forKey: "sourceModeRawValue")
2114
        maybeTriggerTargetBatteryAlert(for: session, observedAt: snapshot.observedAt)
2115

            
Bogdan Timofte authored a month ago
2116
        guard boolValue(session, key: "autoStopEnabled"), let stopThreshold, stopThreshold > 0, observedChargeFlow else {
2117
            session.setValue(nil, forKey: "belowThresholdSince")
2118
            clearCompletionConfirmationState(for: session)
2119
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
2120
            return
2121
        }
2122

            
2123
        if effectiveCurrent <= stopThreshold {
Bogdan Timofte authored a month ago
2124
            let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt
2125
            session.setValue(belowThresholdSince, forKey: "belowThresholdSince")
2126
            if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
2127
                if boolValue(session, key: "requiresCompletionConfirmation") {
2128
                    // Leave the session active until the user explicitly confirms or charging resumes.
2129
                    return
2130
                }
2131

            
2132
                if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
2133
                    requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt)
2134
                } else {
Bogdan Timofte authored a month ago
2135
                    finishSession(
2136
                        session,
2137
                        observedAt: snapshot.observedAt,
2138
                        finalBatteryPercent: nil,
2139
                        status: .completed
2140
                    )
Bogdan Timofte authored a month ago
2141
                }
2142
            }
2143
        } else {
2144
            session.setValue(nil, forKey: "belowThresholdSince")
2145
            clearCompletionConfirmationState(for: session)
2146
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
2147
        }
2148
    }
2149

            
2150
    private func updateAggregatedSample(
2151
        session: NSManagedObject,
2152
        with snapshot: ChargingMonitorSnapshot
Bogdan Timofte authored a month ago
2153
    ) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2154
        guard
2155
            let sessionID = stringValue(session, key: "id"),
2156
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2157
            let startedAt = dateValue(session, key: "startedAt"),
2158
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context)
2159
        else {
Bogdan Timofte authored a month ago
2160
            return nil
Bogdan Timofte authored a month ago
2161
        }
2162

            
2163
        let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0)
2164
        let bucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
2165
        let bucketStart = startedAt.addingTimeInterval(Double(bucketIndex) * Self.aggregatedSampleBucketDuration)
2166
        let bucketIdentifier = "\(sessionID)-\(bucketIndex)"
2167
        let sample = fetchSessionSampleObject(sessionID: sessionID, bucketIndex: bucketIndex)
2168
            ?? NSManagedObject(entity: entity, insertInto: context)
2169
        let sessionChargingTransportMode = chargingTransportMode(for: session)
2170
        let sampleVoltage = sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil
2171

            
2172
        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
2173
        let updatedCount = existingCount + 1
2174

            
2175
        sample.setValue(bucketIdentifier, forKey: "id")
2176
        sample.setValue(sessionID, forKey: "sessionID")
2177
        sample.setValue(chargedDeviceID, forKey: "chargedDeviceID")
2178
        sample.setValue(bucketIndex, forKey: "bucketIndex")
2179
        sample.setValue(max(snapshot.observedAt, bucketStart), forKey: "timestamp")
2180
        sample.setValue(
2181
            runningAverage(
2182
                currentAverage: optionalDoubleValue(sample, key: "averageCurrentAmps") ?? snapshot.currentAmps,
2183
                currentCount: Int(existingCount),
2184
                newValue: snapshot.currentAmps
2185
            ),
2186
            forKey: "averageCurrentAmps"
2187
        )
2188
        sample.setValue(
2189
            sampleVoltage.flatMap { voltage in
2190
                runningAverage(
2191
                    currentAverage: optionalDoubleValue(sample, key: "averageVoltageVolts") ?? voltage,
2192
                    currentCount: Int(existingCount),
2193
                    newValue: voltage
2194
                )
2195
            },
2196
            forKey: "averageVoltageVolts"
2197
        )
2198
        sample.setValue(
2199
            runningAverage(
2200
                currentAverage: optionalDoubleValue(sample, key: "averagePowerWatts") ?? snapshot.powerWatts,
2201
                currentCount: Int(existingCount),
2202
                newValue: snapshot.powerWatts
2203
            ),
2204
            forKey: "averagePowerWatts"
2205
        )
2206
        sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh")
2207
        sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
Bogdan Timofte authored a month ago
2208
        setValue(predictedBatteryPercent(for: session), on: sample, key: "estimatedBatteryPercent")
Bogdan Timofte authored a month ago
2209
        sample.setValue(Int16(updatedCount), forKey: "sampleCount")
2210
        sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
2211
        sample.setValue(snapshot.observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
2212
        return sample
Bogdan Timofte authored a month ago
2213
    }
2214

            
Bogdan Timofte authored a month ago
2215
    private func maybeTriggerTargetBatteryAlert(
2216
        for session: NSManagedObject,
2217
        observedAt: Date,
2218
        completionFallbackPercent: Double? = nil
2219
    ) {
Bogdan Timofte authored a month ago
2220
        guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
2221
            return
2222
        }
2223

            
2224
        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
2225
            return
2226
        }
2227

            
2228
        let predictedBatteryPercent = predictedBatteryPercent(for: session)
2229
            ?? optionalDoubleValue(session, key: "endBatteryPercent")
Bogdan Timofte authored a month ago
2230
            ?? completionFallbackPercent
Bogdan Timofte authored a month ago
2231

            
2232
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
2233
            return
2234
        }
2235

            
2236
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
2237
    }
2238

            
2239
    private func shouldRequireCompletionConfirmation(
2240
        for session: NSManagedObject,
2241
        observedAt: Date
2242
    ) -> Bool {
2243
        if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"),
2244
           cooldownUntil > observedAt {
2245
            return false
2246
        }
2247

            
2248
        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
2249
            return false
2250
        }
2251

            
2252
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
2253
            ?? defaultCompletionPercentThreshold
2254

            
2255
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
2256
    }
2257

            
2258
    private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
2259
        guard !boolValue(session, key: "requiresCompletionConfirmation") else {
2260
            return
2261
        }
2262

            
2263
        session.setValue(true, forKey: "requiresCompletionConfirmation")
2264
        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
2265
        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
2266
    }
2267

            
2268
    private func clearCompletionConfirmationState(for session: NSManagedObject) {
2269
        session.setValue(false, forKey: "requiresCompletionConfirmation")
2270
        session.setValue(nil, forKey: "completionConfirmationRequestedAt")
2271
        session.setValue(nil, forKey: "completionContradictionPercent")
2272
    }
2273

            
Bogdan Timofte authored a month ago
2274
    private func snapshotDateForManualStop(_ session: NSManagedObject) -> Date {
2275
        if statusValue(session, key: "statusRawValue") == .paused {
2276
            return dateValue(session, key: "pausedAt")
2277
                ?? dateValue(session, key: "lastObservedAt")
2278
                ?? Date()
2279
        }
2280
        return dateValue(session, key: "lastObservedAt") ?? Date()
2281
    }
2282

            
Bogdan Timofte authored a month ago
2283
    private func snapshotClampedToMaximumDuration(
2284
        _ snapshot: ChargingMonitorSnapshot,
2285
        for session: NSManagedObject
2286
    ) -> ChargingMonitorSnapshot {
2287
        guard let maximumEndDate = maximumEndDate(for: session),
2288
              snapshot.observedAt > maximumEndDate else {
2289
            return snapshot
2290
        }
2291

            
2292
        return ChargingMonitorSnapshot(
2293
            meterMACAddress: snapshot.meterMACAddress,
2294
            meterName: snapshot.meterName,
2295
            meterModel: snapshot.meterModel,
2296
            observedAt: maximumEndDate,
2297
            voltageVolts: snapshot.voltageVolts,
2298
            currentAmps: snapshot.currentAmps,
2299
            powerWatts: snapshot.powerWatts,
2300
            selectedDataGroup: snapshot.selectedDataGroup,
2301
            meterChargeCounterAh: snapshot.meterChargeCounterAh,
2302
            meterEnergyCounterWh: snapshot.meterEnergyCounterWh,
2303
            meterRecordingDurationSeconds: snapshot.meterRecordingDurationSeconds,
2304
            fallbackStopThresholdAmps: snapshot.fallbackStopThresholdAmps
2305
        )
2306
    }
2307

            
2308
    private func automaticCompletionDate(
2309
        for session: NSManagedObject,
2310
        referenceDate: Date
2311
    ) -> Date? {
2312
        guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
2313
            return nil
Bogdan Timofte authored a month ago
2314
        }
2315

            
Bogdan Timofte authored a month ago
2316
        var completionDates: [Date] = []
2317

            
2318
        if let maximumEndDate = maximumEndDate(for: session) {
2319
            completionDates.append(maximumEndDate)
2320
        }
2321

            
2322
        if statusValue(session, key: "statusRawValue") == .paused,
2323
           let pausedAt = dateValue(session, key: "pausedAt") {
2324
            completionDates.append(pausedAt.addingTimeInterval(pausedSessionTimeout))
2325
        }
2326

            
2327
        guard let completionDate = completionDates.min(),
2328
              referenceDate >= completionDate else {
2329
            return nil
2330
        }
2331

            
2332
        return completionDate
2333
    }
2334

            
2335
    private func maximumEndDate(for session: NSManagedObject) -> Date? {
2336
        dateValue(session, key: "startedAt")?.addingTimeInterval(Self.maximumChargeSessionDuration)
2337
    }
2338

            
2339
    @discardableResult
2340
    private func maybeCompleteOpenSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
2341
        guard statusValue(session, key: "statusRawValue")?.isOpen == true,
2342
              let completionDate = automaticCompletionDate(for: session, referenceDate: observedAt) else {
Bogdan Timofte authored a month ago
2343
            return false
2344
        }
2345

            
2346
        finishSession(
2347
            session,
Bogdan Timofte authored a month ago
2348
            observedAt: completionDate,
Bogdan Timofte authored a month ago
2349
            finalBatteryPercent: nil,
2350
            status: .completed
2351
        )
2352

            
2353
        guard saveContext() else {
2354
            return false
2355
        }
2356

            
2357
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
2358
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
2359
            return saveContext()
2360
        }
2361

            
2362
        return true
2363
    }
2364

            
2365
    private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
2366
        let chargingTransportMode = chargingTransportMode(for: session)
2367
        let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps")
2368
            ?? doubleValue(session, key: "lastObservedCurrentAmps")
2369

            
2370
        guard measuredCurrent > 0 else {
2371
            return nil
2372
        }
2373

            
2374
        let charger = chargingTransportMode == .wireless
2375
            ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
2376
            : nil
2377

            
2378
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
2379
            return nil
2380
        }
2381

            
2382
        let effectiveCurrent = effectiveCurrentAmps(
2383
            fromMeasuredCurrent: measuredCurrent,
2384
            chargingTransportMode: chargingTransportMode,
2385
            charger: charger
2386
        )
2387
        guard effectiveCurrent > 0 else {
2388
            return nil
2389
        }
2390
        return effectiveCurrent
2391
    }
2392

            
2393
    private func finishSession(
2394
        _ session: NSManagedObject,
2395
        observedAt: Date,
2396
        finalBatteryPercent: Double?,
2397
        status: ChargeSessionStatus
2398
    ) {
2399
        if let finalBatteryPercent {
2400
            _ = insertBatteryCheckpoint(
2401
                percent: finalBatteryPercent,
Bogdan Timofte authored a month ago
2402
                flag: .final,
Bogdan Timofte authored a month ago
2403
                timestamp: observedAt,
Bogdan Timofte authored a month ago
2404
                subject: stringValue(session, key: "chargedPowerbankID") == nil ? .chargedDevice : .powerbank,
Bogdan Timofte authored a month ago
2405
                to: session
2406
            )
2407
        }
2408

            
2409
        session.setValue(status.rawValue, forKey: "statusRawValue")
2410
        session.setValue(nil, forKey: "pausedAt")
2411
        session.setValue(nil, forKey: "belowThresholdSince")
2412
        session.setValue(observedAt, forKey: "endedAt")
2413
        session.setValue(observedAt, forKey: "lastObservedAt")
2414
        session.setValue(completionCurrentForSessionEnd(session), forKey: "completionCurrentAmps")
2415
        clearCompletionConfirmationState(for: session)
2416
        session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
2417
        updateCapacityEstimate(for: session)
Bogdan Timofte authored a month ago
2418
        refreshEstimatedBatteryPercents(for: session)
Bogdan Timofte authored a month ago
2419
        session.setValue(observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
2420

            
2421
        if status == .completed {
2422
            maybeTriggerTargetBatteryAlert(
2423
                for: session,
2424
                observedAt: observedAt,
2425
                completionFallbackPercent: defaultCompletionPercentThreshold
2426
            )
2427
        }
Bogdan Timofte authored a month ago
2428
    }
2429

            
Bogdan Timofte authored a month ago
2430
    private func predictedBatteryPercent(
2431
        for session: NSManagedObject,
2432
        effectiveEnergyWhOverride: Double? = nil,
2433
        referenceTimestamp: Date? = nil
2434
    ) -> Double? {
Bogdan Timofte authored a month ago
2435
        guard
2436
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
Bogdan Timofte authored a month ago
2437
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
Bogdan Timofte authored a month ago
2438
        else {
2439
            return nil
2440
        }
2441

            
Bogdan Timofte authored a month ago
2442
        let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(
2443
            for: session,
2444
            chargedDevice: chargedDevice
2445
        )
2446
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
2447
        let canUseCheckpointEnergyMap = deviceClass == .watch || deviceClass == .iphone
Bogdan Timofte authored a month ago
2448
        let measuredEnergyWh = effectiveEnergyWhOverride
2449
            ?? effectiveBatteryEnergyWh(
2450
                rawMeasuredEnergyWh: doubleValue(session, key: "measuredEnergyWh"),
2451
                for: session
2452
            )
Bogdan Timofte authored a month ago
2453
        let sessionID = stringValue(session, key: "id") ?? ""
2454

            
Bogdan Timofte authored a month ago
2455
        func anchorCapacityCandidates(from anchors: [BatteryLevelPredictionAnchor]) -> [Double] {
Bogdan Timofte authored a month ago
2456
            var candidates: [Double] = []
2457

            
2458
            for lowerIndex in anchors.indices {
2459
                for upperIndex in anchors.indices where upperIndex > lowerIndex {
2460
                    let lower = anchors[lowerIndex]
2461
                    let upper = anchors[upperIndex]
2462
                    let percentDelta = upper.percent - lower.percent
2463
                    let energyDelta = upper.energyWh - lower.energyWh
2464

            
2465
                    guard percentDelta >= 3, energyDelta > 0.01 else {
2466
                        continue
2467
                    }
2468

            
2469
                    let capacityWh = energyDelta / (percentDelta / 100)
2470
                    guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
2471
                        continue
2472
                    }
2473

            
2474
                    candidates.append(capacityWh)
2475
                }
2476
            }
2477

            
2478
            return candidates
2479
        }
2480

            
Bogdan Timofte authored a month ago
2481
        func inferredCheckpointEnergyMapCapacityWh(from anchors: [BatteryLevelPredictionAnchor]) -> Double? {
Bogdan Timofte authored a month ago
2482
            let candidates = anchorCapacityCandidates(from: anchors)
2483
            guard !candidates.isEmpty else {
2484
                return nil
2485
            }
2486

            
2487
            let sortedCandidates = candidates.sorted()
2488
            return sortedCandidates[sortedCandidates.count / 2]
2489
        }
2490

            
Bogdan Timofte authored a month ago
2491
        var anchors: [BatteryLevelPredictionAnchor] = []
Bogdan Timofte authored a month ago
2492
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2493
           startBatteryPercent >= 0 {
2494
            anchors.append(
Bogdan Timofte authored a month ago
2495
                BatteryLevelPredictionAnchor(
Bogdan Timofte authored a month ago
2496
                    percent: startBatteryPercent,
2497
                    energyWh: 0,
Bogdan Timofte authored a month ago
2498
                    timestamp: dateValue(session, key: "trimStart")
2499
                        ?? dateValue(session, key: "startedAt")
2500
                        ?? Date.distantPast,
Bogdan Timofte authored a month ago
2501
                    description: "session start",
Bogdan Timofte authored a month ago
2502
                    isCheckpoint: false
2503
                )
2504
            )
Bogdan Timofte authored a month ago
2505
        }
2506

            
2507
        let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
2508
            .compactMap(makeCheckpointSummary(from:))
2509
            .sorted { lhs, rhs in
2510
                if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
2511
                    return lhs.measuredEnergyWh < rhs.measuredEnergyWh
2512
                }
2513
                return lhs.timestamp < rhs.timestamp
2514
            }
Bogdan Timofte authored a month ago
2515
            .filter { $0.batteryPercent >= 0 }
2516
            .map {
Bogdan Timofte authored a month ago
2517
                BatteryLevelPredictionAnchor(
Bogdan Timofte authored a month ago
2518
                    percent: $0.batteryPercent,
2519
                    energyWh: $0.measuredEnergyWh,
2520
                    timestamp: $0.timestamp,
Bogdan Timofte authored a month ago
2521
                    description: $0.flag.anchorDescription,
Bogdan Timofte authored a month ago
2522
                    isCheckpoint: true
2523
                )
2524
            }
Bogdan Timofte authored a month ago
2525
        anchors.append(contentsOf: checkpointAnchors)
2526

            
Bogdan Timofte authored a month ago
2527
        if optionalDoubleValue(session, key: "startBatteryPercent") == unresolvedFlatBatteryPercent {
2528
            if let virtualZeroEnergyWh = BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
2529
                from: anchors,
2530
                estimatedCapacityWh: estimatedCapacityWh,
2531
                historicalReserveEnergyWh: estimatedFlatReserveEnergyWh(
2532
                    forChargedDeviceID: chargedDeviceID,
2533
                    excludingSessionID: sessionID
2534
                )
2535
            ) {
2536
                anchors.append(
2537
                    BatteryLevelPredictionAnchor(
2538
                        percent: 0,
2539
                        energyWh: virtualZeroEnergyWh,
2540
                        timestamp: dateValue(session, key: "trimStart")
2541
                            ?? dateValue(session, key: "startedAt")
2542
                            ?? Date.distantPast,
2543
                        description: "estimated flat reserve",
2544
                        isCheckpoint: false
2545
                    )
2546
                )
2547
            } else if let firstCheckpoint = anchors.min(by: { $0.energyWh < $1.energyWh }),
2548
                      measuredEnergyWh < firstCheckpoint.energyWh - 0.05 {
2549
                return nil
2550
            }
2551
        }
2552

            
Bogdan Timofte authored a month ago
2553
        let sortedAnchors = anchors.sorted { lhs, rhs in
2554
            if lhs.energyWh != rhs.energyWh {
2555
                return lhs.energyWh < rhs.energyWh
2556
            }
2557
            return lhs.timestamp < rhs.timestamp
2558
        }
2559

            
2560
        guard !sortedAnchors.isEmpty else {
Bogdan Timofte authored a month ago
2561
            return optionalDoubleValue(session, key: "endBatteryPercent")
2562
        }
2563

            
Bogdan Timofte authored a month ago
2564
        let inferredCapacityWh = estimatedCapacityWh
2565
            ?? (canUseCheckpointEnergyMap ? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors) : nil)
2566
        let lowerAnchor = sortedAnchors.last { $0.energyWh <= measuredEnergyWh + 0.05 }
2567
        let upperAnchor = sortedAnchors.first { $0.energyWh > measuredEnergyWh + 0.05 }
2568
        let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!
Bogdan Timofte authored a month ago
2569

            
2570
        if let lowerAnchor,
2571
           let upperAnchor,
2572
           upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
2573
            let interpolationProgress = min(
2574
                max(
2575
                    (measuredEnergyWh - lowerAnchor.energyWh) /
2576
                    (upperAnchor.energyWh - lowerAnchor.energyWh),
2577
                    0
2578
                ),
2579
                1
2580
            )
2581
            return min(
2582
                max(
2583
                    lowerAnchor.percent +
2584
                    (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
2585
                    0
2586
                ),
2587
                100
2588
            )
2589
        }
2590

            
Bogdan Timofte authored a month ago
2591
        if let chargeCurve = typicalChargeCurve(
2592
            forChargedDeviceID: chargedDeviceID,
2593
            excludingSessionID: sessionID
2594
        ),
2595
           let curvePredictedPercent = BatteryLevelPredictionTuning.predictedPercent(
2596
            anchorPercent: anchor.percent,
2597
            anchorEnergyWh: anchor.energyWh,
2598
            effectiveEnergyWh: measuredEnergyWh,
2599
            chargeCurve: chargeCurve,
2600
            deviationFactor: BatteryLevelPredictionTuning.deviationFactor(
2601
                anchors: sortedAnchors,
2602
                chargeCurve: chargeCurve
2603
            )
2604
           ) {
2605
            return curvePredictedPercent
2606
        }
2607

            
Bogdan Timofte authored a month ago
2608
        guard let inferredCapacityWh, inferredCapacityWh > 0 else {
2609
            return nil
2610
        }
2611

            
Bogdan Timofte authored a month ago
2612
        return BatteryLevelPredictionTuning.predictedPercent(
2613
            anchorPercent: anchor.percent,
2614
            anchorEnergyWh: anchor.energyWh,
2615
            anchorTimestamp: anchor.timestamp,
2616
            anchorIsCheckpoint: anchor.isCheckpoint,
2617
            effectiveEnergyWh: measuredEnergyWh,
Bogdan Timofte authored a month ago
2618
            referenceTimestamp: referenceTimestamp
2619
                ?? dateValue(session, key: "lastObservedAt")
2620
                ?? anchor.timestamp,
Bogdan Timofte authored a month ago
2621
            estimatedCapacityWh: inferredCapacityWh
Bogdan Timofte authored a month ago
2622
        )
2623
    }
2624

            
Bogdan Timofte authored a month ago
2625
    private func effectiveBatteryEnergyWh(
2626
        rawMeasuredEnergyWh: Double,
2627
        for session: NSManagedObject
2628
    ) -> Double {
2629
        switch chargingTransportMode(for: session) {
2630
        case .wired:
2631
            return rawMeasuredEnergyWh
2632
        case .wireless:
2633
            if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 {
2634
                return rawMeasuredEnergyWh * factor
2635
            }
2636
            let sessionMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2637
            if let sessionEffectiveEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh"),
2638
               sessionMeasuredEnergyWh > 0 {
2639
                return rawMeasuredEnergyWh * (sessionEffectiveEnergyWh / sessionMeasuredEnergyWh)
2640
            }
2641
            return rawMeasuredEnergyWh
2642
        }
2643
    }
2644

            
2645
    private func refreshEstimatedBatteryPercents(for session: NSManagedObject) {
2646
        guard let sessionID = stringValue(session, key: "id") else {
2647
            return
2648
        }
2649

            
2650
        for sample in fetchSessionSampleObjects(forSessionID: sessionID) {
2651
            let effectiveEnergyWh = effectiveBatteryEnergyWh(
2652
                rawMeasuredEnergyWh: doubleValue(sample, key: "measuredEnergyWh"),
2653
                for: session
2654
            )
2655
            let percent = predictedBatteryPercent(
2656
                for: session,
2657
                effectiveEnergyWhOverride: effectiveEnergyWh,
2658
                referenceTimestamp: dateValue(sample, key: "timestamp")
2659
            )
2660
            setValue(percent, on: sample, key: "estimatedBatteryPercent")
2661
            setValue(Date(), on: sample, key: "updatedAt")
2662
        }
2663
    }
2664

            
Bogdan Timofte authored a month ago
2665
    private func resolvedEstimatedBatteryCapacityWh(
2666
        for session: NSManagedObject,
2667
        chargedDevice: NSManagedObject
2668
    ) -> Double? {
2669
        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
2670
           sessionCapacityEstimate > 0 {
2671
            return sessionCapacityEstimate
2672
        }
2673

            
2674
        switch chargingTransportMode(for: session) {
2675
        case .wired:
2676
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
2677
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2678
        case .wireless:
2679
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
2680
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2681
        }
2682
    }
2683

            
2684
    private func updateCapacityEstimate(for session: NSManagedObject) {
2685
        guard
2686
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2687
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
2688
        else {
2689
            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
2690
            session.setValue(nil, forKey: "capacityEstimateWh")
2691
            return
2692
        }
2693

            
2694
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2695
        let chargingMode = chargingTransportMode(for: session)
2696
        let wirelessResolution = chargingMode == .wireless
2697
            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
2698
            : nil
2699
        let effectiveBatteryEnergyWh = chargingMode == .wired
2700
            ? measuredEnergyWh
2701
            : wirelessResolution.map { measuredEnergyWh * $0.factor }
2702

            
2703
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
2704
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
2705
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
2706
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
2707

            
Bogdan Timofte authored a month ago
2708
        let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
2709

            
2710
        guard let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
Bogdan Timofte authored a month ago
2711
            session.setValue(nil, forKey: "capacityEstimateWh")
2712
            return
2713
        }
2714

            
Bogdan Timofte authored a month ago
2715
        struct CapacityAnchor {
2716
            let percent: Double
2717
            let energyWh: Double
2718
            let timestamp: Date
2719
        }
2720

            
2721
        var anchors: [CapacityAnchor] = []
2722

            
2723
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2724
           startBatteryPercent >= 0 {
2725
            anchors.append(
2726
                CapacityAnchor(
2727
                    percent: startBatteryPercent,
2728
                    energyWh: 0,
2729
                    timestamp: dateValue(session, key: "trimStart")
2730
                        ?? dateValue(session, key: "startedAt")
2731
                        ?? Date.distantPast
2732
                )
2733
            )
2734
        }
2735

            
2736
        if let sessionID = stringValue(session, key: "id") {
2737
            anchors.append(
2738
                contentsOf: fetchCheckpointObjects(forSessionID: sessionID).compactMap { checkpoint in
2739
                    guard
2740
                        let percent = optionalDoubleValue(checkpoint, key: "batteryPercent"),
2741
                        percent >= 0,
2742
                        let timestamp = dateValue(checkpoint, key: "timestamp")
2743
                    else {
2744
                        return nil
2745
                    }
2746

            
2747
                    return CapacityAnchor(
2748
                        percent: percent,
2749
                        energyWh: doubleValue(checkpoint, key: "measuredEnergyWh"),
2750
                        timestamp: timestamp
2751
                    )
2752
                }
2753
            )
2754
        }
2755

            
2756
        if let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"),
2757
           endBatteryPercent >= 0 {
2758
            anchors.append(
2759
                CapacityAnchor(
2760
                    percent: endBatteryPercent,
2761
                    energyWh: effectiveBatteryEnergyWh,
2762
                    timestamp: dateValue(session, key: "endedAt")
2763
                        ?? dateValue(session, key: "lastObservedAt")
2764
                        ?? Date.distantPast
2765
                )
2766
            )
2767
        }
2768

            
2769
        let sortedAnchors = anchors.sorted { lhs, rhs in
2770
            if lhs.energyWh != rhs.energyWh {
2771
                return lhs.energyWh < rhs.energyWh
2772
            }
2773
            return lhs.timestamp < rhs.timestamp
2774
        }
2775

            
2776
        guard let firstAnchor = sortedAnchors.first,
2777
              let lastAnchor = sortedAnchors.last else {
Bogdan Timofte authored a month ago
2778
            session.setValue(nil, forKey: "capacityEstimateWh")
2779
            return
2780
        }
2781

            
Bogdan Timofte authored a month ago
2782
        let percentDelta = lastAnchor.percent - firstAnchor.percent
2783
        let energyDelta = lastAnchor.energyWh - firstAnchor.energyWh
Bogdan Timofte authored a month ago
2784

            
Bogdan Timofte authored a month ago
2785
        guard percentDelta >= 20, energyDelta > 0 else {
Bogdan Timofte authored a month ago
2786
            session.setValue(nil, forKey: "capacityEstimateWh")
2787
            return
2788
        }
2789

            
Bogdan Timofte authored a month ago
2790
        if !supportsChargingWhileOff && lastAnchor.percent >= 99.5 {
Bogdan Timofte authored a month ago
2791
            session.setValue(nil, forKey: "capacityEstimateWh")
2792
            return
2793
        }
2794

            
Bogdan Timofte authored a month ago
2795
        let capacityEstimateWh = energyDelta / (percentDelta / 100)
Bogdan Timofte authored a month ago
2796
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
2797
    }
2798

            
2799
    @discardableResult
Bogdan Timofte authored a month ago
2800
    private func insertBatteryCheckpoint(
Bogdan Timofte authored a month ago
2801
        percent: Double,
Bogdan Timofte authored a month ago
2802
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
2803
        timestamp: Date = Date(),
Bogdan Timofte authored a month ago
2804
        measuredEnergyWhOverride: Double? = nil,
Bogdan Timofte authored a month ago
2805
        subject: CheckpointSubject = .chargedDevice,
2806
        barsValue: Int = 0,
Bogdan Timofte authored a month ago
2807
        to session: NSManagedObject
Bogdan Timofte authored a month ago
2808
    ) -> String? {
Bogdan Timofte authored a month ago
2809
        guard
2810
            let sessionID = stringValue(session, key: "id"),
2811
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2812
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
2813
        else {
Bogdan Timofte authored a month ago
2814
            return nil
Bogdan Timofte authored a month ago
2815
        }
2816

            
2817
        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
Bogdan Timofte authored a month ago
2818
        let checkpointEnergyWh = measuredEnergyWhOverride
2819
            ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
Bogdan Timofte authored a month ago
2820
            ?? doubleValue(session, key: "measuredEnergyWh")
2821
        checkpoint.setValue(UUID().uuidString, forKey: "id")
2822
        checkpoint.setValue(sessionID, forKey: "sessionID")
Bogdan Timofte authored a month ago
2823
        switch subject {
2824
        case .chargedDevice:
2825
            checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
2826
            checkpoint.setValue(nil, forKey: "powerbankID")
2827
        case .powerbank:
Bogdan Timofte authored a month ago
2828
            // Link to the charged powerbank when it is the session subject, otherwise
2829
            // to the source powerbank being monitored alongside a device session.
2830
            let powerbankID = stringValue(session, key: "chargedPowerbankID")
2831
                ?? stringValue(session, key: "sourcePowerbankID")
2832
            checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
Bogdan Timofte authored a month ago
2833
            checkpoint.setValue(powerbankID, forKey: "powerbankID")
2834
        }
2835
        checkpoint.setValue(Int16(max(0, barsValue)), forKey: "batteryBarsValue")
Bogdan Timofte authored a month ago
2836
        checkpoint.setValue(timestamp, forKey: "timestamp")
Bogdan Timofte authored a month ago
2837
        checkpoint.setValue(percent, forKey: "batteryPercent")
2838
        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
2839
        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
2840
        checkpoint.setValue(
2841
            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
2842
            forKey: "voltageVolts"
2843
        )
Bogdan Timofte authored a month ago
2844
        checkpoint.setValue(flag.rawValue, forKey: "label")
Bogdan Timofte authored a month ago
2845
        checkpoint.setValue(timestamp, forKey: "createdAt")
Bogdan Timofte authored a month ago
2846

            
Bogdan Timofte authored a month ago
2847
        let tracksSessionSubject = subject == .chargedDevice || stringValue(session, key: "chargedPowerbankID") != nil
2848
        if tracksSessionSubject {
Bogdan Timofte authored a month ago
2849
            let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
Bogdan Timofte authored a month ago
2850
            if existingStartBatteryPercent == nil {
Bogdan Timofte authored a month ago
2851
                session.setValue(percent, forKey: "startBatteryPercent")
2852
            }
2853
            if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
2854
                session.setValue(percent, forKey: "endBatteryPercent")
2855
            }
Bogdan Timofte authored a month ago
2856
        }
Bogdan Timofte authored a month ago
2857
        session.setValue(timestamp, forKey: "updatedAt")
Bogdan Timofte authored a month ago
2858
        updateCapacityEstimate(for: session)
Bogdan Timofte authored a month ago
2859
        refreshEstimatedBatteryPercents(for: session)
Bogdan Timofte authored a month ago
2860

            
Bogdan Timofte authored a month ago
2861
        return chargedDeviceID
2862
    }
2863

            
Bogdan Timofte authored a month ago
2864
    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
2865
        guard let sessionID = stringValue(session, key: "id") else {
2866
            return
2867
        }
2868

            
2869
        let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
2870
        if let latestCheckpoint = remainingCheckpoints.last {
2871
            session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
2872
        } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2873
                  startBatteryPercent >= 0 {
2874
            session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
2875
        } else {
2876
            session.setValue(nil, forKey: "endBatteryPercent")
2877
        }
2878

            
2879
        session.setValue(Date(), forKey: "updatedAt")
2880
        updateCapacityEstimate(for: session)
Bogdan Timofte authored a month ago
2881
        refreshEstimatedBatteryPercents(for: session)
Bogdan Timofte authored a month ago
2882
    }
2883

            
Bogdan Timofte authored a month ago
2884
    @discardableResult
2885
    private func addBatteryCheckpoint(
2886
        percent: Double,
Bogdan Timofte authored a month ago
2887
        measuredEnergyWh: Double? = nil,
Bogdan Timofte authored a month ago
2888
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
2889
        subject: CheckpointSubject = .chargedDevice,
2890
        barsValue: Int = 0,
Bogdan Timofte authored a month ago
2891
        to session: NSManagedObject,
2892
        timestamp: Date = Date()
2893
    ) -> Bool {
Bogdan Timofte authored a month ago
2894
        if let measuredEnergyWh, measuredEnergyWh.isFinite {
2895
            session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
2896
        }
2897

            
Bogdan Timofte authored a month ago
2898
        guard let chargedDeviceID = insertBatteryCheckpoint(
2899
            percent: percent,
Bogdan Timofte authored a month ago
2900
            flag: flag,
Bogdan Timofte authored a month ago
2901
            timestamp: timestamp,
Bogdan Timofte authored a month ago
2902
            measuredEnergyWhOverride: measuredEnergyWh,
Bogdan Timofte authored a month ago
2903
            subject: subject,
2904
            barsValue: barsValue,
Bogdan Timofte authored a month ago
2905
            to: session
2906
        ) else {
2907
            return false
2908
        }
2909

            
Bogdan Timofte authored a month ago
2910
        guard saveContext() else {
2911
            return false
2912
        }
2913

            
Bogdan Timofte authored a month ago
2914
        // Device-subject checkpoints feed device-side capacity learning. Powerbank-subject
2915
        // checkpoints feed powerbank-side derivation, which is computed at materialization time
2916
        // (see PowerbankSummary fetch path).
2917
        if subject == .chargedDevice {
2918
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
2919
        }
Bogdan Timofte authored a month ago
2920
        return saveContext()
2921
    }
2922

            
2923
    private func resolvedWirelessEfficiency(
2924
        for session: NSManagedObject,
2925
        chargedDevice: NSManagedObject
2926
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
2927
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
2928
           storedFactor > 0 {
2929
            return (
2930
                factor: storedFactor,
2931
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
2932
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
2933
            )
2934
        }
2935

            
2936
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
2937
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2938
        guard measuredEnergyWh > 0 else {
2939
            return nil
2940
        }
2941

            
2942
        if chargingProfile == .magsafe,
2943
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
2944
           calibratedFactor > 0 {
2945
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
2946
        }
2947

            
2948
        guard
2949
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2950
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
2951
        else {
2952
            return nil
2953
        }
2954

            
2955
        let percentDelta = endBatteryPercent - startBatteryPercent
2956
        guard percentDelta >= 20 else {
2957
            return nil
2958
        }
2959

            
2960
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
Bogdan Timofte authored a month ago
2961
            ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
Bogdan Timofte authored a month ago
2962
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2963
                : nil),
2964
              wiredCapacityWh > 0
2965
        else {
2966
            return nil
2967
        }
2968

            
2969
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
2970
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
2971
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
2972
        let usesEstimated = chargingProfile != .magsafe
2973
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
2974

            
2975
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
2976
    }
2977

            
2978
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
2979
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
2980
            return
2981
        }
2982

            
Bogdan Timofte authored a month ago
2983
        let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
Bogdan Timofte authored a month ago
2984
        let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
2985
        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
2986
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
2987
        let sessions = relevantSessionObjects(
2988
            for: chargedDeviceID,
2989
            deviceClass: deviceClass,
2990
            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
2991
            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
2992
        )
Bogdan Timofte authored a month ago
2993
        let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
Bogdan Timofte authored a month ago
2994
        let wiredMinimumCurrent = derivedMinimumCurrent(
2995
            from: sessions,
2996
            chargingTransportMode: .wired
2997
        )
2998
        let wirelessMinimumCurrent = derivedMinimumCurrent(
2999
            from: sessions,
3000
            chargingTransportMode: .wireless
3001
        )
3002

            
3003
        let wiredCapacity = derivedCapacity(
3004
            from: sessions,
3005
            chargingTransportMode: .wired,
3006
            supportsChargingWhileOff: supportsChargingWhileOff
3007
        )
3008
        let wirelessCapacity = derivedCapacity(
3009
            from: sessions,
3010
            chargingTransportMode: .wireless,
3011
            supportsChargingWhileOff: supportsChargingWhileOff
3012
        )
3013
        let wirelessEfficiency = derivedWirelessEfficiency(
3014
            from: sessions,
3015
            chargingProfile: wirelessProfile
3016
        )
Bogdan Timofte authored a month ago
3017
        let configuredCompletionCurrents = decodedCompletionCurrents(
3018
            from: chargedDevice,
3019
            key: "configuredCompletionCurrentsRawValue"
3020
        )
Bogdan Timofte authored a month ago
3021
        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
3022
        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
3023
        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
3024
        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
3025
        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
3026
        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
3027

            
Bogdan Timofte authored a month ago
3028
        let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
3029
        let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
Bogdan Timofte authored a month ago
3030
        let preferredMinimumCurrent: Double?
3031
        let preferredCapacity: Double?
3032
        switch preferredChargingTransportMode {
3033
        case .wired:
Bogdan Timofte authored a month ago
3034
            preferredMinimumCurrent = configuredCompletionCurrents[
3035
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
3036
            ] ?? learnedCompletionCurrents[
3037
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
3038
            ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
Bogdan Timofte authored a month ago
3039
            preferredCapacity = wiredCapacity ?? wirelessCapacity
3040
        case .wireless:
Bogdan Timofte authored a month ago
3041
            preferredMinimumCurrent = configuredCompletionCurrents[
3042
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
3043
            ] ?? learnedCompletionCurrents[
3044
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
3045
            ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
Bogdan Timofte authored a month ago
3046
            preferredCapacity = wirelessCapacity ?? wiredCapacity
3047
        }
3048

            
Bogdan Timofte authored a month ago
3049
        setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
3050
        setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
3051
        setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
3052
        setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
3053
        setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
3054
        setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
3055
        setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
3056
        setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
3057
        setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
3058
        setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
3059
        setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
3060
        setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
3061
        setValue(Date(), on: chargedDevice, key: "updatedAt")
Bogdan Timofte authored a month ago
3062
    }
3063

            
3064
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
3065
        sessions
3066
            .filter { $0.status == .completed }
3067
            .compactMap { session in
3068
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
3069
                let timestamp = session.endedAt ?? session.lastObservedAt
3070
                return CapacityTrendPoint(
3071
                    sessionID: session.id,
3072
                    timestamp: timestamp,
3073
                    capacityWh: capacityEstimateWh,
3074
                    chargingTransportMode: session.chargingTransportMode
3075
                )
3076
            }
3077
            .sorted { $0.timestamp < $1.timestamp }
3078
    }
3079

            
Bogdan Timofte authored a month ago
3080
    private func typicalChargeCurve(
3081
        forChargedDeviceID chargedDeviceID: String,
3082
        excludingSessionID excludedSessionID: String? = nil
3083
    ) -> BatteryChargeCurve? {
3084
        let sessionObjects = fetchSessions(forChargedDeviceID: chargedDeviceID)
3085
            .filter {
3086
                statusValue($0, key: "statusRawValue") == .completed
3087
            }
3088

            
3089
        let sessionSummaries = sessionObjects.compactMap { session -> ChargeSessionSummary? in
3090
            guard let sessionID = stringValue(session, key: "id"),
3091
                  sessionID != excludedSessionID else {
3092
                return nil
3093
            }
3094

            
3095
            return makeSessionSummary(
3096
                from: session,
3097
                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
3098
                samples: []
3099
            )
3100
        }
3101

            
3102
        return BatteryChargeCurve(
3103
            typicalCurvePoints: buildTypicalCurve(from: sessionSummaries)
3104
        )
3105
    }
3106

            
3107
    private func estimatedFlatReserveEnergyWh(
3108
        forChargedDeviceID chargedDeviceID: String,
3109
        excludingSessionID excludedSessionID: String? = nil
3110
    ) -> Double? {
3111
        let reserves = fetchSessions(forChargedDeviceID: chargedDeviceID)
3112
            .filter {
3113
                statusValue($0, key: "statusRawValue") == .completed
3114
                    && optionalDoubleValue($0, key: "startBatteryPercent") == unresolvedFlatBatteryPercent
3115
                    && stringValue($0, key: "id") != excludedSessionID
3116
            }
3117
            .compactMap { session -> Double? in
3118
                guard let sessionID = stringValue(session, key: "id") else {
3119
                    return nil
3120
                }
3121

            
3122
                let anchors = fetchCheckpointObjects(forSessionID: sessionID)
3123
                    .compactMap(makeCheckpointSummary(from:))
3124
                    .map {
3125
                        BatteryLevelPredictionAnchor(
3126
                            percent: $0.batteryPercent,
3127
                            energyWh: $0.measuredEnergyWh,
3128
                            timestamp: $0.timestamp,
3129
                            description: $0.flag.anchorDescription,
3130
                            isCheckpoint: true
3131
                        )
3132
                    }
3133

            
3134
                return BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
3135
                    from: anchors,
3136
                    estimatedCapacityWh: optionalDoubleValue(session, key: "capacityEstimateWh")
3137
                )
3138
            }
3139

            
3140
        guard !reserves.isEmpty else {
3141
            return nil
3142
        }
3143

            
3144
        let sortedReserves = reserves.sorted()
3145
        return sortedReserves[sortedReserves.count / 2]
3146
    }
3147

            
Bogdan Timofte authored a month ago
3148
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
3149
        var groupedEnergyByBin: [Int: [Double]] = [:]
3150

            
3151
        for session in sessions where session.status == .completed {
Bogdan Timofte authored a month ago
3152
            let anchors = normalizedTypicalCurveAnchors(for: session)
3153
            guard anchors.count >= 2 else {
3154
                continue
Bogdan Timofte authored a month ago
3155
            }
3156

            
Bogdan Timofte authored a month ago
3157
            for percentBin in stride(from: 0, through: 100, by: 10) {
Bogdan Timofte authored a month ago
3158
                guard let energyWh = interpolatedTypicalCurvePoint(
Bogdan Timofte authored a month ago
3159
                    for: Double(percentBin),
3160
                    anchors: anchors
3161
                ) else {
3162
                    continue
3163
                }
Bogdan Timofte authored a month ago
3164

            
Bogdan Timofte authored a month ago
3165
                groupedEnergyByBin[percentBin, default: []].append(energyWh)
Bogdan Timofte authored a month ago
3166
            }
3167
        }
3168

            
Bogdan Timofte authored a month ago
3169
        let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
Bogdan Timofte authored a month ago
3170
            guard let energies = groupedEnergyByBin[percentBin], !energies.isEmpty else {
Bogdan Timofte authored a month ago
3171
                return nil
3172
            }
3173

            
3174
            return TypicalChargeCurvePoint(
3175
                percentBin: percentBin,
3176
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
Bogdan Timofte authored a month ago
3177
                sampleCount: energies.count
Bogdan Timofte authored a month ago
3178
            )
3179
        }
Bogdan Timofte authored a month ago
3180

            
3181
        var runningMaximumEnergyWh = 0.0
3182

            
3183
        return averagedPoints.map { point in
3184
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
3185
            return TypicalChargeCurvePoint(
3186
                percentBin: point.percentBin,
3187
                averageEnergyWh: runningMaximumEnergyWh,
3188
                sampleCount: point.sampleCount
3189
            )
3190
        }
3191
    }
3192

            
3193
    private func normalizedTypicalCurveAnchors(
3194
        for session: ChargeSessionSummary
Bogdan Timofte authored a month ago
3195
    ) -> [(percent: Double, energyWh: Double)] {
Bogdan Timofte authored a month ago
3196
        struct Anchor {
3197
            let percent: Double
3198
            let energyWh: Double
3199
            let timestamp: Date
3200
        }
3201

            
3202
        var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
3203
            guard checkpoint.batteryPercent.isFinite,
3204
                  checkpoint.measuredEnergyWh.isFinite,
3205
                  checkpoint.batteryPercent >= 0,
3206
                  checkpoint.batteryPercent <= 100,
Bogdan Timofte authored a month ago
3207
                  checkpoint.measuredEnergyWh >= 0 else {
Bogdan Timofte authored a month ago
3208
                return nil
3209
            }
3210

            
3211
            return Anchor(
3212
                percent: checkpoint.batteryPercent,
3213
                energyWh: checkpoint.measuredEnergyWh,
3214
                timestamp: checkpoint.timestamp
3215
            )
3216
        }
3217

            
3218
        if let startBatteryPercent = session.startBatteryPercent,
3219
           startBatteryPercent.isFinite,
3220
           startBatteryPercent >= 0,
3221
           startBatteryPercent <= 100 {
3222
            anchors.append(
3223
                Anchor(
3224
                    percent: startBatteryPercent,
3225
                    energyWh: 0,
3226
                    timestamp: session.startedAt
3227
                )
3228
            )
3229
        }
3230

            
3231
        if let endBatteryPercent = session.endBatteryPercent,
3232
           endBatteryPercent.isFinite,
3233
           endBatteryPercent >= 0,
3234
           endBatteryPercent <= 100 {
3235
            anchors.append(
3236
                Anchor(
3237
                    percent: endBatteryPercent,
3238
                    energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
3239
                    timestamp: session.endedAt ?? session.lastObservedAt
3240
                )
3241
            )
3242
        }
3243

            
3244
        let sortedAnchors = anchors.sorted { lhs, rhs in
3245
            if lhs.percent != rhs.percent {
3246
                return lhs.percent < rhs.percent
3247
            }
3248
            if lhs.energyWh != rhs.energyWh {
3249
                return lhs.energyWh < rhs.energyWh
3250
            }
3251
            return lhs.timestamp < rhs.timestamp
3252
        }
3253

            
Bogdan Timofte authored a month ago
3254
        var collapsedAnchors: [(percent: Double, energyWh: Double)] = []
Bogdan Timofte authored a month ago
3255

            
3256
        for anchor in sortedAnchors {
3257
            if let lastIndex = collapsedAnchors.indices.last,
3258
               abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
3259
                collapsedAnchors[lastIndex] = (
3260
                    percent: collapsedAnchors[lastIndex].percent,
Bogdan Timofte authored a month ago
3261
                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh)
Bogdan Timofte authored a month ago
3262
                )
3263
            } else {
3264
                collapsedAnchors.append(
Bogdan Timofte authored a month ago
3265
                    (percent: anchor.percent, energyWh: anchor.energyWh)
Bogdan Timofte authored a month ago
3266
                )
3267
            }
3268
        }
3269

            
3270
        var runningMaximumEnergyWh = 0.0
3271

            
3272
        return collapsedAnchors.map { anchor in
3273
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
3274
            return (
3275
                percent: anchor.percent,
Bogdan Timofte authored a month ago
3276
                energyWh: runningMaximumEnergyWh
Bogdan Timofte authored a month ago
3277
            )
3278
        }
3279
    }
3280

            
3281
    private func interpolatedTypicalCurvePoint(
3282
        for percent: Double,
Bogdan Timofte authored a month ago
3283
        anchors: [(percent: Double, energyWh: Double)]
3284
    ) -> Double? {
Bogdan Timofte authored a month ago
3285
        guard
3286
            let firstAnchor = anchors.first,
3287
            let lastAnchor = anchors.last,
3288
            percent >= firstAnchor.percent,
3289
            percent <= lastAnchor.percent
3290
        else {
3291
            return nil
3292
        }
3293

            
3294
        if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
Bogdan Timofte authored a month ago
3295
            return exactAnchor.energyWh
Bogdan Timofte authored a month ago
3296
        }
3297

            
3298
        guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
3299
              upperIndex > 0 else {
3300
            return nil
3301
        }
3302

            
3303
        let lowerAnchor = anchors[upperIndex - 1]
3304
        let upperAnchor = anchors[upperIndex]
3305
        let span = upperAnchor.percent - lowerAnchor.percent
3306
        guard span > 0.000_1 else {
3307
            return nil
3308
        }
3309

            
3310
        let ratio = (percent - lowerAnchor.percent) / span
Bogdan Timofte authored a month ago
3311
        return lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
Bogdan Timofte authored a month ago
3312
    }
3313

            
3314
    private func makeSessionSummary(
3315
        from object: NSManagedObject,
3316
        checkpoints: [NSManagedObject],
3317
        samples: [NSManagedObject]
3318
    ) -> ChargeSessionSummary? {
3319
        let chargingTransportMode = chargingTransportMode(for: object)
3320

            
3321
        guard
3322
            let id = uuidValue(object, key: "id"),
3323
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
3324
            let startedAt = dateValue(object, key: "startedAt"),
3325
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
3326
            let status = statusValue(object, key: "statusRawValue"),
3327
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
3328
        else {
3329
            return nil
3330
        }
3331

            
3332
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
3333
            .sorted { $0.timestamp < $1.timestamp }
3334
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
3335
            .sorted { lhs, rhs in
3336
                if lhs.bucketIndex != rhs.bucketIndex {
3337
                    return lhs.bucketIndex < rhs.bucketIndex
3338
                }
3339
                return lhs.timestamp < rhs.timestamp
3340
            }
3341

            
3342
        return ChargeSessionSummary(
3343
            id: id,
3344
            chargedDeviceID: chargedDeviceID,
Bogdan Timofte authored a month ago
3345
            chargedPowerbankID: uuidValue(object, key: "chargedPowerbankID"),
Bogdan Timofte authored a month ago
3346
            chargerID: uuidValue(object, key: "chargerID"),
Bogdan Timofte authored a month ago
3347
            sourcePowerbankID: uuidValue(object, key: "sourcePowerbankID"),
Bogdan Timofte authored a month ago
3348
            meterMACAddress: stringValue(object, key: "meterMACAddress"),
3349
            meterName: stringValue(object, key: "meterName"),
3350
            meterModel: stringValue(object, key: "meterModel"),
3351
            startedAt: startedAt,
3352
            endedAt: dateValue(object, key: "endedAt"),
3353
            lastObservedAt: lastObservedAt,
Bogdan Timofte authored a month ago
3354
            pausedAt: dateValue(object, key: "pausedAt"),
Bogdan Timofte authored a month ago
3355
            status: status,
3356
            sourceMode: sourceMode,
3357
            chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
3358
            chargingStateMode: chargingStateMode(for: object),
3359
            autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
Bogdan Timofte authored a month ago
3360
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
3361
            effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
Bogdan Timofte authored a month ago
3362
            meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
Bogdan Timofte authored a month ago
3363
            meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"),
3364
            meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"),
Bogdan Timofte authored a month ago
3365
            minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
3366
            maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
3367
            maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
3368
            maximumObservedVoltageVolts: chargingTransportMode == .wired
3369
                ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
3370
                : nil,
Bogdan Timofte authored a month ago
3371
            hasObservedChargeFlow: boolValue(object, key: "hasObservedChargeFlow"),
Bogdan Timofte authored a month ago
3372
            selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
3373
            completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
3374
            stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
3375
            startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
3376
            endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
3377
            capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
3378
            wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
3379
            usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
3380
            shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
3381
            supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
3382
            usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
3383
            targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
3384
            targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
3385
            requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
3386
            completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
3387
            completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
3388
            selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
Bogdan Timofte authored a month ago
3389
            trimStart: dateValue(object, key: "trimStart"),
3390
            trimEnd: dateValue(object, key: "trimEnd"),
Bogdan Timofte authored a month ago
3391
            wasConflictHealed: boolValue(object, key: "wasConflictHealed"),
Bogdan Timofte authored a month ago
3392
            checkpoints: checkpointSummaries,
3393
            aggregatedSamples: sampleSummaries
3394
        )
3395
    }
3396

            
3397
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
3398
        guard
3399
            let id = uuidValue(object, key: "id"),
3400
            let sessionID = uuidValue(object, key: "sessionID"),
Bogdan Timofte authored a month ago
3401
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID") ?? uuidValue(object, key: "powerbankID"),
Bogdan Timofte authored a month ago
3402
            let timestamp = dateValue(object, key: "timestamp")
3403
        else {
3404
            return nil
3405
        }
3406

            
3407
        return ChargeCheckpointSummary(
3408
            id: id,
3409
            sessionID: sessionID,
3410
            chargedDeviceID: chargedDeviceID,
Bogdan Timofte authored a month ago
3411
            powerbankID: uuidValue(object, key: "powerbankID"),
3412
            batteryBarsValue: Int(optionalInt16Value(object, key: "batteryBarsValue") ?? 0),
Bogdan Timofte authored a month ago
3413
            timestamp: timestamp,
3414
            batteryPercent: doubleValue(object, key: "batteryPercent"),
3415
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
3416
            currentAmps: doubleValue(object, key: "currentAmps"),
3417
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
3418
            label: stringValue(object, key: "label")
3419
        )
3420
    }
3421

            
3422
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
3423
        guard
3424
            let sessionID = uuidValue(object, key: "sessionID"),
3425
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
3426
            let timestamp = dateValue(object, key: "timestamp")
3427
        else {
3428
            return nil
3429
        }
3430

            
3431
        return ChargeSessionSampleSummary(
3432
            sessionID: sessionID,
3433
            chargedDeviceID: chargedDeviceID,
3434
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
3435
            timestamp: timestamp,
3436
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
3437
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
3438
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
3439
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
Bogdan Timofte authored a month ago
3440
            estimatedBatteryPercent: optionalDoubleValue(object, key: "estimatedBatteryPercent"),
Bogdan Timofte authored a month ago
3441
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
3442
        )
3443
    }
3444

            
Bogdan Timofte authored a month ago
3445
    private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
3446
        fetchSessionObject(
3447
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
3448
                format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
Bogdan Timofte authored a month ago
3449
                normalizedMACAddress(meterMACAddress),
Bogdan Timofte authored a month ago
3450
                ChargeSessionStatus.active.rawValue,
3451
                ChargeSessionStatus.paused.rawValue
Bogdan Timofte authored a month ago
3452
            )
3453
        )
3454
    }
3455

            
Bogdan Timofte authored a month ago
3456
    private func fetchOpenSessionObjects() -> [NSManagedObject] {
3457
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3458
        request.predicate = NSPredicate(
3459
            format: "statusRawValue == %@ OR statusRawValue == %@",
3460
            ChargeSessionStatus.active.rawValue,
3461
            ChargeSessionStatus.paused.rawValue
3462
        )
3463
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
3464
        return (try? context.fetch(request)) ?? []
3465
    }
3466

            
Bogdan Timofte authored a month ago
3467
    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
3468
        fetchSessionObject(
3469
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
3470
                format: "meterMACAddress == %@ AND statusRawValue == %@",
3471
                normalizedMACAddress(meterMACAddress),
3472
                ChargeSessionStatus.active.rawValue
Bogdan Timofte authored a month ago
3473
            )
3474
        )
3475
    }
3476

            
3477
    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
3478
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3479
        request.predicate = predicate
3480
        request.fetchLimit = 1
3481
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
3482
        return (try? context.fetch(request))?.first
3483
    }
3484

            
3485
    private func fetchSessionObject(id: String) -> NSManagedObject? {
3486
        fetchSessionObject(
3487
            predicate: NSPredicate(format: "id == %@", id)
3488
        )
3489
    }
3490

            
3491
    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
3492
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
3493
        request.predicate = NSPredicate(
3494
            format: "sessionID == %@ AND bucketIndex == %d",
3495
            sessionID,
3496
            bucketIndex
3497
        )
3498
        request.fetchLimit = 1
3499
        return (try? context.fetch(request))?.first
3500
    }
3501

            
3502
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
3503
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
3504
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
3505
        return (try? context.fetch(request)) ?? []
3506
    }
3507

            
Bogdan Timofte authored a month ago
3508
    private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
3509
        guard !sessionIDs.isEmpty else {
3510
            return []
3511
        }
3512

            
3513
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
3514
        request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs)
3515
        return (try? context.fetch(request)) ?? []
3516
    }
3517

            
Bogdan Timofte authored a month ago
3518
    private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
3519
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
3520
        request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
3521
        request.fetchLimit = 1
3522
        return (try? context.fetch(request))?.first
3523
    }
3524

            
Bogdan Timofte authored a month ago
3525
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
3526
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
3527
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
3528
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
3529
        return (try? context.fetch(request)) ?? []
3530
    }
3531

            
3532
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
3533
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3534
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
3535
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
3536
        return (try? context.fetch(request)) ?? []
3537
    }
3538

            
3539
    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
3540
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3541
        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
3542
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
3543
        return (try? context.fetch(request)) ?? []
3544
    }
3545

            
Bogdan Timofte authored a month ago
3546
    private func fetchSessions(forPowerbankSubjectID powerbankID: String) -> [NSManagedObject] {
3547
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3548
        request.predicate = NSPredicate(format: "chargedPowerbankID == %@", powerbankID)
3549
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
3550
        return (try? context.fetch(request)) ?? []
3551
    }
3552

            
3553
    private func fetchSessions(forPowerbankSourceID powerbankID: String) -> [NSManagedObject] {
3554
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3555
        request.predicate = NSPredicate(format: "sourcePowerbankID == %@", powerbankID)
3556
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
3557
        return (try? context.fetch(request)) ?? []
3558
    }
3559

            
Bogdan Timofte authored a month ago
3560
    private func sampleBackedSessionIDs(
3561
        devices: [NSManagedObject],
3562
        sessionsByDeviceID: [String: [NSManagedObject]],
3563
        sessionsByChargerID: [String: [NSManagedObject]]
3564
    ) -> Set<String> {
3565
        var sessionIDs: Set<String> = []
3566

            
3567
        for device in devices {
3568
            guard
3569
                let deviceID = stringValue(device, key: "id"),
3570
                let rawClass = stringValue(device, key: "deviceClassRawValue"),
3571
                let deviceClass = ChargedDeviceClass(rawValue: rawClass)
3572
            else {
3573
                continue
3574
            }
3575

            
3576
            let relevantSessions = relevantSessionObjects(
3577
                for: deviceID,
3578
                deviceClass: deviceClass,
3579
                sessionsByDeviceID: sessionsByDeviceID,
3580
                sessionsByChargerID: sessionsByChargerID
3581
            )
3582
            .sorted { lhs, rhs in
3583
                let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed
3584
                let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed
3585

            
3586
                if lhsStatus.isOpen && !rhsStatus.isOpen {
3587
                    return true
3588
                }
3589
                if !lhsStatus.isOpen && rhsStatus.isOpen {
3590
                    return false
3591
                }
3592

            
3593
                return (dateValue(lhs, key: "startedAt") ?? .distantPast)
3594
                    > (dateValue(rhs, key: "startedAt") ?? .distantPast)
3595
            }
3596

            
3597
            var recentCompletedSamplesIncluded = 0
3598

            
3599
            for session in relevantSessions {
3600
                guard let sessionID = stringValue(session, key: "id"),
3601
                      let status = statusValue(session, key: "statusRawValue") else {
3602
                    continue
3603
                }
3604

            
3605
                if status.isOpen {
3606
                    sessionIDs.insert(sessionID)
3607
                    continue
3608
                }
3609

            
3610
                guard recentCompletedSamplesIncluded < 2 else {
3611
                    continue
3612
                }
3613

            
3614
                sessionIDs.insert(sessionID)
3615
                recentCompletedSamplesIncluded += 1
3616
            }
3617
        }
3618

            
3619
        return sessionIDs
3620
    }
3621

            
Bogdan Timofte authored a month ago
3622
    private func relevantSessionObjects(
3623
        for chargedDeviceID: String,
3624
        deviceClass: ChargedDeviceClass,
3625
        sessionsByDeviceID: [String: [NSManagedObject]],
3626
        sessionsByChargerID: [String: [NSManagedObject]]
3627
    ) -> [NSManagedObject] {
3628
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
3629
        guard deviceClass == .charger else {
3630
            return directSessions
3631
        }
3632

            
3633
        var seenSessionIDs = Set<String>()
3634
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
3635
            .filter { session in
3636
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
3637
                return seenSessionIDs.insert(sessionID).inserted
3638
            }
3639
            .sorted {
3640
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
3641
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
3642
                return lhsDate < rhsDate
3643
            }
3644
    }
3645

            
Bogdan Timofte authored a month ago
3646
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
3647
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
3648
    }
3649

            
Bogdan Timofte authored a month ago
3650
    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
3651
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
3652
        request.predicate = NSPredicate(format: "id == %@", id)
3653
        request.fetchLimit = 1
3654
        return (try? context.fetch(request))?.first
3655
    }
3656

            
Bogdan Timofte authored a month ago
3657
    private func fetchPowerbankObject(id: String) -> NSManagedObject? {
3658
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.powerbank)
3659
        request.predicate = NSPredicate(format: "id == %@", id)
3660
        request.fetchLimit = 1
3661
        return (try? context.fetch(request))?.first
3662
    }
3663

            
Bogdan Timofte authored a month ago
3664
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
3665
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
3666
        return (try? context.fetch(request)) ?? []
3667
    }
3668

            
3669
    private func resolvedStopThreshold(
3670
        for chargedDevice: NSManagedObject,
3671
        chargingTransportMode: ChargingTransportMode,
Bogdan Timofte authored a month ago
3672
        chargingStateMode: ChargingStateMode,
3673
        charger: NSManagedObject?,
3674
        fallback: Double?
3675
    ) -> Double? {
3676
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
3677
            return nil
3678
        }
3679

            
3680
        let sessionKind = ChargeSessionKind(
3681
            chargingTransportMode: chargingTransportMode,
3682
            chargingStateMode: chargingStateMode
3683
        )
3684
        let configuredCurrents = decodedCompletionCurrents(
3685
            from: chargedDevice,
3686
            key: "configuredCompletionCurrentsRawValue"
3687
        )
3688
        let learnedCurrents = decodedCompletionCurrents(
3689
            from: chargedDevice,
3690
            key: "learnedCompletionCurrentsRawValue"
3691
        )
3692
        let legacyCurrent: Double?
Bogdan Timofte authored a month ago
3693
        switch chargingTransportMode {
3694
        case .wired:
Bogdan Timofte authored a month ago
3695
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
3696
                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
3697
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
3698
        case .wireless:
Bogdan Timofte authored a month ago
3699
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
3700
                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
3701
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
3702
        }
Bogdan Timofte authored a month ago
3703

            
3704
        let resolvedCurrent = configuredCurrents[sessionKind]
3705
            ?? learnedCurrents[sessionKind]
3706
            ?? legacyCurrent
3707
            ?? fallback
3708
        guard let resolvedCurrent, resolvedCurrent > 0 else {
3709
            return nil
3710
        }
3711
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
3712
    }
3713

            
Bogdan Timofte authored a month ago
3714
    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
Bogdan Timofte authored a month ago
3715
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
3716
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
3717
        return resolvedPreferredChargingTransportMode(
Bogdan Timofte authored a month ago
3718
            .wired,
Bogdan Timofte authored a month ago
3719
            supportsWiredCharging: supportsWiredCharging,
3720
            supportsWirelessCharging: supportsWirelessCharging
3721
        )
3722
    }
3723

            
Bogdan Timofte authored a month ago
3724
    private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
3725
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other
3726
    }
3727

            
3728
    private func normalizedTemplateID(
3729
        _ templateID: String?,
3730
        kind: ChargedDeviceKind
3731
    ) -> String? {
3732
        guard let templateID,
3733
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
3734
              templateDefinition.kind == kind else {
3735
            return nil
Bogdan Timofte authored a month ago
3736
        }
Bogdan Timofte authored a month ago
3737
        return templateDefinition.id
Bogdan Timofte authored a month ago
3738
    }
3739

            
Bogdan Timofte authored a month ago
3740
    /// Resolves the active DeviceProfile for a ChargedDevice — catalog first
3741
    /// (covers all built-in templates and chargers), then DB-backed custom profiles.
3742
    /// Returns nil only for devices that escaped migration (shouldn't happen post Phase 3).
3743
    private func resolvedProfileDefinition(for chargedDevice: NSManagedObject) -> DeviceProfileDefinition? {
3744
        if let profileID = stringValue(chargedDevice, key: "profileID") {
3745
            if let catalogProfile = DeviceProfileCatalog.shared.profile(id: profileID) {
3746
                return catalogProfile
3747
            }
3748
            if let stored = fetchDeviceProfileObject(id: profileID),
3749
               let definition = makeProfileDefinition(from: stored) {
3750
                return definition
3751
            }
3752
        }
3753
        // Pre-migration fallback: try the legacy template ID against the catalog.
3754
        if let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
3755
           let catalogProfile = DeviceProfileCatalog.shared.profile(id: templateID) {
3756
            return catalogProfile
3757
        }
3758
        return nil
3759
    }
3760

            
3761
    private func fetchDeviceProfileObject(id: String) -> NSManagedObject? {
3762
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.deviceProfile)
3763
        request.predicate = NSPredicate(format: "id == %@", id)
3764
        request.fetchLimit = 1
3765
        return (try? context.fetch(request))?.first
3766
    }
3767

            
3768
    private func makeProfileDefinition(from object: NSManagedObject) -> DeviceProfileDefinition? {
3769
        guard let id = stringValue(object, key: "id"),
3770
              let categoryRaw = stringValue(object, key: "categoryRawValue"),
3771
              let category = ProfileCategory(rawValue: categoryRaw) else {
Bogdan Timofte authored a month ago
3772
            return nil
Bogdan Timofte authored a month ago
3773
        }
Bogdan Timofte authored a month ago
3774
        let name = stringValue(object, key: "name") ?? id
3775
        let group = stringValue(object, key: "group") ?? "Custom"
3776
        let iconName = stringValue(object, key: "iconSymbolName") ?? category.symbolName
3777
        let iconFallback = stringValue(object, key: "iconFallbackSymbolName")
3778
        let icon = ChargedDeviceTemplateIcon(type: .systemSymbol, name: iconName, fallbackSystemName: iconFallback)
3779
        let stateRaw = stringValue(object, key: "capChargingStateAvailabilityRawValue")
3780
            ?? ChargingStateAvailability.onOrOff.rawValue
3781
        let stateAvailability = ChargingStateAvailability(rawValue: stateRaw) ?? .onOrOff
3782
        let allowedWirelessProfiles = DeviceProfileDefinition.decodeWirelessProfilesCSV(
3783
            stringValue(object, key: "capWirelessProfilesRawValue")
3784
        )
3785
        let defaultWirelessProfileRaw = stringValue(object, key: "defaultWirelessChargingProfileRawValue")
3786
        let defaultWirelessProfile = defaultWirelessProfileRaw.flatMap(WirelessChargingProfile.init(rawValue:))
3787

            
3788
        return DeviceProfileDefinition(
3789
            id: id,
3790
            name: name,
3791
            group: group,
3792
            category: category,
3793
            icon: icon,
3794
            sortOrder: Int((object.value(forKey: "sortOrder") as? Int32) ?? 1000),
3795
            capWiredCharging: (object.value(forKey: "capWiredCharging") as? Bool) ?? false,
3796
            capWirelessCharging: (object.value(forKey: "capWirelessCharging") as? Bool) ?? false,
3797
            capWirelessProfiles: allowedWirelessProfiles,
3798
            capChargingStateAvailability: stateAvailability,
3799
            capHasInternalSubject: (object.value(forKey: "capHasInternalSubject") as? Bool) ?? false,
3800
            defaultWirelessChargingProfile: defaultWirelessProfile,
3801
            defaultWiredMinimumCurrentAmps: optionalDoubleValue(object, key: "defaultWiredMinimumCurrentAmps"),
3802
            defaultWirelessMinimumCurrentAmps: optionalDoubleValue(object, key: "defaultWirelessMinimumCurrentAmps"),
3803
            defaultWiredEstimatedBatteryCapacityWh: optionalDoubleValue(object, key: "defaultWiredEstimatedBatteryCapacityWh"),
3804
            defaultWirelessEstimatedBatteryCapacityWh: optionalDoubleValue(object, key: "defaultWirelessEstimatedBatteryCapacityWh")
3805
        )
3806
    }
3807

            
3808
    /// Synthesises a `ChargedDeviceTemplateDefinition` from the active profile so the
3809
    /// existing UI surfaces (icons, group titles, capability summaries) keep working
3810
    /// without forking. Catalog profiles return their canonical template; custom
3811
    /// profiles return a definition derived from the profile's persisted shape.
3812
    private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
3813
        guard let profile = resolvedProfileDefinition(for: chargedDevice) else { return nil }
3814

            
3815
        let kind = profile.category.kind
3816
        // Pick a representative wireless profile for the legacy template shape.
3817
        let wirelessProfile = profile.defaultWirelessChargingProfile
3818
            ?? profile.capWirelessProfiles.first
3819
            ?? .genericQi
3820

            
3821
        return ChargedDeviceTemplateDefinition(
3822
            id: profile.id,
3823
            name: profile.name,
3824
            group: profile.group,
3825
            kind: kind,
3826
            deviceClass: legacyClass(for: profile.category),
3827
            icon: profile.icon,
3828
            chargingStateAvailability: profile.capChargingStateAvailability,
3829
            supportsWiredCharging: profile.capWiredCharging,
3830
            supportsWirelessCharging: profile.capWirelessCharging,
3831
            wirelessChargingProfile: wirelessProfile,
3832
            sortOrder: profile.sortOrder
3833
        )
3834
    }
3835

            
3836
    private func legacyClass(for category: ProfileCategory) -> ChargedDeviceClass {
3837
        switch category {
3838
        case .phone: return .iphone
3839
        case .watch: return .watch
3840
        case .powerbank: return .powerbank
3841
        case .charger: return .charger
3842
        case .tablet, .laptop, .audioAccessory, .accessoryCase, .other: return .other
3843
        }
Bogdan Timofte authored a month ago
3844
    }
3845

            
3846
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
3847
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
3848
            ? true
3849
            : boolValue(chargedDevice, key: "supportsWiredCharging")
Bogdan Timofte authored a month ago
3850

            
3851
        if let profile = resolvedProfileDefinition(for: chargedDevice) {
3852
            // Profile capability is the upper bound; user opt-out preserved.
3853
            return persistedWiredCharging && profile.capWiredCharging
3854
        }
3855

            
3856
        // Pre-migration fallback: legacy class enforcement.
Bogdan Timofte authored a month ago
3857
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
3858
            ? false
3859
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
3860
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
3861
            supportsWiredCharging: persistedWiredCharging,
3862
            supportsWirelessCharging: persistedWirelessCharging
3863
        ).wired
3864
    }
3865

            
3866
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
3867
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
3868
            ? false
3869
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
Bogdan Timofte authored a month ago
3870

            
3871
        if let profile = resolvedProfileDefinition(for: chargedDevice) {
3872
            return persistedWirelessCharging && profile.capWirelessCharging
3873
        }
3874

            
3875
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
3876
            ? true
3877
            : boolValue(chargedDevice, key: "supportsWiredCharging")
Bogdan Timofte authored a month ago
3878
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
3879
            supportsWiredCharging: persistedWiredCharging,
3880
            supportsWirelessCharging: persistedWirelessCharging
3881
        ).wireless
3882
    }
3883

            
3884
    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
Bogdan Timofte authored a month ago
3885
        if let profile = resolvedProfileDefinition(for: chargedDevice) {
3886
            return profile.capChargingStateAvailability
3887
        }
3888

            
Bogdan Timofte authored a month ago
3889
        let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue")
3890
            .flatMap(ChargingStateAvailability.init(rawValue:))
3891
            ?? ChargingStateAvailability.fallback(
Bogdan Timofte authored a month ago
3892
            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
3893
        )
Bogdan Timofte authored a month ago
3894
        return deviceClass(for: chargedDevice).normalizedChargingStateAvailability(persistedAvailability)
Bogdan Timofte authored a month ago
3895
    }
3896

            
3897
    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
Bogdan Timofte authored a month ago
3898
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
3899
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
3900
            let persistedChargingStateMode = stringValue(session, key: "chargingStateRawValue")
3901
                .flatMap(ChargingStateMode.init(rawValue:))
3902
                ?? .on
3903
            return resolvedChargingStateMode(
3904
                persistedChargingStateMode,
3905
                availability: chargingStateAvailability(for: chargedDevice)
3906
            )
3907
        }
3908

            
Bogdan Timofte authored a month ago
3909
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
3910
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
3911
            return chargingStateMode
3912
        }
3913

            
3914
        return .on
3915
    }
3916

            
3917
    private func resolvedChargingStateMode(
3918
        _ chargingStateMode: ChargingStateMode,
3919
        availability: ChargingStateAvailability
3920
    ) -> ChargingStateMode {
3921
        if availability.supportedModes.contains(chargingStateMode) {
3922
            return chargingStateMode
3923
        }
3924
        return availability.supportedModes.first ?? .on
3925
    }
3926

            
Bogdan Timofte authored a month ago
3927
    private func chargerType(for chargedDevice: NSManagedObject) -> ChargerType? {
3928
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
3929
        guard deviceClass == .charger else { return nil }
3930

            
3931
        // Primary: chargerTypeRawValue (set on v13+)
3932
        if let rawValue = stringValue(chargedDevice, key: "chargerTypeRawValue"),
3933
           let type = ChargerType(rawValue: rawValue) {
3934
            return type
3935
        }
3936

            
3937
        // Migration fallback: derive from old deviceTemplateID
3938
        switch stringValue(chargedDevice, key: "deviceTemplateID") {
3939
        case "apple-magsafe-charger": return .appleMagSafe
3940
        case "apple-watch-charger": return .appleWatch
3941
        default: break
3942
        }
3943

            
3944
        // Last resort: derive from wirelessChargingProfileRawValue
3945
        if let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
3946
           let profile = WirelessChargingProfile(rawValue: rawValue),
3947
           profile == .magsafe {
3948
            return .genericMagSafe
3949
        }
3950

            
3951
        return .genericQi
3952
    }
3953

            
Bogdan Timofte authored a month ago
3954
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
Bogdan Timofte authored a month ago
3955
        if let type = chargerType(for: chargedDevice) {
3956
            return type.wirelessChargingProfile
3957
        }
Bogdan Timofte authored a month ago
3958
        let persisted = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue")
3959
            .flatMap(WirelessChargingProfile.init(rawValue:))
3960

            
3961
        if let profile = resolvedProfileDefinition(for: chargedDevice),
3962
           !profile.capWirelessProfiles.isEmpty {
3963
            // Persisted wins iff still allowed by capabilities; else fall back to profile default.
3964
            if let persisted, profile.capWirelessProfiles.contains(persisted) {
3965
                return persisted
3966
            }
3967
            return profile.defaultWirelessChargingProfile
3968
                ?? profile.capWirelessProfiles.first
3969
                ?? .genericQi
Bogdan Timofte authored a month ago
3970
        }
Bogdan Timofte authored a month ago
3971

            
3972
        return persisted ?? .genericQi
Bogdan Timofte authored a month ago
3973
    }
3974

            
3975
    private func resolvedPreferredChargingTransportMode(
3976
        _ preferredChargingTransportMode: ChargingTransportMode,
3977
        supportsWiredCharging: Bool,
3978
        supportsWirelessCharging: Bool
3979
    ) -> ChargingTransportMode {
3980
        switch preferredChargingTransportMode {
3981
        case .wired where supportsWiredCharging:
3982
            return .wired
3983
        case .wireless where supportsWirelessCharging:
3984
            return .wireless
3985
        default:
3986
            if supportsWiredCharging {
3987
                return .wired
3988
            }
3989
            if supportsWirelessCharging {
3990
                return .wireless
3991
            }
3992
            return .wired
3993
        }
3994
    }
3995

            
Bogdan Timofte authored a month ago
3996
    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
3997
        let payload = Dictionary(
3998
            uniqueKeysWithValues: currents.map { key, value in
3999
                (key.rawValue, value)
4000
            }
4001
        )
4002
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
4003
            return nil
4004
        }
4005
        return String(data: data, encoding: .utf8)
4006
    }
4007

            
4008
    private func decodedCompletionCurrents(
4009
        from object: NSManagedObject,
4010
        key: String
4011
    ) -> [ChargeSessionKind: Double] {
4012
        guard let rawValue = stringValue(object, key: key),
4013
              let data = rawValue.data(using: .utf8),
4014
              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
4015
            return [:]
4016
        }
4017

            
4018
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
4019
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
4020
                return
4021
            }
4022
            result[sessionKind] = entry.value
4023
        }
4024
    }
4025

            
4026
    private func legacyConfiguredCompletionCurrent(
4027
        for currents: [ChargeSessionKind: Double],
4028
        chargingTransportMode: ChargingTransportMode
4029
    ) -> Double? {
4030
        let candidates = currents
4031
            .filter { $0.key.chargingTransportMode == chargingTransportMode }
4032
            .sorted { lhs, rhs in
4033
                lhs.key.rawValue < rhs.key.rawValue
4034
            }
4035
            .map(\.value)
4036
        return candidates.first
4037
    }
4038

            
4039
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
4040
        guard let charger else {
4041
            return nil
4042
        }
4043
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
4044
        guard let idleCurrent, idleCurrent >= 0 else {
4045
            return nil
4046
        }
4047
        return idleCurrent
4048
    }
4049

            
4050
    private func effectiveCurrentAmps(
4051
        fromMeasuredCurrent currentAmps: Double,
4052
        chargingTransportMode: ChargingTransportMode,
4053
        charger: NSManagedObject?
4054
    ) -> Double {
4055
        switch chargingTransportMode {
4056
        case .wired:
4057
            return max(currentAmps, 0)
4058
        case .wireless:
4059
            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
4060
                return max(currentAmps, 0)
4061
            }
4062
            return max(currentAmps - idleCurrent, 0)
4063
        }
4064
    }
4065

            
4066
    private func hasObservedChargeFlow(
4067
        currentAmps: Double,
4068
        chargingTransportMode: ChargingTransportMode,
4069
        charger: NSManagedObject?,
4070
        stopThreshold: Double?
4071
    ) -> Bool {
4072
        let effectiveCurrent = effectiveCurrentAmps(
4073
            fromMeasuredCurrent: currentAmps,
4074
            chargingTransportMode: chargingTransportMode,
4075
            charger: charger
4076
        )
4077
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
4078
    }
4079

            
Bogdan Timofte authored a month ago
4080
    private func hasSavableChargeData(_ session: NSManagedObject) -> Bool {
Bogdan Timofte authored a month ago
4081
        if boolValue(session, key: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
4082
            || doubleValue(session, key: "measuredEnergyWh") > 0
4083
            || doubleValue(session, key: "measuredChargeAh") > 0
4084
            || (optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? 0) > 0
Bogdan Timofte authored a month ago
4085
            || (optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? 0) > 0 {
4086
            return true
4087
        }
4088

            
4089
        guard let sessionID = stringValue(session, key: "id") else {
4090
            return false
4091
        }
4092

            
4093
        return fetchSessionSampleObjects(forSessionID: sessionID).contains { sample in
4094
            doubleValue(sample, key: "measuredEnergyWh") > 0
4095
                || doubleValue(sample, key: "measuredChargeAh") > 0
4096
                || (optionalDoubleValue(sample, key: "averageCurrentAmps") ?? 0) > 0
4097
                || (optionalDoubleValue(sample, key: "averagePowerWatts") ?? 0) > 0
4098
        }
4099
    }
4100

            
4101
    private func restoreMeasuredTotalsFromLatestSampleIfNeeded(_ session: NSManagedObject) {
4102
        guard let sessionID = stringValue(session, key: "id"),
4103
              let latestSample = fetchSessionSampleObjects(forSessionID: sessionID).max(by: {
4104
                  (dateValue($0, key: "timestamp") ?? .distantPast) < (dateValue($1, key: "timestamp") ?? .distantPast)
4105
              }) else {
4106
            return
4107
        }
4108

            
4109
        let sampleEnergyWh = doubleValue(latestSample, key: "measuredEnergyWh")
4110
        if doubleValue(session, key: "measuredEnergyWh") <= 0, sampleEnergyWh > 0 {
4111
            session.setValue(sampleEnergyWh, forKey: "measuredEnergyWh")
4112
        }
4113

            
4114
        let sampleChargeAh = doubleValue(latestSample, key: "measuredChargeAh")
4115
        if doubleValue(session, key: "measuredChargeAh") <= 0, sampleChargeAh > 0 {
4116
            session.setValue(sampleChargeAh, forKey: "measuredChargeAh")
4117
        }
Bogdan Timofte authored a month ago
4118
    }
4119

            
Bogdan Timofte authored a month ago
4120
    private func derivedMinimumCurrent(
4121
        from sessions: [NSManagedObject],
4122
        chargingTransportMode: ChargingTransportMode
4123
    ) -> Double? {
4124
        let completionCurrents = sessions.compactMap { session -> Double? in
4125
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
4126
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
4127
                return nil
4128
            }
Bogdan Timofte authored a month ago
4129
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
4130
                return nil
4131
            }
Bogdan Timofte authored a month ago
4132
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
4133
                return nil
4134
            }
4135
            return completionCurrent
4136
        }
4137

            
4138
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
4139
        guard !recentCompletionCurrents.isEmpty else { return nil }
4140
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
4141
    }
4142

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

            
4146
        for session in sessions {
4147
            guard statusValue(session, key: "statusRawValue") == .completed else {
4148
                continue
4149
            }
4150
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
4151
                continue
4152
            }
4153
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
4154
                  completionCurrent > 0 else {
4155
                continue
4156
            }
4157

            
4158
            let sessionKind = ChargeSessionKind(
4159
                chargingTransportMode: chargingTransportMode(for: session),
4160
                chargingStateMode: chargingStateMode(for: session)
4161
            )
4162
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
4163
        }
4164

            
4165
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
4166
            let recentCurrents = Array(entry.value.suffix(5))
4167
            guard !recentCurrents.isEmpty else {
4168
                return
4169
            }
4170
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
4171
        }
4172
    }
4173

            
Bogdan Timofte authored a month ago
4174
    private func derivedCapacity(
4175
        from sessions: [NSManagedObject],
4176
        chargingTransportMode: ChargingTransportMode,
4177
        supportsChargingWhileOff: Bool
4178
    ) -> Double? {
4179
        let capacityCandidates = sessions.compactMap { session -> Double? in
4180
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
4181
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
4182
                return nil
4183
            }
4184
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
4185
                return nil
4186
            }
4187
            if supportsChargingWhileOff {
4188
                return capacityEstimate
4189
            }
4190
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
4191
                return nil
4192
            }
4193
            return capacityEstimate
4194
        }
4195

            
4196
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
4197
        guard !recentCapacityCandidates.isEmpty else { return nil }
4198
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
4199
    }
4200

            
4201
    private func derivedWirelessEfficiency(
4202
        from sessions: [NSManagedObject],
4203
        chargingProfile: WirelessChargingProfile
4204
    ) -> Double? {
4205
        guard chargingProfile == .magsafe else {
4206
            return nil
4207
        }
4208

            
4209
        let candidates = sessions.compactMap { session -> Double? in
4210
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
4211
            guard chargingTransportMode(for: session) == .wireless else { return nil }
4212
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
4213
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
4214
                return nil
4215
            }
4216
            return factor
4217
        }
4218

            
4219
        let recentCandidates = Array(candidates.suffix(6))
4220
        guard !recentCandidates.isEmpty else { return nil }
4221
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
4222
    }
4223

            
4224
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
4225
        let candidates = sessions.compactMap { session -> Double? in
4226
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
4227
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
4228
                return nil
4229
            }
4230
            return (sourceVoltage * 10).rounded() / 10
4231
        }
4232

            
4233
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
4234
        return counts.keys.sorted()
4235
    }
4236

            
4237
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
4238
        let candidates = sessions.compactMap { session -> Double? in
4239
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
4240
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
4241
                return nil
4242
            }
4243
            return minimumObservedCurrent
4244
        }
4245

            
4246
        let recentCandidates = Array(candidates.suffix(6))
4247
        guard !recentCandidates.isEmpty else { return nil }
4248
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
4249
    }
4250

            
4251
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
4252
        let candidates = sessions.compactMap { session -> Double? in
4253
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
4254
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
4255
                return nil
4256
            }
4257
            return factor
4258
        }
4259

            
4260
        let recentCandidates = Array(candidates.suffix(6))
4261
        guard !recentCandidates.isEmpty else { return nil }
4262
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
4263
    }
4264

            
4265
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
4266
        sessions.compactMap { session -> Double? in
4267
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
4268
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
4269
                return nil
4270
            }
4271
            return maximumObservedPower
4272
        }
4273
        .max()
4274
    }
4275

            
Bogdan Timofte authored a month ago
4276
    /// View-time derivation of powerbank metrics from materialized session summaries.
4277
    /// Mirrors the charger derivation pattern but works on `ChargeSessionSummary` (already
4278
    /// projected) so we don't need to re-fetch NSManagedObjects.
4279
    /// - voltage profile: groups source-side sessions by selected voltage palier (rounded
4280
    ///   to 0.5V) and tracks max current observed at each palier.
4281
    /// - max power: max across source-side sessions' `maximumObservedPowerWatts`.
4282
    /// - efficiency: ratio of total Wh delivered (as source) vs total Wh received (as subject).
4283
    ///   Computed only when both sides have non-trivial energy logged.
4284
    /// - apparent capacity: sum of source-side delivered Wh between the most recent two
4285
    ///   powerbank-side checkpoints with sufficient battery percent delta. Best-effort.
4286
    private func derivedPowerbankMetrics(
4287
        sessionsAsSubject: [ChargeSessionSummary],
4288
        sessionsAsSource: [ChargeSessionSummary],
4289
        reporting: BatteryLevelReporting
4290
    ) -> (
4291
        voltageMaxCurrents: [Double: Double],
4292
        maxPowerWatts: Double?,
4293
        efficiencyFactor: Double?,
4294
        apparentCapacityWh: Double?
4295
    ) {
4296
        var voltageMaxCurrents: [Double: Double] = [:]
4297
        var maxPower: Double? = nil
4298

            
4299
        for session in sessionsAsSource {
4300
            if let voltage = session.selectedSourceVoltageVolts, voltage > 0 {
4301
                let palier = (voltage * 2).rounded() / 2  // 0.5V buckets
4302
                if let maxCurrent = session.maximumObservedCurrentAmps, maxCurrent > 0 {
4303
                    let prev = voltageMaxCurrents[palier] ?? 0
4304
                    voltageMaxCurrents[palier] = max(prev, maxCurrent)
4305
                }
4306
            }
4307
            if let power = session.maximumObservedPowerWatts, power > 0 {
4308
                maxPower = max(maxPower ?? 0, power)
4309
            }
4310
        }
4311

            
4312
        let totalDelivered = sessionsAsSource.reduce(0.0) { $0 + $1.measuredEnergyWh }
4313
        let totalReceived = sessionsAsSubject.reduce(0.0) { $0 + $1.measuredEnergyWh }
4314
        let efficiency: Double? = (totalDelivered > 0.5 && totalReceived > 0.5)
4315
            ? totalDelivered / totalReceived
4316
            : nil
4317

            
4318
        // Apparent capacity heuristics depend on what reporting the powerbank supports:
4319
        //
4320
        // - `.fullOnly`: the only honest signal is the "full" anchor. We look for two
4321
        //   consecutive full markers (one before a discharge cycle, one after the next
4322
        //   recharge) and use the source-side energy delivered between them. Single-LED
4323
        //   powerbanks naturally produce two 100% datapoints separated by usage.
4324
        // - `.percent` / `.bars`: pair the most recent powerbank-side checkpoints with a
4325
        //   meaningful battery delta (≥ 30%) and sum source-side energy in that window.
4326
        // - `.none`: no powerbank checkpoints exist, so apparent capacity stays nil here
4327
        //   and would have to be inferred differently (currently not attempted).
4328
        var apparentCapacity: Double? = nil
4329

            
4330
        let powerbankCheckpoints = (sessionsAsSource + sessionsAsSubject)
4331
            .flatMap { $0.checkpoints.filter { $0.subject == .powerbank } }
4332
            .sorted { $0.timestamp < $1.timestamp }
4333

            
4334
        switch reporting {
4335
        case .fullOnly:
4336
            let fullMarkers = powerbankCheckpoints.filter { $0.batteryPercent >= 99 }
4337
            if let lastFull = fullMarkers.last,
4338
               let prevFull = fullMarkers.dropLast().last {
4339
                let lower = prevFull.timestamp
4340
                let upper = lastFull.timestamp
4341
                let energyDelivered = sessionsAsSource
4342
                    .filter { $0.startedAt <= upper && ($0.endedAt ?? $0.lastObservedAt) >= lower }
4343
                    .reduce(0.0) { $0 + $1.measuredEnergyWh }
4344
                if energyDelivered > 0.1 {
4345
                    apparentCapacity = energyDelivered
4346
                }
4347
            }
4348
        case .percent, .bars:
4349
            if powerbankCheckpoints.count >= 2 {
4350
                let last = powerbankCheckpoints.last!
4351
                if let earlier = powerbankCheckpoints.first(where: {
4352
                    abs(last.batteryPercent - $0.batteryPercent) >= 30
4353
                }) {
4354
                    let lower = min(earlier.timestamp, last.timestamp)
4355
                    let upper = max(earlier.timestamp, last.timestamp)
4356
                    let energyDelivered = sessionsAsSource
4357
                        .filter { $0.startedAt <= upper && ($0.endedAt ?? $0.lastObservedAt) >= lower }
4358
                        .reduce(0.0) { $0 + $1.measuredEnergyWh }
4359
                    if energyDelivered > 0.1 {
4360
                        apparentCapacity = energyDelivered
4361
                    }
4362
                }
4363
            }
4364
        case .none:
4365
            break
4366
        }
4367

            
4368
        return (voltageMaxCurrents, maxPower, efficiency, apparentCapacity)
4369
    }
4370

            
Bogdan Timofte authored a month ago
4371
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
4372
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
4373
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
4374
            return resolvedPreferredChargingTransportMode(
4375
                chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired,
4376
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
4377
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
4378
            )
4379
        }
4380

            
4381
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
4382
            return persistedChargingTransportMode
Bogdan Timofte authored a month ago
4383
        }
4384

            
4385
        return .wired
4386
    }
4387

            
4388
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
4389
        if session.isInserted {
4390
            return .created
4391
        }
4392

            
4393
        let committedValues = session.committedValues(
4394
            forKeys: [
4395
                "statusRawValue",
4396
                "updatedAt",
4397
                "targetBatteryAlertTriggeredAt",
4398
                "requiresCompletionConfirmation"
4399
            ]
4400
        )
4401
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
4402
        let currentStatus = statusValue(session, key: "statusRawValue")
4403
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
4404
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
4405
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
4406
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
4407
            ?? false
4408
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
4409

            
4410
        if currentStatus == .completed, committedStatus != .completed {
4411
            return .completed
4412
        }
4413

            
Bogdan Timofte authored a month ago
4414
        if currentStatus != committedStatus {
4415
            return .event
4416
        }
4417

            
Bogdan Timofte authored a month ago
4418
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
4419
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
4420
            return .event
4421
        }
4422

            
4423
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
4424
            ?? dateValue(session, key: "createdAt")
4425
            ?? observedAt
4426

            
4427
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
4428
            return .periodic
4429
        }
4430

            
4431
        return .none
4432
    }
4433

            
Bogdan Timofte authored a month ago
4434
    private func shouldPersistAggregatedSample(
4435
        _ sample: NSManagedObject,
4436
        observedAt: Date
4437
    ) -> Bool {
4438
        if sample.isInserted {
4439
            return true
4440
        }
4441

            
4442
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
4443
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
4444
            ?? dateValue(sample, key: "createdAt")
4445
            ?? observedAt
4446

            
4447
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
4448
    }
4449

            
Bogdan Timofte authored a month ago
4450
    private func generateQRIdentifier() -> String {
4451
        "device:\(UUID().uuidString)"
4452
    }
4453

            
4454
    @discardableResult
4455
    private func saveContext() -> Bool {
4456
        guard context.hasChanges else { return true }
4457
        do {
4458
            try context.save()
4459
            return true
4460
        } catch {
4461
            track("Failed saving charge insights context: \(error)")
4462
            context.rollback()
4463
            return false
4464
        }
4465
    }
4466

            
4467
    private func normalizedText(_ text: String) -> String {
4468
        text.trimmingCharacters(in: .whitespacesAndNewlines)
4469
    }
4470

            
4471
    private func normalizedOptionalText(_ text: String?) -> String? {
4472
        guard let text else { return nil }
4473
        let normalized = normalizedText(text)
4474
        return normalized.isEmpty ? nil : normalized
4475
    }
4476

            
4477
    private func normalizedMACAddress(_ macAddress: String) -> String {
4478
        normalizedText(macAddress).uppercased()
4479
    }
4480

            
Bogdan Timofte authored a month ago
4481
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
4482
        guard object.entity.propertiesByName[key] != nil else {
4483
            return nil
4484
        }
4485
        return object.value(forKey: key)
4486
    }
4487

            
4488
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
4489
        guard object.entity.propertiesByName[key] != nil else {
4490
            return
4491
        }
4492
        object.setValue(value, forKey: key)
4493
    }
4494

            
Bogdan Timofte authored a month ago
4495
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
4496
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
4497
        let normalized = normalizedOptionalText(value)
4498
        return normalized
4499
    }
4500

            
4501
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
4502
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
4503
    }
4504

            
4505
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
4506
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
4507
            return value
4508
        }
Bogdan Timofte authored a month ago
4509
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
4510
            return value.doubleValue
4511
        }
4512
        return 0
4513
    }
4514

            
4515
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
4516
        let value = rawValue(object, key: key)
4517
        if value == nil {
Bogdan Timofte authored a month ago
4518
            return nil
4519
        }
4520
        return doubleValue(object, key: key)
4521
    }
4522

            
4523
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
Bogdan Timofte authored a month ago
4524
        if let value = rawValue(object, key: key) as? Int16 {
Bogdan Timofte authored a month ago
4525
            return value
4526
        }
Bogdan Timofte authored a month ago
4527
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
4528
            return value.int16Value
4529
        }
4530
        return nil
4531
    }
4532

            
4533
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
4534
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
4535
            return value
4536
        }
Bogdan Timofte authored a month ago
4537
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
4538
            return value.int32Value
4539
        }
4540
        return nil
4541
    }
4542

            
4543
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
Bogdan Timofte authored a month ago
4544
        if let value = rawValue(object, key: key) as? Bool {
Bogdan Timofte authored a month ago
4545
            return value
4546
        }
Bogdan Timofte authored a month ago
4547
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
4548
            return value.boolValue
4549
        }
4550
        return false
4551
    }
4552

            
4553
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
4554
        guard let value = stringValue(object, key: key) else { return nil }
4555
        return UUID(uuidString: value)
4556
    }
4557

            
4558
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
4559
        guard let value = stringValue(object, key: key) else { return nil }
4560
        return ChargeSessionStatus(rawValue: value)
4561
    }
4562

            
4563
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
4564
        guard let value = stringValue(object, key: key) else { return nil }
4565
        return ChargingTransportMode(rawValue: value)
4566
    }
4567

            
4568
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
4569
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
4570
            return []
4571
        }
4572
        return rawValue
4573
            .split(separator: ",")
4574
            .compactMap { Double($0) }
4575
            .sorted()
4576
    }
4577

            
4578
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
4579
        let uniqueVoltages = Array(Set(voltages)).sorted()
4580
        guard !uniqueVoltages.isEmpty else {
4581
            return nil
4582
        }
4583
        return uniqueVoltages
4584
            .map { String(format: "%.1f", $0) }
4585
            .joined(separator: ",")
4586
    }
4587

            
4588
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
4589
        guard currentCount > 0 else {
4590
            return newValue
4591
        }
4592
        let total = (currentAverage * Double(currentCount)) + newValue
4593
        return total / Double(currentCount + 1)
4594
    }
4595
}
4596

            
4597
private enum ObservationSaveReason {
4598
    case none
4599
    case created
4600
    case periodic
4601
    case completed
4602
    case event
4603
}