Newer Older
670 lines | 29.949kb
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
    #if os(macOS)
19
    // On macOS (Catalyst, iPad App on Mac), use hostname instead of UIDevice.current.name
20
    let hostname = ProcessInfo.processInfo.hostName
21
    return hostname.trimmingCharacters(in: .whitespacesAndNewlines)
22
    #else
23
    // On iOS/iPadOS, use device name
24
    return UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
25
    #endif
26
}
Bogdan Timofte authored 2 weeks ago
27

            
28
final class AppData : ObservableObject {
Bogdan Timofte authored 2 weeks ago
29
    static let myDeviceID: String = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
30
    static let myDeviceName: String = getDeviceName()
31
    private static let cloudStoreRebuildVersion = 3
32

            
33
    private var icloudDefaultsNotification: AnyCancellable?
Bogdan Timofte authored 2 weeks ago
34
    private var bluetoothManagerNotification: AnyCancellable?
Bogdan Timofte authored 2 weeks ago
35
    private var coreDataSettingsChangeNotification: AnyCancellable?
36
    private var cloudSettingsRefreshTimer: AnyCancellable?
37
    private var cloudDeviceSettingsStore: CloudDeviceSettingsStore?
38
    private var hasMigratedLegacyDeviceSettings = false
39
    private var persistedMeterNames: [String: String] = [:]
40
    private var persistedTC66TemperatureUnits: [String: String] = [:]
Bogdan Timofte authored 2 weeks ago
41

            
Bogdan Timofte authored 2 weeks ago
42
    init() {
Bogdan Timofte authored 2 weeks 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 2 weeks ago
57
    @Published private(set) var knownMetersByMAC: [String: KnownMeterCatalogItem] = [:]
Bogdan Timofte authored 2 weeks ago
58

            
Bogdan Timofte authored 2 weeks 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()
75
        coreDataSettingsChangeNotification = NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context)
76
            .sink { [weak self] _ in
77
                self?.reloadSettingsFromCloudStore(applyToMeters: true)
78
            }
79
        cloudSettingsRefreshTimer = Timer.publish(every: 10, on: .main, in: .common)
80
            .autoconnect()
81
            .sink { [weak self] _ in
82
                self?.reloadSettingsFromCloudStore(applyToMeters: true)
83
            }
84

            
85
        reloadSettingsFromCloudStore(applyToMeters: false)
86
        migrateLegacySettingsIntoCloudIfNeeded()
87
        cloudDeviceSettingsStore?.clearAllConnections(byDeviceID: Self.myDeviceID)
88
        reloadSettingsFromCloudStore(applyToMeters: true)
89
    }
90

            
91
    func persistedMeterName(for macAddress: String) -> String? {
92
        persistedMeterNames[macAddress]
93
    }
94

            
95
    func publishMeterConnection(macAddress: String, modelType: String) {
96
        cloudDeviceSettingsStore?.setConnection(
97
            macAddress: macAddress,
98
            deviceID: Self.myDeviceID,
99
            deviceName: Self.myDeviceName,
100
            modelType: modelType
101
        )
102
    }
103

            
104
    func registerMeterDiscovery(macAddress: String, modelType: String, peripheralName: String?) {
105
        cloudDeviceSettingsStore?.recordDiscovery(
106
            macAddress: macAddress,
107
            modelType: modelType,
108
            peripheralName: peripheralName,
109
            seenByDeviceID: Self.myDeviceID,
110
            seenByDeviceName: Self.myDeviceName
111
        )
112
    }
113

            
114
    func clearMeterConnection(macAddress: String) {
115
        cloudDeviceSettingsStore?.clearConnection(macAddress: macAddress, byDeviceID: Self.myDeviceID)
116
    }
117

            
118
    func persistMeterName(_ name: String, for macAddress: String) {
119
        let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines)
120
        persistedMeterNames[macAddress] = normalized
121

            
122
        var legacyValues = legacyMeterNames
123
        legacyValues[macAddress] = normalized
124
        legacyMeterNames = legacyValues
125

            
126
        cloudDeviceSettingsStore?.upsert(macAddress: macAddress, meterName: normalized, tc66TemperatureUnit: nil)
127
    }
128

            
129
    func persistedTC66TemperatureUnitRawValue(for macAddress: String) -> String? {
130
        persistedTC66TemperatureUnits[macAddress]
131
    }
132

            
133
    func persistTC66TemperatureUnit(rawValue: String, for macAddress: String) {
134
        persistedTC66TemperatureUnits[macAddress] = rawValue
135

            
136
        var legacyValues = legacyTC66TemperatureUnits
137
        legacyValues[macAddress] = rawValue
138
        legacyTC66TemperatureUnits = legacyValues
139

            
140
        cloudDeviceSettingsStore?.upsert(macAddress: macAddress, meterName: nil, tc66TemperatureUnit: rawValue)
141
    }
142

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

            
159
            if requiresMeterRefresh {
160
                migrateLegacySettingsIntoCloudIfNeeded(force: true)
161
                applyPersistedSettingsToKnownMeters()
Bogdan Timofte authored 2 weeks ago
162
                scheduleObjectWillChange()
Bogdan Timofte authored 2 weeks ago
163
            }
164
        }
165
    }
Bogdan Timofte authored 2 weeks ago
166

            
Bogdan Timofte authored 2 weeks ago
167
    private func migrateLegacySettingsIntoCloudIfNeeded(force: Bool = false) {
168
        guard let cloudDeviceSettingsStore else {
169
            return
170
        }
171
        if hasMigratedLegacyDeviceSettings && !force {
172
            return
173
        }
174

            
175
        let cloudRecords = cloudDeviceSettingsStore.fetchByMacAddress()
176

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

            
184
        for (macAddress, unitRawValue) in legacyTC66TemperatureUnits {
185
            let cloudUnit = cloudRecords[macAddress]?.tc66TemperatureUnit
186
            if cloudUnit == nil || cloudUnit?.isEmpty == true {
187
                cloudDeviceSettingsStore.upsert(macAddress: macAddress, meterName: nil, tc66TemperatureUnit: unitRawValue)
188
            }
189
        }
190

            
191
        hasMigratedLegacyDeviceSettings = true
192
    }
193

            
194
    private func reloadSettingsFromCloudStore(applyToMeters: Bool) {
195
        guard let cloudDeviceSettingsStore else {
196
            return
197
        }
198

            
199
        let records = cloudDeviceSettingsStore.fetchAll()
200
        var names = persistedMeterNames
201
        var temperatureUnits = persistedTC66TemperatureUnits
202
        var knownMeters: [String: KnownMeterCatalogItem] = [:]
203

            
204
        for record in records {
205
            if let meterName = record.meterName, !meterName.isEmpty {
206
                names[record.macAddress] = meterName
207
            }
208
            if let unitRawValue = record.tc66TemperatureUnit, !unitRawValue.isEmpty {
209
                temperatureUnits[record.macAddress] = unitRawValue
210
            }
211

            
212
            let displayName: String
213
            if let meterName = record.meterName?.trimmingCharacters(in: .whitespacesAndNewlines), !meterName.isEmpty {
214
                displayName = meterName
215
            } else if let peripheralName = record.lastSeenPeripheralName?.trimmingCharacters(in: .whitespacesAndNewlines), !peripheralName.isEmpty {
216
                displayName = peripheralName
217
            } else {
218
                displayName = record.macAddress
219
            }
220

            
221
            knownMeters[record.macAddress] = KnownMeterCatalogItem(
222
                macAddress: record.macAddress,
223
                displayName: displayName,
224
                modelType: record.modelType,
225
                connectedByDeviceID: record.connectedByDeviceID,
226
                connectedByDeviceName: record.connectedByDeviceName,
227
                connectedAt: record.connectedAt,
228
                connectedExpiryAt: record.connectedExpiryAt,
229
                lastSeenByDeviceID: record.lastSeenByDeviceID,
230
                lastSeenByDeviceName: record.lastSeenByDeviceName,
231
                lastSeenAt: record.lastSeenAt,
232
                lastSeenPeripheralName: record.lastSeenPeripheralName
233
            )
234
        }
235

            
236
        persistedMeterNames = names
237
        persistedTC66TemperatureUnits = temperatureUnits
238
        knownMetersByMAC = knownMeters
239

            
240
        if applyToMeters {
241
            applyPersistedSettingsToKnownMeters()
242
            scheduleObjectWillChange()
243
        }
244
    }
245

            
246
    private func applyPersistedSettingsToKnownMeters() {
247
        for meter in meters.values {
248
            let macAddress = meter.btSerial.macAddress.description
249
            if let newName = persistedMeterNames[macAddress], meter.name != newName {
250
                meter.name = newName
251
            }
252

            
253
            if meter.supportsManualTemperatureUnitSelection {
254
                meter.reloadTemperatureUnitPreference()
255
            }
256
        }
257
    }
258

            
Bogdan Timofte authored 2 weeks ago
259
    private func scheduleObjectWillChange() {
260
        DispatchQueue.main.async { [weak self] in
261
            self?.objectWillChange.send()
262
        }
263
    }
Bogdan Timofte authored 2 weeks ago
264
}
Bogdan Timofte authored 2 weeks ago
265

            
266
struct KnownMeterCatalogItem: Identifiable, Hashable {
267
    var id: String { macAddress }
268
    let macAddress: String
269
    let displayName: String
270
    let modelType: String?
271
    let connectedByDeviceID: String?
272
    let connectedByDeviceName: String?
273
    let connectedAt: Date?
274
    let connectedExpiryAt: Date?
275
    let lastSeenByDeviceID: String?
276
    let lastSeenByDeviceName: String?
277
    let lastSeenAt: Date?
278
    let lastSeenPeripheralName: String?
279
}
280

            
281
private struct CloudDeviceSettingsRecord {
282
    let macAddress: String
283
    let meterName: String?
284
    let tc66TemperatureUnit: String?
285
    let modelType: String?
286
    let connectedByDeviceID: String?
287
    let connectedByDeviceName: String?
288
    let connectedAt: Date?
289
    let connectedExpiryAt: Date?
290
    let lastSeenAt: Date?
291
    let lastSeenByDeviceID: String?
292
    let lastSeenByDeviceName: String?
293
    let lastSeenPeripheralName: String?
294
}
295

            
296
private final class CloudDeviceSettingsStore {
297
    private let entityName = "DeviceSettings"
298
    private let context: NSManagedObjectContext
299
    private static let rebuildDefaultsKeyPrefix = "CloudDeviceSettingsStore.RebuildVersion"
300

            
301
    init(context: NSManagedObjectContext) {
302
        self.context = context
303
    }
304

            
305
    private func refreshContextObjects() {
306
        context.processPendingChanges()
307
    }
308

            
309
    private func normalizedMACAddress(_ value: String) -> String {
310
        value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
311
    }
312

            
313
    private func fetchObjects(for macAddress: String) throws -> [NSManagedObject] {
314
        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
315
        request.predicate = NSPredicate(format: "macAddress == %@", macAddress)
316
        return try context.fetch(request)
317
    }
318

            
319
    private func hasValue(_ value: String?) -> Bool {
320
        guard let value else { return false }
321
        return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
322
    }
323

            
324
    private func preferredObject(from objects: [NSManagedObject]) -> NSManagedObject? {
325
        guard !objects.isEmpty else { return nil }
326
        let now = Date()
327
        return objects.max { lhs, rhs in
328
            let lhsHasOwner = hasValue(lhs.value(forKey: "connectedByDeviceID") as? String)
329
            let rhsHasOwner = hasValue(rhs.value(forKey: "connectedByDeviceID") as? String)
330
            if lhsHasOwner != rhsHasOwner {
331
                return !lhsHasOwner && rhsHasOwner
332
            }
333

            
334
            let lhsOwnerLive = ((lhs.value(forKey: "connectedExpiryAt") as? Date) ?? .distantPast) > now
335
            let rhsOwnerLive = ((rhs.value(forKey: "connectedExpiryAt") as? Date) ?? .distantPast) > now
336
            if lhsOwnerLive != rhsOwnerLive {
337
                return !lhsOwnerLive && rhsOwnerLive
338
            }
339

            
340
            let lhsUpdatedAt = (lhs.value(forKey: "updatedAt") as? Date) ?? .distantPast
341
            let rhsUpdatedAt = (rhs.value(forKey: "updatedAt") as? Date) ?? .distantPast
342
            if lhsUpdatedAt != rhsUpdatedAt {
343
                return lhsUpdatedAt < rhsUpdatedAt
344
            }
345

            
346
            let lhsConnectedAt = (lhs.value(forKey: "connectedAt") as? Date) ?? .distantPast
347
            let rhsConnectedAt = (rhs.value(forKey: "connectedAt") as? Date) ?? .distantPast
348
            return lhsConnectedAt < rhsConnectedAt
349
        }
350
    }
351

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

            
370
    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
371
        guard let value = object.value(forKey: key) as? String else { return nil }
372
        let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
373
        return normalized.isEmpty ? nil : normalized
374
    }
375

            
376
    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
377
        object.value(forKey: key) as? Date
378
    }
379

            
380
    private func mergeBestValues(from source: NSManagedObject, into destination: NSManagedObject) {
381
        if stringValue(destination, key: "meterName") == nil, let value = stringValue(source, key: "meterName") {
382
            destination.setValue(value, forKey: "meterName")
383
        }
384
        if stringValue(destination, key: "tc66TemperatureUnit") == nil, let value = stringValue(source, key: "tc66TemperatureUnit") {
385
            destination.setValue(value, forKey: "tc66TemperatureUnit")
386
        }
387
        if stringValue(destination, key: "modelType") == nil, let value = stringValue(source, key: "modelType") {
388
            destination.setValue(value, forKey: "modelType")
389
        }
390

            
391
        let sourceConnectedExpiry = dateValue(source, key: "connectedExpiryAt") ?? .distantPast
392
        let destinationConnectedExpiry = dateValue(destination, key: "connectedExpiryAt") ?? .distantPast
393
        let destinationOwner = stringValue(destination, key: "connectedByDeviceID")
394
        let sourceOwner = stringValue(source, key: "connectedByDeviceID")
395
        if sourceOwner != nil && (destinationOwner == nil || sourceConnectedExpiry > destinationConnectedExpiry) {
396
            destination.setValue(sourceOwner, forKey: "connectedByDeviceID")
397
            destination.setValue(stringValue(source, key: "connectedByDeviceName"), forKey: "connectedByDeviceName")
398
            destination.setValue(dateValue(source, key: "connectedAt"), forKey: "connectedAt")
399
            destination.setValue(dateValue(source, key: "connectedExpiryAt"), forKey: "connectedExpiryAt")
400
        }
401

            
402
        let sourceLastSeen = dateValue(source, key: "lastSeenAt") ?? .distantPast
403
        let destinationLastSeen = dateValue(destination, key: "lastSeenAt") ?? .distantPast
404
        if sourceLastSeen > destinationLastSeen {
405
            destination.setValue(dateValue(source, key: "lastSeenAt"), forKey: "lastSeenAt")
406
            destination.setValue(stringValue(source, key: "lastSeenByDeviceID"), forKey: "lastSeenByDeviceID")
407
            destination.setValue(stringValue(source, key: "lastSeenByDeviceName"), forKey: "lastSeenByDeviceName")
408
            destination.setValue(stringValue(source, key: "lastSeenPeripheralName"), forKey: "lastSeenPeripheralName")
409
        }
410

            
411
        let sourceUpdatedAt = dateValue(source, key: "updatedAt") ?? .distantPast
412
        let destinationUpdatedAt = dateValue(destination, key: "updatedAt") ?? .distantPast
413
        if sourceUpdatedAt > destinationUpdatedAt {
414
            destination.setValue(sourceUpdatedAt, forKey: "updatedAt")
415
        }
416
    }
417

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

            
432
                var removedDuplicates = 0
433
                for (_, objects) in groupedByMAC {
434
                    guard objects.count > 1, let winner = preferredObject(from: objects) else { continue }
435
                    for duplicate in objects where duplicate.objectID != winner.objectID {
436
                        mergeBestValues(from: duplicate, into: winner)
437
                        context.delete(duplicate)
438
                        removedDuplicates += 1
439
                    }
440
                }
441

            
442
                if context.hasChanges {
443
                    try context.save()
444
                }
445
                if removedDuplicates > 0 {
446
                    track("Compacted \(removedDuplicates) duplicate DeviceSettings row(s)")
447
                }
448
            } catch {
449
                track("Failed compacting duplicate device settings: \(error)")
450
            }
451
        }
452
    }
453

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

            
469
                results = groupedByMAC.compactMap { macAddress, objects in
470
                    guard let preferred = preferredObject(from: objects) else {
471
                        return nil
472
                    }
473
                    return record(from: preferred, macAddress: macAddress)
474
                }
475
            } catch {
476
                track("Failed loading cloud device settings: \(error)")
477
            }
478
        }
479
        return results
480
    }
481

            
482
    func fetchByMacAddress() -> [String: CloudDeviceSettingsRecord] {
483
        Dictionary(uniqueKeysWithValues: fetchAll().map { ($0.macAddress, $0) })
484
    }
485

            
486
    func upsert(macAddress: String, meterName: String?, tc66TemperatureUnit: String?) {
487
        let macAddress = normalizedMACAddress(macAddress)
488
        guard !macAddress.isEmpty else {
489
            return
490
        }
491

            
492
        context.performAndWait {
493
            do {
494
                refreshContextObjects()
495
                let objects = try fetchObjects(for: macAddress)
496
                let object = preferredObject(from: objects) ?? NSManagedObject(entity: NSEntityDescription.entity(forEntityName: entityName, in: context)!, insertInto: context)
497
                for duplicate in objects where duplicate.objectID != object.objectID {
498
                    context.delete(duplicate)
499
                }
500
                object.setValue(macAddress, forKey: "macAddress")
501

            
502
                if let meterName {
503
                    object.setValue(meterName, forKey: "meterName")
504
                }
505
                if let tc66TemperatureUnit {
506
                    object.setValue(tc66TemperatureUnit, forKey: "tc66TemperatureUnit")
507
                }
508

            
509
                object.setValue(Date(), forKey: "updatedAt")
510

            
511
                if context.hasChanges {
512
                    try context.save()
513
                }
514
            } catch {
515
                track("Failed persisting cloud device settings for \(macAddress): \(error)")
516
            }
517
        }
518
    }
519

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

            
546
    func recordDiscovery(macAddress: String, modelType: String, peripheralName: String?, seenByDeviceID: String, seenByDeviceName: String) {
547
        let macAddress = normalizedMACAddress(macAddress)
548
        guard !macAddress.isEmpty else { return }
549
        context.performAndWait {
550
            do {
551
                refreshContextObjects()
552
                let objects = try fetchObjects(for: macAddress)
553
                let object = preferredObject(from: objects) ?? NSManagedObject(entity: NSEntityDescription.entity(forEntityName: entityName, in: context)!, insertInto: context)
554
                for duplicate in objects where duplicate.objectID != object.objectID {
555
                    context.delete(duplicate)
556
                }
557
                let now = Date()
558
                if let previousSeenAt = object.value(forKey: "lastSeenAt") as? Date,
559
                   let previousSeenBy = object.value(forKey: "lastSeenByDeviceID") as? String,
560
                   previousSeenBy == seenByDeviceID,
561
                   now.timeIntervalSince(previousSeenAt) < 15 {
562
                    return
563
                }
564
                object.setValue(macAddress, forKey: "macAddress")
565
                object.setValue(modelType, forKey: "modelType")
566
                object.setValue(now, forKey: "lastSeenAt")
567
                object.setValue(seenByDeviceID, forKey: "lastSeenByDeviceID")
568
                object.setValue(seenByDeviceName, forKey: "lastSeenByDeviceName")
569
                if let peripheralName, !peripheralName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
570
                    object.setValue(peripheralName, forKey: "lastSeenPeripheralName")
571
                }
572
                object.setValue(now, forKey: "updatedAt")
573
                if context.hasChanges { try context.save() }
574
            } catch {
575
                track("Failed recording discovery for \(macAddress): \(error)")
576
            }
577
        }
578
    }
579

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

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

            
629
    func rebuildCanonicalStoreIfNeeded(version: Int) {
630
        let defaultsKey = "\(Self.rebuildDefaultsKeyPrefix).\(version)"
631
        if UserDefaults.standard.bool(forKey: defaultsKey) {
632
            return
633
        }
634

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

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

            
659
                if context.hasChanges {
660
                    try context.save()
661
                }
662

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