1 contributor
670 lines | 29.949kb
//
//  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 {
    #if os(macOS)
    // On macOS (Catalyst, iPad App on Mac), use hostname instead of UIDevice.current.name
    let hostname = ProcessInfo.processInfo.hostName
    return hostname.trimmingCharacters(in: .whitespacesAndNewlines)
    #else
    // On iOS/iPadOS, use device name
    return UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
    #endif
}

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<NSManagedObject>(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<NSManagedObject>(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<NSManagedObject>(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()
                if let previousSeenAt = object.value(forKey: "lastSeenAt") as? Date,
                   let previousSeenBy = object.value(forKey: "lastSeenByDeviceID") as? String,
                   previousSeenBy == seenByDeviceID,
                   now.timeIntervalSince(previousSeenAt) < 15 {
                    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<NSManagedObject>(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<NSManagedObject>(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<NSManagedObject>(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)")
            }
        }
    }
}