// MeterNameStore.swift // USB Meter // // Created by Codex on 2026. // import Foundation final class MeterNameStore { struct Record: Identifiable { let macAddress: String let customName: String? let temperatureUnit: String? let modelName: String? let advertisedName: String? let lastSeen: Date? let lastConnected: Date? var id: String { macAddress } } enum CloudAvailability: Equatable { case unknown case available case noAccount case error(String) var helpTitle: String { switch self { case .unknown: return "Cloud Sync Status Unknown" case .available: return "Cloud Sync Ready" case .noAccount: return "Enable iCloud Drive" case .error: return "Cloud Sync Error" } } var helpMessage: String { switch self { case .unknown: return "The app is still checking whether iCloud sync is available on this device." case .available: return "iCloud sync is available for meter names and TC66 temperature preferences." case .noAccount: return "Meter names and TC66 temperature preferences sync through iCloud Drive. The app keeps a local copy too, but cross-device sync stays off until iCloud Drive is available." case .error(let description): return "The app keeps local values, but iCloud sync reported an error: \(description)" } } } static let shared = MeterNameStore() private enum Keys { static let knownMeters = "MeterNameStore.knownMeters" static let localMeterNames = "MeterNameStore.localMeterNames" static let localTemperatureUnits = "MeterNameStore.localTemperatureUnits" static let localModelNames = "MeterNameStore.localModelNames" static let localAdvertisedNames = "MeterNameStore.localAdvertisedNames" static let localLastSeen = "MeterNameStore.localLastSeen" static let localLastConnected = "MeterNameStore.localLastConnected" static let cloudMeterNames = "MeterNameStore.cloudMeterNames" static let cloudTemperatureUnits = "MeterNameStore.cloudTemperatureUnits" } private let defaults = UserDefaults.standard private let ubiquitousStore = NSUbiquitousKeyValueStore.default private let workQueue = DispatchQueue(label: "MeterNameStore.Queue") private var cloudAvailability: CloudAvailability = .unknown private var ubiquitousObserver: NSObjectProtocol? private var ubiquityIdentityObserver: NSObjectProtocol? private init() { ubiquitousObserver = NotificationCenter.default.addObserver( forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: ubiquitousStore, queue: nil ) { [weak self] notification in self?.handleUbiquitousStoreChange(notification) } ubiquityIdentityObserver = NotificationCenter.default.addObserver( forName: NSNotification.Name.NSUbiquityIdentityDidChange, object: nil, queue: nil ) { [weak self] _ in self?.refreshCloudAvailability(reason: "identity-changed") self?.syncLocalValuesToCloudIfPossible(reason: "identity-changed") } refreshCloudAvailability(reason: "startup") ubiquitousStore.synchronize() syncLocalValuesToCloudIfPossible(reason: "startup") } var currentCloudAvailability: CloudAvailability { workQueue.sync { cloudAvailability } } func name(for macAddress: String) -> String? { let normalizedMAC = normalizedMACAddress(macAddress) guard !normalizedMAC.isEmpty else { return nil } return mergedDictionary(localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames)[normalizedMAC] } func temperatureUnitRawValue(for macAddress: String) -> String? { let normalizedMAC = normalizedMACAddress(macAddress) guard !normalizedMAC.isEmpty else { return nil } return mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits)[normalizedMAC] } func lastSeen(for macAddress: String) -> Date? { let normalizedMAC = normalizedMACAddress(macAddress) guard !normalizedMAC.isEmpty else { return nil } return dateDictionary(for: Keys.localLastSeen)[normalizedMAC] } func lastConnected(for macAddress: String) -> Date? { let normalizedMAC = normalizedMACAddress(macAddress) guard !normalizedMAC.isEmpty else { return nil } return dateDictionary(for: Keys.localLastConnected)[normalizedMAC] } func registerKnownMeter(macAddress: String, modelName: String?, advertisedName: String?) { let normalizedMAC = normalizedMACAddress(macAddress) guard !normalizedMAC.isEmpty else { track("MeterNameStore ignored known meter registration with invalid MAC '\(macAddress)'") return } var didChange = false didChange = updateKnownMeters(normalizedMAC) || didChange didChange = updateDictionaryValue( for: normalizedMAC, value: normalizedName(modelName), localKey: Keys.localModelNames, cloudKey: nil ) || didChange didChange = updateDictionaryValue( for: normalizedMAC, value: normalizedName(advertisedName), localKey: Keys.localAdvertisedNames, cloudKey: nil ) || didChange if didChange { notifyChange() } } func noteLastSeen(_ date: Date, for macAddress: String) { updateDate(date, for: macAddress, key: Keys.localLastSeen) } func noteLastConnected(_ date: Date, for macAddress: String) { updateDate(date, for: macAddress, key: Keys.localLastConnected) } func upsert(macAddress: String, name: String?, temperatureUnitRawValue: String?) { let normalizedMAC = normalizedMACAddress(macAddress) guard !normalizedMAC.isEmpty else { track("MeterNameStore ignored upsert with invalid MAC '\(macAddress)'") return } var didChange = false didChange = updateKnownMeters(normalizedMAC) || didChange if let name { didChange = updateDictionaryValue( for: normalizedMAC, value: normalizedName(name), localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames ) || didChange } if let temperatureUnitRawValue { didChange = updateDictionaryValue( for: normalizedMAC, value: normalizedTemperatureUnit(temperatureUnitRawValue), localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits ) || didChange } if didChange { notifyChange() } } func allRecords() -> [Record] { let names = mergedDictionary(localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames) let temperatureUnits = mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits) let modelNames = dictionary(for: Keys.localModelNames, store: defaults) let advertisedNames = dictionary(for: Keys.localAdvertisedNames, store: defaults) let lastSeenValues = dateDictionary(for: Keys.localLastSeen) let lastConnectedValues = dateDictionary(for: Keys.localLastConnected) let macAddresses = knownMeters() .union(names.keys) .union(temperatureUnits.keys) .union(modelNames.keys) .union(advertisedNames.keys) .union(lastSeenValues.keys) .union(lastConnectedValues.keys) return macAddresses.sorted().map { macAddress in Record( macAddress: macAddress, customName: names[macAddress], temperatureUnit: temperatureUnits[macAddress], modelName: modelNames[macAddress], advertisedName: advertisedNames[macAddress], lastSeen: lastSeenValues[macAddress], lastConnected: lastConnectedValues[macAddress] ) } } private func normalizedMACAddress(_ macAddress: String) -> String { macAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() } private func normalizedName(_ name: String?) -> String? { guard let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil } return trimmed } private func normalizedTemperatureUnit(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil } return trimmed } private func dictionary(for key: String, store: KeyValueReading) -> [String: String] { (store.object(forKey: key) as? [String: String]) ?? [:] } private func dateDictionary(for key: String) -> [String: Date] { let rawValues = (defaults.object(forKey: key) as? [String: TimeInterval]) ?? [:] return rawValues.mapValues(Date.init(timeIntervalSince1970:)) } private func knownMeters() -> Set { Set((defaults.array(forKey: Keys.knownMeters) as? [String]) ?? []) } private func mergedDictionary(localKey: String, cloudKey: String) -> [String: String] { let localValues = dictionary(for: localKey, store: defaults) let cloudValues = dictionary(for: cloudKey, store: ubiquitousStore) return localValues.merging(cloudValues) { _, cloudValue in cloudValue } } @discardableResult private func updateKnownMeters(_ macAddress: String) -> Bool { var known = knownMeters() let initialCount = known.count known.insert(macAddress) guard known.count != initialCount else { return false } defaults.set(Array(known).sorted(), forKey: Keys.knownMeters) return true } @discardableResult private func updateDictionaryValue( for macAddress: String, value: String?, localKey: String, cloudKey: String? ) -> Bool { var localValues = dictionary(for: localKey, store: defaults) let didChangeLocal = setDictionaryValue(&localValues, for: macAddress, value: value) if didChangeLocal { defaults.set(localValues, forKey: localKey) } var didChangeCloud = false if let cloudKey, isICloudDriveAvailable { var cloudValues = dictionary(for: cloudKey, store: ubiquitousStore) didChangeCloud = setDictionaryValue(&cloudValues, for: macAddress, value: value) if didChangeCloud { ubiquitousStore.set(cloudValues, forKey: cloudKey) ubiquitousStore.synchronize() } } return didChangeLocal || didChangeCloud } @discardableResult private func setDictionaryValue( _ dictionary: inout [String: String], for macAddress: String, value: String? ) -> Bool { let currentValue = dictionary[macAddress] guard currentValue != value else { return false } if let value { dictionary[macAddress] = value } else { dictionary.removeValue(forKey: macAddress) } return true } private func updateDate(_ date: Date, for macAddress: String, key: String) { let normalizedMAC = normalizedMACAddress(macAddress) guard !normalizedMAC.isEmpty else { track("MeterNameStore ignored date update with invalid MAC '\(macAddress)'") return } var values = (defaults.object(forKey: key) as? [String: TimeInterval]) ?? [:] let timeInterval = date.timeIntervalSince1970 guard values[normalizedMAC] != timeInterval else { return } values[normalizedMAC] = timeInterval defaults.set(values, forKey: key) _ = updateKnownMeters(normalizedMAC) notifyChange() } private var isICloudDriveAvailable: Bool { FileManager.default.ubiquityIdentityToken != nil } private func refreshCloudAvailability(reason: String) { let newAvailability: CloudAvailability = isICloudDriveAvailable ? .available : .noAccount var shouldNotify = false workQueue.sync { guard cloudAvailability != newAvailability else { return } cloudAvailability = newAvailability shouldNotify = true } guard shouldNotify else { return } track("MeterNameStore iCloud availability (\(reason)): \(newAvailability)") DispatchQueue.main.async { NotificationCenter.default.post(name: .meterNameStoreCloudStatusDidChange, object: nil) } } private func handleUbiquitousStoreChange(_ notification: Notification) { refreshCloudAvailability(reason: "ubiquitous-store-change") if let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String], !changedKeys.isEmpty { track("MeterNameStore received ubiquitous changes for keys: \(changedKeys.joined(separator: ", "))") } notifyChange() } private func syncLocalValuesToCloudIfPossible(reason: String) { guard isICloudDriveAvailable else { refreshCloudAvailability(reason: reason) return } let localNames = dictionary(for: Keys.localMeterNames, store: defaults) let localTemperatureUnits = dictionary(for: Keys.localTemperatureUnits, store: defaults) var cloudNames = dictionary(for: Keys.cloudMeterNames, store: ubiquitousStore) var cloudTemperatureUnits = dictionary(for: Keys.cloudTemperatureUnits, store: ubiquitousStore) let mergedNames = cloudNames.merging(localNames) { cloudValue, _ in cloudValue } let mergedTemperatureUnits = cloudTemperatureUnits.merging(localTemperatureUnits) { cloudValue, _ in cloudValue } var didChange = false if cloudNames != mergedNames { cloudNames = mergedNames ubiquitousStore.set(cloudNames, forKey: Keys.cloudMeterNames) didChange = true } if cloudTemperatureUnits != mergedTemperatureUnits { cloudTemperatureUnits = mergedTemperatureUnits ubiquitousStore.set(cloudTemperatureUnits, forKey: Keys.cloudTemperatureUnits) didChange = true } refreshCloudAvailability(reason: reason) if didChange { ubiquitousStore.synchronize() track("MeterNameStore pushed local fallback values into iCloud KVS (\(reason)).") notifyChange() } } private func notifyChange() { DispatchQueue.main.async { NotificationCenter.default.post(name: .meterNameStoreDidChange, object: nil) } } deinit { if let observer = ubiquitousObserver { NotificationCenter.default.removeObserver(observer) } if let observer = ubiquityIdentityObserver { NotificationCenter.default.removeObserver(observer) } } } private protocol KeyValueReading { func object(forKey defaultName: String) -> Any? } extension UserDefaults: KeyValueReading {} extension NSUbiquitousKeyValueStore: KeyValueReading {} extension Notification.Name { static let meterNameStoreDidChange = Notification.Name("MeterNameStoreDidChange") static let meterNameStoreCloudStatusDidChange = Notification.Name("MeterNameStoreCloudStatusDidChange") }