// // DataStore.swift // USB Meter // // Created by Bogdan Timofte on 03/03/2020. // Copyright © 2020 Bogdan Timofte. All rights reserved. // import SwiftUI import Combine import CoreBluetooth import CoreData import UIKit import Foundation // MARK: - Device Name Helper private func getDeviceName() -> String { let hostname = ProcessInfo.processInfo.hostName return hostname.trimmingCharacters(in: .whitespacesAndNewlines) } final class AppData : ObservableObject { static let myDeviceID: String = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString static let myDeviceName: String = getDeviceName() private static let cloudStoreRebuildVersion = 3 private var icloudDefaultsNotification: AnyCancellable? private var bluetoothManagerNotification: AnyCancellable? private var coreDataSettingsChangeNotification: AnyCancellable? private var cloudSettingsRefreshTimer: AnyCancellable? private var cloudDeviceSettingsStore: CloudDeviceSettingsStore? private var hasMigratedLegacyDeviceSettings = false private var persistedMeterNames: [String: String] = [:] private var persistedTC66TemperatureUnits: [String: String] = [:] init() { persistedMeterNames = legacyMeterNames persistedTC66TemperatureUnits = legacyTC66TemperatureUnits icloudDefaultsNotification = NotificationCenter.default.publisher(for: NSUbiquitousKeyValueStore.didChangeExternallyNotification).sink(receiveValue: handleLegacyICloudDefaultsChange) bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in self?.scheduleObjectWillChange() } } let bluetoothManager = BluetoothManager() @Published var enableRecordFeature: Bool = true @Published var meters: [UUID:Meter] = [UUID:Meter]() @Published private(set) var knownMetersByMAC: [String: KnownMeterCatalogItem] = [:] @ICloudDefault(key: "MeterNames", defaultValue: [:]) private var legacyMeterNames: [String:String] @ICloudDefault(key: "TC66TemperatureUnits", defaultValue: [:]) private var legacyTC66TemperatureUnits: [String:String] func activateCloudDeviceSync(context: NSManagedObjectContext) { guard cloudDeviceSettingsStore == nil else { return } context.automaticallyMergesChangesFromParent = true // Prefer incoming/store values on conflict so frequent local discovery updates // do not wipe remote connection claims from other devices. context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy cloudDeviceSettingsStore = CloudDeviceSettingsStore(context: context) cloudDeviceSettingsStore?.rebuildCanonicalStoreIfNeeded(version: Self.cloudStoreRebuildVersion) cloudDeviceSettingsStore?.compactDuplicateEntriesByMAC() coreDataSettingsChangeNotification = NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context) .sink { [weak self] _ in self?.reloadSettingsFromCloudStore(applyToMeters: true) } cloudSettingsRefreshTimer = Timer.publish(every: 10, on: .main, in: .common) .autoconnect() .sink { [weak self] _ in self?.reloadSettingsFromCloudStore(applyToMeters: true) } reloadSettingsFromCloudStore(applyToMeters: false) migrateLegacySettingsIntoCloudIfNeeded() cloudDeviceSettingsStore?.clearAllConnections(byDeviceID: Self.myDeviceID) reloadSettingsFromCloudStore(applyToMeters: true) } func persistedMeterName(for macAddress: String) -> String? { persistedMeterNames[macAddress] } func publishMeterConnection(macAddress: String, modelType: String) { cloudDeviceSettingsStore?.setConnection( macAddress: macAddress, deviceID: Self.myDeviceID, deviceName: Self.myDeviceName, modelType: modelType ) } func registerMeterDiscovery(macAddress: String, modelType: String, peripheralName: String?) { cloudDeviceSettingsStore?.recordDiscovery( macAddress: macAddress, modelType: modelType, peripheralName: peripheralName, seenByDeviceID: Self.myDeviceID, seenByDeviceName: Self.myDeviceName ) } func clearMeterConnection(macAddress: String) { cloudDeviceSettingsStore?.clearConnection(macAddress: macAddress, byDeviceID: Self.myDeviceID) } func persistMeterName(_ name: String, for macAddress: String) { let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines) persistedMeterNames[macAddress] = normalized var legacyValues = legacyMeterNames legacyValues[macAddress] = normalized legacyMeterNames = legacyValues cloudDeviceSettingsStore?.upsert(macAddress: macAddress, meterName: normalized, tc66TemperatureUnit: nil) } func persistedTC66TemperatureUnitRawValue(for macAddress: String) -> String? { persistedTC66TemperatureUnits[macAddress] } func persistTC66TemperatureUnit(rawValue: String, for macAddress: String) { persistedTC66TemperatureUnits[macAddress] = rawValue var legacyValues = legacyTC66TemperatureUnits legacyValues[macAddress] = rawValue legacyTC66TemperatureUnits = legacyValues cloudDeviceSettingsStore?.upsert(macAddress: macAddress, meterName: nil, tc66TemperatureUnit: rawValue) } private func handleLegacyICloudDefaultsChange(notification: NotificationCenter.Publisher.Output) { if let changedKeys = notification.userInfo?["NSUbiquitousKeyValueStoreChangedKeysKey"] as? [String] { var requiresMeterRefresh = false for changedKey in changedKeys { switch changedKey { case "MeterNames": persistedMeterNames = legacyMeterNames requiresMeterRefresh = true case "TC66TemperatureUnits": persistedTC66TemperatureUnits = legacyTC66TemperatureUnits requiresMeterRefresh = true default: track("Unknown key: '\(changedKey)' changed in iCloud)") } } if requiresMeterRefresh { migrateLegacySettingsIntoCloudIfNeeded(force: true) applyPersistedSettingsToKnownMeters() scheduleObjectWillChange() } } } private func migrateLegacySettingsIntoCloudIfNeeded(force: Bool = false) { guard let cloudDeviceSettingsStore else { return } if hasMigratedLegacyDeviceSettings && !force { return } let cloudRecords = cloudDeviceSettingsStore.fetchByMacAddress() for (macAddress, meterName) in legacyMeterNames { let cloudName = cloudRecords[macAddress]?.meterName if cloudName == nil || cloudName?.isEmpty == true { cloudDeviceSettingsStore.upsert(macAddress: macAddress, meterName: meterName, tc66TemperatureUnit: nil) } } for (macAddress, unitRawValue) in legacyTC66TemperatureUnits { let cloudUnit = cloudRecords[macAddress]?.tc66TemperatureUnit if cloudUnit == nil || cloudUnit?.isEmpty == true { cloudDeviceSettingsStore.upsert(macAddress: macAddress, meterName: nil, tc66TemperatureUnit: unitRawValue) } } hasMigratedLegacyDeviceSettings = true } private func reloadSettingsFromCloudStore(applyToMeters: Bool) { guard let cloudDeviceSettingsStore else { return } let records = cloudDeviceSettingsStore.fetchAll() var names = persistedMeterNames var temperatureUnits = persistedTC66TemperatureUnits var knownMeters: [String: KnownMeterCatalogItem] = [:] for record in records { if let meterName = record.meterName, !meterName.isEmpty { names[record.macAddress] = meterName } if let unitRawValue = record.tc66TemperatureUnit, !unitRawValue.isEmpty { temperatureUnits[record.macAddress] = unitRawValue } let displayName: String if let meterName = record.meterName?.trimmingCharacters(in: .whitespacesAndNewlines), !meterName.isEmpty { displayName = meterName } else if let peripheralName = record.lastSeenPeripheralName?.trimmingCharacters(in: .whitespacesAndNewlines), !peripheralName.isEmpty { displayName = peripheralName } else { displayName = record.macAddress } knownMeters[record.macAddress] = KnownMeterCatalogItem( macAddress: record.macAddress, displayName: displayName, modelType: record.modelType, connectedByDeviceID: record.connectedByDeviceID, connectedByDeviceName: record.connectedByDeviceName, connectedAt: record.connectedAt, connectedExpiryAt: record.connectedExpiryAt, lastSeenByDeviceID: record.lastSeenByDeviceID, lastSeenByDeviceName: record.lastSeenByDeviceName, lastSeenAt: record.lastSeenAt, lastSeenPeripheralName: record.lastSeenPeripheralName ) } persistedMeterNames = names persistedTC66TemperatureUnits = temperatureUnits knownMetersByMAC = knownMeters if applyToMeters { applyPersistedSettingsToKnownMeters() scheduleObjectWillChange() } } private func applyPersistedSettingsToKnownMeters() { for meter in meters.values { let macAddress = meter.btSerial.macAddress.description if let newName = persistedMeterNames[macAddress], meter.name != newName { meter.name = newName } if meter.supportsManualTemperatureUnitSelection { meter.reloadTemperatureUnitPreference() } } } private func scheduleObjectWillChange() { DispatchQueue.main.async { [weak self] in self?.objectWillChange.send() } } } struct KnownMeterCatalogItem: Identifiable, Hashable { var id: String { macAddress } let macAddress: String let displayName: String let modelType: String? let connectedByDeviceID: String? let connectedByDeviceName: String? let connectedAt: Date? let connectedExpiryAt: Date? let lastSeenByDeviceID: String? let lastSeenByDeviceName: String? let lastSeenAt: Date? let lastSeenPeripheralName: String? } private struct CloudDeviceSettingsRecord { let macAddress: String let meterName: String? let tc66TemperatureUnit: String? let modelType: String? let connectedByDeviceID: String? let connectedByDeviceName: String? let connectedAt: Date? let connectedExpiryAt: Date? let lastSeenAt: Date? let lastSeenByDeviceID: String? let lastSeenByDeviceName: String? let lastSeenPeripheralName: String? } private final class CloudDeviceSettingsStore { private let entityName = "DeviceSettings" private let context: NSManagedObjectContext private static let rebuildDefaultsKeyPrefix = "CloudDeviceSettingsStore.RebuildVersion" init(context: NSManagedObjectContext) { self.context = context } private func refreshContextObjects() { context.processPendingChanges() } private func normalizedMACAddress(_ value: String) -> String { value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() } private func fetchObjects(for macAddress: String) throws -> [NSManagedObject] { let request = NSFetchRequest(entityName: entityName) request.predicate = NSPredicate(format: "macAddress == %@", macAddress) return try context.fetch(request) } private func hasValue(_ value: String?) -> Bool { guard let value else { return false } return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private func preferredObject(from objects: [NSManagedObject]) -> NSManagedObject? { guard !objects.isEmpty else { return nil } let now = Date() return objects.max { lhs, rhs in let lhsHasOwner = hasValue(lhs.value(forKey: "connectedByDeviceID") as? String) let rhsHasOwner = hasValue(rhs.value(forKey: "connectedByDeviceID") as? String) if lhsHasOwner != rhsHasOwner { return !lhsHasOwner && rhsHasOwner } let lhsOwnerLive = ((lhs.value(forKey: "connectedExpiryAt") as? Date) ?? .distantPast) > now let rhsOwnerLive = ((rhs.value(forKey: "connectedExpiryAt") as? Date) ?? .distantPast) > now if lhsOwnerLive != rhsOwnerLive { return !lhsOwnerLive && rhsOwnerLive } let lhsUpdatedAt = (lhs.value(forKey: "updatedAt") as? Date) ?? .distantPast let rhsUpdatedAt = (rhs.value(forKey: "updatedAt") as? Date) ?? .distantPast if lhsUpdatedAt != rhsUpdatedAt { return lhsUpdatedAt < rhsUpdatedAt } let lhsConnectedAt = (lhs.value(forKey: "connectedAt") as? Date) ?? .distantPast let rhsConnectedAt = (rhs.value(forKey: "connectedAt") as? Date) ?? .distantPast return lhsConnectedAt < rhsConnectedAt } } private func record(from object: NSManagedObject, macAddress: String) -> CloudDeviceSettingsRecord { let ownerID = stringValue(object, key: "connectedByDeviceID") return CloudDeviceSettingsRecord( macAddress: macAddress, meterName: object.value(forKey: "meterName") as? String, tc66TemperatureUnit: object.value(forKey: "tc66TemperatureUnit") as? String, modelType: object.value(forKey: "modelType") as? String, connectedByDeviceID: ownerID, connectedByDeviceName: ownerID == nil ? nil : stringValue(object, key: "connectedByDeviceName"), connectedAt: ownerID == nil ? nil : dateValue(object, key: "connectedAt"), connectedExpiryAt: ownerID == nil ? nil : dateValue(object, key: "connectedExpiryAt"), lastSeenAt: object.value(forKey: "lastSeenAt") as? Date, lastSeenByDeviceID: object.value(forKey: "lastSeenByDeviceID") as? String, lastSeenByDeviceName: object.value(forKey: "lastSeenByDeviceName") as? String, lastSeenPeripheralName: object.value(forKey: "lastSeenPeripheralName") as? String ) } private func stringValue(_ object: NSManagedObject, key: String) -> String? { guard let value = object.value(forKey: key) as? String else { return nil } let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) return normalized.isEmpty ? nil : normalized } private func dateValue(_ object: NSManagedObject, key: String) -> Date? { object.value(forKey: key) as? Date } private func mergeBestValues(from source: NSManagedObject, into destination: NSManagedObject) { if stringValue(destination, key: "meterName") == nil, let value = stringValue(source, key: "meterName") { destination.setValue(value, forKey: "meterName") } if stringValue(destination, key: "tc66TemperatureUnit") == nil, let value = stringValue(source, key: "tc66TemperatureUnit") { destination.setValue(value, forKey: "tc66TemperatureUnit") } if stringValue(destination, key: "modelType") == nil, let value = stringValue(source, key: "modelType") { destination.setValue(value, forKey: "modelType") } let sourceConnectedExpiry = dateValue(source, key: "connectedExpiryAt") ?? .distantPast let destinationConnectedExpiry = dateValue(destination, key: "connectedExpiryAt") ?? .distantPast let destinationOwner = stringValue(destination, key: "connectedByDeviceID") let sourceOwner = stringValue(source, key: "connectedByDeviceID") if sourceOwner != nil && (destinationOwner == nil || sourceConnectedExpiry > destinationConnectedExpiry) { destination.setValue(sourceOwner, forKey: "connectedByDeviceID") destination.setValue(stringValue(source, key: "connectedByDeviceName"), forKey: "connectedByDeviceName") destination.setValue(dateValue(source, key: "connectedAt"), forKey: "connectedAt") destination.setValue(dateValue(source, key: "connectedExpiryAt"), forKey: "connectedExpiryAt") } let sourceLastSeen = dateValue(source, key: "lastSeenAt") ?? .distantPast let destinationLastSeen = dateValue(destination, key: "lastSeenAt") ?? .distantPast if sourceLastSeen > destinationLastSeen { destination.setValue(dateValue(source, key: "lastSeenAt"), forKey: "lastSeenAt") destination.setValue(stringValue(source, key: "lastSeenByDeviceID"), forKey: "lastSeenByDeviceID") destination.setValue(stringValue(source, key: "lastSeenByDeviceName"), forKey: "lastSeenByDeviceName") destination.setValue(stringValue(source, key: "lastSeenPeripheralName"), forKey: "lastSeenPeripheralName") } let sourceUpdatedAt = dateValue(source, key: "updatedAt") ?? .distantPast let destinationUpdatedAt = dateValue(destination, key: "updatedAt") ?? .distantPast if sourceUpdatedAt > destinationUpdatedAt { destination.setValue(sourceUpdatedAt, forKey: "updatedAt") } } func compactDuplicateEntriesByMAC() { context.performAndWait { refreshContextObjects() let request = NSFetchRequest(entityName: entityName) do { let allObjects = try context.fetch(request) var groupedByMAC: [String: [NSManagedObject]] = [:] for object in allObjects { guard let macAddress = object.value(forKey: "macAddress") as? String, !macAddress.isEmpty else { continue } groupedByMAC[macAddress, default: []].append(object) } var removedDuplicates = 0 for (_, objects) in groupedByMAC { guard objects.count > 1, let winner = preferredObject(from: objects) else { continue } for duplicate in objects where duplicate.objectID != winner.objectID { mergeBestValues(from: duplicate, into: winner) context.delete(duplicate) removedDuplicates += 1 } } if context.hasChanges { try context.save() } if removedDuplicates > 0 { track("Compacted \(removedDuplicates) duplicate DeviceSettings row(s)") } } catch { track("Failed compacting duplicate device settings: \(error)") } } } func fetchAll() -> [CloudDeviceSettingsRecord] { var results: [CloudDeviceSettingsRecord] = [] context.performAndWait { refreshContextObjects() let request = NSFetchRequest(entityName: entityName) do { let allObjects = try context.fetch(request) var groupedByMAC: [String: [NSManagedObject]] = [:] for object in allObjects { guard let macAddress = object.value(forKey: "macAddress") as? String, !macAddress.isEmpty else { continue } groupedByMAC[normalizedMACAddress(macAddress), default: []].append(object) } results = groupedByMAC.compactMap { macAddress, objects in guard let preferred = preferredObject(from: objects) else { return nil } return record(from: preferred, macAddress: macAddress) } } catch { track("Failed loading cloud device settings: \(error)") } } return results } func fetchByMacAddress() -> [String: CloudDeviceSettingsRecord] { Dictionary(uniqueKeysWithValues: fetchAll().map { ($0.macAddress, $0) }) } func upsert(macAddress: String, meterName: String?, tc66TemperatureUnit: String?) { let macAddress = normalizedMACAddress(macAddress) guard !macAddress.isEmpty else { return } context.performAndWait { do { refreshContextObjects() let objects = try fetchObjects(for: macAddress) let object = preferredObject(from: objects) ?? NSManagedObject(entity: NSEntityDescription.entity(forEntityName: entityName, in: context)!, insertInto: context) for duplicate in objects where duplicate.objectID != object.objectID { context.delete(duplicate) } object.setValue(macAddress, forKey: "macAddress") if let meterName { object.setValue(meterName, forKey: "meterName") } if let tc66TemperatureUnit { object.setValue(tc66TemperatureUnit, forKey: "tc66TemperatureUnit") } object.setValue(Date(), forKey: "updatedAt") if context.hasChanges { try context.save() } } catch { track("Failed persisting cloud device settings for \(macAddress): \(error)") } } } func setConnection(macAddress: String, deviceID: String, deviceName: String, modelType: String) { let macAddress = normalizedMACAddress(macAddress) guard !macAddress.isEmpty, !deviceID.isEmpty else { return } context.performAndWait { do { refreshContextObjects() let objects = try fetchObjects(for: macAddress) let object = preferredObject(from: objects) ?? NSManagedObject(entity: NSEntityDescription.entity(forEntityName: entityName, in: context)!, insertInto: context) for duplicate in objects where duplicate.objectID != object.objectID { context.delete(duplicate) } object.setValue(macAddress, forKey: "macAddress") object.setValue(deviceID, forKey: "connectedByDeviceID") object.setValue(deviceName, forKey: "connectedByDeviceName") let now = Date() object.setValue(now, forKey: "connectedAt") object.setValue(Date().addingTimeInterval(120), forKey: "connectedExpiryAt") object.setValue(modelType, forKey: "modelType") object.setValue(now, forKey: "updatedAt") if context.hasChanges { try context.save() } } catch { track("Failed publishing connection for \(macAddress): \(error)") } } } func recordDiscovery(macAddress: String, modelType: String, peripheralName: String?, seenByDeviceID: String, seenByDeviceName: String) { let macAddress = normalizedMACAddress(macAddress) guard !macAddress.isEmpty else { return } context.performAndWait { do { refreshContextObjects() let objects = try fetchObjects(for: macAddress) let object = preferredObject(from: objects) ?? NSManagedObject(entity: NSEntityDescription.entity(forEntityName: entityName, in: context)!, insertInto: context) for duplicate in objects where duplicate.objectID != object.objectID { context.delete(duplicate) } let now = Date() // Throttle CloudKit updates: only persist discovery once per 2 minutes per device // to avoid constant conflicts between devices on frequent BT advertisements if let previousSeenAt = object.value(forKey: "lastSeenAt") as? Date, let previousSeenBy = object.value(forKey: "lastSeenByDeviceID") as? String, previousSeenBy == seenByDeviceID, now.timeIntervalSince(previousSeenAt) < 120 { return } object.setValue(macAddress, forKey: "macAddress") object.setValue(modelType, forKey: "modelType") object.setValue(now, forKey: "lastSeenAt") object.setValue(seenByDeviceID, forKey: "lastSeenByDeviceID") object.setValue(seenByDeviceName, forKey: "lastSeenByDeviceName") if let peripheralName, !peripheralName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { object.setValue(peripheralName, forKey: "lastSeenPeripheralName") } object.setValue(now, forKey: "updatedAt") if context.hasChanges { try context.save() } } catch { track("Failed recording discovery for \(macAddress): \(error)") } } } func clearConnection(macAddress: String, byDeviceID deviceID: String) { let macAddress = normalizedMACAddress(macAddress) guard !macAddress.isEmpty else { return } context.performAndWait { let request = NSFetchRequest(entityName: entityName) request.predicate = NSPredicate(format: "macAddress == %@ AND connectedByDeviceID == %@", macAddress, deviceID) do { let objects = try context.fetch(request) guard !objects.isEmpty else { return } for object in objects { context.refresh(object, mergeChanges: true) object.setValue(nil, forKey: "connectedByDeviceID") object.setValue(nil, forKey: "connectedByDeviceName") object.setValue(nil, forKey: "connectedAt") object.setValue(nil, forKey: "connectedExpiryAt") object.setValue(Date(), forKey: "updatedAt") } if context.hasChanges { try context.save() } } catch { track("Failed clearing connection for \(macAddress): \(error)") } } } func clearAllConnections(byDeviceID deviceID: String) { guard !deviceID.isEmpty else { return } context.performAndWait { refreshContextObjects() let request = NSFetchRequest(entityName: entityName) request.predicate = NSPredicate(format: "connectedByDeviceID == %@", deviceID) do { let objects = try context.fetch(request) guard !objects.isEmpty else { return } for object in objects { context.refresh(object, mergeChanges: true) object.setValue(nil, forKey: "connectedByDeviceID") object.setValue(nil, forKey: "connectedByDeviceName") object.setValue(nil, forKey: "connectedAt") object.setValue(nil, forKey: "connectedExpiryAt") object.setValue(Date(), forKey: "updatedAt") } if context.hasChanges { try context.save() } track("Cleared \(objects.count) stale connection claim(s) for this device") } catch { track("Failed clearing stale connections: \(error)") } } } func rebuildCanonicalStoreIfNeeded(version: Int) { let defaultsKey = "\(Self.rebuildDefaultsKeyPrefix).\(version)" if UserDefaults.standard.bool(forKey: defaultsKey) { return } context.performAndWait { refreshContextObjects() let request = NSFetchRequest(entityName: entityName) do { let allObjects = try context.fetch(request) var groupedByMAC: [String: [NSManagedObject]] = [:] for object in allObjects { guard let rawMAC = object.value(forKey: "macAddress") as? String else { continue } let macAddress = normalizedMACAddress(rawMAC) guard !macAddress.isEmpty else { continue } groupedByMAC[macAddress, default: []].append(object) } var removedDuplicates = 0 for (macAddress, objects) in groupedByMAC { guard let winner = preferredObject(from: objects) else { continue } winner.setValue(macAddress, forKey: "macAddress") for duplicate in objects where duplicate.objectID != winner.objectID { mergeBestValues(from: duplicate, into: winner) context.delete(duplicate) removedDuplicates += 1 } } if context.hasChanges { try context.save() } UserDefaults.standard.set(true, forKey: defaultsKey) track("Rebuilt DeviceSettings store in-place: \(removedDuplicates) duplicate(s) removed") } catch { track("Failed canonical rebuild for DeviceSettings: \(error)") } } } }