Newer Older
797 lines | 34.898kb
Bogdan Timofte authored 2 weeks 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 week ago
12
import CoreData
13
import UIKit
14
import Foundation
15

            
16
// MARK: - Device Name Helper
17
private func getDeviceName() -> String {
18
    let hostname = ProcessInfo.processInfo.hostName
19
    return hostname.trimmingCharacters(in: .whitespacesAndNewlines)
20
}
Bogdan Timofte authored 2 weeks ago
21

            
22
final class AppData : ObservableObject {
Bogdan Timofte authored a week ago
23
    static let myDeviceID: String = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
24
    static let myDeviceName: String = getDeviceName()
25
    private static let cloudStoreRebuildVersion = 3
26

            
27
    private var icloudDefaultsNotification: AnyCancellable?
Bogdan Timofte authored 2 weeks ago
28
    private var bluetoothManagerNotification: AnyCancellable?
Bogdan Timofte authored a week ago
29
    private var coreDataSettingsChangeNotification: AnyCancellable?
Bogdan Timofte authored a week ago
30
    private var coreDataRemoteChangeNotification: AnyCancellable?
Bogdan Timofte authored a week ago
31
    private var cloudSettingsRefreshTimer: AnyCancellable?
Bogdan Timofte authored a week ago
32
    private var observerKeepaliveTimer: AnyCancellable?
33
    private var isRefreshingObservers = false
Bogdan Timofte authored a week ago
34
    private var cloudDeviceSettingsStore: CloudDeviceSettingsStore?
Bogdan Timofte authored a week ago
35
    private var observerStore: ObserverStore?
Bogdan Timofte authored a week ago
36
    private var hasMigratedLegacyDeviceSettings = false
37
    private var persistedMeterNames: [String: String] = [:]
38
    private var persistedTC66TemperatureUnits: [String: String] = [:]
Bogdan Timofte authored 2 weeks ago
39

            
Bogdan Timofte authored a week ago
40
    @Published private(set) var observers: [ObserverRecord] = []
41

            
Bogdan Timofte authored 2 weeks ago
42
    init() {
Bogdan Timofte authored a week ago
43
        persistedMeterNames = legacyMeterNames
44
        persistedTC66TemperatureUnits = legacyTC66TemperatureUnits
45

            
46
        icloudDefaultsNotification = NotificationCenter.default.publisher(for: NSUbiquitousKeyValueStore.didChangeExternallyNotification).sink(receiveValue: handleLegacyICloudDefaultsChange)
Bogdan Timofte authored 2 weeks ago
47
        bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in
Bogdan Timofte authored 2 weeks ago
48
            self?.scheduleObjectWillChange()
Bogdan Timofte authored 2 weeks ago
49
        }
Bogdan Timofte authored 2 weeks ago
50
    }
51

            
52
    let bluetoothManager = BluetoothManager()
53

            
54
    @Published var enableRecordFeature: Bool = true
55

            
Bogdan Timofte authored 2 weeks ago
56
    @Published var meters: [UUID:Meter] = [UUID:Meter]()
Bogdan Timofte authored a week ago
57
    @Published private(set) var knownMetersByMAC: [String: KnownMeterCatalogItem] = [:]
Bogdan Timofte authored 2 weeks ago
58

            
Bogdan Timofte authored a week ago
59
    @ICloudDefault(key: "MeterNames", defaultValue: [:]) private var legacyMeterNames: [String:String]
60
    @ICloudDefault(key: "TC66TemperatureUnits", defaultValue: [:]) private var legacyTC66TemperatureUnits: [String:String]
61

            
62
    func activateCloudDeviceSync(context: NSManagedObjectContext) {
63
        guard cloudDeviceSettingsStore == nil else {
64
            return
65
        }
66

            
67
        context.automaticallyMergesChangesFromParent = true
68
        // Prefer incoming/store values on conflict so frequent local discovery updates
69
        // do not wipe remote connection claims from other devices.
70
        context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
71

            
72
        cloudDeviceSettingsStore = CloudDeviceSettingsStore(context: context)
73
        cloudDeviceSettingsStore?.rebuildCanonicalStoreIfNeeded(version: Self.cloudStoreRebuildVersion)
74
        cloudDeviceSettingsStore?.compactDuplicateEntriesByMAC()
Bogdan Timofte authored a week ago
75

            
76
        // Observer registry setup
77
        observerStore = ObserverStore(context: context)
78
        sendObserverKeepalive() // Initial keepalive
79

            
80
        observerKeepaliveTimer = Timer.publish(every: 15, on: .main, in: .common)
81
            .autoconnect()
82
            .sink { [weak self] _ in
83
                self?.sendObserverKeepalive()
84
                self?.refreshObservers()
85
            }
86

            
87
        // Observe local Core Data changes
Bogdan Timofte authored a week ago
88
        coreDataSettingsChangeNotification = NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context)
89
            .sink { [weak self] _ in
90
                self?.reloadSettingsFromCloudStore(applyToMeters: true)
91
            }
Bogdan Timofte authored a week ago
92

            
93
        // Observe CloudKit remote sync changes (critical for observer registry)
94
        coreDataRemoteChangeNotification = NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange, object: nil)
95
            .sink { [weak self] notification in
96
                track("CloudKit remote change detected: \(notification)")
97
                DispatchQueue.main.async {
98
                    self?.reloadSettingsFromCloudStore(applyToMeters: true)
99
                    self?.refreshObservers()
100
                }
101
            }
102

            
Bogdan Timofte authored a week ago
103
        cloudSettingsRefreshTimer = Timer.publish(every: 10, on: .main, in: .common)
104
            .autoconnect()
105
            .sink { [weak self] _ in
106
                self?.reloadSettingsFromCloudStore(applyToMeters: true)
107
            }
108

            
109
        reloadSettingsFromCloudStore(applyToMeters: false)
110
        migrateLegacySettingsIntoCloudIfNeeded()
111
        cloudDeviceSettingsStore?.clearAllConnections(byDeviceID: Self.myDeviceID)
112
        reloadSettingsFromCloudStore(applyToMeters: true)
Bogdan Timofte authored a week ago
113
        refreshObservers()
114
    }
115

            
116
    private func sendObserverKeepalive() {
117
        let bluetoothEnabled = bluetoothManager.managerState == .poweredOn
118
        observerStore?.upsertSelfAsObserver(
119
            deviceID: Self.myDeviceID,
120
            deviceName: Self.myDeviceName,
121
            bluetoothEnabled: bluetoothEnabled
122
        )
123
    }
124

            
125
    private func refreshObservers() {
126
        guard !isRefreshingObservers else {
127
            return
128
        }
129

            
130
        isRefreshingObservers = true
131
        defer { isRefreshingObservers = false }
132

            
133
        // Keep observer fetch in sync with pending context work without forcing a full object refresh.
134
        observerStore?.refreshContext()
135
        observers = observerStore?.fetchAllObservers() ?? []
Bogdan Timofte authored a week ago
136
    }
137

            
138
    func persistedMeterName(for macAddress: String) -> String? {
139
        persistedMeterNames[macAddress]
140
    }
141

            
142
    func publishMeterConnection(macAddress: String, modelType: String) {
143
        cloudDeviceSettingsStore?.setConnection(
144
            macAddress: macAddress,
145
            deviceID: Self.myDeviceID,
146
            deviceName: Self.myDeviceName,
147
            modelType: modelType
148
        )
149
    }
150

            
151
    func registerMeterDiscovery(macAddress: String, modelType: String, peripheralName: String?) {
152
        cloudDeviceSettingsStore?.recordDiscovery(
153
            macAddress: macAddress,
154
            modelType: modelType,
155
            peripheralName: peripheralName,
156
            seenByDeviceID: Self.myDeviceID,
157
            seenByDeviceName: Self.myDeviceName
158
        )
159
    }
160

            
161
    func clearMeterConnection(macAddress: String) {
162
        cloudDeviceSettingsStore?.clearConnection(macAddress: macAddress, byDeviceID: Self.myDeviceID)
163
    }
164

            
165
    func persistMeterName(_ name: String, for macAddress: String) {
166
        let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines)
167
        persistedMeterNames[macAddress] = normalized
168

            
169
        var legacyValues = legacyMeterNames
170
        legacyValues[macAddress] = normalized
171
        legacyMeterNames = legacyValues
172

            
173
        cloudDeviceSettingsStore?.upsert(macAddress: macAddress, meterName: normalized, tc66TemperatureUnit: nil)
174
    }
175

            
176
    func persistedTC66TemperatureUnitRawValue(for macAddress: String) -> String? {
177
        persistedTC66TemperatureUnits[macAddress]
178
    }
179

            
180
    func persistTC66TemperatureUnit(rawValue: String, for macAddress: String) {
181
        persistedTC66TemperatureUnits[macAddress] = rawValue
182

            
183
        var legacyValues = legacyTC66TemperatureUnits
184
        legacyValues[macAddress] = rawValue
185
        legacyTC66TemperatureUnits = legacyValues
186

            
187
        cloudDeviceSettingsStore?.upsert(macAddress: macAddress, meterName: nil, tc66TemperatureUnit: rawValue)
188
    }
189

            
190
    private func handleLegacyICloudDefaultsChange(notification: NotificationCenter.Publisher.Output) {
Bogdan Timofte authored 2 weeks ago
191
        if let changedKeys = notification.userInfo?["NSUbiquitousKeyValueStoreChangedKeysKey"] as? [String] {
Bogdan Timofte authored a week ago
192
            var requiresMeterRefresh = false
Bogdan Timofte authored 2 weeks ago
193
            for changedKey in changedKeys {
194
                switch changedKey {
195
                case "MeterNames":
Bogdan Timofte authored a week ago
196
                    persistedMeterNames = legacyMeterNames
197
                    requiresMeterRefresh = true
Bogdan Timofte authored 2 weeks ago
198
                case "TC66TemperatureUnits":
Bogdan Timofte authored a week ago
199
                    persistedTC66TemperatureUnits = legacyTC66TemperatureUnits
200
                    requiresMeterRefresh = true
Bogdan Timofte authored 2 weeks ago
201
                default:
202
                    track("Unknown key: '\(changedKey)' changed in iCloud)")
203
                }
204
            }
Bogdan Timofte authored a week ago
205

            
206
            if requiresMeterRefresh {
207
                migrateLegacySettingsIntoCloudIfNeeded(force: true)
208
                applyPersistedSettingsToKnownMeters()
Bogdan Timofte authored 2 weeks ago
209
                scheduleObjectWillChange()
Bogdan Timofte authored 2 weeks ago
210
            }
211
        }
212
    }
Bogdan Timofte authored 2 weeks ago
213

            
Bogdan Timofte authored a week ago
214
    private func migrateLegacySettingsIntoCloudIfNeeded(force: Bool = false) {
215
        guard let cloudDeviceSettingsStore else {
216
            return
217
        }
218
        if hasMigratedLegacyDeviceSettings && !force {
219
            return
220
        }
221

            
222
        let cloudRecords = cloudDeviceSettingsStore.fetchByMacAddress()
223

            
224
        for (macAddress, meterName) in legacyMeterNames {
225
            let cloudName = cloudRecords[macAddress]?.meterName
226
            if cloudName == nil || cloudName?.isEmpty == true {
227
                cloudDeviceSettingsStore.upsert(macAddress: macAddress, meterName: meterName, tc66TemperatureUnit: nil)
228
            }
229
        }
230

            
231
        for (macAddress, unitRawValue) in legacyTC66TemperatureUnits {
232
            let cloudUnit = cloudRecords[macAddress]?.tc66TemperatureUnit
233
            if cloudUnit == nil || cloudUnit?.isEmpty == true {
234
                cloudDeviceSettingsStore.upsert(macAddress: macAddress, meterName: nil, tc66TemperatureUnit: unitRawValue)
235
            }
236
        }
237

            
238
        hasMigratedLegacyDeviceSettings = true
239
    }
240

            
241
    private func reloadSettingsFromCloudStore(applyToMeters: Bool) {
242
        guard let cloudDeviceSettingsStore else {
243
            return
244
        }
245

            
246
        let records = cloudDeviceSettingsStore.fetchAll()
247
        var names = persistedMeterNames
248
        var temperatureUnits = persistedTC66TemperatureUnits
249
        var knownMeters: [String: KnownMeterCatalogItem] = [:]
250

            
251
        for record in records {
252
            if let meterName = record.meterName, !meterName.isEmpty {
253
                names[record.macAddress] = meterName
254
            }
255
            if let unitRawValue = record.tc66TemperatureUnit, !unitRawValue.isEmpty {
256
                temperatureUnits[record.macAddress] = unitRawValue
257
            }
258

            
259
            let displayName: String
260
            if let meterName = record.meterName?.trimmingCharacters(in: .whitespacesAndNewlines), !meterName.isEmpty {
261
                displayName = meterName
262
            } else if let peripheralName = record.lastSeenPeripheralName?.trimmingCharacters(in: .whitespacesAndNewlines), !peripheralName.isEmpty {
263
                displayName = peripheralName
264
            } else {
265
                displayName = record.macAddress
266
            }
267

            
268
            knownMeters[record.macAddress] = KnownMeterCatalogItem(
269
                macAddress: record.macAddress,
270
                displayName: displayName,
271
                modelType: record.modelType,
272
                connectedByDeviceID: record.connectedByDeviceID,
273
                connectedByDeviceName: record.connectedByDeviceName,
274
                connectedAt: record.connectedAt,
275
                connectedExpiryAt: record.connectedExpiryAt,
276
                lastSeenByDeviceID: record.lastSeenByDeviceID,
277
                lastSeenByDeviceName: record.lastSeenByDeviceName,
278
                lastSeenAt: record.lastSeenAt,
279
                lastSeenPeripheralName: record.lastSeenPeripheralName
280
            )
281
        }
282

            
283
        persistedMeterNames = names
284
        persistedTC66TemperatureUnits = temperatureUnits
285
        knownMetersByMAC = knownMeters
286

            
287
        if applyToMeters {
288
            applyPersistedSettingsToKnownMeters()
289
            scheduleObjectWillChange()
290
        }
291
    }
292

            
293
    private func applyPersistedSettingsToKnownMeters() {
294
        for meter in meters.values {
295
            let macAddress = meter.btSerial.macAddress.description
296
            if let newName = persistedMeterNames[macAddress], meter.name != newName {
297
                meter.name = newName
298
            }
299

            
300
            if meter.supportsManualTemperatureUnitSelection {
301
                meter.reloadTemperatureUnitPreference()
302
            }
303
        }
304
    }
305

            
Bogdan Timofte authored 2 weeks ago
306
    private func scheduleObjectWillChange() {
307
        DispatchQueue.main.async { [weak self] in
308
            self?.objectWillChange.send()
309
        }
310
    }
Bogdan Timofte authored 2 weeks ago
311
}
Bogdan Timofte authored a week ago
312

            
Bogdan Timofte authored a week ago
313
struct ObserverRecord: Identifiable, Hashable {
314
    var id: String { deviceID }
315
    let deviceID: String
316
    let deviceName: String
317
    let lastKeepalive: Date
318
    let bluetoothEnabled: Bool
319

            
320
    var isAlive: Bool {
321
        Date().timeIntervalSince(lastKeepalive) < 60 // считается alive если keepalive < 60s
322
    }
323
}
324

            
325
private final class ObserverStore {
326
    private let entityName = "Observer"
327
    private let context: NSManagedObjectContext
328

            
329
    init(context: NSManagedObjectContext) {
330
        self.context = context
331
    }
332

            
333
    func refreshContext() {
334
        context.performAndWait {
335
            // Avoid refreshAllObjects() here; it emits ObjectsDidChange notifications and can recurse.
336
            context.processPendingChanges()
337
        }
338
    }
339

            
340
    func upsertSelfAsObserver(deviceID: String, deviceName: String, bluetoothEnabled: Bool) {
341
        context.performAndWait {
342
            do {
343
                let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
344
                request.predicate = NSPredicate(format: "deviceID == %@", deviceID)
345

            
346
                let object = (try? context.fetch(request).first) ?? NSManagedObject(entity: NSEntityDescription.entity(forEntityName: entityName, in: context)!, insertInto: context)
347

            
348
                object.setValue(deviceID, forKey: "deviceID")
349
                object.setValue(deviceName, forKey: "deviceName")
350
                object.setValue(Date(), forKey: "lastKeepalive")
351
                object.setValue(bluetoothEnabled, forKey: "bluetoothEnabled")
352

            
353
                if context.hasChanges {
354
                    try context.save()
355
                    track("Observer keepalive: \(deviceName)")
356
                }
357
            } catch {
358
                track("Failed to upsert observer: \(error)")
359
            }
360
        }
361
    }
362

            
363
    func fetchAllObservers() -> [ObserverRecord] {
364
        var records: [ObserverRecord] = []
365
        context.performAndWait {
366
            let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
367
            do {
368
                let objects = try context.fetch(request)
369
                records = objects.compactMap { object in
370
                    guard let deviceID = object.value(forKey: "deviceID") as? String,
371
                          let deviceName = object.value(forKey: "deviceName") as? String,
372
                          let lastKeepalive = object.value(forKey: "lastKeepalive") as? Date else {
373
                        return nil
374
                    }
375
                    let bluetoothEnabled = (object.value(forKey: "bluetoothEnabled") as? Bool) ?? true
376
                    return ObserverRecord(
377
                        deviceID: deviceID,
378
                        deviceName: deviceName,
379
                        lastKeepalive: lastKeepalive,
380
                        bluetoothEnabled: bluetoothEnabled
381
                    )
382
                }
383
            } catch {
384
                track("Failed to fetch observers: \(error)")
385
            }
386
        }
387
        return records
388
    }
389
}
390

            
Bogdan Timofte authored a week ago
391
struct KnownMeterCatalogItem: Identifiable, Hashable {
392
    var id: String { macAddress }
393
    let macAddress: String
394
    let displayName: String
395
    let modelType: String?
396
    let connectedByDeviceID: String?
397
    let connectedByDeviceName: String?
398
    let connectedAt: Date?
399
    let connectedExpiryAt: Date?
400
    let lastSeenByDeviceID: String?
401
    let lastSeenByDeviceName: String?
402
    let lastSeenAt: Date?
403
    let lastSeenPeripheralName: String?
404
}
405

            
406
private struct CloudDeviceSettingsRecord {
407
    let macAddress: String
408
    let meterName: String?
409
    let tc66TemperatureUnit: String?
410
    let modelType: String?
411
    let connectedByDeviceID: String?
412
    let connectedByDeviceName: String?
413
    let connectedAt: Date?
414
    let connectedExpiryAt: Date?
415
    let lastSeenAt: Date?
416
    let lastSeenByDeviceID: String?
417
    let lastSeenByDeviceName: String?
418
    let lastSeenPeripheralName: String?
419
}
420

            
421
private final class CloudDeviceSettingsStore {
422
    private let entityName = "DeviceSettings"
423
    private let context: NSManagedObjectContext
424
    private static let rebuildDefaultsKeyPrefix = "CloudDeviceSettingsStore.RebuildVersion"
425

            
426
    init(context: NSManagedObjectContext) {
427
        self.context = context
428
    }
429

            
430
    private func refreshContextObjects() {
431
        context.processPendingChanges()
432
    }
433

            
434
    private func normalizedMACAddress(_ value: String) -> String {
435
        value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
436
    }
437

            
438
    private func fetchObjects(for macAddress: String) throws -> [NSManagedObject] {
439
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
440
        request.predicate = NSPredicate(format: "macAddress == %@", macAddress)
441
        return try context.fetch(request)
442
    }
443

            
444
    private func hasValue(_ value: String?) -> Bool {
445
        guard let value else { return false }
446
        return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
447
    }
448

            
449
    private func preferredObject(from objects: [NSManagedObject]) -> NSManagedObject? {
450
        guard !objects.isEmpty else { return nil }
451
        let now = Date()
452
        return objects.max { lhs, rhs in
453
            let lhsHasOwner = hasValue(lhs.value(forKey: "connectedByDeviceID") as? String)
454
            let rhsHasOwner = hasValue(rhs.value(forKey: "connectedByDeviceID") as? String)
455
            if lhsHasOwner != rhsHasOwner {
456
                return !lhsHasOwner && rhsHasOwner
457
            }
458

            
459
            let lhsOwnerLive = ((lhs.value(forKey: "connectedExpiryAt") as? Date) ?? .distantPast) > now
460
            let rhsOwnerLive = ((rhs.value(forKey: "connectedExpiryAt") as? Date) ?? .distantPast) > now
461
            if lhsOwnerLive != rhsOwnerLive {
462
                return !lhsOwnerLive && rhsOwnerLive
463
            }
464

            
465
            let lhsUpdatedAt = (lhs.value(forKey: "updatedAt") as? Date) ?? .distantPast
466
            let rhsUpdatedAt = (rhs.value(forKey: "updatedAt") as? Date) ?? .distantPast
467
            if lhsUpdatedAt != rhsUpdatedAt {
468
                return lhsUpdatedAt < rhsUpdatedAt
469
            }
470

            
471
            let lhsConnectedAt = (lhs.value(forKey: "connectedAt") as? Date) ?? .distantPast
472
            let rhsConnectedAt = (rhs.value(forKey: "connectedAt") as? Date) ?? .distantPast
473
            return lhsConnectedAt < rhsConnectedAt
474
        }
475
    }
476

            
477
    private func record(from object: NSManagedObject, macAddress: String) -> CloudDeviceSettingsRecord {
478
        let ownerID = stringValue(object, key: "connectedByDeviceID")
479
        return CloudDeviceSettingsRecord(
480
            macAddress: macAddress,
481
            meterName: object.value(forKey: "meterName") as? String,
482
            tc66TemperatureUnit: object.value(forKey: "tc66TemperatureUnit") as? String,
483
            modelType: object.value(forKey: "modelType") as? String,
484
            connectedByDeviceID: ownerID,
485
            connectedByDeviceName: ownerID == nil ? nil : stringValue(object, key: "connectedByDeviceName"),
486
            connectedAt: ownerID == nil ? nil : dateValue(object, key: "connectedAt"),
487
            connectedExpiryAt: ownerID == nil ? nil : dateValue(object, key: "connectedExpiryAt"),
488
            lastSeenAt: object.value(forKey: "lastSeenAt") as? Date,
489
            lastSeenByDeviceID: object.value(forKey: "lastSeenByDeviceID") as? String,
490
            lastSeenByDeviceName: object.value(forKey: "lastSeenByDeviceName") as? String,
491
            lastSeenPeripheralName: object.value(forKey: "lastSeenPeripheralName") as? String
492
        )
493
    }
494

            
495
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
496
        guard let value = object.value(forKey: key) as? String else { return nil }
497
        let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
498
        return normalized.isEmpty ? nil : normalized
499
    }
500

            
501
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
502
        object.value(forKey: key) as? Date
503
    }
504

            
505
    private func mergeBestValues(from source: NSManagedObject, into destination: NSManagedObject) {
506
        if stringValue(destination, key: "meterName") == nil, let value = stringValue(source, key: "meterName") {
507
            destination.setValue(value, forKey: "meterName")
508
        }
509
        if stringValue(destination, key: "tc66TemperatureUnit") == nil, let value = stringValue(source, key: "tc66TemperatureUnit") {
510
            destination.setValue(value, forKey: "tc66TemperatureUnit")
511
        }
512
        if stringValue(destination, key: "modelType") == nil, let value = stringValue(source, key: "modelType") {
513
            destination.setValue(value, forKey: "modelType")
514
        }
515

            
516
        let sourceConnectedExpiry = dateValue(source, key: "connectedExpiryAt") ?? .distantPast
517
        let destinationConnectedExpiry = dateValue(destination, key: "connectedExpiryAt") ?? .distantPast
518
        let destinationOwner = stringValue(destination, key: "connectedByDeviceID")
519
        let sourceOwner = stringValue(source, key: "connectedByDeviceID")
520
        if sourceOwner != nil && (destinationOwner == nil || sourceConnectedExpiry > destinationConnectedExpiry) {
521
            destination.setValue(sourceOwner, forKey: "connectedByDeviceID")
522
            destination.setValue(stringValue(source, key: "connectedByDeviceName"), forKey: "connectedByDeviceName")
523
            destination.setValue(dateValue(source, key: "connectedAt"), forKey: "connectedAt")
524
            destination.setValue(dateValue(source, key: "connectedExpiryAt"), forKey: "connectedExpiryAt")
525
        }
526

            
527
        let sourceLastSeen = dateValue(source, key: "lastSeenAt") ?? .distantPast
528
        let destinationLastSeen = dateValue(destination, key: "lastSeenAt") ?? .distantPast
529
        if sourceLastSeen > destinationLastSeen {
530
            destination.setValue(dateValue(source, key: "lastSeenAt"), forKey: "lastSeenAt")
531
            destination.setValue(stringValue(source, key: "lastSeenByDeviceID"), forKey: "lastSeenByDeviceID")
532
            destination.setValue(stringValue(source, key: "lastSeenByDeviceName"), forKey: "lastSeenByDeviceName")
533
            destination.setValue(stringValue(source, key: "lastSeenPeripheralName"), forKey: "lastSeenPeripheralName")
534
        }
535

            
536
        let sourceUpdatedAt = dateValue(source, key: "updatedAt") ?? .distantPast
537
        let destinationUpdatedAt = dateValue(destination, key: "updatedAt") ?? .distantPast
538
        if sourceUpdatedAt > destinationUpdatedAt {
539
            destination.setValue(sourceUpdatedAt, forKey: "updatedAt")
540
        }
541
    }
542

            
543
    func compactDuplicateEntriesByMAC() {
544
        context.performAndWait {
545
            refreshContextObjects()
546
            let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
547
            do {
548
                let allObjects = try context.fetch(request)
549
                var groupedByMAC: [String: [NSManagedObject]] = [:]
550
                for object in allObjects {
551
                    guard let macAddress = object.value(forKey: "macAddress") as? String, !macAddress.isEmpty else {
552
                        continue
553
                    }
554
                    groupedByMAC[macAddress, default: []].append(object)
555
                }
556

            
557
                var removedDuplicates = 0
558
                for (_, objects) in groupedByMAC {
559
                    guard objects.count > 1, let winner = preferredObject(from: objects) else { continue }
560
                    for duplicate in objects where duplicate.objectID != winner.objectID {
561
                        mergeBestValues(from: duplicate, into: winner)
562
                        context.delete(duplicate)
563
                        removedDuplicates += 1
564
                    }
565
                }
566

            
567
                if context.hasChanges {
568
                    try context.save()
569
                }
570
                if removedDuplicates > 0 {
571
                    track("Compacted \(removedDuplicates) duplicate DeviceSettings row(s)")
572
                }
573
            } catch {
574
                track("Failed compacting duplicate device settings: \(error)")
575
            }
576
        }
577
    }
578

            
579
    func fetchAll() -> [CloudDeviceSettingsRecord] {
580
        var results: [CloudDeviceSettingsRecord] = []
581
        context.performAndWait {
582
            refreshContextObjects()
583
            let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
584
            do {
585
                let allObjects = try context.fetch(request)
586
                var groupedByMAC: [String: [NSManagedObject]] = [:]
587
                for object in allObjects {
588
                    guard let macAddress = object.value(forKey: "macAddress") as? String, !macAddress.isEmpty else {
589
                        continue
590
                    }
591
                    groupedByMAC[normalizedMACAddress(macAddress), default: []].append(object)
592
                }
593

            
594
                results = groupedByMAC.compactMap { macAddress, objects in
595
                    guard let preferred = preferredObject(from: objects) else {
596
                        return nil
597
                    }
598
                    return record(from: preferred, macAddress: macAddress)
599
                }
600
            } catch {
601
                track("Failed loading cloud device settings: \(error)")
602
            }
603
        }
604
        return results
605
    }
606

            
607
    func fetchByMacAddress() -> [String: CloudDeviceSettingsRecord] {
608
        Dictionary(uniqueKeysWithValues: fetchAll().map { ($0.macAddress, $0) })
609
    }
610

            
611
    func upsert(macAddress: String, meterName: String?, tc66TemperatureUnit: String?) {
612
        let macAddress = normalizedMACAddress(macAddress)
613
        guard !macAddress.isEmpty else {
614
            return
615
        }
616

            
617
        context.performAndWait {
618
            do {
619
                refreshContextObjects()
620
                let objects = try fetchObjects(for: macAddress)
621
                let object = preferredObject(from: objects) ?? NSManagedObject(entity: NSEntityDescription.entity(forEntityName: entityName, in: context)!, insertInto: context)
622
                for duplicate in objects where duplicate.objectID != object.objectID {
623
                    context.delete(duplicate)
624
                }
625
                object.setValue(macAddress, forKey: "macAddress")
626

            
627
                if let meterName {
628
                    object.setValue(meterName, forKey: "meterName")
629
                }
630
                if let tc66TemperatureUnit {
631
                    object.setValue(tc66TemperatureUnit, forKey: "tc66TemperatureUnit")
632
                }
633

            
634
                object.setValue(Date(), forKey: "updatedAt")
635

            
636
                if context.hasChanges {
637
                    try context.save()
638
                }
639
            } catch {
640
                track("Failed persisting cloud device settings for \(macAddress): \(error)")
641
            }
642
        }
643
    }
644

            
645
    func setConnection(macAddress: String, deviceID: String, deviceName: String, modelType: String) {
646
        let macAddress = normalizedMACAddress(macAddress)
647
        guard !macAddress.isEmpty, !deviceID.isEmpty else { return }
648
        context.performAndWait {
649
            do {
650
                refreshContextObjects()
651
                let objects = try fetchObjects(for: macAddress)
652
                let object = preferredObject(from: objects) ?? NSManagedObject(entity: NSEntityDescription.entity(forEntityName: entityName, in: context)!, insertInto: context)
653
                for duplicate in objects where duplicate.objectID != object.objectID {
654
                    context.delete(duplicate)
655
                }
656
                object.setValue(macAddress, forKey: "macAddress")
657
                object.setValue(deviceID, forKey: "connectedByDeviceID")
658
                object.setValue(deviceName, forKey: "connectedByDeviceName")
659
                let now = Date()
660
                object.setValue(now, forKey: "connectedAt")
661
                object.setValue(Date().addingTimeInterval(120), forKey: "connectedExpiryAt")
662
                object.setValue(modelType, forKey: "modelType")
663
                object.setValue(now, forKey: "updatedAt")
664
                if context.hasChanges { try context.save() }
665
            } catch {
666
                track("Failed publishing connection for \(macAddress): \(error)")
667
            }
668
        }
669
    }
670

            
671
    func recordDiscovery(macAddress: String, modelType: String, peripheralName: String?, seenByDeviceID: String, seenByDeviceName: String) {
672
        let macAddress = normalizedMACAddress(macAddress)
673
        guard !macAddress.isEmpty else { return }
674
        context.performAndWait {
675
            do {
676
                refreshContextObjects()
677
                let objects = try fetchObjects(for: macAddress)
678
                let object = preferredObject(from: objects) ?? NSManagedObject(entity: NSEntityDescription.entity(forEntityName: entityName, in: context)!, insertInto: context)
679
                for duplicate in objects where duplicate.objectID != object.objectID {
680
                    context.delete(duplicate)
681
                }
682
                let now = Date()
Bogdan Timofte authored a week ago
683
                // Throttle CloudKit updates: only persist discovery once per 2 minutes per device
684
                // to avoid constant conflicts between devices on frequent BT advertisements
Bogdan Timofte authored a week ago
685
                if let previousSeenAt = object.value(forKey: "lastSeenAt") as? Date,
686
                   let previousSeenBy = object.value(forKey: "lastSeenByDeviceID") as? String,
687
                   previousSeenBy == seenByDeviceID,
Bogdan Timofte authored a week ago
688
                   now.timeIntervalSince(previousSeenAt) < 120 {
Bogdan Timofte authored a week ago
689
                    return
690
                }
691
                object.setValue(macAddress, forKey: "macAddress")
692
                object.setValue(modelType, forKey: "modelType")
693
                object.setValue(now, forKey: "lastSeenAt")
694
                object.setValue(seenByDeviceID, forKey: "lastSeenByDeviceID")
695
                object.setValue(seenByDeviceName, forKey: "lastSeenByDeviceName")
696
                if let peripheralName, !peripheralName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
697
                    object.setValue(peripheralName, forKey: "lastSeenPeripheralName")
698
                }
699
                object.setValue(now, forKey: "updatedAt")
700
                if context.hasChanges { try context.save() }
701
            } catch {
702
                track("Failed recording discovery for \(macAddress): \(error)")
703
            }
704
        }
705
    }
706

            
707
    func clearConnection(macAddress: String, byDeviceID deviceID: String) {
708
        let macAddress = normalizedMACAddress(macAddress)
709
        guard !macAddress.isEmpty else { return }
710
        context.performAndWait {
711
            let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
712
            request.predicate = NSPredicate(format: "macAddress == %@ AND connectedByDeviceID == %@", macAddress, deviceID)
713
            do {
714
                let objects = try context.fetch(request)
715
                guard !objects.isEmpty else { return }
716
                for object in objects {
717
                    context.refresh(object, mergeChanges: true)
718
                    object.setValue(nil, forKey: "connectedByDeviceID")
719
                    object.setValue(nil, forKey: "connectedByDeviceName")
720
                    object.setValue(nil, forKey: "connectedAt")
721
                    object.setValue(nil, forKey: "connectedExpiryAt")
722
                    object.setValue(Date(), forKey: "updatedAt")
723
                }
724
                if context.hasChanges { try context.save() }
725
            } catch {
726
                track("Failed clearing connection for \(macAddress): \(error)")
727
            }
728
        }
729
    }
730

            
731
    func clearAllConnections(byDeviceID deviceID: String) {
732
        guard !deviceID.isEmpty else { return }
733
        context.performAndWait {
734
            refreshContextObjects()
735
            let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
736
            request.predicate = NSPredicate(format: "connectedByDeviceID == %@", deviceID)
737
            do {
738
                let objects = try context.fetch(request)
739
                guard !objects.isEmpty else { return }
740
                for object in objects {
741
                    context.refresh(object, mergeChanges: true)
742
                    object.setValue(nil, forKey: "connectedByDeviceID")
743
                    object.setValue(nil, forKey: "connectedByDeviceName")
744
                    object.setValue(nil, forKey: "connectedAt")
745
                    object.setValue(nil, forKey: "connectedExpiryAt")
746
                    object.setValue(Date(), forKey: "updatedAt")
747
                }
748
                if context.hasChanges { try context.save() }
749
                track("Cleared \(objects.count) stale connection claim(s) for this device")
750
            } catch {
751
                track("Failed clearing stale connections: \(error)")
752
            }
753
        }
754
    }
755

            
756
    func rebuildCanonicalStoreIfNeeded(version: Int) {
757
        let defaultsKey = "\(Self.rebuildDefaultsKeyPrefix).\(version)"
758
        if UserDefaults.standard.bool(forKey: defaultsKey) {
759
            return
760
        }
761

            
762
        context.performAndWait {
763
            refreshContextObjects()
764
            let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
765
            do {
766
                let allObjects = try context.fetch(request)
767
                var groupedByMAC: [String: [NSManagedObject]] = [:]
768
                for object in allObjects {
769
                    guard let rawMAC = object.value(forKey: "macAddress") as? String else { continue }
770
                    let macAddress = normalizedMACAddress(rawMAC)
771
                    guard !macAddress.isEmpty else { continue }
772
                    groupedByMAC[macAddress, default: []].append(object)
773
                }
774

            
775
                var removedDuplicates = 0
776
                for (macAddress, objects) in groupedByMAC {
777
                    guard let winner = preferredObject(from: objects) else { continue }
778
                    winner.setValue(macAddress, forKey: "macAddress")
779
                    for duplicate in objects where duplicate.objectID != winner.objectID {
780
                        mergeBestValues(from: duplicate, into: winner)
781
                        context.delete(duplicate)
782
                        removedDuplicates += 1
783
                    }
784
                }
785

            
786
                if context.hasChanges {
787
                    try context.save()
788
                }
789

            
790
                UserDefaults.standard.set(true, forKey: defaultsKey)
791
                track("Rebuilt DeviceSettings store in-place: \(removedDuplicates) duplicate(s) removed")
792
            } catch {
793
                track("Failed canonical rebuild for DeviceSettings: \(error)")
794
            }
795
        }
796
    }
797
}