Newer Older
666 lines | 29.893kb
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 2 weeks 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 2 weeks 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 2 weeks ago
29
    private var coreDataSettingsChangeNotification: AnyCancellable?
30
    private var cloudSettingsRefreshTimer: AnyCancellable?
31
    private var cloudDeviceSettingsStore: CloudDeviceSettingsStore?
32
    private var hasMigratedLegacyDeviceSettings = false
33
    private var persistedMeterNames: [String: String] = [:]
34
    private var persistedTC66TemperatureUnits: [String: String] = [:]
Bogdan Timofte authored 2 weeks ago
35

            
Bogdan Timofte authored 2 weeks ago
36
    init() {
Bogdan Timofte authored 2 weeks ago
37
        persistedMeterNames = legacyMeterNames
38
        persistedTC66TemperatureUnits = legacyTC66TemperatureUnits
39

            
40
        icloudDefaultsNotification = NotificationCenter.default.publisher(for: NSUbiquitousKeyValueStore.didChangeExternallyNotification).sink(receiveValue: handleLegacyICloudDefaultsChange)
Bogdan Timofte authored 2 weeks ago
41
        bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in
Bogdan Timofte authored 2 weeks ago
42
            self?.scheduleObjectWillChange()
Bogdan Timofte authored 2 weeks ago
43
        }
Bogdan Timofte authored 2 weeks ago
44
    }
45

            
46
    let bluetoothManager = BluetoothManager()
47

            
48
    @Published var enableRecordFeature: Bool = true
49

            
Bogdan Timofte authored 2 weeks ago
50
    @Published var meters: [UUID:Meter] = [UUID:Meter]()
Bogdan Timofte authored 2 weeks ago
51
    @Published private(set) var knownMetersByMAC: [String: KnownMeterCatalogItem] = [:]
Bogdan Timofte authored 2 weeks ago
52

            
Bogdan Timofte authored 2 weeks ago
53
    @ICloudDefault(key: "MeterNames", defaultValue: [:]) private var legacyMeterNames: [String:String]
54
    @ICloudDefault(key: "TC66TemperatureUnits", defaultValue: [:]) private var legacyTC66TemperatureUnits: [String:String]
55

            
56
    func activateCloudDeviceSync(context: NSManagedObjectContext) {
57
        guard cloudDeviceSettingsStore == nil else {
58
            return
59
        }
60

            
61
        context.automaticallyMergesChangesFromParent = true
62
        // Prefer incoming/store values on conflict so frequent local discovery updates
63
        // do not wipe remote connection claims from other devices.
64
        context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
65

            
66
        cloudDeviceSettingsStore = CloudDeviceSettingsStore(context: context)
67
        cloudDeviceSettingsStore?.rebuildCanonicalStoreIfNeeded(version: Self.cloudStoreRebuildVersion)
68
        cloudDeviceSettingsStore?.compactDuplicateEntriesByMAC()
69
        coreDataSettingsChangeNotification = NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context)
70
            .sink { [weak self] _ in
71
                self?.reloadSettingsFromCloudStore(applyToMeters: true)
72
            }
73
        cloudSettingsRefreshTimer = Timer.publish(every: 10, on: .main, in: .common)
74
            .autoconnect()
75
            .sink { [weak self] _ in
76
                self?.reloadSettingsFromCloudStore(applyToMeters: true)
77
            }
78

            
79
        reloadSettingsFromCloudStore(applyToMeters: false)
80
        migrateLegacySettingsIntoCloudIfNeeded()
81
        cloudDeviceSettingsStore?.clearAllConnections(byDeviceID: Self.myDeviceID)
82
        reloadSettingsFromCloudStore(applyToMeters: true)
83
    }
84

            
85
    func persistedMeterName(for macAddress: String) -> String? {
86
        persistedMeterNames[macAddress]
87
    }
88

            
89
    func publishMeterConnection(macAddress: String, modelType: String) {
90
        cloudDeviceSettingsStore?.setConnection(
91
            macAddress: macAddress,
92
            deviceID: Self.myDeviceID,
93
            deviceName: Self.myDeviceName,
94
            modelType: modelType
95
        )
96
    }
97

            
98
    func registerMeterDiscovery(macAddress: String, modelType: String, peripheralName: String?) {
99
        cloudDeviceSettingsStore?.recordDiscovery(
100
            macAddress: macAddress,
101
            modelType: modelType,
102
            peripheralName: peripheralName,
103
            seenByDeviceID: Self.myDeviceID,
104
            seenByDeviceName: Self.myDeviceName
105
        )
106
    }
107

            
108
    func clearMeterConnection(macAddress: String) {
109
        cloudDeviceSettingsStore?.clearConnection(macAddress: macAddress, byDeviceID: Self.myDeviceID)
110
    }
111

            
112
    func persistMeterName(_ name: String, for macAddress: String) {
113
        let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines)
114
        persistedMeterNames[macAddress] = normalized
115

            
116
        var legacyValues = legacyMeterNames
117
        legacyValues[macAddress] = normalized
118
        legacyMeterNames = legacyValues
119

            
120
        cloudDeviceSettingsStore?.upsert(macAddress: macAddress, meterName: normalized, tc66TemperatureUnit: nil)
121
    }
122

            
123
    func persistedTC66TemperatureUnitRawValue(for macAddress: String) -> String? {
124
        persistedTC66TemperatureUnits[macAddress]
125
    }
126

            
127
    func persistTC66TemperatureUnit(rawValue: String, for macAddress: String) {
128
        persistedTC66TemperatureUnits[macAddress] = rawValue
129

            
130
        var legacyValues = legacyTC66TemperatureUnits
131
        legacyValues[macAddress] = rawValue
132
        legacyTC66TemperatureUnits = legacyValues
133

            
134
        cloudDeviceSettingsStore?.upsert(macAddress: macAddress, meterName: nil, tc66TemperatureUnit: rawValue)
135
    }
136

            
137
    private func handleLegacyICloudDefaultsChange(notification: NotificationCenter.Publisher.Output) {
Bogdan Timofte authored 2 weeks ago
138
        if let changedKeys = notification.userInfo?["NSUbiquitousKeyValueStoreChangedKeysKey"] as? [String] {
Bogdan Timofte authored 2 weeks ago
139
            var requiresMeterRefresh = false
Bogdan Timofte authored 2 weeks ago
140
            for changedKey in changedKeys {
141
                switch changedKey {
142
                case "MeterNames":
Bogdan Timofte authored 2 weeks ago
143
                    persistedMeterNames = legacyMeterNames
144
                    requiresMeterRefresh = true
Bogdan Timofte authored 2 weeks ago
145
                case "TC66TemperatureUnits":
Bogdan Timofte authored 2 weeks ago
146
                    persistedTC66TemperatureUnits = legacyTC66TemperatureUnits
147
                    requiresMeterRefresh = true
Bogdan Timofte authored 2 weeks ago
148
                default:
149
                    track("Unknown key: '\(changedKey)' changed in iCloud)")
150
                }
151
            }
Bogdan Timofte authored 2 weeks ago
152

            
153
            if requiresMeterRefresh {
154
                migrateLegacySettingsIntoCloudIfNeeded(force: true)
155
                applyPersistedSettingsToKnownMeters()
Bogdan Timofte authored 2 weeks ago
156
                scheduleObjectWillChange()
Bogdan Timofte authored 2 weeks ago
157
            }
158
        }
159
    }
Bogdan Timofte authored 2 weeks ago
160

            
Bogdan Timofte authored 2 weeks ago
161
    private func migrateLegacySettingsIntoCloudIfNeeded(force: Bool = false) {
162
        guard let cloudDeviceSettingsStore else {
163
            return
164
        }
165
        if hasMigratedLegacyDeviceSettings && !force {
166
            return
167
        }
168

            
169
        let cloudRecords = cloudDeviceSettingsStore.fetchByMacAddress()
170

            
171
        for (macAddress, meterName) in legacyMeterNames {
172
            let cloudName = cloudRecords[macAddress]?.meterName
173
            if cloudName == nil || cloudName?.isEmpty == true {
174
                cloudDeviceSettingsStore.upsert(macAddress: macAddress, meterName: meterName, tc66TemperatureUnit: nil)
175
            }
176
        }
177

            
178
        for (macAddress, unitRawValue) in legacyTC66TemperatureUnits {
179
            let cloudUnit = cloudRecords[macAddress]?.tc66TemperatureUnit
180
            if cloudUnit == nil || cloudUnit?.isEmpty == true {
181
                cloudDeviceSettingsStore.upsert(macAddress: macAddress, meterName: nil, tc66TemperatureUnit: unitRawValue)
182
            }
183
        }
184

            
185
        hasMigratedLegacyDeviceSettings = true
186
    }
187

            
188
    private func reloadSettingsFromCloudStore(applyToMeters: Bool) {
189
        guard let cloudDeviceSettingsStore else {
190
            return
191
        }
192

            
193
        let records = cloudDeviceSettingsStore.fetchAll()
194
        var names = persistedMeterNames
195
        var temperatureUnits = persistedTC66TemperatureUnits
196
        var knownMeters: [String: KnownMeterCatalogItem] = [:]
197

            
198
        for record in records {
199
            if let meterName = record.meterName, !meterName.isEmpty {
200
                names[record.macAddress] = meterName
201
            }
202
            if let unitRawValue = record.tc66TemperatureUnit, !unitRawValue.isEmpty {
203
                temperatureUnits[record.macAddress] = unitRawValue
204
            }
205

            
206
            let displayName: String
207
            if let meterName = record.meterName?.trimmingCharacters(in: .whitespacesAndNewlines), !meterName.isEmpty {
208
                displayName = meterName
209
            } else if let peripheralName = record.lastSeenPeripheralName?.trimmingCharacters(in: .whitespacesAndNewlines), !peripheralName.isEmpty {
210
                displayName = peripheralName
211
            } else {
212
                displayName = record.macAddress
213
            }
214

            
215
            knownMeters[record.macAddress] = KnownMeterCatalogItem(
216
                macAddress: record.macAddress,
217
                displayName: displayName,
218
                modelType: record.modelType,
219
                connectedByDeviceID: record.connectedByDeviceID,
220
                connectedByDeviceName: record.connectedByDeviceName,
221
                connectedAt: record.connectedAt,
222
                connectedExpiryAt: record.connectedExpiryAt,
223
                lastSeenByDeviceID: record.lastSeenByDeviceID,
224
                lastSeenByDeviceName: record.lastSeenByDeviceName,
225
                lastSeenAt: record.lastSeenAt,
226
                lastSeenPeripheralName: record.lastSeenPeripheralName
227
            )
228
        }
229

            
230
        persistedMeterNames = names
231
        persistedTC66TemperatureUnits = temperatureUnits
232
        knownMetersByMAC = knownMeters
233

            
234
        if applyToMeters {
235
            applyPersistedSettingsToKnownMeters()
236
            scheduleObjectWillChange()
237
        }
238
    }
239

            
240
    private func applyPersistedSettingsToKnownMeters() {
241
        for meter in meters.values {
242
            let macAddress = meter.btSerial.macAddress.description
243
            if let newName = persistedMeterNames[macAddress], meter.name != newName {
244
                meter.name = newName
245
            }
246

            
247
            if meter.supportsManualTemperatureUnitSelection {
248
                meter.reloadTemperatureUnitPreference()
249
            }
250
        }
251
    }
252

            
Bogdan Timofte authored 2 weeks ago
253
    private func scheduleObjectWillChange() {
254
        DispatchQueue.main.async { [weak self] in
255
            self?.objectWillChange.send()
256
        }
257
    }
Bogdan Timofte authored 2 weeks ago
258
}
Bogdan Timofte authored 2 weeks ago
259

            
260
struct KnownMeterCatalogItem: Identifiable, Hashable {
261
    var id: String { macAddress }
262
    let macAddress: String
263
    let displayName: String
264
    let modelType: String?
265
    let connectedByDeviceID: String?
266
    let connectedByDeviceName: String?
267
    let connectedAt: Date?
268
    let connectedExpiryAt: Date?
269
    let lastSeenByDeviceID: String?
270
    let lastSeenByDeviceName: String?
271
    let lastSeenAt: Date?
272
    let lastSeenPeripheralName: String?
273
}
274

            
275
private struct CloudDeviceSettingsRecord {
276
    let macAddress: String
277
    let meterName: String?
278
    let tc66TemperatureUnit: String?
279
    let modelType: String?
280
    let connectedByDeviceID: String?
281
    let connectedByDeviceName: String?
282
    let connectedAt: Date?
283
    let connectedExpiryAt: Date?
284
    let lastSeenAt: Date?
285
    let lastSeenByDeviceID: String?
286
    let lastSeenByDeviceName: String?
287
    let lastSeenPeripheralName: String?
288
}
289

            
290
private final class CloudDeviceSettingsStore {
291
    private let entityName = "DeviceSettings"
292
    private let context: NSManagedObjectContext
293
    private static let rebuildDefaultsKeyPrefix = "CloudDeviceSettingsStore.RebuildVersion"
294

            
295
    init(context: NSManagedObjectContext) {
296
        self.context = context
297
    }
298

            
299
    private func refreshContextObjects() {
300
        context.processPendingChanges()
301
    }
302

            
303
    private func normalizedMACAddress(_ value: String) -> String {
304
        value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
305
    }
306

            
307
    private func fetchObjects(for macAddress: String) throws -> [NSManagedObject] {
308
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
309
        request.predicate = NSPredicate(format: "macAddress == %@", macAddress)
310
        return try context.fetch(request)
311
    }
312

            
313
    private func hasValue(_ value: String?) -> Bool {
314
        guard let value else { return false }
315
        return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
316
    }
317

            
318
    private func preferredObject(from objects: [NSManagedObject]) -> NSManagedObject? {
319
        guard !objects.isEmpty else { return nil }
320
        let now = Date()
321
        return objects.max { lhs, rhs in
322
            let lhsHasOwner = hasValue(lhs.value(forKey: "connectedByDeviceID") as? String)
323
            let rhsHasOwner = hasValue(rhs.value(forKey: "connectedByDeviceID") as? String)
324
            if lhsHasOwner != rhsHasOwner {
325
                return !lhsHasOwner && rhsHasOwner
326
            }
327

            
328
            let lhsOwnerLive = ((lhs.value(forKey: "connectedExpiryAt") as? Date) ?? .distantPast) > now
329
            let rhsOwnerLive = ((rhs.value(forKey: "connectedExpiryAt") as? Date) ?? .distantPast) > now
330
            if lhsOwnerLive != rhsOwnerLive {
331
                return !lhsOwnerLive && rhsOwnerLive
332
            }
333

            
334
            let lhsUpdatedAt = (lhs.value(forKey: "updatedAt") as? Date) ?? .distantPast
335
            let rhsUpdatedAt = (rhs.value(forKey: "updatedAt") as? Date) ?? .distantPast
336
            if lhsUpdatedAt != rhsUpdatedAt {
337
                return lhsUpdatedAt < rhsUpdatedAt
338
            }
339

            
340
            let lhsConnectedAt = (lhs.value(forKey: "connectedAt") as? Date) ?? .distantPast
341
            let rhsConnectedAt = (rhs.value(forKey: "connectedAt") as? Date) ?? .distantPast
342
            return lhsConnectedAt < rhsConnectedAt
343
        }
344
    }
345

            
346
    private func record(from object: NSManagedObject, macAddress: String) -> CloudDeviceSettingsRecord {
347
        let ownerID = stringValue(object, key: "connectedByDeviceID")
348
        return CloudDeviceSettingsRecord(
349
            macAddress: macAddress,
350
            meterName: object.value(forKey: "meterName") as? String,
351
            tc66TemperatureUnit: object.value(forKey: "tc66TemperatureUnit") as? String,
352
            modelType: object.value(forKey: "modelType") as? String,
353
            connectedByDeviceID: ownerID,
354
            connectedByDeviceName: ownerID == nil ? nil : stringValue(object, key: "connectedByDeviceName"),
355
            connectedAt: ownerID == nil ? nil : dateValue(object, key: "connectedAt"),
356
            connectedExpiryAt: ownerID == nil ? nil : dateValue(object, key: "connectedExpiryAt"),
357
            lastSeenAt: object.value(forKey: "lastSeenAt") as? Date,
358
            lastSeenByDeviceID: object.value(forKey: "lastSeenByDeviceID") as? String,
359
            lastSeenByDeviceName: object.value(forKey: "lastSeenByDeviceName") as? String,
360
            lastSeenPeripheralName: object.value(forKey: "lastSeenPeripheralName") as? String
361
        )
362
    }
363

            
364
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
365
        guard let value = object.value(forKey: key) as? String else { return nil }
366
        let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
367
        return normalized.isEmpty ? nil : normalized
368
    }
369

            
370
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
371
        object.value(forKey: key) as? Date
372
    }
373

            
374
    private func mergeBestValues(from source: NSManagedObject, into destination: NSManagedObject) {
375
        if stringValue(destination, key: "meterName") == nil, let value = stringValue(source, key: "meterName") {
376
            destination.setValue(value, forKey: "meterName")
377
        }
378
        if stringValue(destination, key: "tc66TemperatureUnit") == nil, let value = stringValue(source, key: "tc66TemperatureUnit") {
379
            destination.setValue(value, forKey: "tc66TemperatureUnit")
380
        }
381
        if stringValue(destination, key: "modelType") == nil, let value = stringValue(source, key: "modelType") {
382
            destination.setValue(value, forKey: "modelType")
383
        }
384

            
385
        let sourceConnectedExpiry = dateValue(source, key: "connectedExpiryAt") ?? .distantPast
386
        let destinationConnectedExpiry = dateValue(destination, key: "connectedExpiryAt") ?? .distantPast
387
        let destinationOwner = stringValue(destination, key: "connectedByDeviceID")
388
        let sourceOwner = stringValue(source, key: "connectedByDeviceID")
389
        if sourceOwner != nil && (destinationOwner == nil || sourceConnectedExpiry > destinationConnectedExpiry) {
390
            destination.setValue(sourceOwner, forKey: "connectedByDeviceID")
391
            destination.setValue(stringValue(source, key: "connectedByDeviceName"), forKey: "connectedByDeviceName")
392
            destination.setValue(dateValue(source, key: "connectedAt"), forKey: "connectedAt")
393
            destination.setValue(dateValue(source, key: "connectedExpiryAt"), forKey: "connectedExpiryAt")
394
        }
395

            
396
        let sourceLastSeen = dateValue(source, key: "lastSeenAt") ?? .distantPast
397
        let destinationLastSeen = dateValue(destination, key: "lastSeenAt") ?? .distantPast
398
        if sourceLastSeen > destinationLastSeen {
399
            destination.setValue(dateValue(source, key: "lastSeenAt"), forKey: "lastSeenAt")
400
            destination.setValue(stringValue(source, key: "lastSeenByDeviceID"), forKey: "lastSeenByDeviceID")
401
            destination.setValue(stringValue(source, key: "lastSeenByDeviceName"), forKey: "lastSeenByDeviceName")
402
            destination.setValue(stringValue(source, key: "lastSeenPeripheralName"), forKey: "lastSeenPeripheralName")
403
        }
404

            
405
        let sourceUpdatedAt = dateValue(source, key: "updatedAt") ?? .distantPast
406
        let destinationUpdatedAt = dateValue(destination, key: "updatedAt") ?? .distantPast
407
        if sourceUpdatedAt > destinationUpdatedAt {
408
            destination.setValue(sourceUpdatedAt, forKey: "updatedAt")
409
        }
410
    }
411

            
412
    func compactDuplicateEntriesByMAC() {
413
        context.performAndWait {
414
            refreshContextObjects()
415
            let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
416
            do {
417
                let allObjects = try context.fetch(request)
418
                var groupedByMAC: [String: [NSManagedObject]] = [:]
419
                for object in allObjects {
420
                    guard let macAddress = object.value(forKey: "macAddress") as? String, !macAddress.isEmpty else {
421
                        continue
422
                    }
423
                    groupedByMAC[macAddress, default: []].append(object)
424
                }
425

            
426
                var removedDuplicates = 0
427
                for (_, objects) in groupedByMAC {
428
                    guard objects.count > 1, let winner = preferredObject(from: objects) else { continue }
429
                    for duplicate in objects where duplicate.objectID != winner.objectID {
430
                        mergeBestValues(from: duplicate, into: winner)
431
                        context.delete(duplicate)
432
                        removedDuplicates += 1
433
                    }
434
                }
435

            
436
                if context.hasChanges {
437
                    try context.save()
438
                }
439
                if removedDuplicates > 0 {
440
                    track("Compacted \(removedDuplicates) duplicate DeviceSettings row(s)")
441
                }
442
            } catch {
443
                track("Failed compacting duplicate device settings: \(error)")
444
            }
445
        }
446
    }
447

            
448
    func fetchAll() -> [CloudDeviceSettingsRecord] {
449
        var results: [CloudDeviceSettingsRecord] = []
450
        context.performAndWait {
451
            refreshContextObjects()
452
            let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
453
            do {
454
                let allObjects = try context.fetch(request)
455
                var groupedByMAC: [String: [NSManagedObject]] = [:]
456
                for object in allObjects {
457
                    guard let macAddress = object.value(forKey: "macAddress") as? String, !macAddress.isEmpty else {
458
                        continue
459
                    }
460
                    groupedByMAC[normalizedMACAddress(macAddress), default: []].append(object)
461
                }
462

            
463
                results = groupedByMAC.compactMap { macAddress, objects in
464
                    guard let preferred = preferredObject(from: objects) else {
465
                        return nil
466
                    }
467
                    return record(from: preferred, macAddress: macAddress)
468
                }
469
            } catch {
470
                track("Failed loading cloud device settings: \(error)")
471
            }
472
        }
473
        return results
474
    }
475

            
476
    func fetchByMacAddress() -> [String: CloudDeviceSettingsRecord] {
477
        Dictionary(uniqueKeysWithValues: fetchAll().map { ($0.macAddress, $0) })
478
    }
479

            
480
    func upsert(macAddress: String, meterName: String?, tc66TemperatureUnit: String?) {
481
        let macAddress = normalizedMACAddress(macAddress)
482
        guard !macAddress.isEmpty else {
483
            return
484
        }
485

            
486
        context.performAndWait {
487
            do {
488
                refreshContextObjects()
489
                let objects = try fetchObjects(for: macAddress)
490
                let object = preferredObject(from: objects) ?? NSManagedObject(entity: NSEntityDescription.entity(forEntityName: entityName, in: context)!, insertInto: context)
491
                for duplicate in objects where duplicate.objectID != object.objectID {
492
                    context.delete(duplicate)
493
                }
494
                object.setValue(macAddress, forKey: "macAddress")
495

            
496
                if let meterName {
497
                    object.setValue(meterName, forKey: "meterName")
498
                }
499
                if let tc66TemperatureUnit {
500
                    object.setValue(tc66TemperatureUnit, forKey: "tc66TemperatureUnit")
501
                }
502

            
503
                object.setValue(Date(), forKey: "updatedAt")
504

            
505
                if context.hasChanges {
506
                    try context.save()
507
                }
508
            } catch {
509
                track("Failed persisting cloud device settings for \(macAddress): \(error)")
510
            }
511
        }
512
    }
513

            
514
    func setConnection(macAddress: String, deviceID: String, deviceName: String, modelType: String) {
515
        let macAddress = normalizedMACAddress(macAddress)
516
        guard !macAddress.isEmpty, !deviceID.isEmpty else { return }
517
        context.performAndWait {
518
            do {
519
                refreshContextObjects()
520
                let objects = try fetchObjects(for: macAddress)
521
                let object = preferredObject(from: objects) ?? NSManagedObject(entity: NSEntityDescription.entity(forEntityName: entityName, in: context)!, insertInto: context)
522
                for duplicate in objects where duplicate.objectID != object.objectID {
523
                    context.delete(duplicate)
524
                }
525
                object.setValue(macAddress, forKey: "macAddress")
526
                object.setValue(deviceID, forKey: "connectedByDeviceID")
527
                object.setValue(deviceName, forKey: "connectedByDeviceName")
528
                let now = Date()
529
                object.setValue(now, forKey: "connectedAt")
530
                object.setValue(Date().addingTimeInterval(120), forKey: "connectedExpiryAt")
531
                object.setValue(modelType, forKey: "modelType")
532
                object.setValue(now, forKey: "updatedAt")
533
                if context.hasChanges { try context.save() }
534
            } catch {
535
                track("Failed publishing connection for \(macAddress): \(error)")
536
            }
537
        }
538
    }
539

            
540
    func recordDiscovery(macAddress: String, modelType: String, peripheralName: String?, seenByDeviceID: String, seenByDeviceName: String) {
541
        let macAddress = normalizedMACAddress(macAddress)
542
        guard !macAddress.isEmpty else { return }
543
        context.performAndWait {
544
            do {
545
                refreshContextObjects()
546
                let objects = try fetchObjects(for: macAddress)
547
                let object = preferredObject(from: objects) ?? NSManagedObject(entity: NSEntityDescription.entity(forEntityName: entityName, in: context)!, insertInto: context)
548
                for duplicate in objects where duplicate.objectID != object.objectID {
549
                    context.delete(duplicate)
550
                }
551
                let now = Date()
Bogdan Timofte authored a week ago
552
                // Throttle CloudKit updates: only persist discovery once per 2 minutes per device
553
                // to avoid constant conflicts between devices on frequent BT advertisements
Bogdan Timofte authored 2 weeks ago
554
                if let previousSeenAt = object.value(forKey: "lastSeenAt") as? Date,
555
                   let previousSeenBy = object.value(forKey: "lastSeenByDeviceID") as? String,
556
                   previousSeenBy == seenByDeviceID,
Bogdan Timofte authored a week ago
557
                   now.timeIntervalSince(previousSeenAt) < 120 {
Bogdan Timofte authored 2 weeks ago
558
                    return
559
                }
560
                object.setValue(macAddress, forKey: "macAddress")
561
                object.setValue(modelType, forKey: "modelType")
562
                object.setValue(now, forKey: "lastSeenAt")
563
                object.setValue(seenByDeviceID, forKey: "lastSeenByDeviceID")
564
                object.setValue(seenByDeviceName, forKey: "lastSeenByDeviceName")
565
                if let peripheralName, !peripheralName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
566
                    object.setValue(peripheralName, forKey: "lastSeenPeripheralName")
567
                }
568
                object.setValue(now, forKey: "updatedAt")
569
                if context.hasChanges { try context.save() }
570
            } catch {
571
                track("Failed recording discovery for \(macAddress): \(error)")
572
            }
573
        }
574
    }
575

            
576
    func clearConnection(macAddress: String, byDeviceID deviceID: String) {
577
        let macAddress = normalizedMACAddress(macAddress)
578
        guard !macAddress.isEmpty else { return }
579
        context.performAndWait {
580
            let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
581
            request.predicate = NSPredicate(format: "macAddress == %@ AND connectedByDeviceID == %@", macAddress, deviceID)
582
            do {
583
                let objects = try context.fetch(request)
584
                guard !objects.isEmpty else { return }
585
                for object in objects {
586
                    context.refresh(object, mergeChanges: true)
587
                    object.setValue(nil, forKey: "connectedByDeviceID")
588
                    object.setValue(nil, forKey: "connectedByDeviceName")
589
                    object.setValue(nil, forKey: "connectedAt")
590
                    object.setValue(nil, forKey: "connectedExpiryAt")
591
                    object.setValue(Date(), forKey: "updatedAt")
592
                }
593
                if context.hasChanges { try context.save() }
594
            } catch {
595
                track("Failed clearing connection for \(macAddress): \(error)")
596
            }
597
        }
598
    }
599

            
600
    func clearAllConnections(byDeviceID deviceID: String) {
601
        guard !deviceID.isEmpty else { return }
602
        context.performAndWait {
603
            refreshContextObjects()
604
            let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
605
            request.predicate = NSPredicate(format: "connectedByDeviceID == %@", deviceID)
606
            do {
607
                let objects = try context.fetch(request)
608
                guard !objects.isEmpty else { return }
609
                for object in objects {
610
                    context.refresh(object, mergeChanges: true)
611
                    object.setValue(nil, forKey: "connectedByDeviceID")
612
                    object.setValue(nil, forKey: "connectedByDeviceName")
613
                    object.setValue(nil, forKey: "connectedAt")
614
                    object.setValue(nil, forKey: "connectedExpiryAt")
615
                    object.setValue(Date(), forKey: "updatedAt")
616
                }
617
                if context.hasChanges { try context.save() }
618
                track("Cleared \(objects.count) stale connection claim(s) for this device")
619
            } catch {
620
                track("Failed clearing stale connections: \(error)")
621
            }
622
        }
623
    }
624

            
625
    func rebuildCanonicalStoreIfNeeded(version: Int) {
626
        let defaultsKey = "\(Self.rebuildDefaultsKeyPrefix).\(version)"
627
        if UserDefaults.standard.bool(forKey: defaultsKey) {
628
            return
629
        }
630

            
631
        context.performAndWait {
632
            refreshContextObjects()
633
            let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
634
            do {
635
                let allObjects = try context.fetch(request)
636
                var groupedByMAC: [String: [NSManagedObject]] = [:]
637
                for object in allObjects {
638
                    guard let rawMAC = object.value(forKey: "macAddress") as? String else { continue }
639
                    let macAddress = normalizedMACAddress(rawMAC)
640
                    guard !macAddress.isEmpty else { continue }
641
                    groupedByMAC[macAddress, default: []].append(object)
642
                }
643

            
644
                var removedDuplicates = 0
645
                for (macAddress, objects) in groupedByMAC {
646
                    guard let winner = preferredObject(from: objects) else { continue }
647
                    winner.setValue(macAddress, forKey: "macAddress")
648
                    for duplicate in objects where duplicate.objectID != winner.objectID {
649
                        mergeBestValues(from: duplicate, into: winner)
650
                        context.delete(duplicate)
651
                        removedDuplicates += 1
652
                    }
653
                }
654

            
655
                if context.hasChanges {
656
                    try context.save()
657
                }
658

            
659
                UserDefaults.standard.set(true, forKey: defaultsKey)
660
                track("Rebuilt DeviceSettings store in-place: \(removedDuplicates) duplicate(s) removed")
661
            } catch {
662
                track("Failed canonical rebuild for DeviceSettings: \(error)")
663
            }
664
        }
665
    }
666
}