|
Bogdan Timofte
authored
2 months ago
|
1
|
//
|
|
|
2
|
// DataStore.swift
|
|
|
3
|
// USB Meter
|
|
|
4
|
//
|
|
|
5
|
// Created by Bogdan Timofte on 03/03/2020.
|
|
|
6
|
// Copyright © 2020 Bogdan Timofte. All rights reserved.
|
|
|
7
|
//
|
|
|
8
|
|
|
|
9
|
import SwiftUI
|
|
|
10
|
import Combine
|
|
|
11
|
import CoreBluetooth
|
|
Bogdan Timofte
authored
a month ago
|
12
|
import CoreData
|
|
|
13
|
import UserNotifications
|
|
Bogdan Timofte
authored
2 months ago
|
14
|
|
|
Bogdan Timofte
authored
a month ago
|
15
|
struct BatteryCheckpointPlausibilityWarning: Identifiable, Hashable {
|
|
|
16
|
let title: String
|
|
|
17
|
let message: String
|
|
|
18
|
|
|
|
19
|
var id: String {
|
|
|
20
|
"\(title)\n\(message)"
|
|
|
21
|
}
|
|
|
22
|
}
|
|
|
23
|
|
|
Bogdan Timofte
authored
2 months ago
|
24
|
final class AppData : ObservableObject {
|
|
Bogdan Timofte
authored
2 months ago
|
25
|
struct MeterSummary: Identifiable {
|
|
Bogdan Timofte
authored
2 months ago
|
26
|
let macAddress: String
|
|
|
27
|
let displayName: String
|
|
|
28
|
let modelSummary: String
|
|
|
29
|
let advertisedName: String?
|
|
|
30
|
let lastSeen: Date?
|
|
|
31
|
let lastConnected: Date?
|
|
|
32
|
let meter: Meter?
|
|
|
33
|
|
|
|
34
|
var id: String {
|
|
|
35
|
macAddress
|
|
|
36
|
}
|
|
|
37
|
}
|
|
|
38
|
|
|
Bogdan Timofte
authored
2 months ago
|
39
|
private var bluetoothManagerNotification: AnyCancellable?
|
|
Bogdan Timofte
authored
2 months ago
|
40
|
private var meterStoreObserver: AnyCancellable?
|
|
|
41
|
private var meterStoreCloudObserver: AnyCancellable?
|
|
Bogdan Timofte
authored
a month ago
|
42
|
private var chargeInsightsStoreObserver: AnyCancellable?
|
|
|
43
|
private var chargeInsightsRemoteObserver: AnyCancellable?
|
|
Bogdan Timofte
authored
a month ago
|
44
|
private var chargerStandbyPowerStoreObserver: AnyCancellable?
|
|
Bogdan Timofte
authored
a month ago
|
45
|
private var pendingChargedDevicesReloadWorkItem: DispatchWorkItem?
|
|
Bogdan Timofte
authored
a month ago
|
46
|
private var chargeInsightsReadStore: ChargeInsightsStore?
|
|
Bogdan Timofte
authored
a month ago
|
47
|
private var pendingChargeObservationSnapshots: [String: ChargingMonitorSnapshot] = [:]
|
|
|
48
|
private var pendingChargeObservationWorkItems: [String: DispatchWorkItem] = [:]
|
|
Bogdan Timofte
authored
a month ago
|
49
|
private let chargedDevicesReloadQueue = DispatchQueue(
|
|
|
50
|
label: "ro.xdev.usb-meter.charged-devices-reload",
|
|
|
51
|
qos: .userInitiated
|
|
|
52
|
)
|
|
Bogdan Timofte
authored
a month ago
|
53
|
private var chargedDevicesReloadInFlight = false
|
|
|
54
|
private var chargedDevicesReloadPending = false
|
|
Bogdan Timofte
authored
a month ago
|
55
|
private let chargeObservationPersistInterval: TimeInterval = 30
|
|
|
56
|
private let meterPresencePersistInterval: TimeInterval = 15
|
|
Bogdan Timofte
authored
2 months ago
|
57
|
private let meterStore = MeterNameStore.shared
|
|
Bogdan Timofte
authored
a month ago
|
58
|
private var chargeInsightsStore: ChargeInsightsStore?
|
|
Bogdan Timofte
authored
a month ago
|
59
|
private let chargerStandbyPowerStore = ChargerStandbyPowerStore()
|
|
Bogdan Timofte
authored
a month ago
|
60
|
private let chargeNotificationCoordinator = ChargeNotificationCoordinator()
|
|
Bogdan Timofte
authored
a month ago
|
61
|
private var meterSummariesCache: (version: Int, summaries: [MeterSummary])?
|
|
|
62
|
private var meterSummariesVersion: Int = 0
|
|
Bogdan Timofte
authored
2 months ago
|
63
|
|
|
Bogdan Timofte
authored
2 months ago
|
64
|
init() {
|
|
Bogdan Timofte
authored
2 months ago
|
65
|
bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in
|
|
Bogdan Timofte
authored
2 months ago
|
66
|
self?.scheduleObjectWillChange()
|
|
Bogdan Timofte
authored
2 months ago
|
67
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
68
|
meterStoreObserver = NotificationCenter.default.publisher(for: .meterNameStoreDidChange)
|
|
|
69
|
.receive(on: DispatchQueue.main)
|
|
|
70
|
.sink { [weak self] _ in
|
|
Bogdan Timofte
authored
a month ago
|
71
|
self?.invalidateMeterSummaries()
|
|
Bogdan Timofte
authored
2 months ago
|
72
|
self?.refreshMeterMetadata()
|
|
Bogdan Timofte
authored
a month ago
|
73
|
self?.scheduleObjectWillChange()
|
|
Bogdan Timofte
authored
2 months ago
|
74
|
}
|
|
|
75
|
meterStoreCloudObserver = NotificationCenter.default.publisher(for: .meterNameStoreCloudStatusDidChange)
|
|
|
76
|
.receive(on: DispatchQueue.main)
|
|
|
77
|
.sink { [weak self] _ in
|
|
|
78
|
self?.scheduleObjectWillChange()
|
|
|
79
|
}
|
|
Bogdan Timofte
authored
a month ago
|
80
|
chargerStandbyPowerStoreObserver = NotificationCenter.default.publisher(for: .chargerStandbyPowerStoreDidChange)
|
|
|
81
|
.receive(on: DispatchQueue.main)
|
|
|
82
|
.sink { [weak self] _ in
|
|
|
83
|
self?.reloadChargedDevices()
|
|
|
84
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
85
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
86
|
|
|
Bogdan Timofte
authored
2 months ago
|
87
|
let bluetoothManager = BluetoothManager()
|
|
Bogdan Timofte
authored
2 months ago
|
88
|
|
|
Bogdan Timofte
authored
2 months ago
|
89
|
@Published var enableRecordFeature: Bool = true
|
|
Bogdan Timofte
authored
2 months ago
|
90
|
|
|
Bogdan Timofte
authored
a month ago
|
91
|
@Published var meters: [UUID:Meter] = [UUID:Meter]() {
|
|
|
92
|
didSet {
|
|
|
93
|
invalidateMeterSummaries()
|
|
|
94
|
}
|
|
|
95
|
}
|
|
Bogdan Timofte
authored
a month ago
|
96
|
@Published private(set) var chargedDevices: [ChargedDeviceSummary] = []
|
|
Bogdan Timofte
authored
a month ago
|
97
|
@Published private(set) var activeChargerStandbySessions: [String: ChargerStandbyPowerMonitorSession] = [:]
|
|
Bogdan Timofte
authored
a month ago
|
98
|
|
|
|
99
|
var deviceSummaries: [ChargedDeviceSummary] {
|
|
|
100
|
chargedDevices.filter { !$0.isCharger }
|
|
|
101
|
}
|
|
|
102
|
|
|
|
103
|
var chargerSummaries: [ChargedDeviceSummary] {
|
|
|
104
|
chargedDevices.filter { $0.isCharger }
|
|
|
105
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
106
|
|
|
|
107
|
var cloudAvailability: MeterNameStore.CloudAvailability {
|
|
|
108
|
meterStore.currentCloudAvailability
|
|
|
109
|
}
|
|
|
110
|
|
|
Bogdan Timofte
authored
a month ago
|
111
|
func activateChargeInsights(context: NSManagedObjectContext) {
|
|
|
112
|
guard chargeInsightsStore == nil else {
|
|
|
113
|
return
|
|
|
114
|
}
|
|
|
115
|
|
|
|
116
|
context.automaticallyMergesChangesFromParent = true
|
|
Bogdan Timofte
authored
a month ago
|
117
|
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
|
Bogdan Timofte
authored
a month ago
|
118
|
if let coordinator = context.persistentStoreCoordinator {
|
|
Bogdan Timofte
authored
a month ago
|
119
|
let writeContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
|
|
120
|
writeContext.persistentStoreCoordinator = coordinator
|
|
|
121
|
writeContext.automaticallyMergesChangesFromParent = false
|
|
|
122
|
writeContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
|
|
123
|
chargeInsightsStore = ChargeInsightsStore(context: writeContext)
|
|
|
124
|
|
|
Bogdan Timofte
authored
a month ago
|
125
|
let readContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
|
|
126
|
readContext.persistentStoreCoordinator = coordinator
|
|
|
127
|
readContext.automaticallyMergesChangesFromParent = true
|
|
|
128
|
readContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
|
|
129
|
chargeInsightsReadStore = ChargeInsightsStore(context: readContext)
|
|
|
130
|
|
|
Bogdan Timofte
authored
a month ago
|
131
|
chargeInsightsStoreObserver = NotificationCenter.default.publisher(
|
|
|
132
|
for: .NSManagedObjectContextDidSave,
|
|
|
133
|
object: writeContext
|
|
|
134
|
)
|
|
|
135
|
.sink { [weak self, weak context] notification in
|
|
|
136
|
guard let self, let context else { return }
|
|
|
137
|
context.perform {
|
|
|
138
|
context.mergeChanges(fromContextDidSave: notification)
|
|
|
139
|
DispatchQueue.main.async {
|
|
|
140
|
self.scheduleChargedDevicesReload()
|
|
|
141
|
}
|
|
|
142
|
}
|
|
|
143
|
}
|
|
|
144
|
} else {
|
|
|
145
|
chargeInsightsStore = ChargeInsightsStore(context: context)
|
|
|
146
|
chargeInsightsReadStore = ChargeInsightsStore(context: context)
|
|
|
147
|
|
|
|
148
|
chargeInsightsStoreObserver = NotificationCenter.default.publisher(
|
|
|
149
|
for: .NSManagedObjectContextDidSave,
|
|
|
150
|
object: context
|
|
|
151
|
)
|
|
|
152
|
.receive(on: DispatchQueue.main)
|
|
|
153
|
.sink { [weak self] _ in
|
|
|
154
|
self?.scheduleChargedDevicesReload()
|
|
|
155
|
}
|
|
Bogdan Timofte
authored
a month ago
|
156
|
}
|
|
|
157
|
|
|
|
158
|
chargeInsightsRemoteObserver = NotificationCenter.default.publisher(
|
|
|
159
|
for: .NSPersistentStoreRemoteChange,
|
|
|
160
|
object: nil
|
|
|
161
|
)
|
|
|
162
|
.receive(on: DispatchQueue.main)
|
|
|
163
|
.sink { [weak self] _ in
|
|
Bogdan Timofte
authored
a month ago
|
164
|
self?.scheduleChargedDevicesReload()
|
|
Bogdan Timofte
authored
a month ago
|
165
|
}
|
|
|
166
|
|
|
|
167
|
chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
|
|
|
168
|
reloadChargedDevices()
|
|
|
169
|
}
|
|
|
170
|
|
|
Bogdan Timofte
authored
2 months ago
|
171
|
func meterName(for macAddress: String) -> String? {
|
|
|
172
|
meterStore.name(for: macAddress)
|
|
|
173
|
}
|
|
|
174
|
|
|
|
175
|
func setMeterName(_ name: String, for macAddress: String) {
|
|
|
176
|
meterStore.upsert(macAddress: macAddress, name: name, temperatureUnitRawValue: nil)
|
|
|
177
|
}
|
|
|
178
|
|
|
|
179
|
func temperatureUnitPreference(for macAddress: String) -> TemperatureUnitPreference {
|
|
|
180
|
let rawValue = meterStore.temperatureUnitRawValue(for: macAddress) ?? TemperatureUnitPreference.celsius.rawValue
|
|
|
181
|
return TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
|
|
|
182
|
}
|
|
|
183
|
|
|
|
184
|
func setTemperatureUnitPreference(_ preference: TemperatureUnitPreference, for macAddress: String) {
|
|
|
185
|
meterStore.upsert(macAddress: macAddress, name: nil, temperatureUnitRawValue: preference.rawValue)
|
|
Bogdan Timofte
authored
2 months ago
|
186
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
187
|
|
|
Bogdan Timofte
authored
2 months ago
|
188
|
func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
|
|
|
189
|
meterStore.registerMeter(macAddress: macAddress, modelName: modelName, advertisedName: advertisedName)
|
|
Bogdan Timofte
authored
2 months ago
|
190
|
}
|
|
|
191
|
|
|
|
192
|
func noteMeterSeen(at date: Date, macAddress: String) {
|
|
Bogdan Timofte
authored
a month ago
|
193
|
if let persistedLastSeen = meterStore.lastSeen(for: macAddress),
|
|
|
194
|
date.timeIntervalSince(persistedLastSeen) < meterPresencePersistInterval {
|
|
|
195
|
return
|
|
|
196
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
197
|
meterStore.noteLastSeen(date, for: macAddress)
|
|
|
198
|
}
|
|
|
199
|
|
|
|
200
|
func noteMeterConnected(at date: Date, macAddress: String) {
|
|
|
201
|
meterStore.noteLastConnected(date, for: macAddress)
|
|
|
202
|
}
|
|
|
203
|
|
|
|
204
|
func lastSeen(for macAddress: String) -> Date? {
|
|
|
205
|
meterStore.lastSeen(for: macAddress)
|
|
|
206
|
}
|
|
|
207
|
|
|
|
208
|
func lastConnected(for macAddress: String) -> Date? {
|
|
|
209
|
meterStore.lastConnected(for: macAddress)
|
|
|
210
|
}
|
|
|
211
|
|
|
Bogdan Timofte
authored
a month ago
|
212
|
func chargedDeviceSummary(id: UUID) -> ChargedDeviceSummary? {
|
|
|
213
|
chargedDevices.first(where: { $0.id == id })
|
|
|
214
|
}
|
|
|
215
|
|
|
Bogdan Timofte
authored
a month ago
|
216
|
func chargeSessionSummary(id: UUID) -> ChargeSessionSummary? {
|
|
|
217
|
for chargedDevice in chargedDevices {
|
|
|
218
|
if let session = chargedDevice.sessions.first(where: { $0.id == id }) {
|
|
|
219
|
return session
|
|
|
220
|
}
|
|
|
221
|
}
|
|
|
222
|
return nil
|
|
|
223
|
}
|
|
|
224
|
|
|
Bogdan Timofte
authored
a month ago
|
225
|
func chargedDevices(for meterMACAddress: String) -> [ChargedDeviceSummary] {
|
|
|
226
|
let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
|
|
227
|
return chargedDevices.filter { chargedDevice in
|
|
|
228
|
guard chargedDevice.isCharger == false else {
|
|
|
229
|
return false
|
|
|
230
|
}
|
|
Bogdan Timofte
authored
a month ago
|
231
|
return chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
|
|
Bogdan Timofte
authored
a month ago
|
232
|
}
|
|
|
233
|
}
|
|
|
234
|
|
|
|
235
|
func chargers(for meterMACAddress: String) -> [ChargedDeviceSummary] {
|
|
|
236
|
let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
|
|
237
|
return chargedDevices.filter { chargedDevice in
|
|
|
238
|
guard chargedDevice.isCharger else {
|
|
|
239
|
return false
|
|
|
240
|
}
|
|
Bogdan Timofte
authored
a month ago
|
241
|
return chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
|
|
Bogdan Timofte
authored
a month ago
|
242
|
}
|
|
|
243
|
}
|
|
|
244
|
|
|
|
245
|
func currentChargedDeviceSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
|
|
|
246
|
let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
|
|
247
|
|
|
Bogdan Timofte
authored
a month ago
|
248
|
if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC),
|
|
Bogdan Timofte
authored
a month ago
|
249
|
let liveDevice = chargedDevices.first(where: {
|
|
|
250
|
$0.id == activeSession.chargedDeviceID && $0.isCharger == false
|
|
|
251
|
}) {
|
|
|
252
|
return liveDevice
|
|
|
253
|
}
|
|
|
254
|
|
|
|
255
|
return chargedDevices.first(where: {
|
|
|
256
|
$0.isCharger == false && $0.lastAssociatedMeterMAC == normalizedMAC
|
|
|
257
|
})
|
|
|
258
|
}
|
|
|
259
|
|
|
|
260
|
func currentChargerSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
|
|
|
261
|
let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
|
|
262
|
|
|
Bogdan Timofte
authored
a month ago
|
263
|
if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC),
|
|
Bogdan Timofte
authored
a month ago
|
264
|
let chargerID = activeSession.chargerID,
|
|
|
265
|
let liveCharger = chargedDevices.first(where: {
|
|
|
266
|
$0.id == chargerID && $0.isCharger
|
|
|
267
|
}) {
|
|
|
268
|
return liveCharger
|
|
|
269
|
}
|
|
|
270
|
|
|
|
271
|
return chargedDevices.first(where: {
|
|
|
272
|
$0.isCharger && $0.lastAssociatedMeterMAC == normalizedMAC
|
|
|
273
|
})
|
|
|
274
|
}
|
|
|
275
|
|
|
|
276
|
func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
|
|
Bogdan Timofte
authored
a month ago
|
277
|
let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
|
|
|
278
|
|
|
Bogdan Timofte
authored
a month ago
|
279
|
if expireOverlongChargeSessionsIfNeeded() {
|
|
|
280
|
reloadChargedDevices()
|
|
|
281
|
return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC)
|
|
|
282
|
}
|
|
|
283
|
|
|
Bogdan Timofte
authored
a month ago
|
284
|
if let cachedSummary = cachedActiveChargeSessionSummary(for: normalizedMAC) {
|
|
Bogdan Timofte
authored
a month ago
|
285
|
if let persistedSummary = chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC),
|
|
|
286
|
persistedSummary.aggregatedSamples.count > cachedSummary.aggregatedSamples.count {
|
|
|
287
|
return persistedSummary
|
|
|
288
|
}
|
|
Bogdan Timofte
authored
a month ago
|
289
|
return cachedSummary
|
|
|
290
|
}
|
|
Bogdan Timofte
authored
a month ago
|
291
|
return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC)
|
|
Bogdan Timofte
authored
a month ago
|
292
|
}
|
|
|
293
|
|
|
Bogdan Timofte
authored
a month ago
|
294
|
func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? {
|
|
|
295
|
activeChargerStandbySessions[Self.normalizedMACAddress(meterMACAddress)]
|
|
|
296
|
}
|
|
|
297
|
|
|
|
298
|
@discardableResult
|
|
|
299
|
func startChargerStandbyMeasurement(for chargerID: UUID, on meter: Meter) -> Bool {
|
|
|
300
|
guard chargedDeviceSummary(id: chargerID)?.isCharger == true else {
|
|
|
301
|
return false
|
|
|
302
|
}
|
|
|
303
|
|
|
|
304
|
let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description)
|
|
|
305
|
if let existingSession = activeChargerStandbySessions[normalizedMAC] {
|
|
|
306
|
return existingSession.chargerID == chargerID
|
|
|
307
|
}
|
|
|
308
|
|
|
|
309
|
let session = ChargerStandbyPowerMonitorSession(chargerID: chargerID, meter: meter)
|
|
|
310
|
session.onChange = { [weak self] in
|
|
|
311
|
self?.scheduleObjectWillChange()
|
|
|
312
|
}
|
|
|
313
|
session.onStabilized = { [weak self, weak session] in
|
|
|
314
|
guard let self, let session else { return }
|
|
|
315
|
self.notifyChargerStandbyMeasurementReady(for: session)
|
|
|
316
|
}
|
|
|
317
|
|
|
|
318
|
activeChargerStandbySessions[normalizedMAC] = session
|
|
|
319
|
session.start()
|
|
|
320
|
|
|
|
321
|
// Starting a standby run on an available meter should also initiate the BLE link.
|
|
|
322
|
if meter.operationalState == .peripheralNotConnected {
|
|
|
323
|
meter.connect()
|
|
|
324
|
}
|
|
|
325
|
|
|
|
326
|
scheduleObjectWillChange()
|
|
|
327
|
return true
|
|
|
328
|
}
|
|
|
329
|
|
|
|
330
|
@discardableResult
|
|
|
331
|
func finishChargerStandbyMeasurement(for meterMACAddress: String, save: Bool) -> Bool {
|
|
|
332
|
let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
|
|
|
333
|
guard let session = activeChargerStandbySessions[normalizedMAC] else {
|
|
|
334
|
return false
|
|
|
335
|
}
|
|
|
336
|
|
|
|
337
|
session.stop()
|
|
|
338
|
|
|
|
339
|
guard save else {
|
|
|
340
|
activeChargerStandbySessions[normalizedMAC] = nil
|
|
|
341
|
scheduleObjectWillChange()
|
|
|
342
|
return true
|
|
|
343
|
}
|
|
|
344
|
|
|
|
345
|
guard let summary = session.makeSummary() else {
|
|
|
346
|
scheduleObjectWillChange()
|
|
|
347
|
return false
|
|
|
348
|
}
|
|
|
349
|
|
|
|
350
|
let didSave = chargerStandbyPowerStore.save(summary)
|
|
|
351
|
if didSave {
|
|
|
352
|
activeChargerStandbySessions[normalizedMAC] = nil
|
|
|
353
|
reloadChargedDevices()
|
|
|
354
|
} else {
|
|
|
355
|
scheduleObjectWillChange()
|
|
|
356
|
}
|
|
|
357
|
|
|
|
358
|
return didSave
|
|
|
359
|
}
|
|
|
360
|
|
|
Bogdan Timofte
authored
a month ago
|
361
|
@discardableResult
|
|
|
362
|
func deleteChargerStandbyMeasurement(id: UUID, chargerID: UUID) -> Bool {
|
|
|
363
|
let didDelete = chargerStandbyPowerStore.removeMeasurement(id: id, chargerID: chargerID)
|
|
|
364
|
if didDelete {
|
|
|
365
|
reloadChargedDevices()
|
|
|
366
|
} else {
|
|
|
367
|
scheduleObjectWillChange()
|
|
|
368
|
}
|
|
|
369
|
return didDelete
|
|
|
370
|
}
|
|
|
371
|
|
|
Bogdan Timofte
authored
a month ago
|
372
|
@discardableResult
|
|
Bogdan Timofte
authored
a month ago
|
373
|
func createDevice(
|
|
Bogdan Timofte
authored
a month ago
|
374
|
name: String,
|
|
|
375
|
deviceClass: ChargedDeviceClass,
|
|
Bogdan Timofte
authored
a month ago
|
376
|
templateID: String?,
|
|
Bogdan Timofte
authored
a month ago
|
377
|
chargingStateAvailability: ChargingStateAvailability,
|
|
Bogdan Timofte
authored
a month ago
|
378
|
supportsWiredCharging: Bool,
|
|
|
379
|
supportsWirelessCharging: Bool,
|
|
|
380
|
wirelessChargingProfile: WirelessChargingProfile,
|
|
Bogdan Timofte
authored
a month ago
|
381
|
configuredCompletionCurrents: [ChargeSessionKind: Double],
|
|
Bogdan Timofte
authored
a month ago
|
382
|
notes: String?,
|
|
|
383
|
meterMACAddress: String?
|
|
|
384
|
) -> Bool {
|
|
Bogdan Timofte
authored
a month ago
|
385
|
let didSave = chargeInsightsStore?.createDevice(
|
|
Bogdan Timofte
authored
a month ago
|
386
|
name: name,
|
|
|
387
|
deviceClass: deviceClass,
|
|
Bogdan Timofte
authored
a month ago
|
388
|
templateID: templateID,
|
|
Bogdan Timofte
authored
a month ago
|
389
|
chargingStateAvailability: chargingStateAvailability,
|
|
Bogdan Timofte
authored
a month ago
|
390
|
supportsWiredCharging: supportsWiredCharging,
|
|
|
391
|
supportsWirelessCharging: supportsWirelessCharging,
|
|
|
392
|
wirelessChargingProfile: wirelessChargingProfile,
|
|
Bogdan Timofte
authored
a month ago
|
393
|
configuredCompletionCurrents: configuredCompletionCurrents,
|
|
Bogdan Timofte
authored
a month ago
|
394
|
notes: notes,
|
|
|
395
|
assignTo: meterMACAddress
|
|
|
396
|
) ?? false
|
|
|
397
|
|
|
|
398
|
if didSave {
|
|
|
399
|
reloadChargedDevices()
|
|
|
400
|
}
|
|
|
401
|
|
|
|
402
|
return didSave
|
|
|
403
|
}
|
|
|
404
|
|
|
|
405
|
@discardableResult
|
|
Bogdan Timofte
authored
a month ago
|
406
|
func createCharger(
|
|
|
407
|
name: String,
|
|
Bogdan Timofte
authored
a month ago
|
408
|
chargerType: ChargerType,
|
|
Bogdan Timofte
authored
a month ago
|
409
|
notes: String?,
|
|
|
410
|
meterMACAddress: String?
|
|
|
411
|
) -> Bool {
|
|
|
412
|
let didSave = chargeInsightsStore?.createCharger(
|
|
|
413
|
name: name,
|
|
Bogdan Timofte
authored
a month ago
|
414
|
chargerType: chargerType,
|
|
Bogdan Timofte
authored
a month ago
|
415
|
notes: notes,
|
|
|
416
|
assignTo: meterMACAddress
|
|
|
417
|
) ?? false
|
|
|
418
|
|
|
|
419
|
if didSave {
|
|
|
420
|
reloadChargedDevices()
|
|
|
421
|
}
|
|
|
422
|
|
|
|
423
|
return didSave
|
|
|
424
|
}
|
|
|
425
|
|
|
|
426
|
@discardableResult
|
|
|
427
|
func updateDevice(
|
|
Bogdan Timofte
authored
a month ago
|
428
|
id: UUID,
|
|
|
429
|
name: String,
|
|
|
430
|
deviceClass: ChargedDeviceClass,
|
|
Bogdan Timofte
authored
a month ago
|
431
|
templateID: String?,
|
|
Bogdan Timofte
authored
a month ago
|
432
|
chargingStateAvailability: ChargingStateAvailability,
|
|
Bogdan Timofte
authored
a month ago
|
433
|
supportsWiredCharging: Bool,
|
|
|
434
|
supportsWirelessCharging: Bool,
|
|
|
435
|
wirelessChargingProfile: WirelessChargingProfile,
|
|
Bogdan Timofte
authored
a month ago
|
436
|
configuredCompletionCurrents: [ChargeSessionKind: Double],
|
|
Bogdan Timofte
authored
a month ago
|
437
|
notes: String?
|
|
|
438
|
) -> Bool {
|
|
Bogdan Timofte
authored
a month ago
|
439
|
let didSave = chargeInsightsStore?.updateDevice(
|
|
Bogdan Timofte
authored
a month ago
|
440
|
id: id,
|
|
|
441
|
name: name,
|
|
|
442
|
deviceClass: deviceClass,
|
|
Bogdan Timofte
authored
a month ago
|
443
|
templateID: templateID,
|
|
Bogdan Timofte
authored
a month ago
|
444
|
chargingStateAvailability: chargingStateAvailability,
|
|
Bogdan Timofte
authored
a month ago
|
445
|
supportsWiredCharging: supportsWiredCharging,
|
|
|
446
|
supportsWirelessCharging: supportsWirelessCharging,
|
|
|
447
|
wirelessChargingProfile: wirelessChargingProfile,
|
|
Bogdan Timofte
authored
a month ago
|
448
|
configuredCompletionCurrents: configuredCompletionCurrents,
|
|
Bogdan Timofte
authored
a month ago
|
449
|
notes: notes
|
|
|
450
|
) ?? false
|
|
|
451
|
|
|
|
452
|
if didSave {
|
|
|
453
|
reloadChargedDevices()
|
|
|
454
|
}
|
|
|
455
|
|
|
|
456
|
return didSave
|
|
|
457
|
}
|
|
|
458
|
|
|
|
459
|
@discardableResult
|
|
Bogdan Timofte
authored
a month ago
|
460
|
func updateCharger(
|
|
|
461
|
id: UUID,
|
|
|
462
|
name: String,
|
|
Bogdan Timofte
authored
a month ago
|
463
|
chargerType: ChargerType,
|
|
Bogdan Timofte
authored
a month ago
|
464
|
notes: String?
|
|
|
465
|
) -> Bool {
|
|
|
466
|
let didSave = chargeInsightsStore?.updateCharger(
|
|
|
467
|
id: id,
|
|
|
468
|
name: name,
|
|
Bogdan Timofte
authored
a month ago
|
469
|
chargerType: chargerType,
|
|
Bogdan Timofte
authored
a month ago
|
470
|
notes: notes
|
|
Bogdan Timofte
authored
a month ago
|
471
|
) ?? false
|
|
|
472
|
|
|
|
473
|
if didSave {
|
|
|
474
|
reloadChargedDevices()
|
|
|
475
|
}
|
|
|
476
|
|
|
|
477
|
return didSave
|
|
|
478
|
}
|
|
|
479
|
|
|
|
480
|
@discardableResult
|
|
|
481
|
func assignChargedDevice(_ chargedDeviceID: UUID, to meterMACAddress: String) -> Bool {
|
|
|
482
|
let didSave = chargeInsightsStore?.assignChargedDevice(id: chargedDeviceID, to: meterMACAddress) ?? false
|
|
|
483
|
if didSave {
|
|
|
484
|
reloadChargedDevices()
|
|
|
485
|
}
|
|
|
486
|
return didSave
|
|
|
487
|
}
|
|
|
488
|
|
|
|
489
|
@discardableResult
|
|
|
490
|
func assignCharger(_ chargerID: UUID, to meterMACAddress: String) -> Bool {
|
|
|
491
|
let didSave = chargeInsightsStore?.assignCharger(id: chargerID, to: meterMACAddress) ?? false
|
|
|
492
|
if didSave {
|
|
|
493
|
reloadChargedDevices()
|
|
|
494
|
}
|
|
|
495
|
return didSave
|
|
|
496
|
}
|
|
|
497
|
|
|
Bogdan Timofte
authored
a month ago
|
498
|
func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
|
|
|
499
|
guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
|
|
|
500
|
return
|
|
|
501
|
}
|
|
Bogdan Timofte
authored
a month ago
|
502
|
guard activeSession.status.isOpen else {
|
|
Bogdan Timofte
authored
a month ago
|
503
|
return
|
|
|
504
|
}
|
|
|
505
|
meter.restoreChargeMonitoringIfNeeded(from: activeSession)
|
|
|
506
|
}
|
|
|
507
|
|
|
Bogdan Timofte
authored
a month ago
|
508
|
@discardableResult
|
|
Bogdan Timofte
authored
a month ago
|
509
|
func startChargeSession(
|
|
|
510
|
for meter: Meter,
|
|
|
511
|
chargedDeviceID: UUID,
|
|
|
512
|
chargerID: UUID?,
|
|
|
513
|
chargingTransportMode: ChargingTransportMode,
|
|
|
514
|
chargingStateMode: ChargingStateMode,
|
|
|
515
|
autoStopEnabled: Bool,
|
|
Bogdan Timofte
authored
a month ago
|
516
|
initialBatteryPercent: Double?,
|
|
|
517
|
startsFromFlatBattery: Bool
|
|
Bogdan Timofte
authored
a month ago
|
518
|
) -> Bool {
|
|
Bogdan Timofte
authored
a month ago
|
519
|
meter.resetMeterCountersForNewSession()
|
|
|
520
|
|
|
Bogdan Timofte
authored
a month ago
|
521
|
guard let snapshot = meter.chargingMonitorSnapshot else {
|
|
|
522
|
return false
|
|
|
523
|
}
|
|
|
524
|
|
|
Bogdan Timofte
authored
a month ago
|
525
|
let didSave = chargeInsightsStore?.startSession(
|
|
|
526
|
for: snapshot,
|
|
|
527
|
chargedDeviceID: chargedDeviceID,
|
|
|
528
|
chargerID: chargerID,
|
|
|
529
|
chargingTransportMode: chargingTransportMode,
|
|
|
530
|
chargingStateMode: chargingStateMode,
|
|
|
531
|
autoStopEnabled: autoStopEnabled,
|
|
Bogdan Timofte
authored
a month ago
|
532
|
initialBatteryPercent: initialBatteryPercent,
|
|
|
533
|
startsFromFlatBattery: startsFromFlatBattery
|
|
Bogdan Timofte
authored
a month ago
|
534
|
) ?? false
|
|
|
535
|
if didSave {
|
|
Bogdan Timofte
authored
a month ago
|
536
|
meter.resetChargeRecordGraph()
|
|
Bogdan Timofte
authored
a month ago
|
537
|
let activeSession = chargeInsightsStore?.activeChargeSessionSummary(
|
|
|
538
|
forMeterMACAddress: meter.btSerial.macAddress.description
|
|
|
539
|
)
|
|
|
540
|
if let activeSession,
|
|
Bogdan Timofte
authored
a month ago
|
541
|
meter.supportsRecordingThreshold,
|
|
|
542
|
activeSession.stopThresholdAmps > 0 {
|
|
|
543
|
meter.recordingTreshold = activeSession.stopThresholdAmps
|
|
|
544
|
}
|
|
Bogdan Timofte
authored
a month ago
|
545
|
if let activeSession {
|
|
|
546
|
meter.restoreChargeMonitoringIfNeeded(from: activeSession)
|
|
|
547
|
}
|
|
|
548
|
reloadChargedDevices()
|
|
Bogdan Timofte
authored
a month ago
|
549
|
}
|
|
|
550
|
return didSave
|
|
|
551
|
}
|
|
|
552
|
|
|
|
553
|
@discardableResult
|
|
|
554
|
func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
|
|
|
555
|
let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date()
|
|
Bogdan Timofte
authored
a month ago
|
556
|
|
|
|
557
|
if let meter {
|
|
|
558
|
_ = persistChargeSnapshot(from: meter, observedAt: observedAt)
|
|
|
559
|
} else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
|
|
|
560
|
_ = flushPendingChargeObservation(for: meterMACAddress)
|
|
|
561
|
}
|
|
|
562
|
|
|
Bogdan Timofte
authored
a month ago
|
563
|
let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false
|
|
|
564
|
if didSave {
|
|
|
565
|
reloadChargedDevices()
|
|
|
566
|
}
|
|
|
567
|
return didSave
|
|
|
568
|
}
|
|
|
569
|
|
|
|
570
|
@discardableResult
|
|
|
571
|
func resumeChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
|
|
|
572
|
let snapshot = meter?.chargingMonitorSnapshot
|
|
|
573
|
let didSave = chargeInsightsStore?.resumeSession(id: sessionID, snapshot: snapshot) ?? false
|
|
|
574
|
if didSave {
|
|
|
575
|
reloadChargedDevices()
|
|
|
576
|
}
|
|
|
577
|
return didSave
|
|
|
578
|
}
|
|
|
579
|
|
|
|
580
|
@discardableResult
|
|
Bogdan Timofte
authored
a month ago
|
581
|
func stopChargeSession(sessionID: UUID, finalBatteryPercent: Double? = nil, from meter: Meter? = nil) -> Bool {
|
|
|
582
|
if let meter {
|
|
|
583
|
_ = persistChargeSnapshot(from: meter)
|
|
|
584
|
} else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
|
|
Bogdan Timofte
authored
a month ago
|
585
|
_ = flushPendingChargeObservation(for: meterMACAddress)
|
|
|
586
|
}
|
|
|
587
|
|
|
Bogdan Timofte
authored
a month ago
|
588
|
let didSave = chargeInsightsStore?.stopSession(
|
|
|
589
|
id: sessionID,
|
|
Bogdan Timofte
authored
a month ago
|
590
|
finalBatteryPercent: finalBatteryPercent
|
|
Bogdan Timofte
authored
a month ago
|
591
|
) ?? false
|
|
Bogdan Timofte
authored
a month ago
|
592
|
reloadChargedDevices()
|
|
Bogdan Timofte
authored
a month ago
|
593
|
return didSave
|
|
|
594
|
}
|
|
|
595
|
|
|
|
596
|
func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) {
|
|
|
597
|
guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
|
|
|
598
|
return
|
|
|
599
|
}
|
|
|
600
|
|
|
Bogdan Timofte
authored
a month ago
|
601
|
stageChargeObservation(snapshot)
|
|
Bogdan Timofte
authored
a month ago
|
602
|
}
|
|
|
603
|
|
|
|
604
|
@discardableResult
|
|
Bogdan Timofte
authored
a month ago
|
605
|
func addBatteryCheckpoint(percent: Double, for meter: Meter) -> Bool {
|
|
Bogdan Timofte
authored
a month ago
|
606
|
_ = persistChargeSnapshot(from: meter)
|
|
Bogdan Timofte
authored
a month ago
|
607
|
|
|
|
608
|
let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
|
|
|
609
|
let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
|
|
|
610
|
let checkpointChargeAh = activeSession.map { displayedSessionChargeAh(for: $0, on: meter) }
|
|
|
611
|
|
|
Bogdan Timofte
authored
a month ago
|
612
|
let didSave = chargeInsightsStore?.addBatteryCheckpoint(
|
|
|
613
|
percent: percent,
|
|
Bogdan Timofte
authored
a month ago
|
614
|
for: meter.btSerial.macAddress.description,
|
|
|
615
|
measuredEnergyWh: checkpointEnergyWh,
|
|
|
616
|
measuredChargeAh: checkpointChargeAh
|
|
Bogdan Timofte
authored
a month ago
|
617
|
) ?? false
|
|
|
618
|
|
|
|
619
|
if didSave {
|
|
|
620
|
reloadChargedDevices()
|
|
|
621
|
}
|
|
|
622
|
|
|
|
623
|
return didSave
|
|
|
624
|
}
|
|
|
625
|
|
|
|
626
|
@discardableResult
|
|
Bogdan Timofte
authored
a month ago
|
627
|
func addBatteryCheckpoint(percent: Double, for sessionID: UUID) -> Bool {
|
|
|
628
|
guard canAddBatteryCheckpoint(to: sessionID) else {
|
|
|
629
|
return false
|
|
|
630
|
}
|
|
|
631
|
|
|
Bogdan Timofte
authored
a month ago
|
632
|
let didSave = chargeInsightsStore?.addBatteryCheckpoint(
|
|
|
633
|
percent: percent,
|
|
|
634
|
for: sessionID
|
|
|
635
|
) ?? false
|
|
|
636
|
|
|
|
637
|
if didSave {
|
|
|
638
|
reloadChargedDevices()
|
|
|
639
|
}
|
|
|
640
|
|
|
|
641
|
return didSave
|
|
|
642
|
}
|
|
|
643
|
|
|
Bogdan Timofte
authored
a month ago
|
644
|
@discardableResult
|
|
|
645
|
func addBatteryCheckpoint(
|
|
|
646
|
percent: Double,
|
|
|
647
|
for sessionID: UUID,
|
|
|
648
|
measuredEnergyWh: Double?,
|
|
|
649
|
measuredChargeAh: Double?
|
|
|
650
|
) -> Bool {
|
|
Bogdan Timofte
authored
a month ago
|
651
|
guard canAddBatteryCheckpoint(to: sessionID) else {
|
|
|
652
|
return false
|
|
|
653
|
}
|
|
|
654
|
|
|
Bogdan Timofte
authored
a month ago
|
655
|
let didSave = chargeInsightsStore?.addBatteryCheckpoint(
|
|
|
656
|
percent: percent,
|
|
|
657
|
for: sessionID,
|
|
|
658
|
measuredEnergyWh: measuredEnergyWh,
|
|
|
659
|
measuredChargeAh: measuredChargeAh
|
|
|
660
|
) ?? false
|
|
|
661
|
|
|
|
662
|
if didSave {
|
|
|
663
|
reloadChargedDevices()
|
|
|
664
|
}
|
|
|
665
|
|
|
|
666
|
return didSave
|
|
|
667
|
}
|
|
|
668
|
|
|
Bogdan Timofte
authored
a month ago
|
669
|
func canAddBatteryCheckpoint(to sessionID: UUID) -> Bool {
|
|
|
670
|
guard let session = chargeSessionSummary(id: sessionID),
|
|
|
671
|
session.status.isOpen,
|
|
|
672
|
let meterMACAddress = session.meterMACAddress else {
|
|
|
673
|
return false
|
|
|
674
|
}
|
|
|
675
|
|
|
|
676
|
return meter(for: meterMACAddress) != nil
|
|
|
677
|
}
|
|
|
678
|
|
|
|
679
|
func batteryCheckpointCaptureRequirementMessage(for sessionID: UUID) -> String? {
|
|
|
680
|
guard let session = chargeSessionSummary(id: sessionID) else {
|
|
|
681
|
return "Battery checkpoints are available only while the charge session is still active."
|
|
|
682
|
}
|
|
|
683
|
|
|
|
684
|
guard session.status.isOpen else {
|
|
|
685
|
return "Battery checkpoints are available only while the charge session is still active."
|
|
|
686
|
}
|
|
|
687
|
|
|
|
688
|
guard let meterMACAddress = session.meterMACAddress,
|
|
|
689
|
meter(for: meterMACAddress) != nil else {
|
|
|
690
|
return "Add battery checkpoints only on the device that is actively monitoring this charging session. Devices following the session through iCloud may not have data that is fresh or precise enough."
|
|
|
691
|
}
|
|
|
692
|
|
|
|
693
|
return nil
|
|
|
694
|
}
|
|
|
695
|
|
|
Bogdan Timofte
authored
a month ago
|
696
|
func batteryCheckpointPlausibilityWarning(
|
|
|
697
|
percent: Double,
|
|
Bogdan Timofte
authored
a month ago
|
698
|
for sessionID: UUID,
|
|
|
699
|
effectiveEnergyWhOverride: Double? = nil
|
|
Bogdan Timofte
authored
a month ago
|
700
|
) -> BatteryCheckpointPlausibilityWarning? {
|
|
|
701
|
guard let session = chargeSessionSummary(id: sessionID) else {
|
|
|
702
|
return nil
|
|
|
703
|
}
|
|
Bogdan Timofte
authored
a month ago
|
704
|
return batteryCheckpointPlausibilityWarning(
|
|
|
705
|
percent: percent,
|
|
|
706
|
for: session,
|
|
|
707
|
effectiveEnergyWhOverride: effectiveEnergyWhOverride
|
|
|
708
|
)
|
|
Bogdan Timofte
authored
a month ago
|
709
|
}
|
|
|
710
|
|
|
|
711
|
@discardableResult
|
|
|
712
|
func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
|
|
|
713
|
let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
|
|
|
714
|
id: checkpointID,
|
|
|
715
|
from: sessionID
|
|
|
716
|
) ?? false
|
|
|
717
|
|
|
|
718
|
if didDelete {
|
|
Bogdan Timofte
authored
a month ago
|
719
|
reloadChargedDevices()
|
|
Bogdan Timofte
authored
a month ago
|
720
|
}
|
|
|
721
|
|
|
|
722
|
return didDelete
|
|
|
723
|
}
|
|
|
724
|
|
|
Bogdan Timofte
authored
a month ago
|
725
|
@discardableResult
|
|
|
726
|
func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
|
|
|
727
|
let didSave = chargeInsightsStore?.setSessionTrim(
|
|
|
728
|
sessionID: sessionID,
|
|
|
729
|
start: start,
|
|
|
730
|
end: end
|
|
|
731
|
) ?? false
|
|
|
732
|
if didSave {
|
|
|
733
|
reloadChargedDevices()
|
|
|
734
|
}
|
|
|
735
|
return didSave
|
|
|
736
|
}
|
|
|
737
|
|
|
Bogdan Timofte
authored
a month ago
|
738
|
@discardableResult
|
|
|
739
|
func flushChargeInsights() -> Bool {
|
|
Bogdan Timofte
authored
a month ago
|
740
|
let didFlushObservations = flushAllPendingChargeObservations()
|
|
Bogdan Timofte
authored
a month ago
|
741
|
let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
|
|
Bogdan Timofte
authored
a month ago
|
742
|
if didFlushObservations || didSave {
|
|
|
743
|
reloadChargedDevices()
|
|
|
744
|
}
|
|
|
745
|
return didFlushObservations || didSave
|
|
Bogdan Timofte
authored
a month ago
|
746
|
}
|
|
|
747
|
|
|
|
748
|
@discardableResult
|
|
|
749
|
func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
|
|
|
750
|
guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
|
|
|
751
|
return false
|
|
|
752
|
}
|
|
|
753
|
return setTargetBatteryPercent(percent, for: activeSession.id)
|
|
|
754
|
}
|
|
|
755
|
|
|
|
756
|
@discardableResult
|
|
|
757
|
func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
|
|
|
758
|
if percent != nil {
|
|
|
759
|
chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
|
|
|
760
|
}
|
|
|
761
|
|
|
|
762
|
let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
|
|
|
763
|
if didSave {
|
|
|
764
|
reloadChargedDevices()
|
|
|
765
|
}
|
|
|
766
|
return didSave
|
|
|
767
|
}
|
|
|
768
|
|
|
|
769
|
@discardableResult
|
|
|
770
|
func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
|
|
|
771
|
let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
|
|
|
772
|
if didSave {
|
|
|
773
|
reloadChargedDevices()
|
|
|
774
|
}
|
|
|
775
|
return didSave
|
|
|
776
|
}
|
|
|
777
|
|
|
|
778
|
@discardableResult
|
|
|
779
|
func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
|
|
|
780
|
let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
|
|
|
781
|
if didSave {
|
|
|
782
|
reloadChargedDevices()
|
|
|
783
|
}
|
|
|
784
|
return didSave
|
|
|
785
|
}
|
|
|
786
|
|
|
|
787
|
@discardableResult
|
|
|
788
|
func deleteChargeSession(sessionID: UUID) -> Bool {
|
|
|
789
|
let deletedSession = chargedDevices
|
|
|
790
|
.flatMap(\.sessions)
|
|
|
791
|
.first(where: { $0.id == sessionID })
|
|
|
792
|
|
|
|
793
|
let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
|
|
|
794
|
guard didDelete else {
|
|
|
795
|
return false
|
|
|
796
|
}
|
|
|
797
|
|
|
Bogdan Timofte
authored
a month ago
|
798
|
if deletedSession?.status.isOpen == true,
|
|
Bogdan Timofte
authored
a month ago
|
799
|
let meterMACAddress = deletedSession?.meterMACAddress,
|
|
|
800
|
let liveMeter = meter(for: meterMACAddress) {
|
|
|
801
|
liveMeter.resetChargeRecord()
|
|
|
802
|
}
|
|
|
803
|
|
|
|
804
|
reloadChargedDevices()
|
|
|
805
|
return true
|
|
|
806
|
}
|
|
|
807
|
|
|
|
808
|
@discardableResult
|
|
|
809
|
func deleteChargedDevice(id: UUID) -> Bool {
|
|
|
810
|
let deletedDevice = chargedDeviceSummary(id: id)
|
|
|
811
|
let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
|
|
|
812
|
guard didDelete else {
|
|
|
813
|
return false
|
|
|
814
|
}
|
|
|
815
|
|
|
Bogdan Timofte
authored
a month ago
|
816
|
if deletedDevice?.isCharger == true {
|
|
|
817
|
_ = chargerStandbyPowerStore.removeMeasurements(for: id)
|
|
|
818
|
for (meterMACAddress, session) in activeChargerStandbySessions where session.chargerID == id {
|
|
|
819
|
session.stop()
|
|
|
820
|
activeChargerStandbySessions[meterMACAddress] = nil
|
|
|
821
|
}
|
|
|
822
|
}
|
|
|
823
|
|
|
Bogdan Timofte
authored
a month ago
|
824
|
if deletedDevice?.isCharger == false,
|
|
Bogdan Timofte
authored
a month ago
|
825
|
deletedDevice?.activeSession?.status.isOpen == true,
|
|
Bogdan Timofte
authored
a month ago
|
826
|
let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
|
|
|
827
|
let liveMeter = meter(for: meterMACAddress) {
|
|
|
828
|
liveMeter.resetChargeRecord()
|
|
|
829
|
}
|
|
|
830
|
|
|
|
831
|
reloadChargedDevices()
|
|
|
832
|
return true
|
|
|
833
|
}
|
|
|
834
|
|
|
|
835
|
@discardableResult
|
|
|
836
|
func createKnownMeter(
|
|
|
837
|
macAddress: String,
|
|
|
838
|
customName: String?,
|
|
|
839
|
modelName: String,
|
|
|
840
|
advertisedName: String?
|
|
|
841
|
) -> Bool {
|
|
|
842
|
let normalizedMAC = Self.normalizedMACAddress(macAddress)
|
|
|
843
|
guard Self.isValidMACAddress(normalizedMAC) else {
|
|
|
844
|
return false
|
|
|
845
|
}
|
|
|
846
|
|
|
|
847
|
registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
|
|
|
848
|
if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
|
|
|
849
|
setMeterName(customName, for: normalizedMAC)
|
|
|
850
|
}
|
|
|
851
|
noteMeterSeen(at: Date(), macAddress: normalizedMAC)
|
|
|
852
|
return true
|
|
|
853
|
}
|
|
|
854
|
|
|
|
855
|
@discardableResult
|
|
|
856
|
func deleteMeter(macAddress: String) -> Bool {
|
|
|
857
|
let normalizedMAC = Self.normalizedMACAddress(macAddress)
|
|
|
858
|
guard Self.isValidMACAddress(normalizedMAC) else {
|
|
|
859
|
return false
|
|
|
860
|
}
|
|
|
861
|
|
|
|
862
|
for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
|
|
|
863
|
meter.disconnect()
|
|
|
864
|
}
|
|
|
865
|
meters = meters.filter { element in
|
|
|
866
|
element.value.btSerial.macAddress.description != normalizedMAC
|
|
|
867
|
}
|
|
|
868
|
|
|
|
869
|
let didDelete = meterStore.remove(macAddress: normalizedMAC)
|
|
|
870
|
if didDelete {
|
|
|
871
|
scheduleObjectWillChange()
|
|
|
872
|
}
|
|
|
873
|
return didDelete
|
|
|
874
|
}
|
|
|
875
|
|
|
Bogdan Timofte
authored
2 months ago
|
876
|
var meterSummaries: [MeterSummary] {
|
|
Bogdan Timofte
authored
a month ago
|
877
|
if let meterSummariesCache, meterSummariesCache.version == meterSummariesVersion {
|
|
|
878
|
return meterSummariesCache.summaries
|
|
|
879
|
}
|
|
|
880
|
|
|
Bogdan Timofte
authored
2 months ago
|
881
|
let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
|
|
|
882
|
let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
|
|
|
883
|
let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys)
|
|
|
884
|
|
|
Bogdan Timofte
authored
a month ago
|
885
|
let summaries = macAddresses.map { macAddress in
|
|
Bogdan Timofte
authored
2 months ago
|
886
|
let liveMeter = liveMetersByMAC[macAddress]
|
|
|
887
|
let record = recordsByMAC[macAddress]
|
|
|
888
|
|
|
Bogdan Timofte
authored
2 months ago
|
889
|
return MeterSummary(
|
|
Bogdan Timofte
authored
2 months ago
|
890
|
macAddress: macAddress,
|
|
Bogdan Timofte
authored
2 months ago
|
891
|
displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record),
|
|
Bogdan Timofte
authored
2 months ago
|
892
|
modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter",
|
|
Bogdan Timofte
authored
2 months ago
|
893
|
advertisedName: liveMeter?.modelString ?? record?.advertisedName,
|
|
|
894
|
lastSeen: liveMeter?.lastSeen ?? record?.lastSeen,
|
|
|
895
|
lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected,
|
|
|
896
|
meter: liveMeter
|
|
|
897
|
)
|
|
|
898
|
}
|
|
|
899
|
.sorted { lhs, rhs in
|
|
Bogdan Timofte
authored
2 months ago
|
900
|
if lhs.meter != nil && rhs.meter == nil {
|
|
|
901
|
return true
|
|
|
902
|
}
|
|
|
903
|
if lhs.meter == nil && rhs.meter != nil {
|
|
|
904
|
return false
|
|
|
905
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
906
|
let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
|
|
|
907
|
if byName != .orderedSame {
|
|
|
908
|
return byName == .orderedAscending
|
|
|
909
|
}
|
|
|
910
|
return lhs.macAddress < rhs.macAddress
|
|
|
911
|
}
|
|
Bogdan Timofte
authored
a month ago
|
912
|
|
|
|
913
|
meterSummariesCache = (version: meterSummariesVersion, summaries: summaries)
|
|
|
914
|
return summaries
|
|
Bogdan Timofte
authored
2 months ago
|
915
|
}
|
|
|
916
|
|
|
Bogdan Timofte
authored
2 months ago
|
917
|
private func scheduleObjectWillChange() {
|
|
|
918
|
DispatchQueue.main.async { [weak self] in
|
|
|
919
|
self?.objectWillChange.send()
|
|
|
920
|
}
|
|
|
921
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
922
|
|
|
Bogdan Timofte
authored
a month ago
|
923
|
private func invalidateMeterSummaries() {
|
|
|
924
|
meterSummariesVersion += 1
|
|
|
925
|
meterSummariesCache = nil
|
|
|
926
|
}
|
|
|
927
|
|
|
Bogdan Timofte
authored
a month ago
|
928
|
private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
|
|
|
929
|
pendingChargedDevicesReloadWorkItem?.cancel()
|
|
|
930
|
|
|
|
931
|
let workItem = DispatchWorkItem { [weak self] in
|
|
|
932
|
self?.reloadChargedDevices()
|
|
|
933
|
}
|
|
|
934
|
pendingChargedDevicesReloadWorkItem = workItem
|
|
|
935
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
|
|
|
936
|
}
|
|
|
937
|
|
|
Bogdan Timofte
authored
a month ago
|
938
|
private func stageChargeObservation(_ snapshot: ChargingMonitorSnapshot, scheduleFlush: Bool = true) {
|
|
|
939
|
let normalizedMAC = Self.normalizedMACAddress(snapshot.meterMACAddress)
|
|
|
940
|
guard !normalizedMAC.isEmpty else {
|
|
|
941
|
return
|
|
|
942
|
}
|
|
|
943
|
|
|
|
944
|
pendingChargeObservationSnapshots[normalizedMAC] = snapshot
|
|
|
945
|
|
|
|
946
|
guard scheduleFlush else {
|
|
|
947
|
return
|
|
|
948
|
}
|
|
|
949
|
|
|
|
950
|
guard pendingChargeObservationWorkItems[normalizedMAC] == nil else {
|
|
|
951
|
return
|
|
|
952
|
}
|
|
|
953
|
|
|
|
954
|
let workItem = DispatchWorkItem { [weak self] in
|
|
|
955
|
guard let self else { return }
|
|
|
956
|
self.pendingChargeObservationWorkItems[normalizedMAC] = nil
|
|
|
957
|
guard let snapshot = self.pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
|
|
|
958
|
return
|
|
|
959
|
}
|
|
|
960
|
// CoreData write on background — DidSave observer handles the reload
|
|
|
961
|
let store = self.chargeInsightsStore
|
|
|
962
|
DispatchQueue.global(qos: .utility).async {
|
|
|
963
|
store?.observe(snapshot: snapshot)
|
|
|
964
|
}
|
|
|
965
|
}
|
|
|
966
|
pendingChargeObservationWorkItems[normalizedMAC] = workItem
|
|
|
967
|
DispatchQueue.main.asyncAfter(deadline: .now() + chargeObservationPersistInterval, execute: workItem)
|
|
|
968
|
}
|
|
|
969
|
|
|
|
970
|
@discardableResult
|
|
|
971
|
private func persistChargeSnapshot(from meter: Meter, observedAt: Date = Date()) -> Bool {
|
|
|
972
|
guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
|
|
|
973
|
return false
|
|
|
974
|
}
|
|
|
975
|
|
|
|
976
|
stageChargeObservation(snapshot, scheduleFlush: false)
|
|
|
977
|
return flushPendingChargeObservation(for: snapshot.meterMACAddress)
|
|
|
978
|
}
|
|
|
979
|
|
|
|
980
|
@discardableResult
|
|
|
981
|
private func flushPendingChargeObservation(for meterMACAddress: String) -> Bool {
|
|
|
982
|
let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
|
|
|
983
|
guard !normalizedMAC.isEmpty else {
|
|
|
984
|
return false
|
|
|
985
|
}
|
|
|
986
|
|
|
|
987
|
pendingChargeObservationWorkItems[normalizedMAC]?.cancel()
|
|
|
988
|
pendingChargeObservationWorkItems[normalizedMAC] = nil
|
|
|
989
|
|
|
|
990
|
guard let snapshot = pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
|
|
|
991
|
return false
|
|
|
992
|
}
|
|
|
993
|
|
|
|
994
|
let didSave = chargeInsightsStore?.observe(snapshot: snapshot) ?? false
|
|
|
995
|
return didSave
|
|
|
996
|
}
|
|
|
997
|
|
|
|
998
|
@discardableResult
|
|
|
999
|
private func flushAllPendingChargeObservations() -> Bool {
|
|
|
1000
|
let pendingMeterMACAddresses = Array(pendingChargeObservationSnapshots.keys)
|
|
|
1001
|
var didSave = false
|
|
|
1002
|
|
|
|
1003
|
for meterMACAddress in pendingMeterMACAddresses {
|
|
|
1004
|
if flushPendingChargeObservation(for: meterMACAddress) {
|
|
|
1005
|
didSave = true
|
|
|
1006
|
}
|
|
|
1007
|
}
|
|
|
1008
|
|
|
|
1009
|
return didSave
|
|
|
1010
|
}
|
|
|
1011
|
|
|
Bogdan Timofte
authored
a month ago
|
1012
|
private func cachedActiveChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
|
|
|
1013
|
let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
|
|
|
1014
|
guard !normalizedMAC.isEmpty else {
|
|
|
1015
|
return nil
|
|
|
1016
|
}
|
|
|
1017
|
|
|
|
1018
|
return chargedDevices
|
|
|
1019
|
.lazy
|
|
|
1020
|
.compactMap(\.activeSession)
|
|
Bogdan Timofte
authored
a month ago
|
1021
|
.first(where: { $0.status.isOpen && $0.meterMACAddress == normalizedMAC })
|
|
Bogdan Timofte
authored
a month ago
|
1022
|
}
|
|
|
1023
|
|
|
Bogdan Timofte
authored
a month ago
|
1024
|
@discardableResult
|
|
|
1025
|
private func expireOverlongChargeSessionsIfNeeded(referenceDate: Date = Date()) -> Bool {
|
|
|
1026
|
chargeInsightsStore?.completeExpiredOpenSessions(referenceDate: referenceDate) ?? false
|
|
|
1027
|
}
|
|
|
1028
|
|
|
Bogdan Timofte
authored
a month ago
|
1029
|
private func reloadChargedDevices() {
|
|
Bogdan Timofte
authored
a month ago
|
1030
|
if Thread.isMainThread == false {
|
|
|
1031
|
DispatchQueue.main.async { [weak self] in
|
|
|
1032
|
self?.reloadChargedDevices()
|
|
|
1033
|
}
|
|
|
1034
|
return
|
|
|
1035
|
}
|
|
|
1036
|
|
|
Bogdan Timofte
authored
a month ago
|
1037
|
pendingChargedDevicesReloadWorkItem?.cancel()
|
|
|
1038
|
pendingChargedDevicesReloadWorkItem = nil
|
|
|
1039
|
|
|
Bogdan Timofte
authored
a month ago
|
1040
|
_ = expireOverlongChargeSessionsIfNeeded()
|
|
|
1041
|
|
|
Bogdan Timofte
authored
a month ago
|
1042
|
guard chargedDevicesReloadInFlight == false else {
|
|
|
1043
|
chargedDevicesReloadPending = true
|
|
|
1044
|
return
|
|
|
1045
|
}
|
|
|
1046
|
|
|
Bogdan Timofte
authored
a month ago
|
1047
|
let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
|
|
Bogdan Timofte
authored
a month ago
|
1048
|
let readStore = chargeInsightsReadStore ?? chargeInsightsStore
|
|
Bogdan Timofte
authored
a month ago
|
1049
|
chargedDevicesReloadInFlight = true
|
|
|
1050
|
chargedDevicesReloadPending = false
|
|
Bogdan Timofte
authored
a month ago
|
1051
|
|
|
|
1052
|
chargedDevicesReloadQueue.async { [weak self] in
|
|
|
1053
|
guard let self else { return }
|
|
|
1054
|
|
|
|
1055
|
readStore?.resetContext()
|
|
|
1056
|
let summaries = (readStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
|
|
|
1057
|
chargedDevice.withStandbyPowerMeasurements(
|
|
|
1058
|
standbyMeasurementsByChargerID[chargedDevice.id] ?? []
|
|
|
1059
|
)
|
|
|
1060
|
}
|
|
|
1061
|
|
|
|
1062
|
DispatchQueue.main.async { [weak self] in
|
|
|
1063
|
guard let self else { return }
|
|
|
1064
|
|
|
|
1065
|
self.chargedDevices = summaries
|
|
|
1066
|
self.chargeNotificationCoordinator.process(chargedDevices: self.deviceSummaries)
|
|
|
1067
|
for meter in self.meters.values {
|
|
|
1068
|
self.restoreChargeMonitoringStateIfNeeded(for: meter)
|
|
|
1069
|
}
|
|
Bogdan Timofte
authored
a month ago
|
1070
|
|
|
|
1071
|
self.chargedDevicesReloadInFlight = false
|
|
|
1072
|
if self.chargedDevicesReloadPending {
|
|
|
1073
|
self.reloadChargedDevices()
|
|
|
1074
|
}
|
|
Bogdan Timofte
authored
a month ago
|
1075
|
}
|
|
Bogdan Timofte
authored
a month ago
|
1076
|
}
|
|
|
1077
|
}
|
|
|
1078
|
|
|
|
1079
|
private func meter(for meterMACAddress: String) -> Meter? {
|
|
|
1080
|
meters.values.first { meter in
|
|
|
1081
|
meter.btSerial.macAddress.description == meterMACAddress
|
|
|
1082
|
}
|
|
|
1083
|
}
|
|
|
1084
|
|
|
Bogdan Timofte
authored
2 months ago
|
1085
|
private func refreshMeterMetadata() {
|
|
|
1086
|
DispatchQueue.main.async { [weak self] in
|
|
|
1087
|
guard let self else { return }
|
|
|
1088
|
var didUpdateAnyMeter = false
|
|
|
1089
|
for meter in self.meters.values {
|
|
|
1090
|
let mac = meter.btSerial.macAddress.description
|
|
|
1091
|
let displayName = self.meterName(for: mac) ?? mac
|
|
|
1092
|
if meter.name != displayName {
|
|
|
1093
|
meter.updateNameFromStore(displayName)
|
|
|
1094
|
didUpdateAnyMeter = true
|
|
|
1095
|
}
|
|
|
1096
|
|
|
|
1097
|
let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
|
|
|
1098
|
meter.reloadTemperatureUnitPreference()
|
|
|
1099
|
if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
|
|
|
1100
|
didUpdateAnyMeter = true
|
|
|
1101
|
}
|
|
|
1102
|
}
|
|
|
1103
|
|
|
|
1104
|
if didUpdateAnyMeter {
|
|
|
1105
|
self.scheduleObjectWillChange()
|
|
|
1106
|
}
|
|
|
1107
|
}
|
|
|
1108
|
}
|
|
Bogdan Timofte
authored
a month ago
|
1109
|
|
|
Bogdan Timofte
authored
a month ago
|
1110
|
private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
|
|
|
1111
|
guard let charger = chargedDeviceSummary(id: session.chargerID),
|
|
|
1112
|
let statistics = session.statistics else {
|
|
|
1113
|
return
|
|
|
1114
|
}
|
|
|
1115
|
|
|
|
1116
|
let content = UNMutableNotificationContent()
|
|
|
1117
|
content.title = "Standby baseline stabilised"
|
|
|
1118
|
content.body = "\(charger.name) is holding around \(statistics.averagePowerWatts.format(decimalDigits: 3)) W. You can save the measurement when ready."
|
|
|
1119
|
content.sound = .default
|
|
|
1120
|
content.threadIdentifier = "charger-standby-\(charger.id.uuidString)"
|
|
|
1121
|
|
|
|
1122
|
let request = UNNotificationRequest(
|
|
|
1123
|
identifier: "charger-standby-\(session.id.uuidString)",
|
|
|
1124
|
content: content,
|
|
|
1125
|
trigger: nil
|
|
|
1126
|
)
|
|
|
1127
|
UNUserNotificationCenter.current().add(request)
|
|
|
1128
|
scheduleObjectWillChange()
|
|
|
1129
|
}
|
|
|
1130
|
|
|
Bogdan Timofte
authored
a month ago
|
1131
|
private func batteryCheckpointPlausibilityWarning(
|
|
|
1132
|
percent: Double,
|
|
Bogdan Timofte
authored
a month ago
|
1133
|
for session: ChargeSessionSummary,
|
|
|
1134
|
effectiveEnergyWhOverride: Double? = nil
|
|
Bogdan Timofte
authored
a month ago
|
1135
|
) -> BatteryCheckpointPlausibilityWarning? {
|
|
|
1136
|
guard percent.isFinite, percent >= 0, percent <= 100 else {
|
|
|
1137
|
return nil
|
|
|
1138
|
}
|
|
|
1139
|
|
|
|
1140
|
let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
|
|
|
1141
|
if lhs.timestamp != rhs.timestamp {
|
|
|
1142
|
return lhs.timestamp < rhs.timestamp
|
|
|
1143
|
}
|
|
|
1144
|
if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
|
|
|
1145
|
return lhs.measuredEnergyWh < rhs.measuredEnergyWh
|
|
|
1146
|
}
|
|
|
1147
|
return lhs.id.uuidString < rhs.id.uuidString
|
|
|
1148
|
}
|
|
|
1149
|
|
|
|
1150
|
if let lastCheckpoint = sortedCheckpoints.last,
|
|
|
1151
|
percent < lastCheckpoint.batteryPercent - 1.5 {
|
|
|
1152
|
return BatteryCheckpointPlausibilityWarning(
|
|
|
1153
|
title: "Checkpoint Goes Backwards",
|
|
|
1154
|
message: "The latest checkpoint is \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))% at \(lastCheckpoint.timestamp.format()). A new value of \(percent.format(decimalDigits: 0))% is unexpectedly lower while the session is still charging."
|
|
|
1155
|
)
|
|
|
1156
|
}
|
|
|
1157
|
|
|
Bogdan Timofte
authored
a month ago
|
1158
|
let effectiveEnergyWh = effectiveEnergyWhOverride
|
|
|
1159
|
?? session.effectiveBatteryEnergyWh
|
|
|
1160
|
?? session.measuredEnergyWh
|
|
|
1161
|
|
|
|
1162
|
if let lastCheckpoint = sortedCheckpoints.last,
|
|
|
1163
|
let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
|
|
|
1164
|
let estimatedCapacityWh = session.capacityEstimateWh
|
|
|
1165
|
?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
|
|
|
1166
|
?? chargedDevice.estimatedBatteryCapacityWh
|
|
|
1167
|
|
|
|
1168
|
if let estimatedCapacityWh, estimatedCapacityWh > 0 {
|
|
|
1169
|
let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
|
|
|
1170
|
let expectedPercent = min(
|
|
|
1171
|
100,
|
|
|
1172
|
max(
|
|
|
1173
|
lastCheckpoint.batteryPercent,
|
|
|
1174
|
lastCheckpoint.batteryPercent + (energyDeltaWh / estimatedCapacityWh) * 100
|
|
|
1175
|
)
|
|
|
1176
|
)
|
|
|
1177
|
let predictionGap = percent - expectedPercent
|
|
|
1178
|
guard abs(predictionGap) >= 4 else {
|
|
|
1179
|
return nil
|
|
|
1180
|
}
|
|
|
1181
|
|
|
|
1182
|
let direction = predictionGap > 0 ? "above" : "below"
|
|
|
1183
|
let gapText = abs(predictionGap).format(decimalDigits: 0)
|
|
|
1184
|
let expectedText = expectedPercent.format(decimalDigits: 0)
|
|
|
1185
|
|
|
|
1186
|
return BatteryCheckpointPlausibilityWarning(
|
|
|
1187
|
title: "Checkpoint Looks Implausible",
|
|
|
1188
|
message: "The last checkpoint stored \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))% at \(lastCheckpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh. The current counted energy is \(effectiveEnergyWh.format(decimalDigits: 2)) Wh, which supports about \(expectedText)% based on \(estimatedCapacityWh.format(decimalDigits: 2)) Wh capacity. The entered value is about \(gapText) percentage points \(direction) that."
|
|
|
1189
|
)
|
|
|
1190
|
}
|
|
|
1191
|
}
|
|
|
1192
|
|
|
Bogdan Timofte
authored
a month ago
|
1193
|
guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
|
|
Bogdan Timofte
authored
a month ago
|
1194
|
let prediction = chargedDevice.batteryLevelPrediction(
|
|
|
1195
|
for: session,
|
|
|
1196
|
effectiveEnergyWhOverride: effectiveEnergyWh
|
|
|
1197
|
)
|
|
Bogdan Timofte
authored
a month ago
|
1198
|
else {
|
|
|
1199
|
return nil
|
|
|
1200
|
}
|
|
|
1201
|
|
|
|
1202
|
let predictionGap = percent - prediction.predictedPercent
|
|
|
1203
|
guard abs(predictionGap) >= 4 else {
|
|
|
1204
|
return nil
|
|
|
1205
|
}
|
|
|
1206
|
|
|
|
1207
|
let direction = predictionGap > 0 ? "above" : "below"
|
|
|
1208
|
let gapText = abs(predictionGap).format(decimalDigits: 0)
|
|
|
1209
|
let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
|
|
|
1210
|
|
|
|
1211
|
if let lastCheckpoint = sortedCheckpoints.last {
|
|
|
1212
|
let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
|
|
|
1213
|
return BatteryCheckpointPlausibilityWarning(
|
|
|
1214
|
title: "Checkpoint Looks Implausible",
|
|
|
1215
|
message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Since the last checkpoint at \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))%, only \(energyDeltaWh.format(decimalDigits: 2)) Wh were added."
|
|
|
1216
|
)
|
|
|
1217
|
}
|
|
|
1218
|
|
|
|
1219
|
return BatteryCheckpointPlausibilityWarning(
|
|
|
1220
|
title: "Checkpoint Looks Implausible",
|
|
|
1221
|
message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Save it only if the device gauge really jumped that much."
|
|
|
1222
|
)
|
|
|
1223
|
}
|
|
Bogdan Timofte
authored
a month ago
|
1224
|
|
|
|
1225
|
private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
|
|
|
1226
|
let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
|
|
Bogdan Timofte
authored
a month ago
|
1227
|
guard session.isTrimmed == false else {
|
|
|
1228
|
return storedEnergyWh
|
|
|
1229
|
}
|
|
Bogdan Timofte
authored
a month ago
|
1230
|
guard session.status.isOpen else {
|
|
|
1231
|
return storedEnergyWh
|
|
|
1232
|
}
|
|
|
1233
|
|
|
|
1234
|
guard session.meterMACAddress == meter.btSerial.macAddress.description else {
|
|
|
1235
|
return storedEnergyWh
|
|
|
1236
|
}
|
|
|
1237
|
|
|
|
1238
|
if let baselineEnergyWh = session.meterEnergyBaselineWh {
|
|
|
1239
|
return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
|
|
|
1240
|
}
|
|
|
1241
|
|
|
|
1242
|
return storedEnergyWh
|
|
|
1243
|
}
|
|
|
1244
|
|
|
|
1245
|
private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
|
|
|
1246
|
let storedChargeAh = session.measuredChargeAh
|
|
Bogdan Timofte
authored
a month ago
|
1247
|
guard session.isTrimmed == false else {
|
|
|
1248
|
return storedChargeAh
|
|
|
1249
|
}
|
|
Bogdan Timofte
authored
a month ago
|
1250
|
guard session.status.isOpen else {
|
|
|
1251
|
return storedChargeAh
|
|
|
1252
|
}
|
|
|
1253
|
|
|
|
1254
|
guard session.meterMACAddress == meter.btSerial.macAddress.description else {
|
|
|
1255
|
return storedChargeAh
|
|
|
1256
|
}
|
|
|
1257
|
|
|
|
1258
|
if let baselineChargeAh = session.meterChargeBaselineAh {
|
|
|
1259
|
return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
|
|
|
1260
|
}
|
|
|
1261
|
|
|
|
1262
|
return storedChargeAh
|
|
|
1263
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
1264
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
1265
|
|
|
|
1266
|
extension AppData.MeterSummary {
|
|
|
1267
|
var tint: Color {
|
|
|
1268
|
switch modelSummary {
|
|
|
1269
|
case "UM25C":
|
|
|
1270
|
return .blue
|
|
|
1271
|
case "UM34C":
|
|
|
1272
|
return .yellow
|
|
|
1273
|
case "TC66C":
|
|
|
1274
|
return Model.TC66C.color
|
|
|
1275
|
default:
|
|
|
1276
|
return .secondary
|
|
|
1277
|
}
|
|
|
1278
|
}
|
|
|
1279
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
1280
|
|
|
Bogdan Timofte
authored
a month ago
|
1281
|
extension AppData {
|
|
Bogdan Timofte
authored
2 months ago
|
1282
|
static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
|
|
|
1283
|
if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
|
|
|
1284
|
return liveName
|
|
|
1285
|
}
|
|
|
1286
|
if let customName = record?.customName {
|
|
|
1287
|
return customName
|
|
|
1288
|
}
|
|
|
1289
|
if let advertisedName = record?.advertisedName {
|
|
|
1290
|
return advertisedName
|
|
|
1291
|
}
|
|
|
1292
|
if let recordModel = record?.modelName {
|
|
|
1293
|
return recordModel
|
|
|
1294
|
}
|
|
|
1295
|
if let liveModel = liveMeter?.deviceModelSummary {
|
|
|
1296
|
return liveModel
|
|
|
1297
|
}
|
|
|
1298
|
return "Meter"
|
|
|
1299
|
}
|
|
Bogdan Timofte
authored
a month ago
|
1300
|
|
|
|
1301
|
static func normalizedMACAddress(_ macAddress: String) -> String {
|
|
|
1302
|
macAddress
|
|
|
1303
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
1304
|
.uppercased()
|
|
|
1305
|
}
|
|
|
1306
|
|
|
|
1307
|
static func isValidMACAddress(_ macAddress: String) -> Bool {
|
|
|
1308
|
macAddress.range(
|
|
|
1309
|
of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
|
|
|
1310
|
options: .regularExpression
|
|
|
1311
|
) != nil
|
|
|
1312
|
}
|
|
|
1313
|
}
|
|
|
1314
|
|
|
|
1315
|
private final class ChargeNotificationCoordinator {
|
|
|
1316
|
private struct Payload {
|
|
|
1317
|
let id: String
|
|
|
1318
|
let title: String
|
|
|
1319
|
let body: String
|
|
|
1320
|
let threadIdentifier: String
|
|
|
1321
|
}
|
|
|
1322
|
|
|
|
1323
|
private let notificationCenter = UNUserNotificationCenter.current()
|
|
|
1324
|
private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
|
|
|
1325
|
private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
|
|
|
1326
|
private var inFlightEventIDs: Set<String> = []
|
|
|
1327
|
|
|
|
1328
|
func ensureAuthorizationIfNeeded() {
|
|
|
1329
|
notificationCenter.getNotificationSettings { [weak self] settings in
|
|
|
1330
|
guard settings.authorizationStatus == .notDetermined else {
|
|
|
1331
|
return
|
|
|
1332
|
}
|
|
|
1333
|
|
|
|
1334
|
self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
|
|
|
1335
|
if let error {
|
|
|
1336
|
track("Notification authorization request failed: \(error.localizedDescription)")
|
|
|
1337
|
}
|
|
|
1338
|
}
|
|
|
1339
|
}
|
|
|
1340
|
}
|
|
|
1341
|
|
|
|
1342
|
func process(chargedDevices: [ChargedDeviceSummary]) {
|
|
|
1343
|
let now = Date()
|
|
|
1344
|
let pendingPayloads = chargedDevices.flatMap { chargedDevice in
|
|
|
1345
|
payloads(for: chargedDevice, now: now)
|
|
|
1346
|
}
|
|
|
1347
|
|
|
|
1348
|
for payload in pendingPayloads {
|
|
|
1349
|
scheduleIfNeeded(payload)
|
|
|
1350
|
}
|
|
|
1351
|
}
|
|
|
1352
|
|
|
|
1353
|
private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
|
|
|
1354
|
chargedDevice.sessions.compactMap { session in
|
|
|
1355
|
if let triggeredAt = session.targetBatteryAlertTriggeredAt,
|
|
|
1356
|
now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
|
|
|
1357
|
let targetBatteryPercent = session.targetBatteryPercent {
|
|
|
1358
|
let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
|
|
|
1359
|
?? session.endBatteryPercent
|
|
|
1360
|
?? targetBatteryPercent
|
|
|
1361
|
|
|
|
1362
|
return Payload(
|
|
|
1363
|
id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
|
|
|
1364
|
title: "Battery target reached",
|
|
|
1365
|
body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
|
|
|
1366
|
threadIdentifier: session.id.uuidString
|
|
|
1367
|
)
|
|
|
1368
|
}
|
|
|
1369
|
|
|
|
1370
|
if session.requiresCompletionConfirmation,
|
|
|
1371
|
let requestedAt = session.completionConfirmationRequestedAt,
|
|
|
1372
|
now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
|
|
|
1373
|
let estimatedPercent = session.completionContradictionPercent
|
|
|
1374
|
?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
|
|
|
1375
|
let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
|
|
|
1376
|
let detail = estimatedPercent.map {
|
|
|
1377
|
" Estimated battery is only \($0.format(decimalDigits: 0))%."
|
|
|
1378
|
} ?? ""
|
|
|
1379
|
|
|
|
1380
|
return Payload(
|
|
|
1381
|
id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
|
|
|
1382
|
title: "Confirm charge completion",
|
|
|
1383
|
body: bodyPrefix + detail,
|
|
|
1384
|
threadIdentifier: session.id.uuidString
|
|
|
1385
|
)
|
|
|
1386
|
}
|
|
|
1387
|
|
|
|
1388
|
return nil
|
|
|
1389
|
}
|
|
|
1390
|
}
|
|
|
1391
|
|
|
|
1392
|
private func scheduleIfNeeded(_ payload: Payload) {
|
|
|
1393
|
guard deliveredEventIDs().contains(payload.id) == false else {
|
|
|
1394
|
return
|
|
|
1395
|
}
|
|
|
1396
|
|
|
|
1397
|
guard inFlightEventIDs.contains(payload.id) == false else {
|
|
|
1398
|
return
|
|
|
1399
|
}
|
|
|
1400
|
|
|
|
1401
|
inFlightEventIDs.insert(payload.id)
|
|
|
1402
|
|
|
|
1403
|
notificationCenter.getNotificationSettings { [weak self] settings in
|
|
|
1404
|
guard let self else { return }
|
|
|
1405
|
guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
|
|
|
1406
|
DispatchQueue.main.async {
|
|
|
1407
|
self.inFlightEventIDs.remove(payload.id)
|
|
|
1408
|
}
|
|
|
1409
|
return
|
|
|
1410
|
}
|
|
|
1411
|
|
|
|
1412
|
let content = UNMutableNotificationContent()
|
|
|
1413
|
content.title = payload.title
|
|
|
1414
|
content.body = payload.body
|
|
|
1415
|
content.sound = .default
|
|
|
1416
|
content.threadIdentifier = payload.threadIdentifier
|
|
|
1417
|
|
|
|
1418
|
let request = UNNotificationRequest(
|
|
|
1419
|
identifier: payload.id,
|
|
|
1420
|
content: content,
|
|
|
1421
|
trigger: nil
|
|
|
1422
|
)
|
|
|
1423
|
|
|
|
1424
|
self.notificationCenter.add(request) { error in
|
|
|
1425
|
DispatchQueue.main.async {
|
|
|
1426
|
self.inFlightEventIDs.remove(payload.id)
|
|
|
1427
|
if let error {
|
|
|
1428
|
track("Failed scheduling local notification: \(error.localizedDescription)")
|
|
|
1429
|
return
|
|
|
1430
|
}
|
|
|
1431
|
self.storeDeliveredEventID(payload.id)
|
|
|
1432
|
}
|
|
|
1433
|
}
|
|
|
1434
|
}
|
|
|
1435
|
}
|
|
|
1436
|
|
|
|
1437
|
private func deliveredEventIDs() -> Set<String> {
|
|
|
1438
|
let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
|
|
|
1439
|
return Set(values)
|
|
|
1440
|
}
|
|
|
1441
|
|
|
|
1442
|
private func storeDeliveredEventID(_ id: String) {
|
|
|
1443
|
var values = deliveredEventIDs()
|
|
|
1444
|
values.insert(id)
|
|
|
1445
|
UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
|
|
|
1446
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
1447
|
}
|