USB-Meter / USB Meter / Model / ChargeInsightsStore.swift
Newer Older
4465 lines | 200.388kb
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 pauseSession(id sessionID: UUID, observedAt: Date) -> Bool {
825
        var didSave = false
826
        context.performAndWait {
827
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
828
                return
829
            }
830

            
831
            guard statusValue(session, key: "statusRawValue") == .active else {
832
                return
833
            }
834

            
835
            session.setValue(ChargeSessionStatus.paused.rawValue, forKey: "statusRawValue")
836
            session.setValue(observedAt, forKey: "pausedAt")
837
            session.setValue(nil, forKey: "belowThresholdSince")
838
            clearCompletionConfirmationState(for: session)
839
            session.setValue(observedAt, forKey: "updatedAt")
840
            didSave = saveContext()
841
        }
842
        return didSave
843
    }
844

            
845
    @discardableResult
846
    func resumeSession(id sessionID: UUID, snapshot: ChargingMonitorSnapshot?) -> Bool {
847
        var didSave = false
848
        context.performAndWait {
849
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
850
                return
851
            }
852

            
853
            guard statusValue(session, key: "statusRawValue") == .paused else {
854
                return
855
            }
856

            
857
            let resumedAt = snapshot?.observedAt ?? Date()
Bogdan Timofte authored a month ago
858
            if let completionDate = automaticCompletionDate(for: session, referenceDate: resumedAt) {
Bogdan Timofte authored a month ago
859
                finishSession(
860
                    session,
Bogdan Timofte authored a month ago
861
                    observedAt: completionDate,
Bogdan Timofte authored a month ago
862
                    finalBatteryPercent: nil,
863
                    status: .completed
864
                )
865
                guard saveContext() else {
866
                    return
867
                }
868
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
869
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
870
                    didSave = saveContext()
871
                } else {
872
                    didSave = true
873
                }
874
                return
875
            }
876

            
877
            session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
878
            session.setValue(nil, forKey: "pausedAt")
879
            session.setValue(nil, forKey: "belowThresholdSince")
880
            clearCompletionConfirmationState(for: session)
881
            session.setValue(resumedAt, forKey: "lastObservedAt")
882
            if let snapshot {
883
                session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
884
                session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
885
                session.setValue(
886
                    chargingTransportMode(for: session) == .wired ? snapshot.voltageVolts : nil,
887
                    forKey: "lastObservedVoltageVolts"
888
                )
889
            } else {
890
                session.setValue(0, forKey: "lastObservedCurrentAmps")
891
                session.setValue(0, forKey: "lastObservedPowerWatts")
892
                session.setValue(nil, forKey: "lastObservedVoltageVolts")
893
            }
894
            session.setValue(resumedAt, forKey: "updatedAt")
895
            didSave = saveContext()
896
        }
897
        return didSave
898
    }
899

            
900
    @discardableResult
901
    func stopSession(
902
        id sessionID: UUID,
Bogdan Timofte authored a month ago
903
        finalBatteryPercent: Double? = nil
Bogdan Timofte authored a month ago
904
    ) -> Bool {
Bogdan Timofte authored a month ago
905
        if let finalBatteryPercent {
906
            guard finalBatteryPercent.isFinite, finalBatteryPercent >= 0, finalBatteryPercent <= 100 else {
907
                return false
908
            }
Bogdan Timofte authored a month ago
909
        }
910

            
911
        var didSave = false
Bogdan Timofte authored a month ago
912
        var deviceIDToRefresh: String?
913

            
Bogdan Timofte authored a month ago
914
        context.performAndWait {
915
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
916
                return
917
            }
918

            
919
            guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
920
                return
921
            }
922

            
Bogdan Timofte authored a month ago
923
            restoreMeasuredTotalsFromLatestSampleIfNeeded(session)
924

            
Bogdan Timofte authored a month ago
925
            guard hasSavableChargeData(session) else {
926
                return
927
            }
928

            
Bogdan Timofte authored a month ago
929
            let observedAt = snapshotDateForManualStop(session)
930
            finishSession(
931
                session,
932
                observedAt: observedAt,
933
                finalBatteryPercent: finalBatteryPercent,
934
                status: .completed
935
            )
936

            
937
            guard saveContext() else {
938
                return
939
            }
940

            
Bogdan Timofte authored a month ago
941
            didSave = true
942
            deviceIDToRefresh = stringValue(session, key: "chargedDeviceID")
943
        }
944

            
945
        if let deviceID = deviceIDToRefresh {
946
            context.perform { [weak self] in
947
                guard let self else { return }
948
                self.refreshDerivedMetrics(forChargedDeviceID: deviceID)
949
                self.saveContext()
Bogdan Timofte authored a month ago
950
            }
951
        }
Bogdan Timofte authored a month ago
952

            
Bogdan Timofte authored a month ago
953
        return didSave
954
    }
955

            
Bogdan Timofte authored a month ago
956
    @discardableResult
957
    func addBatteryCheckpoint(
958
        percent: Double,
Bogdan Timofte authored a month ago
959
        for meterMACAddress: String,
Bogdan Timofte authored a month ago
960
        measuredEnergyWh: Double? = nil
Bogdan Timofte authored a month ago
961
    ) -> Bool {
962
        guard percent.isFinite, percent >= 0, percent <= 100 else {
963
            return false
964
        }
965

            
966
        var didSave = false
967
        context.performAndWait {
Bogdan Timofte authored a month ago
968
            guard let session = fetchOpenSessionObject(forMeterMACAddress: meterMACAddress) else {
Bogdan Timofte authored a month ago
969
                return
970
            }
971

            
Bogdan Timofte authored a month ago
972
            didSave = addBatteryCheckpoint(
973
                percent: percent,
974
                measuredEnergyWh: measuredEnergyWh,
Bogdan Timofte authored a month ago
975
                flag: .intermediate,
Bogdan Timofte authored a month ago
976
                to: session
977
            )
Bogdan Timofte authored a month ago
978
        }
979
        return didSave
980
    }
981

            
982
    @discardableResult
983
    func addBatteryCheckpoint(
984
        percent: Double,
Bogdan Timofte authored a month ago
985
        for sessionID: UUID,
Bogdan Timofte authored a month ago
986
        measuredEnergyWh: Double? = nil,
987
        subject: CheckpointSubject = .chargedDevice,
988
        barsValue: Int = 0
Bogdan Timofte authored a month ago
989
    ) -> Bool {
990
        guard percent.isFinite, percent >= 0, percent <= 100 else {
991
            return false
992
        }
993

            
994
        var didSave = false
995
        context.performAndWait {
996
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
997
                return
998
            }
999

            
Bogdan Timofte authored a month ago
1000
            didSave = addBatteryCheckpoint(
1001
                percent: percent,
1002
                measuredEnergyWh: measuredEnergyWh,
Bogdan Timofte authored a month ago
1003
                flag: .intermediate,
Bogdan Timofte authored a month ago
1004
                subject: subject,
1005
                barsValue: barsValue,
Bogdan Timofte authored a month ago
1006
                to: session
1007
            )
Bogdan Timofte authored a month ago
1008
        }
1009
        return didSave
1010
    }
1011

            
Bogdan Timofte authored a month ago
1012
    @discardableResult
1013
    func deleteBatteryCheckpoint(
1014
        id checkpointID: UUID,
1015
        from sessionID: UUID
1016
    ) -> Bool {
1017
        var didSave = false
1018
        context.performAndWait {
1019
            guard let session = fetchSessionObject(id: sessionID.uuidString),
1020
                  let checkpoint = fetchCheckpointObject(
1021
                    id: checkpointID.uuidString,
1022
                    sessionID: sessionID.uuidString
1023
                  ) else {
1024
                return
1025
            }
1026

            
1027
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
1028
            context.delete(checkpoint)
1029
            refreshCheckpointDerivedValues(for: session)
1030

            
1031
            if let chargedDeviceID {
1032
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1033
            }
Bogdan Timofte authored a month ago
1034

            
1035
            didSave = saveContext()
Bogdan Timofte authored a month ago
1036
        }
1037
        return didSave
1038
    }
1039

            
Bogdan Timofte authored a month ago
1040
    @discardableResult
1041
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
1042
        if let percent, (!percent.isFinite || percent <= 0 || percent > 100) {
1043
            return false
1044
        }
1045

            
1046
        var didSave = false
1047
        context.performAndWait {
1048
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
1049
                return
1050
            }
1051

            
1052
            session.setValue(percent, forKey: "targetBatteryPercent")
1053
            session.setValue(nil, forKey: "targetBatteryAlertTriggeredAt")
1054
            session.setValue(Date(), forKey: "updatedAt")
1055
            didSave = saveContext()
1056
        }
1057
        return didSave
1058
    }
1059

            
1060
    @discardableResult
1061
    func confirmCompletion(for sessionID: UUID) -> Bool {
1062
        var didSave = false
1063
        context.performAndWait {
1064
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
1065
                return
1066
            }
1067

            
1068
            guard statusValue(session, key: "statusRawValue") == .active else {
1069
                return
1070
            }
1071

            
Bogdan Timofte authored a month ago
1072
            finishSession(
1073
                session,
1074
                observedAt: dateValue(session, key: "lastObservedAt") ?? Date(),
1075
                finalBatteryPercent: nil,
1076
                status: .completed
1077
            )
Bogdan Timofte authored a month ago
1078

            
1079
            if saveContext() {
1080
                if let deviceID = stringValue(session, key: "chargedDeviceID") {
1081
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
1082
                    didSave = saveContext()
1083
                } else {
1084
                    didSave = true
1085
                }
1086
            }
1087
        }
1088
        return didSave
1089
    }
1090

            
1091
    @discardableResult
1092
    func continueMonitoringDespiteCompletionContradiction(for sessionID: UUID) -> Bool {
1093
        var didSave = false
1094
        context.performAndWait {
1095
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
1096
                return
1097
            }
1098

            
1099
            guard statusValue(session, key: "statusRawValue") == .active else {
1100
                return
1101
            }
1102

            
1103
            clearCompletionConfirmationState(for: session)
Bogdan Timofte authored a month ago
1104
            session.setValue(nil, forKey: "belowThresholdSince")
Bogdan Timofte authored a month ago
1105
            session.setValue(Date().addingTimeInterval(completionConfirmationCooldown), forKey: "completionConfirmationCooldownUntil")
1106
            session.setValue(Date(), forKey: "updatedAt")
1107
            didSave = saveContext()
1108
        }
1109
        return didSave
1110
    }
1111

            
Bogdan Timofte authored a month ago
1112
    @discardableResult
1113
    func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
1114
        var didSave = false
1115
        context.performAndWait {
1116
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
1117
                return
1118
            }
1119

            
1120
            let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast
1121
            let sessionEnd   = dateValue(session, key: "endedAt")
1122
                ?? dateValue(session, key: "lastObservedAt")
1123
                ?? Date.distantFuture
1124

            
1125
            let effectiveStart = min(max(start ?? sessionStart, sessionStart), sessionEnd)
1126
            let effectiveEnd   = max(min(end ?? sessionEnd, sessionEnd), effectiveStart)
1127
            let persistedStart = effectiveStart == sessionStart ? nil : effectiveStart
1128
            let persistedEnd   = effectiveEnd == sessionEnd ? nil : effectiveEnd
1129

            
1130
            let allSamples = fetchSessionSampleObjects(forSessionID: sessionID.uuidString)
1131
                .compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in
1132
                    guard let ts = dateValue(obj, key: "timestamp") else { return nil }
1133
                    return (
1134
                        timestamp: ts,
1135
                        energy: doubleValue(obj, key: "measuredEnergyWh"),
1136
                        charge: doubleValue(obj, key: "measuredChargeAh")
1137
                    )
1138
                }
1139
                .sorted { $0.timestamp < $1.timestamp }
1140

            
1141
            // Each sample stores cumulative energy since session start.
1142
            // Trimmed energy = value at trimEnd  -  value just before trimStart.
1143
            let baselineSample = allSamples.last { $0.timestamp <= effectiveStart }
1144
            let endSample      = allSamples.last { $0.timestamp <= effectiveEnd }
1145
            let baselineEnergy = baselineSample?.energy ?? 0
1146
            let baselineCharge = baselineSample?.charge ?? 0
1147

            
1148
            if let endSample {
1149
                let trimmedEnergy  = max(endSample.energy - baselineEnergy, 0)
1150
                let trimmedCharge  = max(endSample.charge - baselineCharge, 0)
1151
                session.setValue(trimmedEnergy, forKey: "measuredEnergyWh")
1152
                session.setValue(trimmedCharge, forKey: "measuredChargeAh")
1153
            } else {
1154
                session.setValue(0, forKey: "measuredEnergyWh")
1155
                session.setValue(0, forKey: "measuredChargeAh")
1156
            }
1157

            
1158
            session.setValue(persistedStart, forKey: "trimStart")
1159
            session.setValue(persistedEnd,   forKey: "trimEnd")
1160
            session.setValue(Date(), forKey: "updatedAt")
1161

            
1162
            let checkpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
1163
            for checkpoint in checkpoints {
1164
                guard let timestamp = dateValue(checkpoint, key: "timestamp") else { continue }
1165

            
1166
                if timestamp < effectiveStart || timestamp > effectiveEnd {
1167
                    context.delete(checkpoint)
1168
                    continue
1169
                }
1170

            
1171
                let checkpointSample = allSamples.last { $0.timestamp <= timestamp }
1172
                let rebasedEnergy = max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0)
1173
                let rebasedCharge = max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0)
1174
                checkpoint.setValue(rebasedEnergy, forKey: "measuredEnergyWh")
1175
                checkpoint.setValue(rebasedCharge, forKey: "measuredChargeAh")
1176
            }
1177

            
1178
            let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString)
1179
                .sorted {
1180
                    (dateValue($0, key: "timestamp") ?? .distantPast)
1181
                        < (dateValue($1, key: "timestamp") ?? .distantPast)
1182
                }
1183
            let restoredInitialCheckpoint = remainingCheckpoints.first { checkpoint in
1184
                let label = stringValue(checkpoint, key: "label")
1185
                let timestamp = dateValue(checkpoint, key: "timestamp") ?? .distantFuture
1186
                return ChargeCheckpointFlag.fromStoredLabel(label) == .initial || timestamp <= effectiveStart
1187
            }
1188

            
1189
            if persistedStart == nil {
1190
                if let restoredInitialCheckpoint,
1191
                   let percent = optionalDoubleValue(restoredInitialCheckpoint, key: "batteryPercent"),
1192
                   percent >= 0 {
1193
                    session.setValue(percent, forKey: "startBatteryPercent")
1194
                }
1195
            } else {
1196
                session.setValue(nil, forKey: "startBatteryPercent")
1197
            }
1198

            
1199
            refreshCheckpointDerivedValues(for: session)
1200

            
1201
            if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1202
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1203
            }
1204

            
1205
            didSave = saveContext()
1206
        }
1207
        return didSave
1208
    }
1209

            
Bogdan Timofte authored a month ago
1210
    @discardableResult
1211
    func commitSessionTrim(sessionID: UUID) -> Bool {
1212
        var didSave = false
1213
        context.performAndWait {
1214
            guard let session = fetchSessionObject(id: sessionID.uuidString),
1215
                  statusValue(session, key: "statusRawValue")?.isOpen == false else {
1216
                return
1217
            }
1218

            
1219
            guard dateValue(session, key: "trimStart") != nil
1220
                    || dateValue(session, key: "trimEnd") != nil else {
1221
                return
1222
            }
1223

            
1224
            let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast
1225
            let sessionEnd = dateValue(session, key: "endedAt")
1226
                ?? dateValue(session, key: "lastObservedAt")
1227
                ?? sessionStart
1228

            
1229
            let effectiveStart = min(max(dateValue(session, key: "trimStart") ?? sessionStart, sessionStart), sessionEnd)
1230
            let effectiveEnd = max(
1231
                min(dateValue(session, key: "trimEnd") ?? sessionEnd, sessionEnd),
1232
                effectiveStart
1233
            )
1234

            
1235
            let sampleObjects = fetchSessionSampleObjects(forSessionID: sessionID.uuidString)
1236
            let allSamples = sampleObjects
1237
                .compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in
1238
                    guard let timestamp = dateValue(obj, key: "timestamp") else { return nil }
1239
                    return (
1240
                        timestamp: timestamp,
1241
                        energy: doubleValue(obj, key: "measuredEnergyWh"),
1242
                        charge: doubleValue(obj, key: "measuredChargeAh")
1243
                    )
1244
                }
1245
                .sorted { $0.timestamp < $1.timestamp }
1246

            
1247
            let baselineSample = allSamples.last { $0.timestamp <= effectiveStart }
1248
            let endSample = allSamples.last { $0.timestamp <= effectiveEnd }
1249
            let baselineEnergy = baselineSample?.energy ?? 0
1250
            let baselineCharge = baselineSample?.charge ?? 0
1251
            let committedEnergy = endSample.map { max($0.energy - baselineEnergy, 0) }
1252
                ?? doubleValue(session, key: "measuredEnergyWh")
1253
            let committedCharge = endSample.map { max($0.charge - baselineCharge, 0) }
1254
                ?? doubleValue(session, key: "measuredChargeAh")
1255

            
1256
            var retainedSamples: [(current: Double, power: Double, voltage: Double?)] = []
1257
            for sample in sampleObjects {
1258
                guard let timestamp = dateValue(sample, key: "timestamp") else {
1259
                    context.delete(sample)
1260
                    continue
1261
                }
1262

            
1263
                guard timestamp >= effectiveStart && timestamp <= effectiveEnd else {
1264
                    context.delete(sample)
1265
                    continue
1266
                }
1267

            
1268
                let rebasedEnergy = max(doubleValue(sample, key: "measuredEnergyWh") - baselineEnergy, 0)
1269
                let rebasedCharge = max(doubleValue(sample, key: "measuredChargeAh") - baselineCharge, 0)
1270
                let elapsed = max(timestamp.timeIntervalSince(effectiveStart), 0)
1271
                let rebasedBucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
1272

            
1273
                sample.setValue("\(sessionID.uuidString)-\(rebasedBucketIndex)", forKey: "id")
1274
                sample.setValue(rebasedBucketIndex, forKey: "bucketIndex")
1275
                sample.setValue(rebasedEnergy, forKey: "measuredEnergyWh")
1276
                sample.setValue(rebasedCharge, forKey: "measuredChargeAh")
1277
                sample.setValue(Date(), forKey: "updatedAt")
1278

            
1279
                retainedSamples.append(
1280
                    (
1281
                        current: doubleValue(sample, key: "averageCurrentAmps"),
1282
                        power: doubleValue(sample, key: "averagePowerWatts"),
1283
                        voltage: optionalDoubleValue(sample, key: "averageVoltageVolts")
1284
                    )
1285
                )
1286
            }
1287

            
1288
            for checkpoint in fetchCheckpointObjects(forSessionID: sessionID.uuidString) {
1289
                guard let timestamp = dateValue(checkpoint, key: "timestamp") else {
1290
                    context.delete(checkpoint)
1291
                    continue
1292
                }
1293

            
1294
                guard timestamp >= effectiveStart && timestamp <= effectiveEnd else {
1295
                    context.delete(checkpoint)
1296
                    continue
1297
                }
1298

            
1299
                let checkpointSample = allSamples.last { $0.timestamp <= timestamp }
1300
                checkpoint.setValue(
1301
                    max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0),
1302
                    forKey: "measuredEnergyWh"
1303
                )
1304
                checkpoint.setValue(
1305
                    max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0),
1306
                    forKey: "measuredChargeAh"
1307
                )
1308
            }
1309

            
1310
            if !retainedSamples.isEmpty {
1311
                let positiveCurrents = retainedSamples.map { $0.current }.filter { $0 > 0 }
1312
                session.setValue(positiveCurrents.min(), forKey: "minimumObservedCurrentAmps")
1313
                session.setValue(retainedSamples.map { $0.current }.max(), forKey: "maximumObservedCurrentAmps")
1314
                session.setValue(retainedSamples.map { $0.power }.max(), forKey: "maximumObservedPowerWatts")
1315
                session.setValue(retainedSamples.compactMap { $0.voltage }.max(), forKey: "maximumObservedVoltageVolts")
1316
                session.setValue(
1317
                    retainedSamples.contains { $0.power > 0.05 || $0.current > 0.01 },
1318
                    forKey: "hasObservedChargeFlow"
1319
                )
1320
            } else {
1321
                session.setValue(nil, forKey: "minimumObservedCurrentAmps")
1322
                session.setValue(nil, forKey: "maximumObservedCurrentAmps")
1323
                session.setValue(nil, forKey: "maximumObservedPowerWatts")
1324
                session.setValue(nil, forKey: "maximumObservedVoltageVolts")
1325
                session.setValue(committedEnergy > 0 || committedCharge > 0, forKey: "hasObservedChargeFlow")
1326
            }
1327

            
1328
            session.setValue(effectiveStart, forKey: "startedAt")
1329
            session.setValue(effectiveEnd, forKey: "lastObservedAt")
1330
            if dateValue(session, key: "endedAt") != nil {
1331
                session.setValue(effectiveEnd, forKey: "endedAt")
1332
            }
1333
            session.setValue(committedEnergy, forKey: "measuredEnergyWh")
1334
            session.setValue(committedCharge, forKey: "measuredChargeAh")
1335
            session.setValue(nil, forKey: "trimStart")
1336
            session.setValue(nil, forKey: "trimEnd")
1337
            session.setValue(Date(), forKey: "updatedAt")
1338

            
1339
            refreshCheckpointDerivedValues(for: session)
1340

            
1341
            if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1342
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1343
            }
1344

            
1345
            didSave = saveContext()
1346
        }
1347
        return didSave
1348
    }
1349

            
Bogdan Timofte authored a month ago
1350
    @discardableResult
1351
    func deleteChargeSession(id sessionID: UUID) -> Bool {
1352
        var didSave = false
1353
        context.performAndWait {
1354
            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
1355
                return
1356
            }
1357

            
1358
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
1359

            
1360
            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
1361
            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
1362
            context.delete(session)
1363

            
1364
            guard saveContext() else {
1365
                return
1366
            }
1367

            
1368
            if let chargedDeviceID {
1369
                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1370
                didSave = saveContext()
1371
            } else {
1372
                didSave = true
1373
            }
1374
        }
1375
        return didSave
1376
    }
1377

            
1378
    @discardableResult
1379
    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
1380
        var didSave = false
1381

            
1382
        context.performAndWait {
1383
            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
1384
                return
1385
            }
1386

            
1387
            let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
1388
            let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
1389
            let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
1390

            
1391
            var impactedChargedDeviceIDs = Set<String>()
1392

            
1393
            for session in deviceSessions {
1394
                if let impactedID = stringValue(session, key: "chargedDeviceID") {
1395
                    impactedChargedDeviceIDs.insert(impactedID)
1396
                }
1397
                if let impactedChargerID = stringValue(session, key: "chargerID") {
1398
                    impactedChargedDeviceIDs.insert(impactedChargerID)
1399
                }
1400
                if let sessionID = stringValue(session, key: "id") {
1401
                    fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete)
1402
                    fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete)
1403
                }
1404
                context.delete(session)
1405
            }
1406

            
1407
            if deviceClass == .charger {
1408
                for session in linkedWirelessSessions {
1409
                    guard stringValue(session, key: "chargedDeviceID") != chargedDeviceID.uuidString else {
1410
                        continue
1411
                    }
1412
                    if let impactedID = stringValue(session, key: "chargedDeviceID") {
1413
                        impactedChargedDeviceIDs.insert(impactedID)
1414
                    }
1415
                    session.setValue(nil, forKey: "chargerID")
1416
                    session.setValue(Date(), forKey: "updatedAt")
1417
                }
1418
            }
1419

            
1420
            context.delete(chargedDevice)
1421

            
1422
            guard saveContext() else {
1423
                return
1424
            }
1425

            
1426
            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
1427
            for impactedID in impactedChargedDeviceIDs {
1428
                refreshDerivedMetrics(forChargedDeviceID: impactedID)
1429
            }
1430
            didSave = saveContext()
1431
        }
1432

            
1433
        return didSave
1434
    }
1435

            
1436
    @discardableResult
1437
    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
1438
        var didSave = false
1439

            
1440
        context.performAndWait {
Bogdan Timofte authored a month ago
1441
            guard let session = fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress),
1442
                  let resolvedDevice = stringValue(session, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:)) else {
1443
                return
1444
            }
Bogdan Timofte authored a month ago
1445

            
Bogdan Timofte authored a month ago
1446
            if statusValue(session, key: "statusRawValue") == .paused {
Bogdan Timofte authored a month ago
1447
                if maybeCompleteOpenSession(session, observedAt: snapshot.observedAt) {
Bogdan Timofte authored a month ago
1448
                    didSave = true
1449
                }
Bogdan Timofte authored a month ago
1450
                return
1451
            }
1452

            
Bogdan Timofte authored a month ago
1453
            let chargingTransportMode = self.chargingTransportMode(for: session)
1454
            let chargingStateMode = self.chargingStateMode(for: session)
Bogdan Timofte authored a month ago
1455
            let charger = chargingTransportMode == .wireless
Bogdan Timofte authored a month ago
1456
                ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
Bogdan Timofte authored a month ago
1457
                : nil
1458
            guard chargingTransportMode == .wired || charger != nil else {
1459
                return
1460
            }
1461
            let stopThreshold = resolvedStopThreshold(
1462
                for: resolvedDevice,
1463
                chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
1464
                chargingStateMode: chargingStateMode,
1465
                charger: charger,
1466
                fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1467
            )
1468

            
Bogdan Timofte authored a month ago
1469
            let sessionSnapshot = snapshotClampedToMaximumDuration(snapshot, for: session)
1470
            update(session: session, with: sessionSnapshot, stopThreshold: stopThreshold, charger: charger)
1471
            let aggregatedSample = updateAggregatedSample(session: session, with: sessionSnapshot)
1472
            if let completionDate = automaticCompletionDate(for: session, referenceDate: snapshot.observedAt),
1473
               statusValue(session, key: "statusRawValue")?.isOpen == true {
1474
                finishSession(
1475
                    session,
1476
                    observedAt: completionDate,
1477
                    finalBatteryPercent: nil,
1478
                    status: .completed
1479
                )
1480
            }
Bogdan Timofte authored a month ago
1481

            
Bogdan Timofte authored a month ago
1482
            let saveReason = saveReason(for: session, observedAt: sessionSnapshot.observedAt)
Bogdan Timofte authored a month ago
1483
            let shouldPersistAggregatedCurve = aggregatedSample.map {
Bogdan Timofte authored a month ago
1484
                shouldPersistAggregatedSample($0, observedAt: sessionSnapshot.observedAt)
Bogdan Timofte authored a month ago
1485
            } ?? false
1486

            
1487
            guard saveReason != .none || shouldPersistAggregatedCurve else {
Bogdan Timofte authored a month ago
1488
                return
1489
            }
1490

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

            
1493
            if saveContext() {
1494
                if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
1495
                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
1496
                    didSave = saveContext()
1497
                } else {
1498
                    didSave = true
1499
                }
1500
            }
1501
        }
1502

            
1503
        return didSave
1504
    }
1505

            
1506
    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
1507
        var summaries: [ChargedDeviceSummary] = []
1508

            
1509
        context.performAndWait {
1510
            let devices = fetchObjects(entityName: EntityName.chargedDevice)
1511
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
1512
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
1513

            
1514
            let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
1515
            let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
1516
            let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
Bogdan Timofte authored a month ago
1517
            let sampleBackedSessionIDs = sampleBackedSessionIDs(
1518
                devices: devices,
1519
                sessionsByDeviceID: sessionsByDeviceID,
1520
                sessionsByChargerID: sessionsByChargerID
1521
            )
1522
            let samplesBySessionID = Dictionary(
1523
                grouping: fetchSessionSampleObjects(forSessionIDs: Array(sampleBackedSessionIDs))
1524
            ) { stringValue($0, key: "sessionID") ?? "" }
Bogdan Timofte authored a month ago
1525

            
1526
            summaries = devices.compactMap { device in
1527
                guard
1528
                    let id = uuidValue(device, key: "id"),
1529
                    let name = stringValue(device, key: "name"),
1530
                    let qrIdentifier = stringValue(device, key: "qrIdentifier"),
1531
                    let rawClass = stringValue(device, key: "deviceClassRawValue"),
1532
                    let deviceClass = ChargedDeviceClass(rawValue: rawClass)
1533
                else {
1534
                    return nil
1535
                }
1536

            
Bogdan Timofte authored a month ago
1537
                let chargingStateAvailability = chargingStateAvailability(for: device)
1538
                let supportsWiredCharging = supportsWiredCharging(for: device)
1539
                let supportsWirelessCharging = supportsWirelessCharging(for: device)
1540
                let templateDefinition = templateDefinition(for: device)
1541

            
Bogdan Timofte authored a month ago
1542
                let sessionObjects = relevantSessionObjects(
1543
                    for: id.uuidString,
1544
                    deviceClass: deviceClass,
1545
                    sessionsByDeviceID: sessionsByDeviceID,
1546
                    sessionsByChargerID: sessionsByChargerID
1547
                )
1548
                let sessionSummaries = sessionObjects
1549
                    .compactMap { session in
1550
                        makeSessionSummary(
1551
                            from: session,
1552
                            checkpoints: checkpointsBySessionID[stringValue(session, key: "id") ?? ""] ?? [],
1553
                            samples: samplesBySessionID[stringValue(session, key: "id") ?? ""] ?? []
1554
                        )
1555
                    }
1556
                    .sorted { lhs, rhs in
Bogdan Timofte authored a month ago
1557
                        if lhs.status.isOpen && !rhs.status.isOpen {
Bogdan Timofte authored a month ago
1558
                            return true
1559
                        }
Bogdan Timofte authored a month ago
1560
                        if !lhs.status.isOpen && rhs.status.isOpen {
1561
                            return false
1562
                        }
1563
                        if lhs.status == .active && rhs.status == .paused {
1564
                            return true
1565
                        }
1566
                        if lhs.status == .paused && rhs.status == .active {
Bogdan Timofte authored a month ago
1567
                            return false
1568
                        }
1569
                        return lhs.startedAt > rhs.startedAt
1570
                    }
1571

            
1572
                return ChargedDeviceSummary(
1573
                    id: id,
1574
                    qrIdentifier: qrIdentifier,
1575
                    name: name,
1576
                    deviceClass: deviceClass,
Bogdan Timofte authored a month ago
1577
                    deviceTemplateID: stringValue(device, key: "deviceTemplateID"),
1578
                    templateDefinition: templateDefinition,
Bogdan Timofte authored a month ago
1579
                    profileID: stringValue(device, key: "profileID"),
1580
                    hasInternalSubject: (device.value(forKey: "hasInternalSubject") as? Bool) ?? false,
Bogdan Timofte authored a month ago
1581
                    supportsChargingWhileOff: chargingStateAvailability.supportsChargingWhileOff,
1582
                    chargingStateAvailability: chargingStateAvailability,
1583
                    supportsWiredCharging: supportsWiredCharging,
1584
                    supportsWirelessCharging: supportsWirelessCharging,
Bogdan Timofte authored a month ago
1585
                    chargerType: chargerType(for: device),
Bogdan Timofte authored a month ago
1586
                    wirelessChargingProfile: wirelessChargingProfile(for: device),
Bogdan Timofte authored a month ago
1587
                    configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"),
1588
                    learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"),
Bogdan Timofte authored a month ago
1589
                    wirelessChargerEfficiencyFactor: optionalDoubleValue(device, key: "wirelessChargerEfficiencyFactor"),
1590
                    wiredChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wiredChargeCompletionCurrentAmps"),
1591
                    wirelessChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wirelessChargeCompletionCurrentAmps"),
1592
                    chargerObservedVoltageSelections: decodedObservedVoltageSelections(from: device),
1593
                    chargerIdleCurrentAmps: optionalDoubleValue(device, key: "chargerIdleCurrentAmps"),
1594
                    chargerEfficiencyFactor: optionalDoubleValue(device, key: "chargerEfficiencyFactor"),
1595
                    chargerMaximumPowerWatts: optionalDoubleValue(device, key: "chargerMaximumPowerWatts"),
1596
                    notes: stringValue(device, key: "notes"),
1597
                    minimumCurrentAmps: optionalDoubleValue(device, key: "minimumCurrentAmps"),
1598
                    estimatedBatteryCapacityWh: optionalDoubleValue(device, key: "estimatedBatteryCapacityWh"),
1599
                    wiredMinimumCurrentAmps: optionalDoubleValue(device, key: "wiredMinimumCurrentAmps"),
1600
                    wirelessMinimumCurrentAmps: optionalDoubleValue(device, key: "wirelessMinimumCurrentAmps"),
1601
                    wiredEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wiredEstimatedBatteryCapacityWh"),
1602
                    wirelessEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wirelessEstimatedBatteryCapacityWh"),
1603
                    createdAt: dateValue(device, key: "createdAt") ?? .distantPast,
1604
                    updatedAt: dateValue(device, key: "updatedAt") ?? .distantPast,
1605
                    sessions: sessionSummaries,
1606
                    capacityHistory: buildCapacityHistory(from: sessionSummaries),
Bogdan Timofte authored a month ago
1607
                    typicalCurve: buildTypicalCurve(from: sessionSummaries),
1608
                    standbyPowerMeasurements: []
Bogdan Timofte authored a month ago
1609
                )
1610
            }
1611
            .sorted { lhs, rhs in
1612
                if lhs.activeSession != nil && rhs.activeSession == nil {
1613
                    return true
1614
                }
1615
                if lhs.activeSession == nil && rhs.activeSession != nil {
1616
                    return false
1617
                }
1618
                if lhs.updatedAt != rhs.updatedAt {
1619
                    return lhs.updatedAt > rhs.updatedAt
1620
                }
1621
                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
1622
            }
1623
        }
1624

            
1625
        return summaries
1626
    }
1627

            
1628
    func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
1629
        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1630
        guard !normalizedMAC.isEmpty else { return nil }
1631

            
Bogdan Timofte authored a month ago
1632
        var summary: ChargeSessionSummary?
1633

            
1634
        context.performAndWait {
Bogdan Timofte authored a month ago
1635
            guard let session = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC),
Bogdan Timofte authored a month ago
1636
                  let sessionID = stringValue(session, key: "id") else {
1637
                return
1638
            }
1639

            
1640
            summary = makeSessionSummary(
1641
                from: session,
1642
                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
1643
                samples: fetchSessionSampleObjects(forSessionID: sessionID)
1644
            )
1645
        }
1646

            
1647
        return summary
Bogdan Timofte authored a month ago
1648
    }
1649

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

            
1657
        context.performAndWait {
1658
            let powerbanks = fetchObjects(entityName: EntityName.powerbank)
1659
            guard !powerbanks.isEmpty else {
1660
                return
1661
            }
1662

            
1663
            let sessions = fetchObjects(entityName: EntityName.chargeSession)
1664
            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
1665
            let checkpointsBySessionID = Dictionary(grouping: checkpoints) {
1666
                stringValue($0, key: "sessionID") ?? ""
1667
            }
1668
            let sessionsAsSubject = Dictionary(grouping: sessions) {
1669
                stringValue($0, key: "chargedPowerbankID") ?? ""
1670
            }
1671
            let sessionsAsSource = Dictionary(grouping: sessions) {
1672
                stringValue($0, key: "sourcePowerbankID") ?? ""
1673
            }
1674

            
1675
            summaries = powerbanks.compactMap { powerbank in
1676
                guard
1677
                    let id = uuidValue(powerbank, key: "id"),
1678
                    let name = stringValue(powerbank, key: "name"),
1679
                    let qrIdentifier = stringValue(powerbank, key: "qrIdentifier")
1680
                else {
1681
                    return nil
1682
                }
1683

            
1684
                let templateID = stringValue(powerbank, key: "deviceTemplateID")
1685
                let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID)
1686

            
1687
                let reportingRaw = stringValue(powerbank, key: "batteryLevelReportingRawValue")
1688
                let reporting = reportingRaw.flatMap(BatteryLevelReporting.init(rawValue:)) ?? .percent
1689
                let barsCount = Int(optionalInt16Value(powerbank, key: "batteryBarsCount") ?? 0)
1690

            
1691
                let sessionsAsSubjectRaw = sessionsAsSubject[id.uuidString] ?? []
1692
                let sessionsAsSourceRaw = sessionsAsSource[id.uuidString] ?? []
1693

            
1694
                let subjectSessions = sessionsAsSubjectRaw
1695
                    .compactMap { session -> ChargeSessionSummary? in
1696
                        let sessionID = stringValue(session, key: "id") ?? ""
1697
                        return makeSessionSummary(
1698
                            from: session,
1699
                            checkpoints: checkpointsBySessionID[sessionID] ?? [],
1700
                            samples: []
1701
                        )
1702
                    }
1703
                    .sorted { $0.startedAt > $1.startedAt }
1704

            
1705
                let sourceSessions = sessionsAsSourceRaw
1706
                    .compactMap { session -> ChargeSessionSummary? in
1707
                        let sessionID = stringValue(session, key: "id") ?? ""
1708
                        return makeSessionSummary(
1709
                            from: session,
1710
                            checkpoints: checkpointsBySessionID[sessionID] ?? [],
1711
                            samples: []
1712
                        )
1713
                    }
1714
                    .sorted { $0.startedAt > $1.startedAt }
1715

            
1716
                let observedVoltages: [Double] = (stringValue(powerbank, key: "sourceObservedVoltageSelectionsRawValue") ?? "")
1717
                    .split(separator: ",")
1718
                    .compactMap { Double($0) }
1719
                    .sorted()
1720

            
1721
                let derived = derivedPowerbankMetrics(
1722
                    sessionsAsSubject: subjectSessions,
1723
                    sessionsAsSource: sourceSessions,
1724
                    reporting: reporting
1725
                )
1726

            
1727
                return PowerbankSummary(
1728
                    id: id,
1729
                    qrIdentifier: qrIdentifier,
1730
                    name: name,
1731
                    deviceTemplateID: templateID,
1732
                    templateDefinition: templateDefinition,
1733
                    batteryLevelReporting: reporting,
1734
                    batteryBarsCount: barsCount,
1735
                    estimatedBatteryCapacityWh: optionalDoubleValue(powerbank, key: "estimatedBatteryCapacityWh"),
1736
                    apparentCapacityWh: derived.apparentCapacityWh
1737
                        ?? optionalDoubleValue(powerbank, key: "apparentCapacityWh"),
1738
                    configuredCompletionCurrentAmps: optionalDoubleValue(powerbank, key: "configuredCompletionCurrentAmps"),
1739
                    learnedCompletionCurrentAmps: optionalDoubleValue(powerbank, key: "learnedCompletionCurrentAmps"),
1740
                    minimumCurrentAmps: optionalDoubleValue(powerbank, key: "minimumCurrentAmps"),
1741
                    sourceObservedVoltageSelections: derived.voltageMaxCurrents.keys.sorted().isEmpty
1742
                        ? observedVoltages
1743
                        : derived.voltageMaxCurrents.keys.sorted(),
1744
                    sourceVoltageMaxCurrents: derived.voltageMaxCurrents,
1745
                    sourceIdleCurrentAmps: optionalDoubleValue(powerbank, key: "sourceIdleCurrentAmps"),
1746
                    sourceMaximumPowerWatts: derived.maxPowerWatts
1747
                        ?? optionalDoubleValue(powerbank, key: "sourceMaximumPowerWatts"),
1748
                    sourceEfficiencyFactor: derived.efficiencyFactor
1749
                        ?? optionalDoubleValue(powerbank, key: "sourceEfficiencyFactor"),
1750
                    notes: stringValue(powerbank, key: "notes"),
1751
                    createdAt: dateValue(powerbank, key: "createdAt") ?? Date(),
1752
                    updatedAt: dateValue(powerbank, key: "updatedAt") ?? Date(),
1753
                    sessionsAsSubject: subjectSessions,
1754
                    sessionsAsSource: sourceSessions
1755
                )
1756
            }
1757
        }
1758

            
1759
        return summaries
1760
    }
1761

            
Bogdan Timofte authored a month ago
1762
    private func createSessionObject(
1763
        for chargedDevice: NSManagedObject,
1764
        charger: NSManagedObject?,
1765
        snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1766
        stopThreshold: Double?,
1767
        chargingTransportMode: ChargingTransportMode,
1768
        chargingStateMode: ChargingStateMode,
1769
        autoStopEnabled: Bool
Bogdan Timofte authored a month ago
1770
    ) -> NSManagedObject? {
1771
        guard
1772
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
1773
            let chargedDeviceID = stringValue(chargedDevice, key: "id")
1774
        else {
1775
            return nil
1776
        }
1777

            
1778
        let session = NSManagedObject(entity: entity, insertInto: context)
1779
        let now = snapshot.observedAt
1780
        session.setValue(UUID().uuidString, forKey: "id")
1781
        session.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1782
        session.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
1783
        session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress")
1784
        session.setValue(snapshot.meterName, forKey: "meterName")
1785
        session.setValue(snapshot.meterModel, forKey: "meterModel")
1786
        session.setValue(now, forKey: "startedAt")
1787
        session.setValue(now, forKey: "lastObservedAt")
1788
        session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
Bogdan Timofte authored a month ago
1789
        let usesMeterCounters = snapshot.meterEnergyCounterWh != nil || snapshot.meterChargeCounterAh != nil
1790
        session.setValue(
1791
            (usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue,
1792
            forKey: "sourceModeRawValue"
1793
        )
Bogdan Timofte authored a month ago
1794
        session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
Bogdan Timofte authored a month ago
1795
        session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue")
1796
        session.setValue(autoStopEnabled, forKey: "autoStopEnabled")
1797
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1798
        session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
1799
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1800
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1801
        session.setValue(
1802
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1803
            forKey: "lastObservedVoltageVolts"
1804
        )
Bogdan Timofte authored a month ago
1805
        session.setValue(
1806
            hasObservedChargeFlow(
1807
                currentAmps: snapshot.currentAmps,
1808
                chargingTransportMode: chargingTransportMode,
1809
                charger: charger,
1810
                stopThreshold: stopThreshold
1811
            ),
1812
            forKey: "hasObservedChargeFlow"
1813
        )
Bogdan Timofte authored a month ago
1814
        session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
1815
        session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
1816
        session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
1817
        session.setValue(
1818
            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1819
            forKey: "maximumObservedVoltageVolts"
1820
        )
1821
        session.setValue(boolValue(chargedDevice, key: "supportsChargingWhileOff"), forKey: "supportsChargingWhileOff")
1822
        if let selectedDataGroup = snapshot.selectedDataGroup {
1823
            session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup")
1824
        }
1825
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1826
            session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1827
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1828
        }
1829
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1830
            session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1831
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1832
        }
Bogdan Timofte authored a month ago
1833
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1834
            setValue(meterRecordingDurationSeconds, on: session, key: "meterDurationBaselineSeconds")
1835
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1836
        }
Bogdan Timofte authored a month ago
1837
        session.setValue(now, forKey: "createdAt")
1838
        session.setValue(now, forKey: "updatedAt")
1839

            
1840
        return session
1841
    }
1842

            
1843
    private func update(
1844
        session: NSManagedObject,
1845
        with snapshot: ChargingMonitorSnapshot,
Bogdan Timofte authored a month ago
1846
        stopThreshold: Double?,
1847
        charger: NSManagedObject?
Bogdan Timofte authored a month ago
1848
    ) {
1849
        let sessionChargingTransportMode = chargingTransportMode(for: session)
1850
        let lastObservedAt = dateValue(session, key: "lastObservedAt")
1851
        let previousPower = doubleValue(session, key: "lastObservedPowerWatts")
1852
        let previousCurrent = doubleValue(session, key: "lastObservedCurrentAmps")
1853
        var measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1854
        var measuredChargeAh = doubleValue(session, key: "measuredChargeAh")
1855
        var sourceMode = ChargeSessionSourceMode(rawValue: stringValue(session, key: "sourceModeRawValue") ?? "") ?? .live
1856
        var usedOfflineMeterCounters = boolValue(session, key: "usedOfflineMeterCounters")
1857

            
1858
        if let lastObservedAt {
1859
            let deltaSeconds = max(snapshot.observedAt.timeIntervalSince(lastObservedAt), 0)
1860
            if deltaSeconds > 0, deltaSeconds <= maximumLiveIntegrationGap {
1861
                measuredEnergyWh += max(previousPower, 0) * deltaSeconds / 3600
1862
                measuredChargeAh += max(previousCurrent, 0) * deltaSeconds / 3600
1863
                if sourceMode == .offline {
1864
                    sourceMode = .blended
1865
                }
1866
            }
1867
        }
1868

            
1869
        if let counterGroup = snapshot.selectedDataGroup,
1870
           let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
1871
           UInt8(storedGroup) != counterGroup {
1872
            session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
1873
            session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
1874
            session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1875
        }
1876

            
1877
        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
1878
            let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
1879
            let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
1880
            if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
1881
                session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
1882
            }
1883

            
1884
            if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
1885
                let offlineEnergy = meterEnergyCounterWh - baselineEnergy
Bogdan Timofte authored a month ago
1886
                measuredEnergyWh = max(offlineEnergy, 0)
Bogdan Timofte authored a month ago
1887
                usedOfflineMeterCounters = true
Bogdan Timofte authored a month ago
1888
                sourceMode = .offline
Bogdan Timofte authored a month ago
1889
            } else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
1890
                let delta = meterEnergyCounterWh - lastEnergy
1891
                if delta > 0 {
1892
                    measuredEnergyWh += delta
1893
                    usedOfflineMeterCounters = true
1894
                    sourceMode = .blended
1895
                }
1896
            }
1897
            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
1898
        }
1899

            
1900
        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
1901
            let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
1902
            let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
1903
            if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
1904
                session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
1905
            }
1906

            
1907
            if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
1908
                let offlineCharge = meterChargeCounterAh - baselineCharge
Bogdan Timofte authored a month ago
1909
                measuredChargeAh = max(offlineCharge, 0)
Bogdan Timofte authored a month ago
1910
                usedOfflineMeterCounters = true
1911
            } else if let lastCharge, meterChargeCounterAh > lastCharge {
1912
                let delta = meterChargeCounterAh - lastCharge
1913
                if delta > 0 {
1914
                    measuredChargeAh += delta
1915
                    usedOfflineMeterCounters = true
1916
                }
1917
            }
1918
            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1919
        }
1920

            
Bogdan Timofte authored a month ago
1921
        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1922
            let baselineDuration = optionalDoubleValue(session, key: "meterDurationBaselineSeconds") ?? meterRecordingDurationSeconds
1923
            if optionalDoubleValue(session, key: "meterDurationBaselineSeconds") == nil {
1924
                setValue(baselineDuration, on: session, key: "meterDurationBaselineSeconds")
1925
            }
1926
            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1927
        }
1928

            
Bogdan Timofte authored a month ago
1929
        let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps")
1930
        let updatedMinimum: Double
1931
        if snapshot.currentAmps > 0 {
1932
            updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps)
1933
        } else {
1934
            updatedMinimum = existingMinimum ?? 0
1935
        }
1936

            
Bogdan Timofte authored a month ago
1937
        let effectiveCurrent = effectiveCurrentAmps(
1938
            fromMeasuredCurrent: snapshot.currentAmps,
1939
            chargingTransportMode: sessionChargingTransportMode,
1940
            charger: charger
1941
        )
1942
        let observedChargeFlow = boolValue(session, key: "hasObservedChargeFlow")
1943
            || hasObservedChargeFlow(
1944
                currentAmps: snapshot.currentAmps,
1945
                chargingTransportMode: sessionChargingTransportMode,
1946
                charger: charger,
1947
                stopThreshold: stopThreshold
1948
            )
1949

            
Bogdan Timofte authored a month ago
1950
        session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh")
1951
        session.setValue(measuredChargeAh, forKey: "measuredChargeAh")
1952
        session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps")
Bogdan Timofte authored a month ago
1953
        session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps")
Bogdan Timofte authored a month ago
1954
        session.setValue(snapshot.observedAt, forKey: "lastObservedAt")
1955
        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
1956
        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
1957
        session.setValue(
1958
            sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil,
1959
            forKey: "lastObservedVoltageVolts"
1960
        )
Bogdan Timofte authored a month ago
1961
        session.setValue(observedChargeFlow, forKey: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
1962
        session.setValue(
1963
            max(optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? snapshot.currentAmps, snapshot.currentAmps),
1964
            forKey: "maximumObservedCurrentAmps"
1965
        )
1966
        session.setValue(
1967
            max(optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? snapshot.powerWatts, snapshot.powerWatts),
1968
            forKey: "maximumObservedPowerWatts"
1969
        )
1970
        session.setValue(
1971
            sessionChargingTransportMode == .wired
1972
                ? max(optionalDoubleValue(session, key: "maximumObservedVoltageVolts") ?? snapshot.voltageVolts, snapshot.voltageVolts)
1973
                : nil,
1974
            forKey: "maximumObservedVoltageVolts"
1975
        )
1976
        session.setValue(usedOfflineMeterCounters, forKey: "usedOfflineMeterCounters")
1977
        session.setValue(sourceMode.rawValue, forKey: "sourceModeRawValue")
1978
        maybeTriggerTargetBatteryAlert(for: session, observedAt: snapshot.observedAt)
1979

            
Bogdan Timofte authored a month ago
1980
        guard boolValue(session, key: "autoStopEnabled"), let stopThreshold, stopThreshold > 0, observedChargeFlow else {
1981
            session.setValue(nil, forKey: "belowThresholdSince")
1982
            clearCompletionConfirmationState(for: session)
1983
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
1984
            return
1985
        }
1986

            
1987
        if effectiveCurrent <= stopThreshold {
Bogdan Timofte authored a month ago
1988
            let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt
1989
            session.setValue(belowThresholdSince, forKey: "belowThresholdSince")
1990
            if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
1991
                if boolValue(session, key: "requiresCompletionConfirmation") {
1992
                    // Leave the session active until the user explicitly confirms or charging resumes.
1993
                    return
1994
                }
1995

            
1996
                if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
1997
                    requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt)
1998
                } else {
Bogdan Timofte authored a month ago
1999
                    finishSession(
2000
                        session,
2001
                        observedAt: snapshot.observedAt,
2002
                        finalBatteryPercent: nil,
2003
                        status: .completed
2004
                    )
Bogdan Timofte authored a month ago
2005
                }
2006
            }
2007
        } else {
2008
            session.setValue(nil, forKey: "belowThresholdSince")
2009
            clearCompletionConfirmationState(for: session)
2010
            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
2011
        }
2012
    }
2013

            
2014
    private func updateAggregatedSample(
2015
        session: NSManagedObject,
2016
        with snapshot: ChargingMonitorSnapshot
Bogdan Timofte authored a month ago
2017
    ) -> NSManagedObject? {
Bogdan Timofte authored a month ago
2018
        guard
2019
            let sessionID = stringValue(session, key: "id"),
2020
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2021
            let startedAt = dateValue(session, key: "startedAt"),
2022
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context)
2023
        else {
Bogdan Timofte authored a month ago
2024
            return nil
Bogdan Timofte authored a month ago
2025
        }
2026

            
2027
        let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0)
2028
        let bucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
2029
        let bucketStart = startedAt.addingTimeInterval(Double(bucketIndex) * Self.aggregatedSampleBucketDuration)
2030
        let bucketIdentifier = "\(sessionID)-\(bucketIndex)"
2031
        let sample = fetchSessionSampleObject(sessionID: sessionID, bucketIndex: bucketIndex)
2032
            ?? NSManagedObject(entity: entity, insertInto: context)
2033
        let sessionChargingTransportMode = chargingTransportMode(for: session)
2034
        let sampleVoltage = sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil
2035

            
2036
        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
2037
        let updatedCount = existingCount + 1
2038

            
2039
        sample.setValue(bucketIdentifier, forKey: "id")
2040
        sample.setValue(sessionID, forKey: "sessionID")
2041
        sample.setValue(chargedDeviceID, forKey: "chargedDeviceID")
2042
        sample.setValue(bucketIndex, forKey: "bucketIndex")
2043
        sample.setValue(max(snapshot.observedAt, bucketStart), forKey: "timestamp")
2044
        sample.setValue(
2045
            runningAverage(
2046
                currentAverage: optionalDoubleValue(sample, key: "averageCurrentAmps") ?? snapshot.currentAmps,
2047
                currentCount: Int(existingCount),
2048
                newValue: snapshot.currentAmps
2049
            ),
2050
            forKey: "averageCurrentAmps"
2051
        )
2052
        sample.setValue(
2053
            sampleVoltage.flatMap { voltage in
2054
                runningAverage(
2055
                    currentAverage: optionalDoubleValue(sample, key: "averageVoltageVolts") ?? voltage,
2056
                    currentCount: Int(existingCount),
2057
                    newValue: voltage
2058
                )
2059
            },
2060
            forKey: "averageVoltageVolts"
2061
        )
2062
        sample.setValue(
2063
            runningAverage(
2064
                currentAverage: optionalDoubleValue(sample, key: "averagePowerWatts") ?? snapshot.powerWatts,
2065
                currentCount: Int(existingCount),
2066
                newValue: snapshot.powerWatts
2067
            ),
2068
            forKey: "averagePowerWatts"
2069
        )
2070
        sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh")
2071
        sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
Bogdan Timofte authored a month ago
2072
        setValue(predictedBatteryPercent(for: session), on: sample, key: "estimatedBatteryPercent")
Bogdan Timofte authored a month ago
2073
        sample.setValue(Int16(updatedCount), forKey: "sampleCount")
2074
        sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
2075
        sample.setValue(snapshot.observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
2076
        return sample
Bogdan Timofte authored a month ago
2077
    }
2078

            
Bogdan Timofte authored a month ago
2079
    private func maybeTriggerTargetBatteryAlert(
2080
        for session: NSManagedObject,
2081
        observedAt: Date,
2082
        completionFallbackPercent: Double? = nil
2083
    ) {
Bogdan Timofte authored a month ago
2084
        guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
2085
            return
2086
        }
2087

            
2088
        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
2089
            return
2090
        }
2091

            
2092
        let predictedBatteryPercent = predictedBatteryPercent(for: session)
2093
            ?? optionalDoubleValue(session, key: "endBatteryPercent")
Bogdan Timofte authored a month ago
2094
            ?? completionFallbackPercent
Bogdan Timofte authored a month ago
2095

            
2096
        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
2097
            return
2098
        }
2099

            
2100
        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
2101
    }
2102

            
2103
    private func shouldRequireCompletionConfirmation(
2104
        for session: NSManagedObject,
2105
        observedAt: Date
2106
    ) -> Bool {
2107
        if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"),
2108
           cooldownUntil > observedAt {
2109
            return false
2110
        }
2111

            
2112
        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
2113
            return false
2114
        }
2115

            
2116
        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
2117
            ?? defaultCompletionPercentThreshold
2118

            
2119
        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
2120
    }
2121

            
2122
    private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
2123
        guard !boolValue(session, key: "requiresCompletionConfirmation") else {
2124
            return
2125
        }
2126

            
2127
        session.setValue(true, forKey: "requiresCompletionConfirmation")
2128
        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
2129
        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
2130
    }
2131

            
2132
    private func clearCompletionConfirmationState(for session: NSManagedObject) {
2133
        session.setValue(false, forKey: "requiresCompletionConfirmation")
2134
        session.setValue(nil, forKey: "completionConfirmationRequestedAt")
2135
        session.setValue(nil, forKey: "completionContradictionPercent")
2136
    }
2137

            
Bogdan Timofte authored a month ago
2138
    private func snapshotDateForManualStop(_ session: NSManagedObject) -> Date {
2139
        if statusValue(session, key: "statusRawValue") == .paused {
2140
            return dateValue(session, key: "pausedAt")
2141
                ?? dateValue(session, key: "lastObservedAt")
2142
                ?? Date()
2143
        }
2144
        return dateValue(session, key: "lastObservedAt") ?? Date()
2145
    }
2146

            
Bogdan Timofte authored a month ago
2147
    private func snapshotClampedToMaximumDuration(
2148
        _ snapshot: ChargingMonitorSnapshot,
2149
        for session: NSManagedObject
2150
    ) -> ChargingMonitorSnapshot {
2151
        guard let maximumEndDate = maximumEndDate(for: session),
2152
              snapshot.observedAt > maximumEndDate else {
2153
            return snapshot
2154
        }
2155

            
2156
        return ChargingMonitorSnapshot(
2157
            meterMACAddress: snapshot.meterMACAddress,
2158
            meterName: snapshot.meterName,
2159
            meterModel: snapshot.meterModel,
2160
            observedAt: maximumEndDate,
2161
            voltageVolts: snapshot.voltageVolts,
2162
            currentAmps: snapshot.currentAmps,
2163
            powerWatts: snapshot.powerWatts,
2164
            selectedDataGroup: snapshot.selectedDataGroup,
2165
            meterChargeCounterAh: snapshot.meterChargeCounterAh,
2166
            meterEnergyCounterWh: snapshot.meterEnergyCounterWh,
2167
            meterRecordingDurationSeconds: snapshot.meterRecordingDurationSeconds,
2168
            fallbackStopThresholdAmps: snapshot.fallbackStopThresholdAmps
2169
        )
2170
    }
2171

            
2172
    private func automaticCompletionDate(
2173
        for session: NSManagedObject,
2174
        referenceDate: Date
2175
    ) -> Date? {
2176
        guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
2177
            return nil
Bogdan Timofte authored a month ago
2178
        }
2179

            
Bogdan Timofte authored a month ago
2180
        var completionDates: [Date] = []
2181

            
2182
        if let maximumEndDate = maximumEndDate(for: session) {
2183
            completionDates.append(maximumEndDate)
2184
        }
2185

            
2186
        if statusValue(session, key: "statusRawValue") == .paused,
2187
           let pausedAt = dateValue(session, key: "pausedAt") {
2188
            completionDates.append(pausedAt.addingTimeInterval(pausedSessionTimeout))
2189
        }
2190

            
2191
        guard let completionDate = completionDates.min(),
2192
              referenceDate >= completionDate else {
2193
            return nil
2194
        }
2195

            
2196
        return completionDate
2197
    }
2198

            
2199
    private func maximumEndDate(for session: NSManagedObject) -> Date? {
2200
        dateValue(session, key: "startedAt")?.addingTimeInterval(Self.maximumChargeSessionDuration)
2201
    }
2202

            
2203
    @discardableResult
2204
    private func maybeCompleteOpenSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
2205
        guard statusValue(session, key: "statusRawValue")?.isOpen == true,
2206
              let completionDate = automaticCompletionDate(for: session, referenceDate: observedAt) else {
Bogdan Timofte authored a month ago
2207
            return false
2208
        }
2209

            
2210
        finishSession(
2211
            session,
Bogdan Timofte authored a month ago
2212
            observedAt: completionDate,
Bogdan Timofte authored a month ago
2213
            finalBatteryPercent: nil,
2214
            status: .completed
2215
        )
2216

            
2217
        guard saveContext() else {
2218
            return false
2219
        }
2220

            
2221
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
2222
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
2223
            return saveContext()
2224
        }
2225

            
2226
        return true
2227
    }
2228

            
2229
    private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
2230
        let chargingTransportMode = chargingTransportMode(for: session)
2231
        let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps")
2232
            ?? doubleValue(session, key: "lastObservedCurrentAmps")
2233

            
2234
        guard measuredCurrent > 0 else {
2235
            return nil
2236
        }
2237

            
2238
        let charger = chargingTransportMode == .wireless
2239
            ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
2240
            : nil
2241

            
2242
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
2243
            return nil
2244
        }
2245

            
2246
        let effectiveCurrent = effectiveCurrentAmps(
2247
            fromMeasuredCurrent: measuredCurrent,
2248
            chargingTransportMode: chargingTransportMode,
2249
            charger: charger
2250
        )
2251
        guard effectiveCurrent > 0 else {
2252
            return nil
2253
        }
2254
        return effectiveCurrent
2255
    }
2256

            
2257
    private func finishSession(
2258
        _ session: NSManagedObject,
2259
        observedAt: Date,
2260
        finalBatteryPercent: Double?,
2261
        status: ChargeSessionStatus
2262
    ) {
2263
        if let finalBatteryPercent {
2264
            _ = insertBatteryCheckpoint(
2265
                percent: finalBatteryPercent,
Bogdan Timofte authored a month ago
2266
                flag: .final,
Bogdan Timofte authored a month ago
2267
                timestamp: observedAt,
2268
                to: session
2269
            )
2270
        }
2271

            
2272
        session.setValue(status.rawValue, forKey: "statusRawValue")
2273
        session.setValue(nil, forKey: "pausedAt")
2274
        session.setValue(nil, forKey: "belowThresholdSince")
2275
        session.setValue(observedAt, forKey: "endedAt")
2276
        session.setValue(observedAt, forKey: "lastObservedAt")
2277
        session.setValue(completionCurrentForSessionEnd(session), forKey: "completionCurrentAmps")
2278
        clearCompletionConfirmationState(for: session)
2279
        session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
2280
        updateCapacityEstimate(for: session)
Bogdan Timofte authored a month ago
2281
        refreshEstimatedBatteryPercents(for: session)
Bogdan Timofte authored a month ago
2282
        session.setValue(observedAt, forKey: "updatedAt")
Bogdan Timofte authored a month ago
2283

            
2284
        if status == .completed {
2285
            maybeTriggerTargetBatteryAlert(
2286
                for: session,
2287
                observedAt: observedAt,
2288
                completionFallbackPercent: defaultCompletionPercentThreshold
2289
            )
2290
        }
Bogdan Timofte authored a month ago
2291
    }
2292

            
Bogdan Timofte authored a month ago
2293
    private func predictedBatteryPercent(
2294
        for session: NSManagedObject,
2295
        effectiveEnergyWhOverride: Double? = nil,
2296
        referenceTimestamp: Date? = nil
2297
    ) -> Double? {
Bogdan Timofte authored a month ago
2298
        guard
2299
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
Bogdan Timofte authored a month ago
2300
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
Bogdan Timofte authored a month ago
2301
        else {
2302
            return nil
2303
        }
2304

            
Bogdan Timofte authored a month ago
2305
        let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(
2306
            for: session,
2307
            chargedDevice: chargedDevice
2308
        )
2309
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
2310
        let canUseCheckpointEnergyMap = deviceClass == .watch || deviceClass == .iphone
Bogdan Timofte authored a month ago
2311
        let measuredEnergyWh = effectiveEnergyWhOverride
2312
            ?? effectiveBatteryEnergyWh(
2313
                rawMeasuredEnergyWh: doubleValue(session, key: "measuredEnergyWh"),
2314
                for: session
2315
            )
Bogdan Timofte authored a month ago
2316
        let sessionID = stringValue(session, key: "id") ?? ""
2317

            
Bogdan Timofte authored a month ago
2318
        func anchorCapacityCandidates(from anchors: [BatteryLevelPredictionAnchor]) -> [Double] {
Bogdan Timofte authored a month ago
2319
            var candidates: [Double] = []
2320

            
2321
            for lowerIndex in anchors.indices {
2322
                for upperIndex in anchors.indices where upperIndex > lowerIndex {
2323
                    let lower = anchors[lowerIndex]
2324
                    let upper = anchors[upperIndex]
2325
                    let percentDelta = upper.percent - lower.percent
2326
                    let energyDelta = upper.energyWh - lower.energyWh
2327

            
2328
                    guard percentDelta >= 3, energyDelta > 0.01 else {
2329
                        continue
2330
                    }
2331

            
2332
                    let capacityWh = energyDelta / (percentDelta / 100)
2333
                    guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
2334
                        continue
2335
                    }
2336

            
2337
                    candidates.append(capacityWh)
2338
                }
2339
            }
2340

            
2341
            return candidates
2342
        }
2343

            
Bogdan Timofte authored a month ago
2344
        func inferredCheckpointEnergyMapCapacityWh(from anchors: [BatteryLevelPredictionAnchor]) -> Double? {
Bogdan Timofte authored a month ago
2345
            let candidates = anchorCapacityCandidates(from: anchors)
2346
            guard !candidates.isEmpty else {
2347
                return nil
2348
            }
2349

            
2350
            let sortedCandidates = candidates.sorted()
2351
            return sortedCandidates[sortedCandidates.count / 2]
2352
        }
2353

            
Bogdan Timofte authored a month ago
2354
        var anchors: [BatteryLevelPredictionAnchor] = []
Bogdan Timofte authored a month ago
2355
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2356
           startBatteryPercent >= 0 {
2357
            anchors.append(
Bogdan Timofte authored a month ago
2358
                BatteryLevelPredictionAnchor(
Bogdan Timofte authored a month ago
2359
                    percent: startBatteryPercent,
2360
                    energyWh: 0,
Bogdan Timofte authored a month ago
2361
                    timestamp: dateValue(session, key: "trimStart")
2362
                        ?? dateValue(session, key: "startedAt")
2363
                        ?? Date.distantPast,
Bogdan Timofte authored a month ago
2364
                    description: "session start",
Bogdan Timofte authored a month ago
2365
                    isCheckpoint: false
2366
                )
2367
            )
Bogdan Timofte authored a month ago
2368
        }
2369

            
2370
        let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
2371
            .compactMap(makeCheckpointSummary(from:))
2372
            .sorted { lhs, rhs in
2373
                if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
2374
                    return lhs.measuredEnergyWh < rhs.measuredEnergyWh
2375
                }
2376
                return lhs.timestamp < rhs.timestamp
2377
            }
Bogdan Timofte authored a month ago
2378
            .filter { $0.batteryPercent >= 0 }
2379
            .map {
Bogdan Timofte authored a month ago
2380
                BatteryLevelPredictionAnchor(
Bogdan Timofte authored a month ago
2381
                    percent: $0.batteryPercent,
2382
                    energyWh: $0.measuredEnergyWh,
2383
                    timestamp: $0.timestamp,
Bogdan Timofte authored a month ago
2384
                    description: $0.flag.anchorDescription,
Bogdan Timofte authored a month ago
2385
                    isCheckpoint: true
2386
                )
2387
            }
Bogdan Timofte authored a month ago
2388
        anchors.append(contentsOf: checkpointAnchors)
2389

            
Bogdan Timofte authored a month ago
2390
        if optionalDoubleValue(session, key: "startBatteryPercent") == unresolvedFlatBatteryPercent {
2391
            if let virtualZeroEnergyWh = BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
2392
                from: anchors,
2393
                estimatedCapacityWh: estimatedCapacityWh,
2394
                historicalReserveEnergyWh: estimatedFlatReserveEnergyWh(
2395
                    forChargedDeviceID: chargedDeviceID,
2396
                    excludingSessionID: sessionID
2397
                )
2398
            ) {
2399
                anchors.append(
2400
                    BatteryLevelPredictionAnchor(
2401
                        percent: 0,
2402
                        energyWh: virtualZeroEnergyWh,
2403
                        timestamp: dateValue(session, key: "trimStart")
2404
                            ?? dateValue(session, key: "startedAt")
2405
                            ?? Date.distantPast,
2406
                        description: "estimated flat reserve",
2407
                        isCheckpoint: false
2408
                    )
2409
                )
2410
            } else if let firstCheckpoint = anchors.min(by: { $0.energyWh < $1.energyWh }),
2411
                      measuredEnergyWh < firstCheckpoint.energyWh - 0.05 {
2412
                return nil
2413
            }
2414
        }
2415

            
Bogdan Timofte authored a month ago
2416
        let sortedAnchors = anchors.sorted { lhs, rhs in
2417
            if lhs.energyWh != rhs.energyWh {
2418
                return lhs.energyWh < rhs.energyWh
2419
            }
2420
            return lhs.timestamp < rhs.timestamp
2421
        }
2422

            
2423
        guard !sortedAnchors.isEmpty else {
Bogdan Timofte authored a month ago
2424
            return optionalDoubleValue(session, key: "endBatteryPercent")
2425
        }
2426

            
Bogdan Timofte authored a month ago
2427
        let inferredCapacityWh = estimatedCapacityWh
2428
            ?? (canUseCheckpointEnergyMap ? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors) : nil)
2429
        let lowerAnchor = sortedAnchors.last { $0.energyWh <= measuredEnergyWh + 0.05 }
2430
        let upperAnchor = sortedAnchors.first { $0.energyWh > measuredEnergyWh + 0.05 }
2431
        let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!
Bogdan Timofte authored a month ago
2432

            
2433
        if let lowerAnchor,
2434
           let upperAnchor,
2435
           upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
2436
            let interpolationProgress = min(
2437
                max(
2438
                    (measuredEnergyWh - lowerAnchor.energyWh) /
2439
                    (upperAnchor.energyWh - lowerAnchor.energyWh),
2440
                    0
2441
                ),
2442
                1
2443
            )
2444
            return min(
2445
                max(
2446
                    lowerAnchor.percent +
2447
                    (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
2448
                    0
2449
                ),
2450
                100
2451
            )
2452
        }
2453

            
Bogdan Timofte authored a month ago
2454
        if let chargeCurve = typicalChargeCurve(
2455
            forChargedDeviceID: chargedDeviceID,
2456
            excludingSessionID: sessionID
2457
        ),
2458
           let curvePredictedPercent = BatteryLevelPredictionTuning.predictedPercent(
2459
            anchorPercent: anchor.percent,
2460
            anchorEnergyWh: anchor.energyWh,
2461
            effectiveEnergyWh: measuredEnergyWh,
2462
            chargeCurve: chargeCurve,
2463
            deviationFactor: BatteryLevelPredictionTuning.deviationFactor(
2464
                anchors: sortedAnchors,
2465
                chargeCurve: chargeCurve
2466
            )
2467
           ) {
2468
            return curvePredictedPercent
2469
        }
2470

            
Bogdan Timofte authored a month ago
2471
        guard let inferredCapacityWh, inferredCapacityWh > 0 else {
2472
            return nil
2473
        }
2474

            
Bogdan Timofte authored a month ago
2475
        return BatteryLevelPredictionTuning.predictedPercent(
2476
            anchorPercent: anchor.percent,
2477
            anchorEnergyWh: anchor.energyWh,
2478
            anchorTimestamp: anchor.timestamp,
2479
            anchorIsCheckpoint: anchor.isCheckpoint,
2480
            effectiveEnergyWh: measuredEnergyWh,
Bogdan Timofte authored a month ago
2481
            referenceTimestamp: referenceTimestamp
2482
                ?? dateValue(session, key: "lastObservedAt")
2483
                ?? anchor.timestamp,
Bogdan Timofte authored a month ago
2484
            estimatedCapacityWh: inferredCapacityWh
Bogdan Timofte authored a month ago
2485
        )
2486
    }
2487

            
Bogdan Timofte authored a month ago
2488
    private func effectiveBatteryEnergyWh(
2489
        rawMeasuredEnergyWh: Double,
2490
        for session: NSManagedObject
2491
    ) -> Double {
2492
        switch chargingTransportMode(for: session) {
2493
        case .wired:
2494
            return rawMeasuredEnergyWh
2495
        case .wireless:
2496
            if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 {
2497
                return rawMeasuredEnergyWh * factor
2498
            }
2499
            let sessionMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2500
            if let sessionEffectiveEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh"),
2501
               sessionMeasuredEnergyWh > 0 {
2502
                return rawMeasuredEnergyWh * (sessionEffectiveEnergyWh / sessionMeasuredEnergyWh)
2503
            }
2504
            return rawMeasuredEnergyWh
2505
        }
2506
    }
2507

            
2508
    private func refreshEstimatedBatteryPercents(for session: NSManagedObject) {
2509
        guard let sessionID = stringValue(session, key: "id") else {
2510
            return
2511
        }
2512

            
2513
        for sample in fetchSessionSampleObjects(forSessionID: sessionID) {
2514
            let effectiveEnergyWh = effectiveBatteryEnergyWh(
2515
                rawMeasuredEnergyWh: doubleValue(sample, key: "measuredEnergyWh"),
2516
                for: session
2517
            )
2518
            let percent = predictedBatteryPercent(
2519
                for: session,
2520
                effectiveEnergyWhOverride: effectiveEnergyWh,
2521
                referenceTimestamp: dateValue(sample, key: "timestamp")
2522
            )
2523
            setValue(percent, on: sample, key: "estimatedBatteryPercent")
2524
            setValue(Date(), on: sample, key: "updatedAt")
2525
        }
2526
    }
2527

            
Bogdan Timofte authored a month ago
2528
    private func resolvedEstimatedBatteryCapacityWh(
2529
        for session: NSManagedObject,
2530
        chargedDevice: NSManagedObject
2531
    ) -> Double? {
2532
        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
2533
           sessionCapacityEstimate > 0 {
2534
            return sessionCapacityEstimate
2535
        }
2536

            
2537
        switch chargingTransportMode(for: session) {
2538
        case .wired:
2539
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
2540
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2541
        case .wireless:
2542
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
2543
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2544
        }
2545
    }
2546

            
2547
    private func updateCapacityEstimate(for session: NSManagedObject) {
2548
        guard
2549
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2550
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
2551
        else {
2552
            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
2553
            session.setValue(nil, forKey: "capacityEstimateWh")
2554
            return
2555
        }
2556

            
2557
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2558
        let chargingMode = chargingTransportMode(for: session)
2559
        let wirelessResolution = chargingMode == .wireless
2560
            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
2561
            : nil
2562
        let effectiveBatteryEnergyWh = chargingMode == .wired
2563
            ? measuredEnergyWh
2564
            : wirelessResolution.map { measuredEnergyWh * $0.factor }
2565

            
2566
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
2567
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
2568
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
2569
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
2570

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

            
2573
        guard let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
Bogdan Timofte authored a month ago
2574
            session.setValue(nil, forKey: "capacityEstimateWh")
2575
            return
2576
        }
2577

            
Bogdan Timofte authored a month ago
2578
        struct CapacityAnchor {
2579
            let percent: Double
2580
            let energyWh: Double
2581
            let timestamp: Date
2582
        }
2583

            
2584
        var anchors: [CapacityAnchor] = []
2585

            
2586
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2587
           startBatteryPercent >= 0 {
2588
            anchors.append(
2589
                CapacityAnchor(
2590
                    percent: startBatteryPercent,
2591
                    energyWh: 0,
2592
                    timestamp: dateValue(session, key: "trimStart")
2593
                        ?? dateValue(session, key: "startedAt")
2594
                        ?? Date.distantPast
2595
                )
2596
            )
2597
        }
2598

            
2599
        if let sessionID = stringValue(session, key: "id") {
2600
            anchors.append(
2601
                contentsOf: fetchCheckpointObjects(forSessionID: sessionID).compactMap { checkpoint in
2602
                    guard
2603
                        let percent = optionalDoubleValue(checkpoint, key: "batteryPercent"),
2604
                        percent >= 0,
2605
                        let timestamp = dateValue(checkpoint, key: "timestamp")
2606
                    else {
2607
                        return nil
2608
                    }
2609

            
2610
                    return CapacityAnchor(
2611
                        percent: percent,
2612
                        energyWh: doubleValue(checkpoint, key: "measuredEnergyWh"),
2613
                        timestamp: timestamp
2614
                    )
2615
                }
2616
            )
2617
        }
2618

            
2619
        if let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"),
2620
           endBatteryPercent >= 0 {
2621
            anchors.append(
2622
                CapacityAnchor(
2623
                    percent: endBatteryPercent,
2624
                    energyWh: effectiveBatteryEnergyWh,
2625
                    timestamp: dateValue(session, key: "endedAt")
2626
                        ?? dateValue(session, key: "lastObservedAt")
2627
                        ?? Date.distantPast
2628
                )
2629
            )
2630
        }
2631

            
2632
        let sortedAnchors = anchors.sorted { lhs, rhs in
2633
            if lhs.energyWh != rhs.energyWh {
2634
                return lhs.energyWh < rhs.energyWh
2635
            }
2636
            return lhs.timestamp < rhs.timestamp
2637
        }
2638

            
2639
        guard let firstAnchor = sortedAnchors.first,
2640
              let lastAnchor = sortedAnchors.last else {
Bogdan Timofte authored a month ago
2641
            session.setValue(nil, forKey: "capacityEstimateWh")
2642
            return
2643
        }
2644

            
Bogdan Timofte authored a month ago
2645
        let percentDelta = lastAnchor.percent - firstAnchor.percent
2646
        let energyDelta = lastAnchor.energyWh - firstAnchor.energyWh
Bogdan Timofte authored a month ago
2647

            
Bogdan Timofte authored a month ago
2648
        guard percentDelta >= 20, energyDelta > 0 else {
Bogdan Timofte authored a month ago
2649
            session.setValue(nil, forKey: "capacityEstimateWh")
2650
            return
2651
        }
2652

            
Bogdan Timofte authored a month ago
2653
        if !supportsChargingWhileOff && lastAnchor.percent >= 99.5 {
Bogdan Timofte authored a month ago
2654
            session.setValue(nil, forKey: "capacityEstimateWh")
2655
            return
2656
        }
2657

            
Bogdan Timofte authored a month ago
2658
        let capacityEstimateWh = energyDelta / (percentDelta / 100)
Bogdan Timofte authored a month ago
2659
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
2660
    }
2661

            
2662
    @discardableResult
Bogdan Timofte authored a month ago
2663
    private func insertBatteryCheckpoint(
Bogdan Timofte authored a month ago
2664
        percent: Double,
Bogdan Timofte authored a month ago
2665
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
2666
        timestamp: Date = Date(),
Bogdan Timofte authored a month ago
2667
        measuredEnergyWhOverride: Double? = nil,
Bogdan Timofte authored a month ago
2668
        subject: CheckpointSubject = .chargedDevice,
2669
        barsValue: Int = 0,
Bogdan Timofte authored a month ago
2670
        to session: NSManagedObject
Bogdan Timofte authored a month ago
2671
    ) -> String? {
Bogdan Timofte authored a month ago
2672
        guard
2673
            let sessionID = stringValue(session, key: "id"),
2674
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2675
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
2676
        else {
Bogdan Timofte authored a month ago
2677
            return nil
Bogdan Timofte authored a month ago
2678
        }
2679

            
2680
        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
Bogdan Timofte authored a month ago
2681
        let checkpointEnergyWh = measuredEnergyWhOverride
2682
            ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
Bogdan Timofte authored a month ago
2683
            ?? doubleValue(session, key: "measuredEnergyWh")
2684
        checkpoint.setValue(UUID().uuidString, forKey: "id")
2685
        checkpoint.setValue(sessionID, forKey: "sessionID")
Bogdan Timofte authored a month ago
2686
        switch subject {
2687
        case .chargedDevice:
2688
            checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
2689
            checkpoint.setValue(nil, forKey: "powerbankID")
2690
        case .powerbank:
2691
            // Powerbank-side checkpoint: link to the powerbank source instead. ChargedDeviceID
2692
            // stays nil so device capacity learning ignores it; the session backref is via sessionID.
2693
            let powerbankID = stringValue(session, key: "sourcePowerbankID")
2694
            checkpoint.setValue(nil, forKey: "chargedDeviceID")
2695
            checkpoint.setValue(powerbankID, forKey: "powerbankID")
2696
        }
2697
        checkpoint.setValue(Int16(max(0, barsValue)), forKey: "batteryBarsValue")
Bogdan Timofte authored a month ago
2698
        checkpoint.setValue(timestamp, forKey: "timestamp")
Bogdan Timofte authored a month ago
2699
        checkpoint.setValue(percent, forKey: "batteryPercent")
2700
        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
2701
        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
2702
        checkpoint.setValue(
2703
            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
2704
            forKey: "voltageVolts"
2705
        )
Bogdan Timofte authored a month ago
2706
        checkpoint.setValue(flag.rawValue, forKey: "label")
Bogdan Timofte authored a month ago
2707
        checkpoint.setValue(timestamp, forKey: "createdAt")
Bogdan Timofte authored a month ago
2708

            
Bogdan Timofte authored a month ago
2709
        // Session start/end battery percent fields track the device subject only.
2710
        if subject == .chargedDevice {
2711
            let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
Bogdan Timofte authored a month ago
2712
            if existingStartBatteryPercent == nil {
Bogdan Timofte authored a month ago
2713
                session.setValue(percent, forKey: "startBatteryPercent")
2714
            }
2715
            if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
2716
                session.setValue(percent, forKey: "endBatteryPercent")
2717
            }
Bogdan Timofte authored a month ago
2718
        }
Bogdan Timofte authored a month ago
2719
        session.setValue(timestamp, forKey: "updatedAt")
Bogdan Timofte authored a month ago
2720
        updateCapacityEstimate(for: session)
Bogdan Timofte authored a month ago
2721
        refreshEstimatedBatteryPercents(for: session)
Bogdan Timofte authored a month ago
2722

            
Bogdan Timofte authored a month ago
2723
        return chargedDeviceID
2724
    }
2725

            
Bogdan Timofte authored a month ago
2726
    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
2727
        guard let sessionID = stringValue(session, key: "id") else {
2728
            return
2729
        }
2730

            
2731
        let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
2732
        if let latestCheckpoint = remainingCheckpoints.last {
2733
            session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
2734
        } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2735
                  startBatteryPercent >= 0 {
2736
            session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
2737
        } else {
2738
            session.setValue(nil, forKey: "endBatteryPercent")
2739
        }
2740

            
2741
        session.setValue(Date(), forKey: "updatedAt")
2742
        updateCapacityEstimate(for: session)
Bogdan Timofte authored a month ago
2743
        refreshEstimatedBatteryPercents(for: session)
Bogdan Timofte authored a month ago
2744
    }
2745

            
Bogdan Timofte authored a month ago
2746
    @discardableResult
2747
    private func addBatteryCheckpoint(
2748
        percent: Double,
Bogdan Timofte authored a month ago
2749
        measuredEnergyWh: Double? = nil,
Bogdan Timofte authored a month ago
2750
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
2751
        subject: CheckpointSubject = .chargedDevice,
2752
        barsValue: Int = 0,
Bogdan Timofte authored a month ago
2753
        to session: NSManagedObject,
2754
        timestamp: Date = Date()
2755
    ) -> Bool {
Bogdan Timofte authored a month ago
2756
        if let measuredEnergyWh, measuredEnergyWh.isFinite {
2757
            session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
2758
        }
2759

            
Bogdan Timofte authored a month ago
2760
        guard let chargedDeviceID = insertBatteryCheckpoint(
2761
            percent: percent,
Bogdan Timofte authored a month ago
2762
            flag: flag,
Bogdan Timofte authored a month ago
2763
            timestamp: timestamp,
Bogdan Timofte authored a month ago
2764
            measuredEnergyWhOverride: measuredEnergyWh,
Bogdan Timofte authored a month ago
2765
            subject: subject,
2766
            barsValue: barsValue,
Bogdan Timofte authored a month ago
2767
            to: session
2768
        ) else {
2769
            return false
2770
        }
2771

            
Bogdan Timofte authored a month ago
2772
        guard saveContext() else {
2773
            return false
2774
        }
2775

            
Bogdan Timofte authored a month ago
2776
        // Device-subject checkpoints feed device-side capacity learning. Powerbank-subject
2777
        // checkpoints feed powerbank-side derivation, which is computed at materialization time
2778
        // (see PowerbankSummary fetch path).
2779
        if subject == .chargedDevice {
2780
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
2781
        }
Bogdan Timofte authored a month ago
2782
        return saveContext()
2783
    }
2784

            
2785
    private func resolvedWirelessEfficiency(
2786
        for session: NSManagedObject,
2787
        chargedDevice: NSManagedObject
2788
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
2789
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
2790
           storedFactor > 0 {
2791
            return (
2792
                factor: storedFactor,
2793
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
2794
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
2795
            )
2796
        }
2797

            
2798
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
2799
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2800
        guard measuredEnergyWh > 0 else {
2801
            return nil
2802
        }
2803

            
2804
        if chargingProfile == .magsafe,
2805
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
2806
           calibratedFactor > 0 {
2807
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
2808
        }
2809

            
2810
        guard
2811
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2812
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
2813
        else {
2814
            return nil
2815
        }
2816

            
2817
        let percentDelta = endBatteryPercent - startBatteryPercent
2818
        guard percentDelta >= 20 else {
2819
            return nil
2820
        }
2821

            
2822
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
Bogdan Timofte authored a month ago
2823
            ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
Bogdan Timofte authored a month ago
2824
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2825
                : nil),
2826
              wiredCapacityWh > 0
2827
        else {
2828
            return nil
2829
        }
2830

            
2831
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
2832
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
2833
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
2834
        let usesEstimated = chargingProfile != .magsafe
2835
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
2836

            
2837
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
2838
    }
2839

            
2840
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
2841
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
2842
            return
2843
        }
2844

            
Bogdan Timofte authored a month ago
2845
        let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
Bogdan Timofte authored a month ago
2846
        let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
2847
        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
2848
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
2849
        let sessions = relevantSessionObjects(
2850
            for: chargedDeviceID,
2851
            deviceClass: deviceClass,
2852
            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
2853
            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
2854
        )
Bogdan Timofte authored a month ago
2855
        let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
Bogdan Timofte authored a month ago
2856
        let wiredMinimumCurrent = derivedMinimumCurrent(
2857
            from: sessions,
2858
            chargingTransportMode: .wired
2859
        )
2860
        let wirelessMinimumCurrent = derivedMinimumCurrent(
2861
            from: sessions,
2862
            chargingTransportMode: .wireless
2863
        )
2864

            
2865
        let wiredCapacity = derivedCapacity(
2866
            from: sessions,
2867
            chargingTransportMode: .wired,
2868
            supportsChargingWhileOff: supportsChargingWhileOff
2869
        )
2870
        let wirelessCapacity = derivedCapacity(
2871
            from: sessions,
2872
            chargingTransportMode: .wireless,
2873
            supportsChargingWhileOff: supportsChargingWhileOff
2874
        )
2875
        let wirelessEfficiency = derivedWirelessEfficiency(
2876
            from: sessions,
2877
            chargingProfile: wirelessProfile
2878
        )
Bogdan Timofte authored a month ago
2879
        let configuredCompletionCurrents = decodedCompletionCurrents(
2880
            from: chargedDevice,
2881
            key: "configuredCompletionCurrentsRawValue"
2882
        )
Bogdan Timofte authored a month ago
2883
        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
2884
        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
2885
        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
2886
        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
2887
        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
2888
        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
2889

            
Bogdan Timofte authored a month ago
2890
        let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
2891
        let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
Bogdan Timofte authored a month ago
2892
        let preferredMinimumCurrent: Double?
2893
        let preferredCapacity: Double?
2894
        switch preferredChargingTransportMode {
2895
        case .wired:
Bogdan Timofte authored a month ago
2896
            preferredMinimumCurrent = configuredCompletionCurrents[
2897
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2898
            ] ?? learnedCompletionCurrents[
2899
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2900
            ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
Bogdan Timofte authored a month ago
2901
            preferredCapacity = wiredCapacity ?? wirelessCapacity
2902
        case .wireless:
Bogdan Timofte authored a month ago
2903
            preferredMinimumCurrent = configuredCompletionCurrents[
2904
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2905
            ] ?? learnedCompletionCurrents[
2906
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2907
            ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
Bogdan Timofte authored a month ago
2908
            preferredCapacity = wirelessCapacity ?? wiredCapacity
2909
        }
2910

            
Bogdan Timofte authored a month ago
2911
        setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
2912
        setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
2913
        setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
2914
        setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
2915
        setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
2916
        setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
2917
        setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
2918
        setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
2919
        setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
2920
        setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
2921
        setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
2922
        setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
2923
        setValue(Date(), on: chargedDevice, key: "updatedAt")
Bogdan Timofte authored a month ago
2924
    }
2925

            
2926
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
2927
        sessions
2928
            .filter { $0.status == .completed }
2929
            .compactMap { session in
2930
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
2931
                let timestamp = session.endedAt ?? session.lastObservedAt
2932
                return CapacityTrendPoint(
2933
                    sessionID: session.id,
2934
                    timestamp: timestamp,
2935
                    capacityWh: capacityEstimateWh,
2936
                    chargingTransportMode: session.chargingTransportMode
2937
                )
2938
            }
2939
            .sorted { $0.timestamp < $1.timestamp }
2940
    }
2941

            
Bogdan Timofte authored a month ago
2942
    private func typicalChargeCurve(
2943
        forChargedDeviceID chargedDeviceID: String,
2944
        excludingSessionID excludedSessionID: String? = nil
2945
    ) -> BatteryChargeCurve? {
2946
        let sessionObjects = fetchSessions(forChargedDeviceID: chargedDeviceID)
2947
            .filter {
2948
                statusValue($0, key: "statusRawValue") == .completed
2949
            }
2950

            
2951
        let sessionSummaries = sessionObjects.compactMap { session -> ChargeSessionSummary? in
2952
            guard let sessionID = stringValue(session, key: "id"),
2953
                  sessionID != excludedSessionID else {
2954
                return nil
2955
            }
2956

            
2957
            return makeSessionSummary(
2958
                from: session,
2959
                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
2960
                samples: []
2961
            )
2962
        }
2963

            
2964
        return BatteryChargeCurve(
2965
            typicalCurvePoints: buildTypicalCurve(from: sessionSummaries)
2966
        )
2967
    }
2968

            
2969
    private func estimatedFlatReserveEnergyWh(
2970
        forChargedDeviceID chargedDeviceID: String,
2971
        excludingSessionID excludedSessionID: String? = nil
2972
    ) -> Double? {
2973
        let reserves = fetchSessions(forChargedDeviceID: chargedDeviceID)
2974
            .filter {
2975
                statusValue($0, key: "statusRawValue") == .completed
2976
                    && optionalDoubleValue($0, key: "startBatteryPercent") == unresolvedFlatBatteryPercent
2977
                    && stringValue($0, key: "id") != excludedSessionID
2978
            }
2979
            .compactMap { session -> Double? in
2980
                guard let sessionID = stringValue(session, key: "id") else {
2981
                    return nil
2982
                }
2983

            
2984
                let anchors = fetchCheckpointObjects(forSessionID: sessionID)
2985
                    .compactMap(makeCheckpointSummary(from:))
2986
                    .map {
2987
                        BatteryLevelPredictionAnchor(
2988
                            percent: $0.batteryPercent,
2989
                            energyWh: $0.measuredEnergyWh,
2990
                            timestamp: $0.timestamp,
2991
                            description: $0.flag.anchorDescription,
2992
                            isCheckpoint: true
2993
                        )
2994
                    }
2995

            
2996
                return BatteryLevelPredictionTuning.inferredVirtualZeroEnergyWh(
2997
                    from: anchors,
2998
                    estimatedCapacityWh: optionalDoubleValue(session, key: "capacityEstimateWh")
2999
                )
3000
            }
3001

            
3002
        guard !reserves.isEmpty else {
3003
            return nil
3004
        }
3005

            
3006
        let sortedReserves = reserves.sorted()
3007
        return sortedReserves[sortedReserves.count / 2]
3008
    }
3009

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

            
3013
        for session in sessions where session.status == .completed {
Bogdan Timofte authored a month ago
3014
            let anchors = normalizedTypicalCurveAnchors(for: session)
3015
            guard anchors.count >= 2 else {
3016
                continue
Bogdan Timofte authored a month ago
3017
            }
3018

            
Bogdan Timofte authored a month ago
3019
            for percentBin in stride(from: 0, through: 100, by: 10) {
Bogdan Timofte authored a month ago
3020
                guard let energyWh = interpolatedTypicalCurvePoint(
Bogdan Timofte authored a month ago
3021
                    for: Double(percentBin),
3022
                    anchors: anchors
3023
                ) else {
3024
                    continue
3025
                }
Bogdan Timofte authored a month ago
3026

            
Bogdan Timofte authored a month ago
3027
                groupedEnergyByBin[percentBin, default: []].append(energyWh)
Bogdan Timofte authored a month ago
3028
            }
3029
        }
3030

            
Bogdan Timofte authored a month ago
3031
        let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
Bogdan Timofte authored a month ago
3032
            guard let energies = groupedEnergyByBin[percentBin], !energies.isEmpty else {
Bogdan Timofte authored a month ago
3033
                return nil
3034
            }
3035

            
3036
            return TypicalChargeCurvePoint(
3037
                percentBin: percentBin,
3038
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
Bogdan Timofte authored a month ago
3039
                sampleCount: energies.count
Bogdan Timofte authored a month ago
3040
            )
3041
        }
Bogdan Timofte authored a month ago
3042

            
3043
        var runningMaximumEnergyWh = 0.0
3044

            
3045
        return averagedPoints.map { point in
3046
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
3047
            return TypicalChargeCurvePoint(
3048
                percentBin: point.percentBin,
3049
                averageEnergyWh: runningMaximumEnergyWh,
3050
                sampleCount: point.sampleCount
3051
            )
3052
        }
3053
    }
3054

            
3055
    private func normalizedTypicalCurveAnchors(
3056
        for session: ChargeSessionSummary
Bogdan Timofte authored a month ago
3057
    ) -> [(percent: Double, energyWh: Double)] {
Bogdan Timofte authored a month ago
3058
        struct Anchor {
3059
            let percent: Double
3060
            let energyWh: Double
3061
            let timestamp: Date
3062
        }
3063

            
3064
        var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
3065
            guard checkpoint.batteryPercent.isFinite,
3066
                  checkpoint.measuredEnergyWh.isFinite,
3067
                  checkpoint.batteryPercent >= 0,
3068
                  checkpoint.batteryPercent <= 100,
Bogdan Timofte authored a month ago
3069
                  checkpoint.measuredEnergyWh >= 0 else {
Bogdan Timofte authored a month ago
3070
                return nil
3071
            }
3072

            
3073
            return Anchor(
3074
                percent: checkpoint.batteryPercent,
3075
                energyWh: checkpoint.measuredEnergyWh,
3076
                timestamp: checkpoint.timestamp
3077
            )
3078
        }
3079

            
3080
        if let startBatteryPercent = session.startBatteryPercent,
3081
           startBatteryPercent.isFinite,
3082
           startBatteryPercent >= 0,
3083
           startBatteryPercent <= 100 {
3084
            anchors.append(
3085
                Anchor(
3086
                    percent: startBatteryPercent,
3087
                    energyWh: 0,
3088
                    timestamp: session.startedAt
3089
                )
3090
            )
3091
        }
3092

            
3093
        if let endBatteryPercent = session.endBatteryPercent,
3094
           endBatteryPercent.isFinite,
3095
           endBatteryPercent >= 0,
3096
           endBatteryPercent <= 100 {
3097
            anchors.append(
3098
                Anchor(
3099
                    percent: endBatteryPercent,
3100
                    energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
3101
                    timestamp: session.endedAt ?? session.lastObservedAt
3102
                )
3103
            )
3104
        }
3105

            
3106
        let sortedAnchors = anchors.sorted { lhs, rhs in
3107
            if lhs.percent != rhs.percent {
3108
                return lhs.percent < rhs.percent
3109
            }
3110
            if lhs.energyWh != rhs.energyWh {
3111
                return lhs.energyWh < rhs.energyWh
3112
            }
3113
            return lhs.timestamp < rhs.timestamp
3114
        }
3115

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

            
3118
        for anchor in sortedAnchors {
3119
            if let lastIndex = collapsedAnchors.indices.last,
3120
               abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
3121
                collapsedAnchors[lastIndex] = (
3122
                    percent: collapsedAnchors[lastIndex].percent,
Bogdan Timofte authored a month ago
3123
                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh)
Bogdan Timofte authored a month ago
3124
                )
3125
            } else {
3126
                collapsedAnchors.append(
Bogdan Timofte authored a month ago
3127
                    (percent: anchor.percent, energyWh: anchor.energyWh)
Bogdan Timofte authored a month ago
3128
                )
3129
            }
3130
        }
3131

            
3132
        var runningMaximumEnergyWh = 0.0
3133

            
3134
        return collapsedAnchors.map { anchor in
3135
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
3136
            return (
3137
                percent: anchor.percent,
Bogdan Timofte authored a month ago
3138
                energyWh: runningMaximumEnergyWh
Bogdan Timofte authored a month ago
3139
            )
3140
        }
3141
    }
3142

            
3143
    private func interpolatedTypicalCurvePoint(
3144
        for percent: Double,
Bogdan Timofte authored a month ago
3145
        anchors: [(percent: Double, energyWh: Double)]
3146
    ) -> Double? {
Bogdan Timofte authored a month ago
3147
        guard
3148
            let firstAnchor = anchors.first,
3149
            let lastAnchor = anchors.last,
3150
            percent >= firstAnchor.percent,
3151
            percent <= lastAnchor.percent
3152
        else {
3153
            return nil
3154
        }
3155

            
3156
        if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
Bogdan Timofte authored a month ago
3157
            return exactAnchor.energyWh
Bogdan Timofte authored a month ago
3158
        }
3159

            
3160
        guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
3161
              upperIndex > 0 else {
3162
            return nil
3163
        }
3164

            
3165
        let lowerAnchor = anchors[upperIndex - 1]
3166
        let upperAnchor = anchors[upperIndex]
3167
        let span = upperAnchor.percent - lowerAnchor.percent
3168
        guard span > 0.000_1 else {
3169
            return nil
3170
        }
3171

            
3172
        let ratio = (percent - lowerAnchor.percent) / span
Bogdan Timofte authored a month ago
3173
        return lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
Bogdan Timofte authored a month ago
3174
    }
3175

            
3176
    private func makeSessionSummary(
3177
        from object: NSManagedObject,
3178
        checkpoints: [NSManagedObject],
3179
        samples: [NSManagedObject]
3180
    ) -> ChargeSessionSummary? {
3181
        let chargingTransportMode = chargingTransportMode(for: object)
3182

            
3183
        guard
3184
            let id = uuidValue(object, key: "id"),
3185
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
3186
            let startedAt = dateValue(object, key: "startedAt"),
3187
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
3188
            let status = statusValue(object, key: "statusRawValue"),
3189
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
3190
        else {
3191
            return nil
3192
        }
3193

            
3194
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
3195
            .sorted { $0.timestamp < $1.timestamp }
3196
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
3197
            .sorted { lhs, rhs in
3198
                if lhs.bucketIndex != rhs.bucketIndex {
3199
                    return lhs.bucketIndex < rhs.bucketIndex
3200
                }
3201
                return lhs.timestamp < rhs.timestamp
3202
            }
3203

            
3204
        return ChargeSessionSummary(
3205
            id: id,
3206
            chargedDeviceID: chargedDeviceID,
Bogdan Timofte authored a month ago
3207
            chargedPowerbankID: uuidValue(object, key: "chargedPowerbankID"),
Bogdan Timofte authored a month ago
3208
            chargerID: uuidValue(object, key: "chargerID"),
Bogdan Timofte authored a month ago
3209
            sourcePowerbankID: uuidValue(object, key: "sourcePowerbankID"),
Bogdan Timofte authored a month ago
3210
            meterMACAddress: stringValue(object, key: "meterMACAddress"),
3211
            meterName: stringValue(object, key: "meterName"),
3212
            meterModel: stringValue(object, key: "meterModel"),
3213
            startedAt: startedAt,
3214
            endedAt: dateValue(object, key: "endedAt"),
3215
            lastObservedAt: lastObservedAt,
Bogdan Timofte authored a month ago
3216
            pausedAt: dateValue(object, key: "pausedAt"),
Bogdan Timofte authored a month ago
3217
            status: status,
3218
            sourceMode: sourceMode,
3219
            chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
3220
            chargingStateMode: chargingStateMode(for: object),
3221
            autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
Bogdan Timofte authored a month ago
3222
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
3223
            effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
Bogdan Timofte authored a month ago
3224
            meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
Bogdan Timofte authored a month ago
3225
            meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"),
3226
            meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"),
Bogdan Timofte authored a month ago
3227
            minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
3228
            maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
3229
            maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
3230
            maximumObservedVoltageVolts: chargingTransportMode == .wired
3231
                ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
3232
                : nil,
Bogdan Timofte authored a month ago
3233
            hasObservedChargeFlow: boolValue(object, key: "hasObservedChargeFlow"),
Bogdan Timofte authored a month ago
3234
            selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
3235
            completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
3236
            stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
3237
            startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
3238
            endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
3239
            capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
3240
            wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
3241
            usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
3242
            shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
3243
            supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
3244
            usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
3245
            targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
3246
            targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
3247
            requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
3248
            completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
3249
            completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
3250
            selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
Bogdan Timofte authored a month ago
3251
            trimStart: dateValue(object, key: "trimStart"),
3252
            trimEnd: dateValue(object, key: "trimEnd"),
Bogdan Timofte authored a month ago
3253
            wasConflictHealed: boolValue(object, key: "wasConflictHealed"),
Bogdan Timofte authored a month ago
3254
            checkpoints: checkpointSummaries,
3255
            aggregatedSamples: sampleSummaries
3256
        )
3257
    }
3258

            
3259
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
3260
        guard
3261
            let id = uuidValue(object, key: "id"),
3262
            let sessionID = uuidValue(object, key: "sessionID"),
3263
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
3264
            let timestamp = dateValue(object, key: "timestamp")
3265
        else {
3266
            return nil
3267
        }
3268

            
3269
        return ChargeCheckpointSummary(
3270
            id: id,
3271
            sessionID: sessionID,
3272
            chargedDeviceID: chargedDeviceID,
Bogdan Timofte authored a month ago
3273
            powerbankID: uuidValue(object, key: "powerbankID"),
3274
            batteryBarsValue: Int(optionalInt16Value(object, key: "batteryBarsValue") ?? 0),
Bogdan Timofte authored a month ago
3275
            timestamp: timestamp,
3276
            batteryPercent: doubleValue(object, key: "batteryPercent"),
3277
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
3278
            currentAmps: doubleValue(object, key: "currentAmps"),
3279
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
3280
            label: stringValue(object, key: "label")
3281
        )
3282
    }
3283

            
3284
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
3285
        guard
3286
            let sessionID = uuidValue(object, key: "sessionID"),
3287
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
3288
            let timestamp = dateValue(object, key: "timestamp")
3289
        else {
3290
            return nil
3291
        }
3292

            
3293
        return ChargeSessionSampleSummary(
3294
            sessionID: sessionID,
3295
            chargedDeviceID: chargedDeviceID,
3296
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
3297
            timestamp: timestamp,
3298
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
3299
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
3300
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
3301
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
Bogdan Timofte authored a month ago
3302
            estimatedBatteryPercent: optionalDoubleValue(object, key: "estimatedBatteryPercent"),
Bogdan Timofte authored a month ago
3303
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
3304
        )
3305
    }
3306

            
Bogdan Timofte authored a month ago
3307
    private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
3308
        fetchSessionObject(
3309
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
3310
                format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
Bogdan Timofte authored a month ago
3311
                normalizedMACAddress(meterMACAddress),
Bogdan Timofte authored a month ago
3312
                ChargeSessionStatus.active.rawValue,
3313
                ChargeSessionStatus.paused.rawValue
Bogdan Timofte authored a month ago
3314
            )
3315
        )
3316
    }
3317

            
Bogdan Timofte authored a month ago
3318
    private func fetchOpenSessionObjects() -> [NSManagedObject] {
3319
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3320
        request.predicate = NSPredicate(
3321
            format: "statusRawValue == %@ OR statusRawValue == %@",
3322
            ChargeSessionStatus.active.rawValue,
3323
            ChargeSessionStatus.paused.rawValue
3324
        )
3325
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
3326
        return (try? context.fetch(request)) ?? []
3327
    }
3328

            
Bogdan Timofte authored a month ago
3329
    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
3330
        fetchSessionObject(
3331
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
3332
                format: "meterMACAddress == %@ AND statusRawValue == %@",
3333
                normalizedMACAddress(meterMACAddress),
3334
                ChargeSessionStatus.active.rawValue
Bogdan Timofte authored a month ago
3335
            )
3336
        )
3337
    }
3338

            
3339
    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
3340
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3341
        request.predicate = predicate
3342
        request.fetchLimit = 1
3343
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
3344
        return (try? context.fetch(request))?.first
3345
    }
3346

            
3347
    private func fetchSessionObject(id: String) -> NSManagedObject? {
3348
        fetchSessionObject(
3349
            predicate: NSPredicate(format: "id == %@", id)
3350
        )
3351
    }
3352

            
3353
    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
3354
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
3355
        request.predicate = NSPredicate(
3356
            format: "sessionID == %@ AND bucketIndex == %d",
3357
            sessionID,
3358
            bucketIndex
3359
        )
3360
        request.fetchLimit = 1
3361
        return (try? context.fetch(request))?.first
3362
    }
3363

            
3364
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
3365
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
3366
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
3367
        return (try? context.fetch(request)) ?? []
3368
    }
3369

            
Bogdan Timofte authored a month ago
3370
    private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
3371
        guard !sessionIDs.isEmpty else {
3372
            return []
3373
        }
3374

            
3375
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
3376
        request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs)
3377
        return (try? context.fetch(request)) ?? []
3378
    }
3379

            
Bogdan Timofte authored a month ago
3380
    private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
3381
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
3382
        request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
3383
        request.fetchLimit = 1
3384
        return (try? context.fetch(request))?.first
3385
    }
3386

            
Bogdan Timofte authored a month ago
3387
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
3388
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
3389
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
3390
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
3391
        return (try? context.fetch(request)) ?? []
3392
    }
3393

            
3394
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
3395
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3396
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
3397
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
3398
        return (try? context.fetch(request)) ?? []
3399
    }
3400

            
3401
    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
3402
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3403
        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
3404
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
3405
        return (try? context.fetch(request)) ?? []
3406
    }
3407

            
Bogdan Timofte authored a month ago
3408
    private func fetchSessions(forPowerbankSubjectID powerbankID: String) -> [NSManagedObject] {
3409
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3410
        request.predicate = NSPredicate(format: "chargedPowerbankID == %@", powerbankID)
3411
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
3412
        return (try? context.fetch(request)) ?? []
3413
    }
3414

            
3415
    private func fetchSessions(forPowerbankSourceID powerbankID: String) -> [NSManagedObject] {
3416
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3417
        request.predicate = NSPredicate(format: "sourcePowerbankID == %@", powerbankID)
3418
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
3419
        return (try? context.fetch(request)) ?? []
3420
    }
3421

            
Bogdan Timofte authored a month ago
3422
    private func sampleBackedSessionIDs(
3423
        devices: [NSManagedObject],
3424
        sessionsByDeviceID: [String: [NSManagedObject]],
3425
        sessionsByChargerID: [String: [NSManagedObject]]
3426
    ) -> Set<String> {
3427
        var sessionIDs: Set<String> = []
3428

            
3429
        for device in devices {
3430
            guard
3431
                let deviceID = stringValue(device, key: "id"),
3432
                let rawClass = stringValue(device, key: "deviceClassRawValue"),
3433
                let deviceClass = ChargedDeviceClass(rawValue: rawClass)
3434
            else {
3435
                continue
3436
            }
3437

            
3438
            let relevantSessions = relevantSessionObjects(
3439
                for: deviceID,
3440
                deviceClass: deviceClass,
3441
                sessionsByDeviceID: sessionsByDeviceID,
3442
                sessionsByChargerID: sessionsByChargerID
3443
            )
3444
            .sorted { lhs, rhs in
3445
                let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed
3446
                let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed
3447

            
3448
                if lhsStatus.isOpen && !rhsStatus.isOpen {
3449
                    return true
3450
                }
3451
                if !lhsStatus.isOpen && rhsStatus.isOpen {
3452
                    return false
3453
                }
3454

            
3455
                return (dateValue(lhs, key: "startedAt") ?? .distantPast)
3456
                    > (dateValue(rhs, key: "startedAt") ?? .distantPast)
3457
            }
3458

            
3459
            var recentCompletedSamplesIncluded = 0
3460

            
3461
            for session in relevantSessions {
3462
                guard let sessionID = stringValue(session, key: "id"),
3463
                      let status = statusValue(session, key: "statusRawValue") else {
3464
                    continue
3465
                }
3466

            
3467
                if status.isOpen {
3468
                    sessionIDs.insert(sessionID)
3469
                    continue
3470
                }
3471

            
3472
                guard recentCompletedSamplesIncluded < 2 else {
3473
                    continue
3474
                }
3475

            
3476
                sessionIDs.insert(sessionID)
3477
                recentCompletedSamplesIncluded += 1
3478
            }
3479
        }
3480

            
3481
        return sessionIDs
3482
    }
3483

            
Bogdan Timofte authored a month ago
3484
    private func relevantSessionObjects(
3485
        for chargedDeviceID: String,
3486
        deviceClass: ChargedDeviceClass,
3487
        sessionsByDeviceID: [String: [NSManagedObject]],
3488
        sessionsByChargerID: [String: [NSManagedObject]]
3489
    ) -> [NSManagedObject] {
3490
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
3491
        guard deviceClass == .charger else {
3492
            return directSessions
3493
        }
3494

            
3495
        var seenSessionIDs = Set<String>()
3496
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
3497
            .filter { session in
3498
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
3499
                return seenSessionIDs.insert(sessionID).inserted
3500
            }
3501
            .sorted {
3502
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
3503
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
3504
                return lhsDate < rhsDate
3505
            }
3506
    }
3507

            
Bogdan Timofte authored a month ago
3508
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
3509
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
3510
    }
3511

            
Bogdan Timofte authored a month ago
3512
    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
3513
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
3514
        request.predicate = NSPredicate(format: "id == %@", id)
3515
        request.fetchLimit = 1
3516
        return (try? context.fetch(request))?.first
3517
    }
3518

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

            
Bogdan Timofte authored a month ago
3526
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
3527
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
3528
        return (try? context.fetch(request)) ?? []
3529
    }
3530

            
3531
    private func resolvedStopThreshold(
3532
        for chargedDevice: NSManagedObject,
3533
        chargingTransportMode: ChargingTransportMode,
Bogdan Timofte authored a month ago
3534
        chargingStateMode: ChargingStateMode,
3535
        charger: NSManagedObject?,
3536
        fallback: Double?
3537
    ) -> Double? {
3538
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
3539
            return nil
3540
        }
3541

            
3542
        let sessionKind = ChargeSessionKind(
3543
            chargingTransportMode: chargingTransportMode,
3544
            chargingStateMode: chargingStateMode
3545
        )
3546
        let configuredCurrents = decodedCompletionCurrents(
3547
            from: chargedDevice,
3548
            key: "configuredCompletionCurrentsRawValue"
3549
        )
3550
        let learnedCurrents = decodedCompletionCurrents(
3551
            from: chargedDevice,
3552
            key: "learnedCompletionCurrentsRawValue"
3553
        )
3554
        let legacyCurrent: Double?
Bogdan Timofte authored a month ago
3555
        switch chargingTransportMode {
3556
        case .wired:
Bogdan Timofte authored a month ago
3557
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
3558
                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
3559
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
3560
        case .wireless:
Bogdan Timofte authored a month ago
3561
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
3562
                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
3563
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
3564
        }
Bogdan Timofte authored a month ago
3565

            
3566
        let resolvedCurrent = configuredCurrents[sessionKind]
3567
            ?? learnedCurrents[sessionKind]
3568
            ?? legacyCurrent
3569
            ?? fallback
3570
        guard let resolvedCurrent, resolvedCurrent > 0 else {
3571
            return nil
3572
        }
3573
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
3574
    }
3575

            
Bogdan Timofte authored a month ago
3576
    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
Bogdan Timofte authored a month ago
3577
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
3578
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
3579
        return resolvedPreferredChargingTransportMode(
Bogdan Timofte authored a month ago
3580
            .wired,
Bogdan Timofte authored a month ago
3581
            supportsWiredCharging: supportsWiredCharging,
3582
            supportsWirelessCharging: supportsWirelessCharging
3583
        )
3584
    }
3585

            
Bogdan Timofte authored a month ago
3586
    private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
3587
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other
3588
    }
3589

            
3590
    private func normalizedTemplateID(
3591
        _ templateID: String?,
3592
        kind: ChargedDeviceKind
3593
    ) -> String? {
3594
        guard let templateID,
3595
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
3596
              templateDefinition.kind == kind else {
3597
            return nil
Bogdan Timofte authored a month ago
3598
        }
Bogdan Timofte authored a month ago
3599
        return templateDefinition.id
Bogdan Timofte authored a month ago
3600
    }
3601

            
Bogdan Timofte authored a month ago
3602
    /// Resolves the active DeviceProfile for a ChargedDevice — catalog first
3603
    /// (covers all built-in templates and chargers), then DB-backed custom profiles.
3604
    /// Returns nil only for devices that escaped migration (shouldn't happen post Phase 3).
3605
    private func resolvedProfileDefinition(for chargedDevice: NSManagedObject) -> DeviceProfileDefinition? {
3606
        if let profileID = stringValue(chargedDevice, key: "profileID") {
3607
            if let catalogProfile = DeviceProfileCatalog.shared.profile(id: profileID) {
3608
                return catalogProfile
3609
            }
3610
            if let stored = fetchDeviceProfileObject(id: profileID),
3611
               let definition = makeProfileDefinition(from: stored) {
3612
                return definition
3613
            }
3614
        }
3615
        // Pre-migration fallback: try the legacy template ID against the catalog.
3616
        if let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
3617
           let catalogProfile = DeviceProfileCatalog.shared.profile(id: templateID) {
3618
            return catalogProfile
3619
        }
3620
        return nil
3621
    }
3622

            
3623
    private func fetchDeviceProfileObject(id: String) -> NSManagedObject? {
3624
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.deviceProfile)
3625
        request.predicate = NSPredicate(format: "id == %@", id)
3626
        request.fetchLimit = 1
3627
        return (try? context.fetch(request))?.first
3628
    }
3629

            
3630
    private func makeProfileDefinition(from object: NSManagedObject) -> DeviceProfileDefinition? {
3631
        guard let id = stringValue(object, key: "id"),
3632
              let categoryRaw = stringValue(object, key: "categoryRawValue"),
3633
              let category = ProfileCategory(rawValue: categoryRaw) else {
Bogdan Timofte authored a month ago
3634
            return nil
Bogdan Timofte authored a month ago
3635
        }
Bogdan Timofte authored a month ago
3636
        let name = stringValue(object, key: "name") ?? id
3637
        let group = stringValue(object, key: "group") ?? "Custom"
3638
        let iconName = stringValue(object, key: "iconSymbolName") ?? category.symbolName
3639
        let iconFallback = stringValue(object, key: "iconFallbackSymbolName")
3640
        let icon = ChargedDeviceTemplateIcon(type: .systemSymbol, name: iconName, fallbackSystemName: iconFallback)
3641
        let stateRaw = stringValue(object, key: "capChargingStateAvailabilityRawValue")
3642
            ?? ChargingStateAvailability.onOrOff.rawValue
3643
        let stateAvailability = ChargingStateAvailability(rawValue: stateRaw) ?? .onOrOff
3644
        let allowedWirelessProfiles = DeviceProfileDefinition.decodeWirelessProfilesCSV(
3645
            stringValue(object, key: "capWirelessProfilesRawValue")
3646
        )
3647
        let defaultWirelessProfileRaw = stringValue(object, key: "defaultWirelessChargingProfileRawValue")
3648
        let defaultWirelessProfile = defaultWirelessProfileRaw.flatMap(WirelessChargingProfile.init(rawValue:))
3649

            
3650
        return DeviceProfileDefinition(
3651
            id: id,
3652
            name: name,
3653
            group: group,
3654
            category: category,
3655
            icon: icon,
3656
            sortOrder: Int((object.value(forKey: "sortOrder") as? Int32) ?? 1000),
3657
            capWiredCharging: (object.value(forKey: "capWiredCharging") as? Bool) ?? false,
3658
            capWirelessCharging: (object.value(forKey: "capWirelessCharging") as? Bool) ?? false,
3659
            capWirelessProfiles: allowedWirelessProfiles,
3660
            capChargingStateAvailability: stateAvailability,
3661
            capHasInternalSubject: (object.value(forKey: "capHasInternalSubject") as? Bool) ?? false,
3662
            defaultWirelessChargingProfile: defaultWirelessProfile,
3663
            defaultWiredMinimumCurrentAmps: optionalDoubleValue(object, key: "defaultWiredMinimumCurrentAmps"),
3664
            defaultWirelessMinimumCurrentAmps: optionalDoubleValue(object, key: "defaultWirelessMinimumCurrentAmps"),
3665
            defaultWiredEstimatedBatteryCapacityWh: optionalDoubleValue(object, key: "defaultWiredEstimatedBatteryCapacityWh"),
3666
            defaultWirelessEstimatedBatteryCapacityWh: optionalDoubleValue(object, key: "defaultWirelessEstimatedBatteryCapacityWh")
3667
        )
3668
    }
3669

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

            
3677
        let kind = profile.category.kind
3678
        // Pick a representative wireless profile for the legacy template shape.
3679
        let wirelessProfile = profile.defaultWirelessChargingProfile
3680
            ?? profile.capWirelessProfiles.first
3681
            ?? .genericQi
3682

            
3683
        return ChargedDeviceTemplateDefinition(
3684
            id: profile.id,
3685
            name: profile.name,
3686
            group: profile.group,
3687
            kind: kind,
3688
            deviceClass: legacyClass(for: profile.category),
3689
            icon: profile.icon,
3690
            chargingStateAvailability: profile.capChargingStateAvailability,
3691
            supportsWiredCharging: profile.capWiredCharging,
3692
            supportsWirelessCharging: profile.capWirelessCharging,
3693
            wirelessChargingProfile: wirelessProfile,
3694
            sortOrder: profile.sortOrder
3695
        )
3696
    }
3697

            
3698
    private func legacyClass(for category: ProfileCategory) -> ChargedDeviceClass {
3699
        switch category {
3700
        case .phone: return .iphone
3701
        case .watch: return .watch
3702
        case .powerbank: return .powerbank
3703
        case .charger: return .charger
3704
        case .tablet, .laptop, .audioAccessory, .accessoryCase, .other: return .other
3705
        }
Bogdan Timofte authored a month ago
3706
    }
3707

            
3708
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
3709
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
3710
            ? true
3711
            : boolValue(chargedDevice, key: "supportsWiredCharging")
Bogdan Timofte authored a month ago
3712

            
3713
        if let profile = resolvedProfileDefinition(for: chargedDevice) {
3714
            // Profile capability is the upper bound; user opt-out preserved.
3715
            return persistedWiredCharging && profile.capWiredCharging
3716
        }
3717

            
3718
        // Pre-migration fallback: legacy class enforcement.
Bogdan Timofte authored a month ago
3719
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
3720
            ? false
3721
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
3722
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
3723
            supportsWiredCharging: persistedWiredCharging,
3724
            supportsWirelessCharging: persistedWirelessCharging
3725
        ).wired
3726
    }
3727

            
3728
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
3729
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
3730
            ? false
3731
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
Bogdan Timofte authored a month ago
3732

            
3733
        if let profile = resolvedProfileDefinition(for: chargedDevice) {
3734
            return persistedWirelessCharging && profile.capWirelessCharging
3735
        }
3736

            
3737
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
3738
            ? true
3739
            : boolValue(chargedDevice, key: "supportsWiredCharging")
Bogdan Timofte authored a month ago
3740
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
3741
            supportsWiredCharging: persistedWiredCharging,
3742
            supportsWirelessCharging: persistedWirelessCharging
3743
        ).wireless
3744
    }
3745

            
3746
    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
Bogdan Timofte authored a month ago
3747
        if let profile = resolvedProfileDefinition(for: chargedDevice) {
3748
            return profile.capChargingStateAvailability
3749
        }
3750

            
Bogdan Timofte authored a month ago
3751
        let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue")
3752
            .flatMap(ChargingStateAvailability.init(rawValue:))
3753
            ?? ChargingStateAvailability.fallback(
Bogdan Timofte authored a month ago
3754
            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
3755
        )
Bogdan Timofte authored a month ago
3756
        return deviceClass(for: chargedDevice).normalizedChargingStateAvailability(persistedAvailability)
Bogdan Timofte authored a month ago
3757
    }
3758

            
3759
    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
Bogdan Timofte authored a month ago
3760
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
3761
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
3762
            let persistedChargingStateMode = stringValue(session, key: "chargingStateRawValue")
3763
                .flatMap(ChargingStateMode.init(rawValue:))
3764
                ?? .on
3765
            return resolvedChargingStateMode(
3766
                persistedChargingStateMode,
3767
                availability: chargingStateAvailability(for: chargedDevice)
3768
            )
3769
        }
3770

            
Bogdan Timofte authored a month ago
3771
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
3772
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
3773
            return chargingStateMode
3774
        }
3775

            
3776
        return .on
3777
    }
3778

            
3779
    private func resolvedChargingStateMode(
3780
        _ chargingStateMode: ChargingStateMode,
3781
        availability: ChargingStateAvailability
3782
    ) -> ChargingStateMode {
3783
        if availability.supportedModes.contains(chargingStateMode) {
3784
            return chargingStateMode
3785
        }
3786
        return availability.supportedModes.first ?? .on
3787
    }
3788

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

            
3793
        // Primary: chargerTypeRawValue (set on v13+)
3794
        if let rawValue = stringValue(chargedDevice, key: "chargerTypeRawValue"),
3795
           let type = ChargerType(rawValue: rawValue) {
3796
            return type
3797
        }
3798

            
3799
        // Migration fallback: derive from old deviceTemplateID
3800
        switch stringValue(chargedDevice, key: "deviceTemplateID") {
3801
        case "apple-magsafe-charger": return .appleMagSafe
3802
        case "apple-watch-charger": return .appleWatch
3803
        default: break
3804
        }
3805

            
3806
        // Last resort: derive from wirelessChargingProfileRawValue
3807
        if let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
3808
           let profile = WirelessChargingProfile(rawValue: rawValue),
3809
           profile == .magsafe {
3810
            return .genericMagSafe
3811
        }
3812

            
3813
        return .genericQi
3814
    }
3815

            
Bogdan Timofte authored a month ago
3816
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
Bogdan Timofte authored a month ago
3817
        if let type = chargerType(for: chargedDevice) {
3818
            return type.wirelessChargingProfile
3819
        }
Bogdan Timofte authored a month ago
3820
        let persisted = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue")
3821
            .flatMap(WirelessChargingProfile.init(rawValue:))
3822

            
3823
        if let profile = resolvedProfileDefinition(for: chargedDevice),
3824
           !profile.capWirelessProfiles.isEmpty {
3825
            // Persisted wins iff still allowed by capabilities; else fall back to profile default.
3826
            if let persisted, profile.capWirelessProfiles.contains(persisted) {
3827
                return persisted
3828
            }
3829
            return profile.defaultWirelessChargingProfile
3830
                ?? profile.capWirelessProfiles.first
3831
                ?? .genericQi
Bogdan Timofte authored a month ago
3832
        }
Bogdan Timofte authored a month ago
3833

            
3834
        return persisted ?? .genericQi
Bogdan Timofte authored a month ago
3835
    }
3836

            
3837
    private func resolvedPreferredChargingTransportMode(
3838
        _ preferredChargingTransportMode: ChargingTransportMode,
3839
        supportsWiredCharging: Bool,
3840
        supportsWirelessCharging: Bool
3841
    ) -> ChargingTransportMode {
3842
        switch preferredChargingTransportMode {
3843
        case .wired where supportsWiredCharging:
3844
            return .wired
3845
        case .wireless where supportsWirelessCharging:
3846
            return .wireless
3847
        default:
3848
            if supportsWiredCharging {
3849
                return .wired
3850
            }
3851
            if supportsWirelessCharging {
3852
                return .wireless
3853
            }
3854
            return .wired
3855
        }
3856
    }
3857

            
Bogdan Timofte authored a month ago
3858
    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
3859
        let payload = Dictionary(
3860
            uniqueKeysWithValues: currents.map { key, value in
3861
                (key.rawValue, value)
3862
            }
3863
        )
3864
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
3865
            return nil
3866
        }
3867
        return String(data: data, encoding: .utf8)
3868
    }
3869

            
3870
    private func decodedCompletionCurrents(
3871
        from object: NSManagedObject,
3872
        key: String
3873
    ) -> [ChargeSessionKind: Double] {
3874
        guard let rawValue = stringValue(object, key: key),
3875
              let data = rawValue.data(using: .utf8),
3876
              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
3877
            return [:]
3878
        }
3879

            
3880
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
3881
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
3882
                return
3883
            }
3884
            result[sessionKind] = entry.value
3885
        }
3886
    }
3887

            
3888
    private func legacyConfiguredCompletionCurrent(
3889
        for currents: [ChargeSessionKind: Double],
3890
        chargingTransportMode: ChargingTransportMode
3891
    ) -> Double? {
3892
        let candidates = currents
3893
            .filter { $0.key.chargingTransportMode == chargingTransportMode }
3894
            .sorted { lhs, rhs in
3895
                lhs.key.rawValue < rhs.key.rawValue
3896
            }
3897
            .map(\.value)
3898
        return candidates.first
3899
    }
3900

            
3901
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
3902
        guard let charger else {
3903
            return nil
3904
        }
3905
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
3906
        guard let idleCurrent, idleCurrent >= 0 else {
3907
            return nil
3908
        }
3909
        return idleCurrent
3910
    }
3911

            
3912
    private func effectiveCurrentAmps(
3913
        fromMeasuredCurrent currentAmps: Double,
3914
        chargingTransportMode: ChargingTransportMode,
3915
        charger: NSManagedObject?
3916
    ) -> Double {
3917
        switch chargingTransportMode {
3918
        case .wired:
3919
            return max(currentAmps, 0)
3920
        case .wireless:
3921
            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
3922
                return max(currentAmps, 0)
3923
            }
3924
            return max(currentAmps - idleCurrent, 0)
3925
        }
3926
    }
3927

            
3928
    private func hasObservedChargeFlow(
3929
        currentAmps: Double,
3930
        chargingTransportMode: ChargingTransportMode,
3931
        charger: NSManagedObject?,
3932
        stopThreshold: Double?
3933
    ) -> Bool {
3934
        let effectiveCurrent = effectiveCurrentAmps(
3935
            fromMeasuredCurrent: currentAmps,
3936
            chargingTransportMode: chargingTransportMode,
3937
            charger: charger
3938
        )
3939
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
3940
    }
3941

            
Bogdan Timofte authored a month ago
3942
    private func hasSavableChargeData(_ session: NSManagedObject) -> Bool {
Bogdan Timofte authored a month ago
3943
        if boolValue(session, key: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
3944
            || doubleValue(session, key: "measuredEnergyWh") > 0
3945
            || doubleValue(session, key: "measuredChargeAh") > 0
3946
            || (optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? 0) > 0
Bogdan Timofte authored a month ago
3947
            || (optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? 0) > 0 {
3948
            return true
3949
        }
3950

            
3951
        guard let sessionID = stringValue(session, key: "id") else {
3952
            return false
3953
        }
3954

            
3955
        return fetchSessionSampleObjects(forSessionID: sessionID).contains { sample in
3956
            doubleValue(sample, key: "measuredEnergyWh") > 0
3957
                || doubleValue(sample, key: "measuredChargeAh") > 0
3958
                || (optionalDoubleValue(sample, key: "averageCurrentAmps") ?? 0) > 0
3959
                || (optionalDoubleValue(sample, key: "averagePowerWatts") ?? 0) > 0
3960
        }
3961
    }
3962

            
3963
    private func restoreMeasuredTotalsFromLatestSampleIfNeeded(_ session: NSManagedObject) {
3964
        guard let sessionID = stringValue(session, key: "id"),
3965
              let latestSample = fetchSessionSampleObjects(forSessionID: sessionID).max(by: {
3966
                  (dateValue($0, key: "timestamp") ?? .distantPast) < (dateValue($1, key: "timestamp") ?? .distantPast)
3967
              }) else {
3968
            return
3969
        }
3970

            
3971
        let sampleEnergyWh = doubleValue(latestSample, key: "measuredEnergyWh")
3972
        if doubleValue(session, key: "measuredEnergyWh") <= 0, sampleEnergyWh > 0 {
3973
            session.setValue(sampleEnergyWh, forKey: "measuredEnergyWh")
3974
        }
3975

            
3976
        let sampleChargeAh = doubleValue(latestSample, key: "measuredChargeAh")
3977
        if doubleValue(session, key: "measuredChargeAh") <= 0, sampleChargeAh > 0 {
3978
            session.setValue(sampleChargeAh, forKey: "measuredChargeAh")
3979
        }
Bogdan Timofte authored a month ago
3980
    }
3981

            
Bogdan Timofte authored a month ago
3982
    private func derivedMinimumCurrent(
3983
        from sessions: [NSManagedObject],
3984
        chargingTransportMode: ChargingTransportMode
3985
    ) -> Double? {
3986
        let completionCurrents = sessions.compactMap { session -> Double? in
3987
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3988
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
3989
                return nil
3990
            }
Bogdan Timofte authored a month ago
3991
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3992
                return nil
3993
            }
Bogdan Timofte authored a month ago
3994
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
3995
                return nil
3996
            }
3997
            return completionCurrent
3998
        }
3999

            
4000
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
4001
        guard !recentCompletionCurrents.isEmpty else { return nil }
4002
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
4003
    }
4004

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

            
4008
        for session in sessions {
4009
            guard statusValue(session, key: "statusRawValue") == .completed else {
4010
                continue
4011
            }
4012
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
4013
                continue
4014
            }
4015
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
4016
                  completionCurrent > 0 else {
4017
                continue
4018
            }
4019

            
4020
            let sessionKind = ChargeSessionKind(
4021
                chargingTransportMode: chargingTransportMode(for: session),
4022
                chargingStateMode: chargingStateMode(for: session)
4023
            )
4024
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
4025
        }
4026

            
4027
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
4028
            let recentCurrents = Array(entry.value.suffix(5))
4029
            guard !recentCurrents.isEmpty else {
4030
                return
4031
            }
4032
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
4033
        }
4034
    }
4035

            
Bogdan Timofte authored a month ago
4036
    private func derivedCapacity(
4037
        from sessions: [NSManagedObject],
4038
        chargingTransportMode: ChargingTransportMode,
4039
        supportsChargingWhileOff: Bool
4040
    ) -> Double? {
4041
        let capacityCandidates = sessions.compactMap { session -> Double? in
4042
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
4043
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
4044
                return nil
4045
            }
4046
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
4047
                return nil
4048
            }
4049
            if supportsChargingWhileOff {
4050
                return capacityEstimate
4051
            }
4052
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
4053
                return nil
4054
            }
4055
            return capacityEstimate
4056
        }
4057

            
4058
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
4059
        guard !recentCapacityCandidates.isEmpty else { return nil }
4060
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
4061
    }
4062

            
4063
    private func derivedWirelessEfficiency(
4064
        from sessions: [NSManagedObject],
4065
        chargingProfile: WirelessChargingProfile
4066
    ) -> Double? {
4067
        guard chargingProfile == .magsafe else {
4068
            return nil
4069
        }
4070

            
4071
        let candidates = sessions.compactMap { session -> Double? in
4072
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
4073
            guard chargingTransportMode(for: session) == .wireless else { return nil }
4074
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
4075
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
4076
                return nil
4077
            }
4078
            return factor
4079
        }
4080

            
4081
        let recentCandidates = Array(candidates.suffix(6))
4082
        guard !recentCandidates.isEmpty else { return nil }
4083
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
4084
    }
4085

            
4086
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
4087
        let candidates = sessions.compactMap { session -> Double? in
4088
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
4089
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
4090
                return nil
4091
            }
4092
            return (sourceVoltage * 10).rounded() / 10
4093
        }
4094

            
4095
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
4096
        return counts.keys.sorted()
4097
    }
4098

            
4099
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
4100
        let candidates = sessions.compactMap { session -> Double? in
4101
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
4102
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
4103
                return nil
4104
            }
4105
            return minimumObservedCurrent
4106
        }
4107

            
4108
        let recentCandidates = Array(candidates.suffix(6))
4109
        guard !recentCandidates.isEmpty else { return nil }
4110
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
4111
    }
4112

            
4113
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
4114
        let candidates = sessions.compactMap { session -> Double? in
4115
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
4116
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
4117
                return nil
4118
            }
4119
            return factor
4120
        }
4121

            
4122
        let recentCandidates = Array(candidates.suffix(6))
4123
        guard !recentCandidates.isEmpty else { return nil }
4124
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
4125
    }
4126

            
4127
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
4128
        sessions.compactMap { session -> Double? in
4129
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
4130
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
4131
                return nil
4132
            }
4133
            return maximumObservedPower
4134
        }
4135
        .max()
4136
    }
4137

            
Bogdan Timofte authored a month ago
4138
    /// View-time derivation of powerbank metrics from materialized session summaries.
4139
    /// Mirrors the charger derivation pattern but works on `ChargeSessionSummary` (already
4140
    /// projected) so we don't need to re-fetch NSManagedObjects.
4141
    /// - voltage profile: groups source-side sessions by selected voltage palier (rounded
4142
    ///   to 0.5V) and tracks max current observed at each palier.
4143
    /// - max power: max across source-side sessions' `maximumObservedPowerWatts`.
4144
    /// - efficiency: ratio of total Wh delivered (as source) vs total Wh received (as subject).
4145
    ///   Computed only when both sides have non-trivial energy logged.
4146
    /// - apparent capacity: sum of source-side delivered Wh between the most recent two
4147
    ///   powerbank-side checkpoints with sufficient battery percent delta. Best-effort.
4148
    private func derivedPowerbankMetrics(
4149
        sessionsAsSubject: [ChargeSessionSummary],
4150
        sessionsAsSource: [ChargeSessionSummary],
4151
        reporting: BatteryLevelReporting
4152
    ) -> (
4153
        voltageMaxCurrents: [Double: Double],
4154
        maxPowerWatts: Double?,
4155
        efficiencyFactor: Double?,
4156
        apparentCapacityWh: Double?
4157
    ) {
4158
        var voltageMaxCurrents: [Double: Double] = [:]
4159
        var maxPower: Double? = nil
4160

            
4161
        for session in sessionsAsSource {
4162
            if let voltage = session.selectedSourceVoltageVolts, voltage > 0 {
4163
                let palier = (voltage * 2).rounded() / 2  // 0.5V buckets
4164
                if let maxCurrent = session.maximumObservedCurrentAmps, maxCurrent > 0 {
4165
                    let prev = voltageMaxCurrents[palier] ?? 0
4166
                    voltageMaxCurrents[palier] = max(prev, maxCurrent)
4167
                }
4168
            }
4169
            if let power = session.maximumObservedPowerWatts, power > 0 {
4170
                maxPower = max(maxPower ?? 0, power)
4171
            }
4172
        }
4173

            
4174
        let totalDelivered = sessionsAsSource.reduce(0.0) { $0 + $1.measuredEnergyWh }
4175
        let totalReceived = sessionsAsSubject.reduce(0.0) { $0 + $1.measuredEnergyWh }
4176
        let efficiency: Double? = (totalDelivered > 0.5 && totalReceived > 0.5)
4177
            ? totalDelivered / totalReceived
4178
            : nil
4179

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

            
4192
        let powerbankCheckpoints = (sessionsAsSource + sessionsAsSubject)
4193
            .flatMap { $0.checkpoints.filter { $0.subject == .powerbank } }
4194
            .sorted { $0.timestamp < $1.timestamp }
4195

            
4196
        switch reporting {
4197
        case .fullOnly:
4198
            let fullMarkers = powerbankCheckpoints.filter { $0.batteryPercent >= 99 }
4199
            if let lastFull = fullMarkers.last,
4200
               let prevFull = fullMarkers.dropLast().last {
4201
                let lower = prevFull.timestamp
4202
                let upper = lastFull.timestamp
4203
                let energyDelivered = sessionsAsSource
4204
                    .filter { $0.startedAt <= upper && ($0.endedAt ?? $0.lastObservedAt) >= lower }
4205
                    .reduce(0.0) { $0 + $1.measuredEnergyWh }
4206
                if energyDelivered > 0.1 {
4207
                    apparentCapacity = energyDelivered
4208
                }
4209
            }
4210
        case .percent, .bars:
4211
            if powerbankCheckpoints.count >= 2 {
4212
                let last = powerbankCheckpoints.last!
4213
                if let earlier = powerbankCheckpoints.first(where: {
4214
                    abs(last.batteryPercent - $0.batteryPercent) >= 30
4215
                }) {
4216
                    let lower = min(earlier.timestamp, last.timestamp)
4217
                    let upper = max(earlier.timestamp, last.timestamp)
4218
                    let energyDelivered = sessionsAsSource
4219
                        .filter { $0.startedAt <= upper && ($0.endedAt ?? $0.lastObservedAt) >= lower }
4220
                        .reduce(0.0) { $0 + $1.measuredEnergyWh }
4221
                    if energyDelivered > 0.1 {
4222
                        apparentCapacity = energyDelivered
4223
                    }
4224
                }
4225
            }
4226
        case .none:
4227
            break
4228
        }
4229

            
4230
        return (voltageMaxCurrents, maxPower, efficiency, apparentCapacity)
4231
    }
4232

            
Bogdan Timofte authored a month ago
4233
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
4234
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
4235
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
4236
            return resolvedPreferredChargingTransportMode(
4237
                chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired,
4238
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
4239
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
4240
            )
4241
        }
4242

            
4243
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
4244
            return persistedChargingTransportMode
Bogdan Timofte authored a month ago
4245
        }
4246

            
4247
        return .wired
4248
    }
4249

            
4250
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
4251
        if session.isInserted {
4252
            return .created
4253
        }
4254

            
4255
        let committedValues = session.committedValues(
4256
            forKeys: [
4257
                "statusRawValue",
4258
                "updatedAt",
4259
                "targetBatteryAlertTriggeredAt",
4260
                "requiresCompletionConfirmation"
4261
            ]
4262
        )
4263
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
4264
        let currentStatus = statusValue(session, key: "statusRawValue")
4265
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
4266
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
4267
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
4268
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
4269
            ?? false
4270
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
4271

            
4272
        if currentStatus == .completed, committedStatus != .completed {
4273
            return .completed
4274
        }
4275

            
Bogdan Timofte authored a month ago
4276
        if currentStatus != committedStatus {
4277
            return .event
4278
        }
4279

            
Bogdan Timofte authored a month ago
4280
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
4281
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
4282
            return .event
4283
        }
4284

            
4285
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
4286
            ?? dateValue(session, key: "createdAt")
4287
            ?? observedAt
4288

            
4289
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
4290
            return .periodic
4291
        }
4292

            
4293
        return .none
4294
    }
4295

            
Bogdan Timofte authored a month ago
4296
    private func shouldPersistAggregatedSample(
4297
        _ sample: NSManagedObject,
4298
        observedAt: Date
4299
    ) -> Bool {
4300
        if sample.isInserted {
4301
            return true
4302
        }
4303

            
4304
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
4305
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
4306
            ?? dateValue(sample, key: "createdAt")
4307
            ?? observedAt
4308

            
4309
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
4310
    }
4311

            
Bogdan Timofte authored a month ago
4312
    private func generateQRIdentifier() -> String {
4313
        "device:\(UUID().uuidString)"
4314
    }
4315

            
4316
    @discardableResult
4317
    private func saveContext() -> Bool {
4318
        guard context.hasChanges else { return true }
4319
        do {
4320
            try context.save()
4321
            return true
4322
        } catch {
4323
            track("Failed saving charge insights context: \(error)")
4324
            context.rollback()
4325
            return false
4326
        }
4327
    }
4328

            
4329
    private func normalizedText(_ text: String) -> String {
4330
        text.trimmingCharacters(in: .whitespacesAndNewlines)
4331
    }
4332

            
4333
    private func normalizedOptionalText(_ text: String?) -> String? {
4334
        guard let text else { return nil }
4335
        let normalized = normalizedText(text)
4336
        return normalized.isEmpty ? nil : normalized
4337
    }
4338

            
4339
    private func normalizedMACAddress(_ macAddress: String) -> String {
4340
        normalizedText(macAddress).uppercased()
4341
    }
4342

            
Bogdan Timofte authored a month ago
4343
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
4344
        guard object.entity.propertiesByName[key] != nil else {
4345
            return nil
4346
        }
4347
        return object.value(forKey: key)
4348
    }
4349

            
4350
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
4351
        guard object.entity.propertiesByName[key] != nil else {
4352
            return
4353
        }
4354
        object.setValue(value, forKey: key)
4355
    }
4356

            
Bogdan Timofte authored a month ago
4357
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
4358
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
4359
        let normalized = normalizedOptionalText(value)
4360
        return normalized
4361
    }
4362

            
4363
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
4364
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
4365
    }
4366

            
4367
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
4368
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
4369
            return value
4370
        }
Bogdan Timofte authored a month ago
4371
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
4372
            return value.doubleValue
4373
        }
4374
        return 0
4375
    }
4376

            
4377
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
4378
        let value = rawValue(object, key: key)
4379
        if value == nil {
Bogdan Timofte authored a month ago
4380
            return nil
4381
        }
4382
        return doubleValue(object, key: key)
4383
    }
4384

            
4385
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
Bogdan Timofte authored a month ago
4386
        if let value = rawValue(object, key: key) as? Int16 {
Bogdan Timofte authored a month ago
4387
            return value
4388
        }
Bogdan Timofte authored a month ago
4389
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
4390
            return value.int16Value
4391
        }
4392
        return nil
4393
    }
4394

            
4395
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
4396
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
4397
            return value
4398
        }
Bogdan Timofte authored a month ago
4399
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
4400
            return value.int32Value
4401
        }
4402
        return nil
4403
    }
4404

            
4405
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
Bogdan Timofte authored a month ago
4406
        if let value = rawValue(object, key: key) as? Bool {
Bogdan Timofte authored a month ago
4407
            return value
4408
        }
Bogdan Timofte authored a month ago
4409
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
4410
            return value.boolValue
4411
        }
4412
        return false
4413
    }
4414

            
4415
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
4416
        guard let value = stringValue(object, key: key) else { return nil }
4417
        return UUID(uuidString: value)
4418
    }
4419

            
4420
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
4421
        guard let value = stringValue(object, key: key) else { return nil }
4422
        return ChargeSessionStatus(rawValue: value)
4423
    }
4424

            
4425
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
4426
        guard let value = stringValue(object, key: key) else { return nil }
4427
        return ChargingTransportMode(rawValue: value)
4428
    }
4429

            
4430
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
4431
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
4432
            return []
4433
        }
4434
        return rawValue
4435
            .split(separator: ",")
4436
            .compactMap { Double($0) }
4437
            .sorted()
4438
    }
4439

            
4440
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
4441
        let uniqueVoltages = Array(Set(voltages)).sorted()
4442
        guard !uniqueVoltages.isEmpty else {
4443
            return nil
4444
        }
4445
        return uniqueVoltages
4446
            .map { String(format: "%.1f", $0) }
4447
            .joined(separator: ",")
4448
    }
4449

            
4450
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
4451
        guard currentCount > 0 else {
4452
            return newValue
4453
        }
4454
        let total = (currentAverage * Double(currentCount)) + newValue
4455
        return total / Double(currentCount + 1)
4456
    }
4457
}
4458

            
4459
private enum ObservationSaveReason {
4460
    case none
4461
    case created
4462
    case periodic
4463
    case completed
4464
    case event
4465
}