USB-Meter / USB Meter / Model / ChargeInsightsStore.swift
Newer Older
4359 lines | 195.868kb
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

            
2318
        struct Anchor {
2319
            let percent: Double
2320
            let energyWh: Double
Bogdan Timofte authored a month ago
2321
            let timestamp: Date
2322
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
2323
        }
2324

            
Bogdan Timofte authored a month ago
2325
        func anchorCapacityCandidates(from anchors: [Anchor]) -> [Double] {
2326
            var candidates: [Double] = []
2327

            
2328
            for lowerIndex in anchors.indices {
2329
                for upperIndex in anchors.indices where upperIndex > lowerIndex {
2330
                    let lower = anchors[lowerIndex]
2331
                    let upper = anchors[upperIndex]
2332
                    let percentDelta = upper.percent - lower.percent
2333
                    let energyDelta = upper.energyWh - lower.energyWh
2334

            
2335
                    guard percentDelta >= 3, energyDelta > 0.01 else {
2336
                        continue
2337
                    }
2338

            
2339
                    let capacityWh = energyDelta / (percentDelta / 100)
2340
                    guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
2341
                        continue
2342
                    }
2343

            
2344
                    candidates.append(capacityWh)
2345
                }
2346
            }
2347

            
2348
            return candidates
2349
        }
2350

            
2351
        func inferredCheckpointEnergyMapCapacityWh(from anchors: [Anchor]) -> Double? {
2352
            let candidates = anchorCapacityCandidates(from: anchors)
2353
            guard !candidates.isEmpty else {
2354
                return nil
2355
            }
2356

            
2357
            let sortedCandidates = candidates.sorted()
2358
            return sortedCandidates[sortedCandidates.count / 2]
2359
        }
2360

            
Bogdan Timofte authored a month ago
2361
        var anchors: [Anchor] = []
Bogdan Timofte authored a month ago
2362
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2363
           startBatteryPercent >= 0 {
2364
            anchors.append(
2365
                Anchor(
2366
                    percent: startBatteryPercent,
2367
                    energyWh: 0,
Bogdan Timofte authored a month ago
2368
                    timestamp: dateValue(session, key: "trimStart")
2369
                        ?? dateValue(session, key: "startedAt")
2370
                        ?? Date.distantPast,
Bogdan Timofte authored a month ago
2371
                    isCheckpoint: false
2372
                )
2373
            )
Bogdan Timofte authored a month ago
2374
        }
2375

            
2376
        let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
2377
            .compactMap(makeCheckpointSummary(from:))
2378
            .sorted { lhs, rhs in
2379
                if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
2380
                    return lhs.measuredEnergyWh < rhs.measuredEnergyWh
2381
                }
2382
                return lhs.timestamp < rhs.timestamp
2383
            }
Bogdan Timofte authored a month ago
2384
            .filter { $0.batteryPercent >= 0 }
2385
            .map {
2386
                Anchor(
2387
                    percent: $0.batteryPercent,
2388
                    energyWh: $0.measuredEnergyWh,
2389
                    timestamp: $0.timestamp,
2390
                    isCheckpoint: true
2391
                )
2392
            }
Bogdan Timofte authored a month ago
2393
        anchors.append(contentsOf: checkpointAnchors)
2394

            
Bogdan Timofte authored a month ago
2395
        let sortedAnchors = anchors.sorted { lhs, rhs in
2396
            if lhs.energyWh != rhs.energyWh {
2397
                return lhs.energyWh < rhs.energyWh
2398
            }
2399
            return lhs.timestamp < rhs.timestamp
2400
        }
2401

            
2402
        guard !sortedAnchors.isEmpty else {
Bogdan Timofte authored a month ago
2403
            return optionalDoubleValue(session, key: "endBatteryPercent")
2404
        }
2405

            
Bogdan Timofte authored a month ago
2406
        let inferredCapacityWh = estimatedCapacityWh
2407
            ?? (canUseCheckpointEnergyMap ? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors) : nil)
2408
        let lowerAnchor = sortedAnchors.last { $0.energyWh <= measuredEnergyWh + 0.05 }
2409
        let upperAnchor = sortedAnchors.first { $0.energyWh > measuredEnergyWh + 0.05 }
2410
        let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!
Bogdan Timofte authored a month ago
2411

            
2412
        if let lowerAnchor,
2413
           let upperAnchor,
2414
           upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
2415
            let interpolationProgress = min(
2416
                max(
2417
                    (measuredEnergyWh - lowerAnchor.energyWh) /
2418
                    (upperAnchor.energyWh - lowerAnchor.energyWh),
2419
                    0
2420
                ),
2421
                1
2422
            )
2423
            return min(
2424
                max(
2425
                    lowerAnchor.percent +
2426
                    (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
2427
                    0
2428
                ),
2429
                100
2430
            )
2431
        }
2432

            
Bogdan Timofte authored a month ago
2433
        guard let inferredCapacityWh, inferredCapacityWh > 0 else {
2434
            return nil
2435
        }
2436

            
Bogdan Timofte authored a month ago
2437
        return BatteryLevelPredictionTuning.predictedPercent(
2438
            anchorPercent: anchor.percent,
2439
            anchorEnergyWh: anchor.energyWh,
2440
            anchorTimestamp: anchor.timestamp,
2441
            anchorIsCheckpoint: anchor.isCheckpoint,
2442
            effectiveEnergyWh: measuredEnergyWh,
Bogdan Timofte authored a month ago
2443
            referenceTimestamp: referenceTimestamp
2444
                ?? dateValue(session, key: "lastObservedAt")
2445
                ?? anchor.timestamp,
Bogdan Timofte authored a month ago
2446
            estimatedCapacityWh: inferredCapacityWh
Bogdan Timofte authored a month ago
2447
        )
2448
    }
2449

            
Bogdan Timofte authored a month ago
2450
    private func effectiveBatteryEnergyWh(
2451
        rawMeasuredEnergyWh: Double,
2452
        for session: NSManagedObject
2453
    ) -> Double {
2454
        switch chargingTransportMode(for: session) {
2455
        case .wired:
2456
            return rawMeasuredEnergyWh
2457
        case .wireless:
2458
            if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 {
2459
                return rawMeasuredEnergyWh * factor
2460
            }
2461
            let sessionMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2462
            if let sessionEffectiveEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh"),
2463
               sessionMeasuredEnergyWh > 0 {
2464
                return rawMeasuredEnergyWh * (sessionEffectiveEnergyWh / sessionMeasuredEnergyWh)
2465
            }
2466
            return rawMeasuredEnergyWh
2467
        }
2468
    }
2469

            
2470
    private func refreshEstimatedBatteryPercents(for session: NSManagedObject) {
2471
        guard let sessionID = stringValue(session, key: "id") else {
2472
            return
2473
        }
2474

            
2475
        for sample in fetchSessionSampleObjects(forSessionID: sessionID) {
2476
            let effectiveEnergyWh = effectiveBatteryEnergyWh(
2477
                rawMeasuredEnergyWh: doubleValue(sample, key: "measuredEnergyWh"),
2478
                for: session
2479
            )
2480
            let percent = predictedBatteryPercent(
2481
                for: session,
2482
                effectiveEnergyWhOverride: effectiveEnergyWh,
2483
                referenceTimestamp: dateValue(sample, key: "timestamp")
2484
            )
2485
            setValue(percent, on: sample, key: "estimatedBatteryPercent")
2486
            setValue(Date(), on: sample, key: "updatedAt")
2487
        }
2488
    }
2489

            
Bogdan Timofte authored a month ago
2490
    private func resolvedEstimatedBatteryCapacityWh(
2491
        for session: NSManagedObject,
2492
        chargedDevice: NSManagedObject
2493
    ) -> Double? {
2494
        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
2495
           sessionCapacityEstimate > 0 {
2496
            return sessionCapacityEstimate
2497
        }
2498

            
2499
        switch chargingTransportMode(for: session) {
2500
        case .wired:
2501
            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
2502
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2503
        case .wireless:
2504
            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
2505
                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2506
        }
2507
    }
2508

            
2509
    private func updateCapacityEstimate(for session: NSManagedObject) {
2510
        guard
2511
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2512
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
2513
        else {
2514
            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
2515
            session.setValue(nil, forKey: "capacityEstimateWh")
2516
            return
2517
        }
2518

            
2519
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2520
        let chargingMode = chargingTransportMode(for: session)
2521
        let wirelessResolution = chargingMode == .wireless
2522
            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
2523
            : nil
2524
        let effectiveBatteryEnergyWh = chargingMode == .wired
2525
            ? measuredEnergyWh
2526
            : wirelessResolution.map { measuredEnergyWh * $0.factor }
2527

            
2528
        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
2529
        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
2530
        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
2531
        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
2532

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

            
2535
        guard let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
Bogdan Timofte authored a month ago
2536
            session.setValue(nil, forKey: "capacityEstimateWh")
2537
            return
2538
        }
2539

            
Bogdan Timofte authored a month ago
2540
        struct CapacityAnchor {
2541
            let percent: Double
2542
            let energyWh: Double
2543
            let timestamp: Date
2544
        }
2545

            
2546
        var anchors: [CapacityAnchor] = []
2547

            
2548
        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2549
           startBatteryPercent >= 0 {
2550
            anchors.append(
2551
                CapacityAnchor(
2552
                    percent: startBatteryPercent,
2553
                    energyWh: 0,
2554
                    timestamp: dateValue(session, key: "trimStart")
2555
                        ?? dateValue(session, key: "startedAt")
2556
                        ?? Date.distantPast
2557
                )
2558
            )
2559
        }
2560

            
2561
        if let sessionID = stringValue(session, key: "id") {
2562
            anchors.append(
2563
                contentsOf: fetchCheckpointObjects(forSessionID: sessionID).compactMap { checkpoint in
2564
                    guard
2565
                        let percent = optionalDoubleValue(checkpoint, key: "batteryPercent"),
2566
                        percent >= 0,
2567
                        let timestamp = dateValue(checkpoint, key: "timestamp")
2568
                    else {
2569
                        return nil
2570
                    }
2571

            
2572
                    return CapacityAnchor(
2573
                        percent: percent,
2574
                        energyWh: doubleValue(checkpoint, key: "measuredEnergyWh"),
2575
                        timestamp: timestamp
2576
                    )
2577
                }
2578
            )
2579
        }
2580

            
2581
        if let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"),
2582
           endBatteryPercent >= 0 {
2583
            anchors.append(
2584
                CapacityAnchor(
2585
                    percent: endBatteryPercent,
2586
                    energyWh: effectiveBatteryEnergyWh,
2587
                    timestamp: dateValue(session, key: "endedAt")
2588
                        ?? dateValue(session, key: "lastObservedAt")
2589
                        ?? Date.distantPast
2590
                )
2591
            )
2592
        }
2593

            
2594
        let sortedAnchors = anchors.sorted { lhs, rhs in
2595
            if lhs.energyWh != rhs.energyWh {
2596
                return lhs.energyWh < rhs.energyWh
2597
            }
2598
            return lhs.timestamp < rhs.timestamp
2599
        }
2600

            
2601
        guard let firstAnchor = sortedAnchors.first,
2602
              let lastAnchor = sortedAnchors.last else {
Bogdan Timofte authored a month ago
2603
            session.setValue(nil, forKey: "capacityEstimateWh")
2604
            return
2605
        }
2606

            
Bogdan Timofte authored a month ago
2607
        let percentDelta = lastAnchor.percent - firstAnchor.percent
2608
        let energyDelta = lastAnchor.energyWh - firstAnchor.energyWh
Bogdan Timofte authored a month ago
2609

            
Bogdan Timofte authored a month ago
2610
        guard percentDelta >= 20, energyDelta > 0 else {
Bogdan Timofte authored a month ago
2611
            session.setValue(nil, forKey: "capacityEstimateWh")
2612
            return
2613
        }
2614

            
Bogdan Timofte authored a month ago
2615
        if !supportsChargingWhileOff && lastAnchor.percent >= 99.5 {
Bogdan Timofte authored a month ago
2616
            session.setValue(nil, forKey: "capacityEstimateWh")
2617
            return
2618
        }
2619

            
Bogdan Timofte authored a month ago
2620
        let capacityEstimateWh = energyDelta / (percentDelta / 100)
Bogdan Timofte authored a month ago
2621
        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
2622
    }
2623

            
2624
    @discardableResult
Bogdan Timofte authored a month ago
2625
    private func insertBatteryCheckpoint(
Bogdan Timofte authored a month ago
2626
        percent: Double,
Bogdan Timofte authored a month ago
2627
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
2628
        timestamp: Date = Date(),
Bogdan Timofte authored a month ago
2629
        measuredEnergyWhOverride: Double? = nil,
Bogdan Timofte authored a month ago
2630
        subject: CheckpointSubject = .chargedDevice,
2631
        barsValue: Int = 0,
Bogdan Timofte authored a month ago
2632
        to session: NSManagedObject
Bogdan Timofte authored a month ago
2633
    ) -> String? {
Bogdan Timofte authored a month ago
2634
        guard
2635
            let sessionID = stringValue(session, key: "id"),
2636
            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2637
            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
2638
        else {
Bogdan Timofte authored a month ago
2639
            return nil
Bogdan Timofte authored a month ago
2640
        }
2641

            
2642
        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
Bogdan Timofte authored a month ago
2643
        let checkpointEnergyWh = measuredEnergyWhOverride
2644
            ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
Bogdan Timofte authored a month ago
2645
            ?? doubleValue(session, key: "measuredEnergyWh")
2646
        checkpoint.setValue(UUID().uuidString, forKey: "id")
2647
        checkpoint.setValue(sessionID, forKey: "sessionID")
Bogdan Timofte authored a month ago
2648
        switch subject {
2649
        case .chargedDevice:
2650
            checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
2651
            checkpoint.setValue(nil, forKey: "powerbankID")
2652
        case .powerbank:
2653
            // Powerbank-side checkpoint: link to the powerbank source instead. ChargedDeviceID
2654
            // stays nil so device capacity learning ignores it; the session backref is via sessionID.
2655
            let powerbankID = stringValue(session, key: "sourcePowerbankID")
2656
            checkpoint.setValue(nil, forKey: "chargedDeviceID")
2657
            checkpoint.setValue(powerbankID, forKey: "powerbankID")
2658
        }
2659
        checkpoint.setValue(Int16(max(0, barsValue)), forKey: "batteryBarsValue")
Bogdan Timofte authored a month ago
2660
        checkpoint.setValue(timestamp, forKey: "timestamp")
Bogdan Timofte authored a month ago
2661
        checkpoint.setValue(percent, forKey: "batteryPercent")
2662
        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
2663
        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
2664
        checkpoint.setValue(
2665
            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
2666
            forKey: "voltageVolts"
2667
        )
Bogdan Timofte authored a month ago
2668
        checkpoint.setValue(flag.rawValue, forKey: "label")
Bogdan Timofte authored a month ago
2669
        checkpoint.setValue(timestamp, forKey: "createdAt")
Bogdan Timofte authored a month ago
2670

            
Bogdan Timofte authored a month ago
2671
        // Session start/end battery percent fields track the device subject only.
2672
        if subject == .chargedDevice {
2673
            let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
2674
            if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
2675
                session.setValue(percent, forKey: "startBatteryPercent")
2676
            }
2677
            if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
2678
                session.setValue(percent, forKey: "endBatteryPercent")
2679
            }
Bogdan Timofte authored a month ago
2680
        }
Bogdan Timofte authored a month ago
2681
        session.setValue(timestamp, forKey: "updatedAt")
Bogdan Timofte authored a month ago
2682
        updateCapacityEstimate(for: session)
Bogdan Timofte authored a month ago
2683
        refreshEstimatedBatteryPercents(for: session)
Bogdan Timofte authored a month ago
2684

            
Bogdan Timofte authored a month ago
2685
        return chargedDeviceID
2686
    }
2687

            
Bogdan Timofte authored a month ago
2688
    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
2689
        guard let sessionID = stringValue(session, key: "id") else {
2690
            return
2691
        }
2692

            
2693
        let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
2694
        if let latestCheckpoint = remainingCheckpoints.last {
2695
            session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
2696
        } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2697
                  startBatteryPercent >= 0 {
2698
            session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
2699
        } else {
2700
            session.setValue(nil, forKey: "endBatteryPercent")
2701
        }
2702

            
2703
        session.setValue(Date(), forKey: "updatedAt")
2704
        updateCapacityEstimate(for: session)
Bogdan Timofte authored a month ago
2705
        refreshEstimatedBatteryPercents(for: session)
Bogdan Timofte authored a month ago
2706
    }
2707

            
Bogdan Timofte authored a month ago
2708
    @discardableResult
2709
    private func addBatteryCheckpoint(
2710
        percent: Double,
Bogdan Timofte authored a month ago
2711
        measuredEnergyWh: Double? = nil,
Bogdan Timofte authored a month ago
2712
        flag: ChargeCheckpointFlag,
Bogdan Timofte authored a month ago
2713
        subject: CheckpointSubject = .chargedDevice,
2714
        barsValue: Int = 0,
Bogdan Timofte authored a month ago
2715
        to session: NSManagedObject,
2716
        timestamp: Date = Date()
2717
    ) -> Bool {
Bogdan Timofte authored a month ago
2718
        if let measuredEnergyWh, measuredEnergyWh.isFinite {
2719
            session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
2720
        }
2721

            
Bogdan Timofte authored a month ago
2722
        guard let chargedDeviceID = insertBatteryCheckpoint(
2723
            percent: percent,
Bogdan Timofte authored a month ago
2724
            flag: flag,
Bogdan Timofte authored a month ago
2725
            timestamp: timestamp,
Bogdan Timofte authored a month ago
2726
            measuredEnergyWhOverride: measuredEnergyWh,
Bogdan Timofte authored a month ago
2727
            subject: subject,
2728
            barsValue: barsValue,
Bogdan Timofte authored a month ago
2729
            to: session
2730
        ) else {
2731
            return false
2732
        }
2733

            
Bogdan Timofte authored a month ago
2734
        guard saveContext() else {
2735
            return false
2736
        }
2737

            
Bogdan Timofte authored a month ago
2738
        // Device-subject checkpoints feed device-side capacity learning. Powerbank-subject
2739
        // checkpoints feed powerbank-side derivation, which is computed at materialization time
2740
        // (see PowerbankSummary fetch path).
2741
        if subject == .chargedDevice {
2742
            refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
2743
        }
Bogdan Timofte authored a month ago
2744
        return saveContext()
2745
    }
2746

            
2747
    private func resolvedWirelessEfficiency(
2748
        for session: NSManagedObject,
2749
        chargedDevice: NSManagedObject
2750
    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
2751
        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
2752
           storedFactor > 0 {
2753
            return (
2754
                factor: storedFactor,
2755
                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
2756
                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
2757
            )
2758
        }
2759

            
2760
        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
2761
        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
2762
        guard measuredEnergyWh > 0 else {
2763
            return nil
2764
        }
2765

            
2766
        if chargingProfile == .magsafe,
2767
           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
2768
           calibratedFactor > 0 {
2769
            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
2770
        }
2771

            
2772
        guard
2773
            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
2774
            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
2775
        else {
2776
            return nil
2777
        }
2778

            
2779
        let percentDelta = endBatteryPercent - startBatteryPercent
2780
        guard percentDelta >= 20 else {
2781
            return nil
2782
        }
2783

            
2784
        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
Bogdan Timofte authored a month ago
2785
            ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
Bogdan Timofte authored a month ago
2786
                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
2787
                : nil),
2788
              wiredCapacityWh > 0
2789
        else {
2790
            return nil
2791
        }
2792

            
2793
        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
2794
        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
2795
        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
2796
        let usesEstimated = chargingProfile != .magsafe
2797
        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
2798

            
2799
        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
2800
    }
2801

            
2802
    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
2803
        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
2804
            return
2805
        }
2806

            
Bogdan Timofte authored a month ago
2807
        let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
Bogdan Timofte authored a month ago
2808
        let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
Bogdan Timofte authored a month ago
2809
        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
2810
        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
2811
        let sessions = relevantSessionObjects(
2812
            for: chargedDeviceID,
2813
            deviceClass: deviceClass,
2814
            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
2815
            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
2816
        )
Bogdan Timofte authored a month ago
2817
        let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions)
Bogdan Timofte authored a month ago
2818
        let wiredMinimumCurrent = derivedMinimumCurrent(
2819
            from: sessions,
2820
            chargingTransportMode: .wired
2821
        )
2822
        let wirelessMinimumCurrent = derivedMinimumCurrent(
2823
            from: sessions,
2824
            chargingTransportMode: .wireless
2825
        )
2826

            
2827
        let wiredCapacity = derivedCapacity(
2828
            from: sessions,
2829
            chargingTransportMode: .wired,
2830
            supportsChargingWhileOff: supportsChargingWhileOff
2831
        )
2832
        let wirelessCapacity = derivedCapacity(
2833
            from: sessions,
2834
            chargingTransportMode: .wireless,
2835
            supportsChargingWhileOff: supportsChargingWhileOff
2836
        )
2837
        let wirelessEfficiency = derivedWirelessEfficiency(
2838
            from: sessions,
2839
            chargingProfile: wirelessProfile
2840
        )
Bogdan Timofte authored a month ago
2841
        let configuredCompletionCurrents = decodedCompletionCurrents(
2842
            from: chargedDevice,
2843
            key: "configuredCompletionCurrentsRawValue"
2844
        )
Bogdan Timofte authored a month ago
2845
        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
2846
        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
2847
        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
2848
        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
2849
        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
2850
        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
2851

            
Bogdan Timofte authored a month ago
2852
        let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice)
Bogdan Timofte authored a month ago
2853
        let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
Bogdan Timofte authored a month ago
2854
        let preferredMinimumCurrent: Double?
2855
        let preferredCapacity: Double?
2856
        switch preferredChargingTransportMode {
2857
        case .wired:
Bogdan Timofte authored a month ago
2858
            preferredMinimumCurrent = configuredCompletionCurrents[
2859
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2860
            ] ?? learnedCompletionCurrents[
2861
                ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode)
2862
            ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
Bogdan Timofte authored a month ago
2863
            preferredCapacity = wiredCapacity ?? wirelessCapacity
2864
        case .wireless:
Bogdan Timofte authored a month ago
2865
            preferredMinimumCurrent = configuredCompletionCurrents[
2866
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2867
            ] ?? learnedCompletionCurrents[
2868
                ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode)
2869
            ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
Bogdan Timofte authored a month ago
2870
            preferredCapacity = wirelessCapacity ?? wiredCapacity
2871
        }
2872

            
Bogdan Timofte authored a month ago
2873
        setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
2874
        setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
2875
        setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
2876
        setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
2877
        setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
2878
        setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
2879
        setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
2880
        setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
2881
        setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
2882
        setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
2883
        setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
2884
        setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
2885
        setValue(Date(), on: chargedDevice, key: "updatedAt")
Bogdan Timofte authored a month ago
2886
    }
2887

            
2888
    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
2889
        sessions
2890
            .filter { $0.status == .completed }
2891
            .compactMap { session in
2892
                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
2893
                let timestamp = session.endedAt ?? session.lastObservedAt
2894
                return CapacityTrendPoint(
2895
                    sessionID: session.id,
2896
                    timestamp: timestamp,
2897
                    capacityWh: capacityEstimateWh,
2898
                    chargingTransportMode: session.chargingTransportMode
2899
                )
2900
            }
2901
            .sorted { $0.timestamp < $1.timestamp }
2902
    }
2903

            
2904
    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
2905
        var groupedEnergyByBin: [Int: [Double]] = [:]
2906

            
2907
        for session in sessions where session.status == .completed {
Bogdan Timofte authored a month ago
2908
            let anchors = normalizedTypicalCurveAnchors(for: session)
2909
            guard anchors.count >= 2 else {
2910
                continue
Bogdan Timofte authored a month ago
2911
            }
2912

            
Bogdan Timofte authored a month ago
2913
            for percentBin in stride(from: 0, through: 100, by: 10) {
Bogdan Timofte authored a month ago
2914
                guard let energyWh = interpolatedTypicalCurvePoint(
Bogdan Timofte authored a month ago
2915
                    for: Double(percentBin),
2916
                    anchors: anchors
2917
                ) else {
2918
                    continue
2919
                }
Bogdan Timofte authored a month ago
2920

            
Bogdan Timofte authored a month ago
2921
                groupedEnergyByBin[percentBin, default: []].append(energyWh)
Bogdan Timofte authored a month ago
2922
            }
2923
        }
2924

            
Bogdan Timofte authored a month ago
2925
        let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
Bogdan Timofte authored a month ago
2926
            guard let energies = groupedEnergyByBin[percentBin], !energies.isEmpty else {
Bogdan Timofte authored a month ago
2927
                return nil
2928
            }
2929

            
2930
            return TypicalChargeCurvePoint(
2931
                percentBin: percentBin,
2932
                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
Bogdan Timofte authored a month ago
2933
                sampleCount: energies.count
Bogdan Timofte authored a month ago
2934
            )
2935
        }
Bogdan Timofte authored a month ago
2936

            
2937
        var runningMaximumEnergyWh = 0.0
2938

            
2939
        return averagedPoints.map { point in
2940
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
2941
            return TypicalChargeCurvePoint(
2942
                percentBin: point.percentBin,
2943
                averageEnergyWh: runningMaximumEnergyWh,
2944
                sampleCount: point.sampleCount
2945
            )
2946
        }
2947
    }
2948

            
2949
    private func normalizedTypicalCurveAnchors(
2950
        for session: ChargeSessionSummary
Bogdan Timofte authored a month ago
2951
    ) -> [(percent: Double, energyWh: Double)] {
Bogdan Timofte authored a month ago
2952
        struct Anchor {
2953
            let percent: Double
2954
            let energyWh: Double
2955
            let timestamp: Date
2956
        }
2957

            
2958
        var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
2959
            guard checkpoint.batteryPercent.isFinite,
2960
                  checkpoint.measuredEnergyWh.isFinite,
2961
                  checkpoint.batteryPercent >= 0,
2962
                  checkpoint.batteryPercent <= 100,
Bogdan Timofte authored a month ago
2963
                  checkpoint.measuredEnergyWh >= 0 else {
Bogdan Timofte authored a month ago
2964
                return nil
2965
            }
2966

            
2967
            return Anchor(
2968
                percent: checkpoint.batteryPercent,
2969
                energyWh: checkpoint.measuredEnergyWh,
2970
                timestamp: checkpoint.timestamp
2971
            )
2972
        }
2973

            
2974
        if let startBatteryPercent = session.startBatteryPercent,
2975
           startBatteryPercent.isFinite,
2976
           startBatteryPercent >= 0,
2977
           startBatteryPercent <= 100 {
2978
            anchors.append(
2979
                Anchor(
2980
                    percent: startBatteryPercent,
2981
                    energyWh: 0,
2982
                    timestamp: session.startedAt
2983
                )
2984
            )
2985
        }
2986

            
2987
        if let endBatteryPercent = session.endBatteryPercent,
2988
           endBatteryPercent.isFinite,
2989
           endBatteryPercent >= 0,
2990
           endBatteryPercent <= 100 {
2991
            anchors.append(
2992
                Anchor(
2993
                    percent: endBatteryPercent,
2994
                    energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
2995
                    timestamp: session.endedAt ?? session.lastObservedAt
2996
                )
2997
            )
2998
        }
2999

            
3000
        let sortedAnchors = anchors.sorted { lhs, rhs in
3001
            if lhs.percent != rhs.percent {
3002
                return lhs.percent < rhs.percent
3003
            }
3004
            if lhs.energyWh != rhs.energyWh {
3005
                return lhs.energyWh < rhs.energyWh
3006
            }
3007
            return lhs.timestamp < rhs.timestamp
3008
        }
3009

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

            
3012
        for anchor in sortedAnchors {
3013
            if let lastIndex = collapsedAnchors.indices.last,
3014
               abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
3015
                collapsedAnchors[lastIndex] = (
3016
                    percent: collapsedAnchors[lastIndex].percent,
Bogdan Timofte authored a month ago
3017
                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh)
Bogdan Timofte authored a month ago
3018
                )
3019
            } else {
3020
                collapsedAnchors.append(
Bogdan Timofte authored a month ago
3021
                    (percent: anchor.percent, energyWh: anchor.energyWh)
Bogdan Timofte authored a month ago
3022
                )
3023
            }
3024
        }
3025

            
3026
        var runningMaximumEnergyWh = 0.0
3027

            
3028
        return collapsedAnchors.map { anchor in
3029
            runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
3030
            return (
3031
                percent: anchor.percent,
Bogdan Timofte authored a month ago
3032
                energyWh: runningMaximumEnergyWh
Bogdan Timofte authored a month ago
3033
            )
3034
        }
3035
    }
3036

            
3037
    private func interpolatedTypicalCurvePoint(
3038
        for percent: Double,
Bogdan Timofte authored a month ago
3039
        anchors: [(percent: Double, energyWh: Double)]
3040
    ) -> Double? {
Bogdan Timofte authored a month ago
3041
        guard
3042
            let firstAnchor = anchors.first,
3043
            let lastAnchor = anchors.last,
3044
            percent >= firstAnchor.percent,
3045
            percent <= lastAnchor.percent
3046
        else {
3047
            return nil
3048
        }
3049

            
3050
        if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
Bogdan Timofte authored a month ago
3051
            return exactAnchor.energyWh
Bogdan Timofte authored a month ago
3052
        }
3053

            
3054
        guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
3055
              upperIndex > 0 else {
3056
            return nil
3057
        }
3058

            
3059
        let lowerAnchor = anchors[upperIndex - 1]
3060
        let upperAnchor = anchors[upperIndex]
3061
        let span = upperAnchor.percent - lowerAnchor.percent
3062
        guard span > 0.000_1 else {
3063
            return nil
3064
        }
3065

            
3066
        let ratio = (percent - lowerAnchor.percent) / span
Bogdan Timofte authored a month ago
3067
        return lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
Bogdan Timofte authored a month ago
3068
    }
3069

            
3070
    private func makeSessionSummary(
3071
        from object: NSManagedObject,
3072
        checkpoints: [NSManagedObject],
3073
        samples: [NSManagedObject]
3074
    ) -> ChargeSessionSummary? {
3075
        let chargingTransportMode = chargingTransportMode(for: object)
3076

            
3077
        guard
3078
            let id = uuidValue(object, key: "id"),
3079
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
3080
            let startedAt = dateValue(object, key: "startedAt"),
3081
            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
3082
            let status = statusValue(object, key: "statusRawValue"),
3083
            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
3084
        else {
3085
            return nil
3086
        }
3087

            
3088
        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
3089
            .sorted { $0.timestamp < $1.timestamp }
3090
        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
3091
            .sorted { lhs, rhs in
3092
                if lhs.bucketIndex != rhs.bucketIndex {
3093
                    return lhs.bucketIndex < rhs.bucketIndex
3094
                }
3095
                return lhs.timestamp < rhs.timestamp
3096
            }
3097

            
3098
        return ChargeSessionSummary(
3099
            id: id,
3100
            chargedDeviceID: chargedDeviceID,
Bogdan Timofte authored a month ago
3101
            chargedPowerbankID: uuidValue(object, key: "chargedPowerbankID"),
Bogdan Timofte authored a month ago
3102
            chargerID: uuidValue(object, key: "chargerID"),
Bogdan Timofte authored a month ago
3103
            sourcePowerbankID: uuidValue(object, key: "sourcePowerbankID"),
Bogdan Timofte authored a month ago
3104
            meterMACAddress: stringValue(object, key: "meterMACAddress"),
3105
            meterName: stringValue(object, key: "meterName"),
3106
            meterModel: stringValue(object, key: "meterModel"),
3107
            startedAt: startedAt,
3108
            endedAt: dateValue(object, key: "endedAt"),
3109
            lastObservedAt: lastObservedAt,
Bogdan Timofte authored a month ago
3110
            pausedAt: dateValue(object, key: "pausedAt"),
Bogdan Timofte authored a month ago
3111
            status: status,
3112
            sourceMode: sourceMode,
3113
            chargingTransportMode: chargingTransportMode,
Bogdan Timofte authored a month ago
3114
            chargingStateMode: chargingStateMode(for: object),
3115
            autoStopEnabled: boolValue(object, key: "autoStopEnabled"),
Bogdan Timofte authored a month ago
3116
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
3117
            effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
Bogdan Timofte authored a month ago
3118
            meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
Bogdan Timofte authored a month ago
3119
            meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"),
3120
            meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"),
Bogdan Timofte authored a month ago
3121
            minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
3122
            maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
3123
            maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
3124
            maximumObservedVoltageVolts: chargingTransportMode == .wired
3125
                ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
3126
                : nil,
Bogdan Timofte authored a month ago
3127
            hasObservedChargeFlow: boolValue(object, key: "hasObservedChargeFlow"),
Bogdan Timofte authored a month ago
3128
            selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
3129
            completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
3130
            stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
3131
            startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
3132
            endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
3133
            capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
3134
            wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
3135
            usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
3136
            shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
3137
            supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
3138
            usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
3139
            targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
3140
            targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
3141
            requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
3142
            completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
3143
            completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
3144
            selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
Bogdan Timofte authored a month ago
3145
            trimStart: dateValue(object, key: "trimStart"),
3146
            trimEnd: dateValue(object, key: "trimEnd"),
Bogdan Timofte authored a month ago
3147
            wasConflictHealed: boolValue(object, key: "wasConflictHealed"),
Bogdan Timofte authored a month ago
3148
            checkpoints: checkpointSummaries,
3149
            aggregatedSamples: sampleSummaries
3150
        )
3151
    }
3152

            
3153
    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
3154
        guard
3155
            let id = uuidValue(object, key: "id"),
3156
            let sessionID = uuidValue(object, key: "sessionID"),
3157
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
3158
            let timestamp = dateValue(object, key: "timestamp")
3159
        else {
3160
            return nil
3161
        }
3162

            
3163
        return ChargeCheckpointSummary(
3164
            id: id,
3165
            sessionID: sessionID,
3166
            chargedDeviceID: chargedDeviceID,
Bogdan Timofte authored a month ago
3167
            powerbankID: uuidValue(object, key: "powerbankID"),
3168
            batteryBarsValue: Int(optionalInt16Value(object, key: "batteryBarsValue") ?? 0),
Bogdan Timofte authored a month ago
3169
            timestamp: timestamp,
3170
            batteryPercent: doubleValue(object, key: "batteryPercent"),
3171
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
3172
            currentAmps: doubleValue(object, key: "currentAmps"),
3173
            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
3174
            label: stringValue(object, key: "label")
3175
        )
3176
    }
3177

            
3178
    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
3179
        guard
3180
            let sessionID = uuidValue(object, key: "sessionID"),
3181
            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
3182
            let timestamp = dateValue(object, key: "timestamp")
3183
        else {
3184
            return nil
3185
        }
3186

            
3187
        return ChargeSessionSampleSummary(
3188
            sessionID: sessionID,
3189
            chargedDeviceID: chargedDeviceID,
3190
            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
3191
            timestamp: timestamp,
3192
            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
3193
            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
3194
            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
3195
            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
Bogdan Timofte authored a month ago
3196
            estimatedBatteryPercent: optionalDoubleValue(object, key: "estimatedBatteryPercent"),
Bogdan Timofte authored a month ago
3197
            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
3198
        )
3199
    }
3200

            
Bogdan Timofte authored a month ago
3201
    private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
3202
        fetchSessionObject(
3203
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
3204
                format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)",
Bogdan Timofte authored a month ago
3205
                normalizedMACAddress(meterMACAddress),
Bogdan Timofte authored a month ago
3206
                ChargeSessionStatus.active.rawValue,
3207
                ChargeSessionStatus.paused.rawValue
Bogdan Timofte authored a month ago
3208
            )
3209
        )
3210
    }
3211

            
Bogdan Timofte authored a month ago
3212
    private func fetchOpenSessionObjects() -> [NSManagedObject] {
3213
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3214
        request.predicate = NSPredicate(
3215
            format: "statusRawValue == %@ OR statusRawValue == %@",
3216
            ChargeSessionStatus.active.rawValue,
3217
            ChargeSessionStatus.paused.rawValue
3218
        )
3219
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
3220
        return (try? context.fetch(request)) ?? []
3221
    }
3222

            
Bogdan Timofte authored a month ago
3223
    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
Bogdan Timofte authored a month ago
3224
        fetchSessionObject(
3225
            predicate: NSPredicate(
Bogdan Timofte authored a month ago
3226
                format: "meterMACAddress == %@ AND statusRawValue == %@",
3227
                normalizedMACAddress(meterMACAddress),
3228
                ChargeSessionStatus.active.rawValue
Bogdan Timofte authored a month ago
3229
            )
3230
        )
3231
    }
3232

            
3233
    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
3234
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3235
        request.predicate = predicate
3236
        request.fetchLimit = 1
3237
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
3238
        return (try? context.fetch(request))?.first
3239
    }
3240

            
3241
    private func fetchSessionObject(id: String) -> NSManagedObject? {
3242
        fetchSessionObject(
3243
            predicate: NSPredicate(format: "id == %@", id)
3244
        )
3245
    }
3246

            
3247
    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
3248
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
3249
        request.predicate = NSPredicate(
3250
            format: "sessionID == %@ AND bucketIndex == %d",
3251
            sessionID,
3252
            bucketIndex
3253
        )
3254
        request.fetchLimit = 1
3255
        return (try? context.fetch(request))?.first
3256
    }
3257

            
3258
    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
3259
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
3260
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
3261
        return (try? context.fetch(request)) ?? []
3262
    }
3263

            
Bogdan Timofte authored a month ago
3264
    private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
3265
        guard !sessionIDs.isEmpty else {
3266
            return []
3267
        }
3268

            
3269
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
3270
        request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs)
3271
        return (try? context.fetch(request)) ?? []
3272
    }
3273

            
Bogdan Timofte authored a month ago
3274
    private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
3275
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
3276
        request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
3277
        request.fetchLimit = 1
3278
        return (try? context.fetch(request))?.first
3279
    }
3280

            
Bogdan Timofte authored a month ago
3281
    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
3282
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
3283
        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
3284
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
3285
        return (try? context.fetch(request)) ?? []
3286
    }
3287

            
3288
    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
3289
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3290
        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
3291
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
3292
        return (try? context.fetch(request)) ?? []
3293
    }
3294

            
3295
    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
3296
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3297
        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
3298
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
3299
        return (try? context.fetch(request)) ?? []
3300
    }
3301

            
Bogdan Timofte authored a month ago
3302
    private func fetchSessions(forPowerbankSubjectID powerbankID: String) -> [NSManagedObject] {
3303
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3304
        request.predicate = NSPredicate(format: "chargedPowerbankID == %@", powerbankID)
3305
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
3306
        return (try? context.fetch(request)) ?? []
3307
    }
3308

            
3309
    private func fetchSessions(forPowerbankSourceID powerbankID: String) -> [NSManagedObject] {
3310
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
3311
        request.predicate = NSPredicate(format: "sourcePowerbankID == %@", powerbankID)
3312
        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
3313
        return (try? context.fetch(request)) ?? []
3314
    }
3315

            
Bogdan Timofte authored a month ago
3316
    private func sampleBackedSessionIDs(
3317
        devices: [NSManagedObject],
3318
        sessionsByDeviceID: [String: [NSManagedObject]],
3319
        sessionsByChargerID: [String: [NSManagedObject]]
3320
    ) -> Set<String> {
3321
        var sessionIDs: Set<String> = []
3322

            
3323
        for device in devices {
3324
            guard
3325
                let deviceID = stringValue(device, key: "id"),
3326
                let rawClass = stringValue(device, key: "deviceClassRawValue"),
3327
                let deviceClass = ChargedDeviceClass(rawValue: rawClass)
3328
            else {
3329
                continue
3330
            }
3331

            
3332
            let relevantSessions = relevantSessionObjects(
3333
                for: deviceID,
3334
                deviceClass: deviceClass,
3335
                sessionsByDeviceID: sessionsByDeviceID,
3336
                sessionsByChargerID: sessionsByChargerID
3337
            )
3338
            .sorted { lhs, rhs in
3339
                let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed
3340
                let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed
3341

            
3342
                if lhsStatus.isOpen && !rhsStatus.isOpen {
3343
                    return true
3344
                }
3345
                if !lhsStatus.isOpen && rhsStatus.isOpen {
3346
                    return false
3347
                }
3348

            
3349
                return (dateValue(lhs, key: "startedAt") ?? .distantPast)
3350
                    > (dateValue(rhs, key: "startedAt") ?? .distantPast)
3351
            }
3352

            
3353
            var recentCompletedSamplesIncluded = 0
3354

            
3355
            for session in relevantSessions {
3356
                guard let sessionID = stringValue(session, key: "id"),
3357
                      let status = statusValue(session, key: "statusRawValue") else {
3358
                    continue
3359
                }
3360

            
3361
                if status.isOpen {
3362
                    sessionIDs.insert(sessionID)
3363
                    continue
3364
                }
3365

            
3366
                guard recentCompletedSamplesIncluded < 2 else {
3367
                    continue
3368
                }
3369

            
3370
                sessionIDs.insert(sessionID)
3371
                recentCompletedSamplesIncluded += 1
3372
            }
3373
        }
3374

            
3375
        return sessionIDs
3376
    }
3377

            
Bogdan Timofte authored a month ago
3378
    private func relevantSessionObjects(
3379
        for chargedDeviceID: String,
3380
        deviceClass: ChargedDeviceClass,
3381
        sessionsByDeviceID: [String: [NSManagedObject]],
3382
        sessionsByChargerID: [String: [NSManagedObject]]
3383
    ) -> [NSManagedObject] {
3384
        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
3385
        guard deviceClass == .charger else {
3386
            return directSessions
3387
        }
3388

            
3389
        var seenSessionIDs = Set<String>()
3390
        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
3391
            .filter { session in
3392
                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
3393
                return seenSessionIDs.insert(sessionID).inserted
3394
            }
3395
            .sorted {
3396
                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
3397
                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
3398
                return lhsDate < rhsDate
3399
            }
3400
    }
3401

            
Bogdan Timofte authored a month ago
3402
    private func isChargerObject(_ object: NSManagedObject) -> Bool {
3403
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
3404
    }
3405

            
Bogdan Timofte authored a month ago
3406
    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
3407
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
3408
        request.predicate = NSPredicate(format: "id == %@", id)
3409
        request.fetchLimit = 1
3410
        return (try? context.fetch(request))?.first
3411
    }
3412

            
Bogdan Timofte authored a month ago
3413
    private func fetchPowerbankObject(id: String) -> NSManagedObject? {
3414
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.powerbank)
3415
        request.predicate = NSPredicate(format: "id == %@", id)
3416
        request.fetchLimit = 1
3417
        return (try? context.fetch(request))?.first
3418
    }
3419

            
Bogdan Timofte authored a month ago
3420
    private func fetchObjects(entityName: String) -> [NSManagedObject] {
3421
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
3422
        return (try? context.fetch(request)) ?? []
3423
    }
3424

            
3425
    private func resolvedStopThreshold(
3426
        for chargedDevice: NSManagedObject,
3427
        chargingTransportMode: ChargingTransportMode,
Bogdan Timofte authored a month ago
3428
        chargingStateMode: ChargingStateMode,
3429
        charger: NSManagedObject?,
3430
        fallback: Double?
3431
    ) -> Double? {
3432
        if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
3433
            return nil
3434
        }
3435

            
3436
        let sessionKind = ChargeSessionKind(
3437
            chargingTransportMode: chargingTransportMode,
3438
            chargingStateMode: chargingStateMode
3439
        )
3440
        let configuredCurrents = decodedCompletionCurrents(
3441
            from: chargedDevice,
3442
            key: "configuredCompletionCurrentsRawValue"
3443
        )
3444
        let learnedCurrents = decodedCompletionCurrents(
3445
            from: chargedDevice,
3446
            key: "learnedCompletionCurrentsRawValue"
3447
        )
3448
        let legacyCurrent: Double?
Bogdan Timofte authored a month ago
3449
        switch chargingTransportMode {
3450
        case .wired:
Bogdan Timofte authored a month ago
3451
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
3452
                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
3453
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
3454
        case .wireless:
Bogdan Timofte authored a month ago
3455
            legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
Bogdan Timofte authored a month ago
3456
                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
3457
                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
3458
        }
Bogdan Timofte authored a month ago
3459

            
3460
        let resolvedCurrent = configuredCurrents[sessionKind]
3461
            ?? learnedCurrents[sessionKind]
3462
            ?? legacyCurrent
3463
            ?? fallback
3464
        guard let resolvedCurrent, resolvedCurrent > 0 else {
3465
            return nil
3466
        }
3467
        return max(resolvedCurrent, 0.01)
Bogdan Timofte authored a month ago
3468
    }
3469

            
Bogdan Timofte authored a month ago
3470
    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
Bogdan Timofte authored a month ago
3471
        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
3472
        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
3473
        return resolvedPreferredChargingTransportMode(
Bogdan Timofte authored a month ago
3474
            .wired,
Bogdan Timofte authored a month ago
3475
            supportsWiredCharging: supportsWiredCharging,
3476
            supportsWirelessCharging: supportsWirelessCharging
3477
        )
3478
    }
3479

            
Bogdan Timofte authored a month ago
3480
    private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
3481
        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other
3482
    }
3483

            
3484
    private func normalizedTemplateID(
3485
        _ templateID: String?,
3486
        kind: ChargedDeviceKind
3487
    ) -> String? {
3488
        guard let templateID,
3489
              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
3490
              templateDefinition.kind == kind else {
3491
            return nil
Bogdan Timofte authored a month ago
3492
        }
Bogdan Timofte authored a month ago
3493
        return templateDefinition.id
Bogdan Timofte authored a month ago
3494
    }
3495

            
Bogdan Timofte authored a month ago
3496
    /// Resolves the active DeviceProfile for a ChargedDevice — catalog first
3497
    /// (covers all built-in templates and chargers), then DB-backed custom profiles.
3498
    /// Returns nil only for devices that escaped migration (shouldn't happen post Phase 3).
3499
    private func resolvedProfileDefinition(for chargedDevice: NSManagedObject) -> DeviceProfileDefinition? {
3500
        if let profileID = stringValue(chargedDevice, key: "profileID") {
3501
            if let catalogProfile = DeviceProfileCatalog.shared.profile(id: profileID) {
3502
                return catalogProfile
3503
            }
3504
            if let stored = fetchDeviceProfileObject(id: profileID),
3505
               let definition = makeProfileDefinition(from: stored) {
3506
                return definition
3507
            }
3508
        }
3509
        // Pre-migration fallback: try the legacy template ID against the catalog.
3510
        if let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
3511
           let catalogProfile = DeviceProfileCatalog.shared.profile(id: templateID) {
3512
            return catalogProfile
3513
        }
3514
        return nil
3515
    }
3516

            
3517
    private func fetchDeviceProfileObject(id: String) -> NSManagedObject? {
3518
        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.deviceProfile)
3519
        request.predicate = NSPredicate(format: "id == %@", id)
3520
        request.fetchLimit = 1
3521
        return (try? context.fetch(request))?.first
3522
    }
3523

            
3524
    private func makeProfileDefinition(from object: NSManagedObject) -> DeviceProfileDefinition? {
3525
        guard let id = stringValue(object, key: "id"),
3526
              let categoryRaw = stringValue(object, key: "categoryRawValue"),
3527
              let category = ProfileCategory(rawValue: categoryRaw) else {
Bogdan Timofte authored a month ago
3528
            return nil
Bogdan Timofte authored a month ago
3529
        }
Bogdan Timofte authored a month ago
3530
        let name = stringValue(object, key: "name") ?? id
3531
        let group = stringValue(object, key: "group") ?? "Custom"
3532
        let iconName = stringValue(object, key: "iconSymbolName") ?? category.symbolName
3533
        let iconFallback = stringValue(object, key: "iconFallbackSymbolName")
3534
        let icon = ChargedDeviceTemplateIcon(type: .systemSymbol, name: iconName, fallbackSystemName: iconFallback)
3535
        let stateRaw = stringValue(object, key: "capChargingStateAvailabilityRawValue")
3536
            ?? ChargingStateAvailability.onOrOff.rawValue
3537
        let stateAvailability = ChargingStateAvailability(rawValue: stateRaw) ?? .onOrOff
3538
        let allowedWirelessProfiles = DeviceProfileDefinition.decodeWirelessProfilesCSV(
3539
            stringValue(object, key: "capWirelessProfilesRawValue")
3540
        )
3541
        let defaultWirelessProfileRaw = stringValue(object, key: "defaultWirelessChargingProfileRawValue")
3542
        let defaultWirelessProfile = defaultWirelessProfileRaw.flatMap(WirelessChargingProfile.init(rawValue:))
3543

            
3544
        return DeviceProfileDefinition(
3545
            id: id,
3546
            name: name,
3547
            group: group,
3548
            category: category,
3549
            icon: icon,
3550
            sortOrder: Int((object.value(forKey: "sortOrder") as? Int32) ?? 1000),
3551
            capWiredCharging: (object.value(forKey: "capWiredCharging") as? Bool) ?? false,
3552
            capWirelessCharging: (object.value(forKey: "capWirelessCharging") as? Bool) ?? false,
3553
            capWirelessProfiles: allowedWirelessProfiles,
3554
            capChargingStateAvailability: stateAvailability,
3555
            capHasInternalSubject: (object.value(forKey: "capHasInternalSubject") as? Bool) ?? false,
3556
            defaultWirelessChargingProfile: defaultWirelessProfile,
3557
            defaultWiredMinimumCurrentAmps: optionalDoubleValue(object, key: "defaultWiredMinimumCurrentAmps"),
3558
            defaultWirelessMinimumCurrentAmps: optionalDoubleValue(object, key: "defaultWirelessMinimumCurrentAmps"),
3559
            defaultWiredEstimatedBatteryCapacityWh: optionalDoubleValue(object, key: "defaultWiredEstimatedBatteryCapacityWh"),
3560
            defaultWirelessEstimatedBatteryCapacityWh: optionalDoubleValue(object, key: "defaultWirelessEstimatedBatteryCapacityWh")
3561
        )
3562
    }
3563

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

            
3571
        let kind = profile.category.kind
3572
        // Pick a representative wireless profile for the legacy template shape.
3573
        let wirelessProfile = profile.defaultWirelessChargingProfile
3574
            ?? profile.capWirelessProfiles.first
3575
            ?? .genericQi
3576

            
3577
        return ChargedDeviceTemplateDefinition(
3578
            id: profile.id,
3579
            name: profile.name,
3580
            group: profile.group,
3581
            kind: kind,
3582
            deviceClass: legacyClass(for: profile.category),
3583
            icon: profile.icon,
3584
            chargingStateAvailability: profile.capChargingStateAvailability,
3585
            supportsWiredCharging: profile.capWiredCharging,
3586
            supportsWirelessCharging: profile.capWirelessCharging,
3587
            wirelessChargingProfile: wirelessProfile,
3588
            sortOrder: profile.sortOrder
3589
        )
3590
    }
3591

            
3592
    private func legacyClass(for category: ProfileCategory) -> ChargedDeviceClass {
3593
        switch category {
3594
        case .phone: return .iphone
3595
        case .watch: return .watch
3596
        case .powerbank: return .powerbank
3597
        case .charger: return .charger
3598
        case .tablet, .laptop, .audioAccessory, .accessoryCase, .other: return .other
3599
        }
Bogdan Timofte authored a month ago
3600
    }
3601

            
3602
    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
3603
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
3604
            ? true
3605
            : boolValue(chargedDevice, key: "supportsWiredCharging")
Bogdan Timofte authored a month ago
3606

            
3607
        if let profile = resolvedProfileDefinition(for: chargedDevice) {
3608
            // Profile capability is the upper bound; user opt-out preserved.
3609
            return persistedWiredCharging && profile.capWiredCharging
3610
        }
3611

            
3612
        // Pre-migration fallback: legacy class enforcement.
Bogdan Timofte authored a month ago
3613
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
3614
            ? false
3615
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
3616
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
3617
            supportsWiredCharging: persistedWiredCharging,
3618
            supportsWirelessCharging: persistedWirelessCharging
3619
        ).wired
3620
    }
3621

            
3622
    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
3623
        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
3624
            ? false
3625
            : boolValue(chargedDevice, key: "supportsWirelessCharging")
Bogdan Timofte authored a month ago
3626

            
3627
        if let profile = resolvedProfileDefinition(for: chargedDevice) {
3628
            return persistedWirelessCharging && profile.capWirelessCharging
3629
        }
3630

            
3631
        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
3632
            ? true
3633
            : boolValue(chargedDevice, key: "supportsWiredCharging")
Bogdan Timofte authored a month ago
3634
        return deviceClass(for: chargedDevice).normalizedChargingSupport(
3635
            supportsWiredCharging: persistedWiredCharging,
3636
            supportsWirelessCharging: persistedWirelessCharging
3637
        ).wireless
3638
    }
3639

            
3640
    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
Bogdan Timofte authored a month ago
3641
        if let profile = resolvedProfileDefinition(for: chargedDevice) {
3642
            return profile.capChargingStateAvailability
3643
        }
3644

            
Bogdan Timofte authored a month ago
3645
        let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue")
3646
            .flatMap(ChargingStateAvailability.init(rawValue:))
3647
            ?? ChargingStateAvailability.fallback(
Bogdan Timofte authored a month ago
3648
            for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
3649
        )
Bogdan Timofte authored a month ago
3650
        return deviceClass(for: chargedDevice).normalizedChargingStateAvailability(persistedAvailability)
Bogdan Timofte authored a month ago
3651
    }
3652

            
3653
    private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
Bogdan Timofte authored a month ago
3654
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
3655
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
3656
            let persistedChargingStateMode = stringValue(session, key: "chargingStateRawValue")
3657
                .flatMap(ChargingStateMode.init(rawValue:))
3658
                ?? .on
3659
            return resolvedChargingStateMode(
3660
                persistedChargingStateMode,
3661
                availability: chargingStateAvailability(for: chargedDevice)
3662
            )
3663
        }
3664

            
Bogdan Timofte authored a month ago
3665
        if let rawValue = stringValue(session, key: "chargingStateRawValue"),
3666
           let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
3667
            return chargingStateMode
3668
        }
3669

            
3670
        return .on
3671
    }
3672

            
3673
    private func resolvedChargingStateMode(
3674
        _ chargingStateMode: ChargingStateMode,
3675
        availability: ChargingStateAvailability
3676
    ) -> ChargingStateMode {
3677
        if availability.supportedModes.contains(chargingStateMode) {
3678
            return chargingStateMode
3679
        }
3680
        return availability.supportedModes.first ?? .on
3681
    }
3682

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

            
3687
        // Primary: chargerTypeRawValue (set on v13+)
3688
        if let rawValue = stringValue(chargedDevice, key: "chargerTypeRawValue"),
3689
           let type = ChargerType(rawValue: rawValue) {
3690
            return type
3691
        }
3692

            
3693
        // Migration fallback: derive from old deviceTemplateID
3694
        switch stringValue(chargedDevice, key: "deviceTemplateID") {
3695
        case "apple-magsafe-charger": return .appleMagSafe
3696
        case "apple-watch-charger": return .appleWatch
3697
        default: break
3698
        }
3699

            
3700
        // Last resort: derive from wirelessChargingProfileRawValue
3701
        if let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
3702
           let profile = WirelessChargingProfile(rawValue: rawValue),
3703
           profile == .magsafe {
3704
            return .genericMagSafe
3705
        }
3706

            
3707
        return .genericQi
3708
    }
3709

            
Bogdan Timofte authored a month ago
3710
    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
Bogdan Timofte authored a month ago
3711
        if let type = chargerType(for: chargedDevice) {
3712
            return type.wirelessChargingProfile
3713
        }
Bogdan Timofte authored a month ago
3714
        let persisted = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue")
3715
            .flatMap(WirelessChargingProfile.init(rawValue:))
3716

            
3717
        if let profile = resolvedProfileDefinition(for: chargedDevice),
3718
           !profile.capWirelessProfiles.isEmpty {
3719
            // Persisted wins iff still allowed by capabilities; else fall back to profile default.
3720
            if let persisted, profile.capWirelessProfiles.contains(persisted) {
3721
                return persisted
3722
            }
3723
            return profile.defaultWirelessChargingProfile
3724
                ?? profile.capWirelessProfiles.first
3725
                ?? .genericQi
Bogdan Timofte authored a month ago
3726
        }
Bogdan Timofte authored a month ago
3727

            
3728
        return persisted ?? .genericQi
Bogdan Timofte authored a month ago
3729
    }
3730

            
3731
    private func resolvedPreferredChargingTransportMode(
3732
        _ preferredChargingTransportMode: ChargingTransportMode,
3733
        supportsWiredCharging: Bool,
3734
        supportsWirelessCharging: Bool
3735
    ) -> ChargingTransportMode {
3736
        switch preferredChargingTransportMode {
3737
        case .wired where supportsWiredCharging:
3738
            return .wired
3739
        case .wireless where supportsWirelessCharging:
3740
            return .wireless
3741
        default:
3742
            if supportsWiredCharging {
3743
                return .wired
3744
            }
3745
            if supportsWirelessCharging {
3746
                return .wireless
3747
            }
3748
            return .wired
3749
        }
3750
    }
3751

            
Bogdan Timofte authored a month ago
3752
    private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
3753
        let payload = Dictionary(
3754
            uniqueKeysWithValues: currents.map { key, value in
3755
                (key.rawValue, value)
3756
            }
3757
        )
3758
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
3759
            return nil
3760
        }
3761
        return String(data: data, encoding: .utf8)
3762
    }
3763

            
3764
    private func decodedCompletionCurrents(
3765
        from object: NSManagedObject,
3766
        key: String
3767
    ) -> [ChargeSessionKind: Double] {
3768
        guard let rawValue = stringValue(object, key: key),
3769
              let data = rawValue.data(using: .utf8),
3770
              let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
3771
            return [:]
3772
        }
3773

            
3774
        return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
3775
            guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
3776
                return
3777
            }
3778
            result[sessionKind] = entry.value
3779
        }
3780
    }
3781

            
3782
    private func legacyConfiguredCompletionCurrent(
3783
        for currents: [ChargeSessionKind: Double],
3784
        chargingTransportMode: ChargingTransportMode
3785
    ) -> Double? {
3786
        let candidates = currents
3787
            .filter { $0.key.chargingTransportMode == chargingTransportMode }
3788
            .sorted { lhs, rhs in
3789
                lhs.key.rawValue < rhs.key.rawValue
3790
            }
3791
            .map(\.value)
3792
        return candidates.first
3793
    }
3794

            
3795
    private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
3796
        guard let charger else {
3797
            return nil
3798
        }
3799
        let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps")
3800
        guard let idleCurrent, idleCurrent >= 0 else {
3801
            return nil
3802
        }
3803
        return idleCurrent
3804
    }
3805

            
3806
    private func effectiveCurrentAmps(
3807
        fromMeasuredCurrent currentAmps: Double,
3808
        chargingTransportMode: ChargingTransportMode,
3809
        charger: NSManagedObject?
3810
    ) -> Double {
3811
        switch chargingTransportMode {
3812
        case .wired:
3813
            return max(currentAmps, 0)
3814
        case .wireless:
3815
            guard let idleCurrent = chargerIdleCurrent(for: charger) else {
3816
                return max(currentAmps, 0)
3817
            }
3818
            return max(currentAmps - idleCurrent, 0)
3819
        }
3820
    }
3821

            
3822
    private func hasObservedChargeFlow(
3823
        currentAmps: Double,
3824
        chargingTransportMode: ChargingTransportMode,
3825
        charger: NSManagedObject?,
3826
        stopThreshold: Double?
3827
    ) -> Bool {
3828
        let effectiveCurrent = effectiveCurrentAmps(
3829
            fromMeasuredCurrent: currentAmps,
3830
            chargingTransportMode: chargingTransportMode,
3831
            charger: charger
3832
        )
3833
        return effectiveCurrent > max(stopThreshold ?? 0, 0.05)
3834
    }
3835

            
Bogdan Timofte authored a month ago
3836
    private func hasSavableChargeData(_ session: NSManagedObject) -> Bool {
Bogdan Timofte authored a month ago
3837
        if boolValue(session, key: "hasObservedChargeFlow")
Bogdan Timofte authored a month ago
3838
            || doubleValue(session, key: "measuredEnergyWh") > 0
3839
            || doubleValue(session, key: "measuredChargeAh") > 0
3840
            || (optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? 0) > 0
Bogdan Timofte authored a month ago
3841
            || (optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? 0) > 0 {
3842
            return true
3843
        }
3844

            
3845
        guard let sessionID = stringValue(session, key: "id") else {
3846
            return false
3847
        }
3848

            
3849
        return fetchSessionSampleObjects(forSessionID: sessionID).contains { sample in
3850
            doubleValue(sample, key: "measuredEnergyWh") > 0
3851
                || doubleValue(sample, key: "measuredChargeAh") > 0
3852
                || (optionalDoubleValue(sample, key: "averageCurrentAmps") ?? 0) > 0
3853
                || (optionalDoubleValue(sample, key: "averagePowerWatts") ?? 0) > 0
3854
        }
3855
    }
3856

            
3857
    private func restoreMeasuredTotalsFromLatestSampleIfNeeded(_ session: NSManagedObject) {
3858
        guard let sessionID = stringValue(session, key: "id"),
3859
              let latestSample = fetchSessionSampleObjects(forSessionID: sessionID).max(by: {
3860
                  (dateValue($0, key: "timestamp") ?? .distantPast) < (dateValue($1, key: "timestamp") ?? .distantPast)
3861
              }) else {
3862
            return
3863
        }
3864

            
3865
        let sampleEnergyWh = doubleValue(latestSample, key: "measuredEnergyWh")
3866
        if doubleValue(session, key: "measuredEnergyWh") <= 0, sampleEnergyWh > 0 {
3867
            session.setValue(sampleEnergyWh, forKey: "measuredEnergyWh")
3868
        }
3869

            
3870
        let sampleChargeAh = doubleValue(latestSample, key: "measuredChargeAh")
3871
        if doubleValue(session, key: "measuredChargeAh") <= 0, sampleChargeAh > 0 {
3872
            session.setValue(sampleChargeAh, forKey: "measuredChargeAh")
3873
        }
Bogdan Timofte authored a month ago
3874
    }
3875

            
Bogdan Timofte authored a month ago
3876
    private func derivedMinimumCurrent(
3877
        from sessions: [NSManagedObject],
3878
        chargingTransportMode: ChargingTransportMode
3879
    ) -> Double? {
3880
        let completionCurrents = sessions.compactMap { session -> Double? in
3881
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3882
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
3883
                return nil
3884
            }
Bogdan Timofte authored a month ago
3885
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3886
                return nil
3887
            }
Bogdan Timofte authored a month ago
3888
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
3889
                return nil
3890
            }
3891
            return completionCurrent
3892
        }
3893

            
3894
        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
3895
        guard !recentCompletionCurrents.isEmpty else { return nil }
3896
        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
3897
    }
3898

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

            
3902
        for session in sessions {
3903
            guard statusValue(session, key: "statusRawValue") == .completed else {
3904
                continue
3905
            }
3906
            guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
3907
                continue
3908
            }
3909
            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"),
3910
                  completionCurrent > 0 else {
3911
                continue
3912
            }
3913

            
3914
            let sessionKind = ChargeSessionKind(
3915
                chargingTransportMode: chargingTransportMode(for: session),
3916
                chargingStateMode: chargingStateMode(for: session)
3917
            )
3918
            groupedCurrents[sessionKind, default: []].append(completionCurrent)
3919
        }
3920

            
3921
        return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
3922
            let recentCurrents = Array(entry.value.suffix(5))
3923
            guard !recentCurrents.isEmpty else {
3924
                return
3925
            }
3926
            result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count)
3927
        }
3928
    }
3929

            
Bogdan Timofte authored a month ago
3930
    private func derivedCapacity(
3931
        from sessions: [NSManagedObject],
3932
        chargingTransportMode: ChargingTransportMode,
3933
        supportsChargingWhileOff: Bool
3934
    ) -> Double? {
3935
        let capacityCandidates = sessions.compactMap { session -> Double? in
3936
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3937
            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
3938
                return nil
3939
            }
3940
            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
3941
                return nil
3942
            }
3943
            if supportsChargingWhileOff {
3944
                return capacityEstimate
3945
            }
3946
            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
3947
                return nil
3948
            }
3949
            return capacityEstimate
3950
        }
3951

            
3952
        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
3953
        guard !recentCapacityCandidates.isEmpty else { return nil }
3954
        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
3955
    }
3956

            
3957
    private func derivedWirelessEfficiency(
3958
        from sessions: [NSManagedObject],
3959
        chargingProfile: WirelessChargingProfile
3960
    ) -> Double? {
3961
        guard chargingProfile == .magsafe else {
3962
            return nil
3963
        }
3964

            
3965
        let candidates = sessions.compactMap { session -> Double? in
3966
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3967
            guard chargingTransportMode(for: session) == .wireless else { return nil }
3968
            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
3969
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
3970
                return nil
3971
            }
3972
            return factor
3973
        }
3974

            
3975
        let recentCandidates = Array(candidates.suffix(6))
3976
        guard !recentCandidates.isEmpty else { return nil }
3977
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
3978
    }
3979

            
3980
    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
3981
        let candidates = sessions.compactMap { session -> Double? in
3982
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3983
            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
3984
                return nil
3985
            }
3986
            return (sourceVoltage * 10).rounded() / 10
3987
        }
3988

            
3989
        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
3990
        return counts.keys.sorted()
3991
    }
3992

            
3993
    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
3994
        let candidates = sessions.compactMap { session -> Double? in
3995
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
3996
            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
3997
                return nil
3998
            }
3999
            return minimumObservedCurrent
4000
        }
4001

            
4002
        let recentCandidates = Array(candidates.suffix(6))
4003
        guard !recentCandidates.isEmpty else { return nil }
4004
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
4005
    }
4006

            
4007
    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
4008
        let candidates = sessions.compactMap { session -> Double? in
4009
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
4010
            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
4011
                return nil
4012
            }
4013
            return factor
4014
        }
4015

            
4016
        let recentCandidates = Array(candidates.suffix(6))
4017
        guard !recentCandidates.isEmpty else { return nil }
4018
        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
4019
    }
4020

            
4021
    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
4022
        sessions.compactMap { session -> Double? in
4023
            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
4024
            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
4025
                return nil
4026
            }
4027
            return maximumObservedPower
4028
        }
4029
        .max()
4030
    }
4031

            
Bogdan Timofte authored a month ago
4032
    /// View-time derivation of powerbank metrics from materialized session summaries.
4033
    /// Mirrors the charger derivation pattern but works on `ChargeSessionSummary` (already
4034
    /// projected) so we don't need to re-fetch NSManagedObjects.
4035
    /// - voltage profile: groups source-side sessions by selected voltage palier (rounded
4036
    ///   to 0.5V) and tracks max current observed at each palier.
4037
    /// - max power: max across source-side sessions' `maximumObservedPowerWatts`.
4038
    /// - efficiency: ratio of total Wh delivered (as source) vs total Wh received (as subject).
4039
    ///   Computed only when both sides have non-trivial energy logged.
4040
    /// - apparent capacity: sum of source-side delivered Wh between the most recent two
4041
    ///   powerbank-side checkpoints with sufficient battery percent delta. Best-effort.
4042
    private func derivedPowerbankMetrics(
4043
        sessionsAsSubject: [ChargeSessionSummary],
4044
        sessionsAsSource: [ChargeSessionSummary],
4045
        reporting: BatteryLevelReporting
4046
    ) -> (
4047
        voltageMaxCurrents: [Double: Double],
4048
        maxPowerWatts: Double?,
4049
        efficiencyFactor: Double?,
4050
        apparentCapacityWh: Double?
4051
    ) {
4052
        var voltageMaxCurrents: [Double: Double] = [:]
4053
        var maxPower: Double? = nil
4054

            
4055
        for session in sessionsAsSource {
4056
            if let voltage = session.selectedSourceVoltageVolts, voltage > 0 {
4057
                let palier = (voltage * 2).rounded() / 2  // 0.5V buckets
4058
                if let maxCurrent = session.maximumObservedCurrentAmps, maxCurrent > 0 {
4059
                    let prev = voltageMaxCurrents[palier] ?? 0
4060
                    voltageMaxCurrents[palier] = max(prev, maxCurrent)
4061
                }
4062
            }
4063
            if let power = session.maximumObservedPowerWatts, power > 0 {
4064
                maxPower = max(maxPower ?? 0, power)
4065
            }
4066
        }
4067

            
4068
        let totalDelivered = sessionsAsSource.reduce(0.0) { $0 + $1.measuredEnergyWh }
4069
        let totalReceived = sessionsAsSubject.reduce(0.0) { $0 + $1.measuredEnergyWh }
4070
        let efficiency: Double? = (totalDelivered > 0.5 && totalReceived > 0.5)
4071
            ? totalDelivered / totalReceived
4072
            : nil
4073

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

            
4086
        let powerbankCheckpoints = (sessionsAsSource + sessionsAsSubject)
4087
            .flatMap { $0.checkpoints.filter { $0.subject == .powerbank } }
4088
            .sorted { $0.timestamp < $1.timestamp }
4089

            
4090
        switch reporting {
4091
        case .fullOnly:
4092
            let fullMarkers = powerbankCheckpoints.filter { $0.batteryPercent >= 99 }
4093
            if let lastFull = fullMarkers.last,
4094
               let prevFull = fullMarkers.dropLast().last {
4095
                let lower = prevFull.timestamp
4096
                let upper = lastFull.timestamp
4097
                let energyDelivered = sessionsAsSource
4098
                    .filter { $0.startedAt <= upper && ($0.endedAt ?? $0.lastObservedAt) >= lower }
4099
                    .reduce(0.0) { $0 + $1.measuredEnergyWh }
4100
                if energyDelivered > 0.1 {
4101
                    apparentCapacity = energyDelivered
4102
                }
4103
            }
4104
        case .percent, .bars:
4105
            if powerbankCheckpoints.count >= 2 {
4106
                let last = powerbankCheckpoints.last!
4107
                if let earlier = powerbankCheckpoints.first(where: {
4108
                    abs(last.batteryPercent - $0.batteryPercent) >= 30
4109
                }) {
4110
                    let lower = min(earlier.timestamp, last.timestamp)
4111
                    let upper = max(earlier.timestamp, last.timestamp)
4112
                    let energyDelivered = sessionsAsSource
4113
                        .filter { $0.startedAt <= upper && ($0.endedAt ?? $0.lastObservedAt) >= lower }
4114
                        .reduce(0.0) { $0 + $1.measuredEnergyWh }
4115
                    if energyDelivered > 0.1 {
4116
                        apparentCapacity = energyDelivered
4117
                    }
4118
                }
4119
            }
4120
        case .none:
4121
            break
4122
        }
4123

            
4124
        return (voltageMaxCurrents, maxPower, efficiency, apparentCapacity)
4125
    }
4126

            
Bogdan Timofte authored a month ago
4127
    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
4128
        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
4129
           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
Bogdan Timofte authored a month ago
4130
            return resolvedPreferredChargingTransportMode(
4131
                chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired,
4132
                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
4133
                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
4134
            )
4135
        }
4136

            
4137
        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
4138
            return persistedChargingTransportMode
Bogdan Timofte authored a month ago
4139
        }
4140

            
4141
        return .wired
4142
    }
4143

            
4144
    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
4145
        if session.isInserted {
4146
            return .created
4147
        }
4148

            
4149
        let committedValues = session.committedValues(
4150
            forKeys: [
4151
                "statusRawValue",
4152
                "updatedAt",
4153
                "targetBatteryAlertTriggeredAt",
4154
                "requiresCompletionConfirmation"
4155
            ]
4156
        )
4157
        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
4158
        let currentStatus = statusValue(session, key: "statusRawValue")
4159
        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
4160
        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
4161
        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
4162
            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
4163
            ?? false
4164
        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
4165

            
4166
        if currentStatus == .completed, committedStatus != .completed {
4167
            return .completed
4168
        }
4169

            
Bogdan Timofte authored a month ago
4170
        if currentStatus != committedStatus {
4171
            return .event
4172
        }
4173

            
Bogdan Timofte authored a month ago
4174
        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
4175
            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
4176
            return .event
4177
        }
4178

            
4179
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
4180
            ?? dateValue(session, key: "createdAt")
4181
            ?? observedAt
4182

            
4183
        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
4184
            return .periodic
4185
        }
4186

            
4187
        return .none
4188
    }
4189

            
Bogdan Timofte authored a month ago
4190
    private func shouldPersistAggregatedSample(
4191
        _ sample: NSManagedObject,
4192
        observedAt: Date
4193
    ) -> Bool {
4194
        if sample.isInserted {
4195
            return true
4196
        }
4197

            
4198
        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
4199
        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
4200
            ?? dateValue(sample, key: "createdAt")
4201
            ?? observedAt
4202

            
4203
        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
4204
    }
4205

            
Bogdan Timofte authored a month ago
4206
    private func generateQRIdentifier() -> String {
4207
        "device:\(UUID().uuidString)"
4208
    }
4209

            
4210
    @discardableResult
4211
    private func saveContext() -> Bool {
4212
        guard context.hasChanges else { return true }
4213
        do {
4214
            try context.save()
4215
            return true
4216
        } catch {
4217
            track("Failed saving charge insights context: \(error)")
4218
            context.rollback()
4219
            return false
4220
        }
4221
    }
4222

            
4223
    private func normalizedText(_ text: String) -> String {
4224
        text.trimmingCharacters(in: .whitespacesAndNewlines)
4225
    }
4226

            
4227
    private func normalizedOptionalText(_ text: String?) -> String? {
4228
        guard let text else { return nil }
4229
        let normalized = normalizedText(text)
4230
        return normalized.isEmpty ? nil : normalized
4231
    }
4232

            
4233
    private func normalizedMACAddress(_ macAddress: String) -> String {
4234
        normalizedText(macAddress).uppercased()
4235
    }
4236

            
Bogdan Timofte authored a month ago
4237
    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
4238
        guard object.entity.propertiesByName[key] != nil else {
4239
            return nil
4240
        }
4241
        return object.value(forKey: key)
4242
    }
4243

            
4244
    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
4245
        guard object.entity.propertiesByName[key] != nil else {
4246
            return
4247
        }
4248
        object.setValue(value, forKey: key)
4249
    }
4250

            
Bogdan Timofte authored a month ago
4251
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
Bogdan Timofte authored a month ago
4252
        guard let value = rawValue(object, key: key) as? String else { return nil }
Bogdan Timofte authored a month ago
4253
        let normalized = normalizedOptionalText(value)
4254
        return normalized
4255
    }
4256

            
4257
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
Bogdan Timofte authored a month ago
4258
        rawValue(object, key: key) as? Date
Bogdan Timofte authored a month ago
4259
    }
4260

            
4261
    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
Bogdan Timofte authored a month ago
4262
        if let value = rawValue(object, key: key) as? Double {
Bogdan Timofte authored a month ago
4263
            return value
4264
        }
Bogdan Timofte authored a month ago
4265
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
4266
            return value.doubleValue
4267
        }
4268
        return 0
4269
    }
4270

            
4271
    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
Bogdan Timofte authored a month ago
4272
        let value = rawValue(object, key: key)
4273
        if value == nil {
Bogdan Timofte authored a month ago
4274
            return nil
4275
        }
4276
        return doubleValue(object, key: key)
4277
    }
4278

            
4279
    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
Bogdan Timofte authored a month ago
4280
        if let value = rawValue(object, key: key) as? Int16 {
Bogdan Timofte authored a month ago
4281
            return value
4282
        }
Bogdan Timofte authored a month ago
4283
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
4284
            return value.int16Value
4285
        }
4286
        return nil
4287
    }
4288

            
4289
    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
Bogdan Timofte authored a month ago
4290
        if let value = rawValue(object, key: key) as? Int32 {
Bogdan Timofte authored a month ago
4291
            return value
4292
        }
Bogdan Timofte authored a month ago
4293
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
4294
            return value.int32Value
4295
        }
4296
        return nil
4297
    }
4298

            
4299
    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
Bogdan Timofte authored a month ago
4300
        if let value = rawValue(object, key: key) as? Bool {
Bogdan Timofte authored a month ago
4301
            return value
4302
        }
Bogdan Timofte authored a month ago
4303
        if let value = rawValue(object, key: key) as? NSNumber {
Bogdan Timofte authored a month ago
4304
            return value.boolValue
4305
        }
4306
        return false
4307
    }
4308

            
4309
    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
4310
        guard let value = stringValue(object, key: key) else { return nil }
4311
        return UUID(uuidString: value)
4312
    }
4313

            
4314
    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
4315
        guard let value = stringValue(object, key: key) else { return nil }
4316
        return ChargeSessionStatus(rawValue: value)
4317
    }
4318

            
4319
    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
4320
        guard let value = stringValue(object, key: key) else { return nil }
4321
        return ChargingTransportMode(rawValue: value)
4322
    }
4323

            
4324
    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
4325
        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
4326
            return []
4327
        }
4328
        return rawValue
4329
            .split(separator: ",")
4330
            .compactMap { Double($0) }
4331
            .sorted()
4332
    }
4333

            
4334
    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
4335
        let uniqueVoltages = Array(Set(voltages)).sorted()
4336
        guard !uniqueVoltages.isEmpty else {
4337
            return nil
4338
        }
4339
        return uniqueVoltages
4340
            .map { String(format: "%.1f", $0) }
4341
            .joined(separator: ",")
4342
    }
4343

            
4344
    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
4345
        guard currentCount > 0 else {
4346
            return newValue
4347
        }
4348
        let total = (currentAverage * Double(currentCount)) + newValue
4349
        return total / Double(currentCount + 1)
4350
    }
4351
}
4352

            
4353
private enum ObservationSaveReason {
4354
    case none
4355
    case created
4356
    case periodic
4357
    case completed
4358
    case event
4359
}