Showing 7 changed files with 344 additions and 38 deletions
+62 -0
USB Meter/Model/AppData.swift
@@ -11,6 +11,20 @@ import Combine
11 11
 import CoreBluetooth
12 12
 
13 13
 final class AppData : ObservableObject {
14
+    struct KnownMeterSummary: Identifiable {
15
+        let macAddress: String
16
+        let displayName: String
17
+        let modelSummary: String
18
+        let advertisedName: String?
19
+        let lastSeen: Date?
20
+        let lastConnected: Date?
21
+        let meter: Meter?
22
+
23
+        var id: String {
24
+            macAddress
25
+        }
26
+    }
27
+
14 28
     private var bluetoothManagerNotification: AnyCancellable?
15 29
     private var meterStoreObserver: AnyCancellable?
16 30
     private var meterStoreCloudObserver: AnyCancellable?
@@ -59,6 +73,54 @@ final class AppData : ObservableObject {
59 73
         meterStore.upsert(macAddress: macAddress, name: nil, temperatureUnitRawValue: preference.rawValue)
60 74
     }
61 75
 
76
+    func registerKnownMeter(macAddress: String, modelName: String?, advertisedName: String?) {
77
+        meterStore.registerKnownMeter(macAddress: macAddress, modelName: modelName, advertisedName: advertisedName)
78
+    }
79
+
80
+    func noteMeterSeen(at date: Date, macAddress: String) {
81
+        meterStore.noteLastSeen(date, for: macAddress)
82
+    }
83
+
84
+    func noteMeterConnected(at date: Date, macAddress: String) {
85
+        meterStore.noteLastConnected(date, for: macAddress)
86
+    }
87
+
88
+    func lastSeen(for macAddress: String) -> Date? {
89
+        meterStore.lastSeen(for: macAddress)
90
+    }
91
+
92
+    func lastConnected(for macAddress: String) -> Date? {
93
+        meterStore.lastConnected(for: macAddress)
94
+    }
95
+
96
+    var knownMeters: [KnownMeterSummary] {
97
+        let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
98
+        let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
99
+        let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys)
100
+
101
+        return macAddresses.map { macAddress in
102
+            let liveMeter = liveMetersByMAC[macAddress]
103
+            let record = recordsByMAC[macAddress]
104
+
105
+            return KnownMeterSummary(
106
+                macAddress: macAddress,
107
+                displayName: liveMeter?.name ?? record?.customName ?? macAddress,
108
+                modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Known meter",
109
+                advertisedName: liveMeter?.modelString ?? record?.advertisedName,
110
+                lastSeen: liveMeter?.lastSeen ?? record?.lastSeen,
111
+                lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected,
112
+                meter: liveMeter
113
+            )
114
+        }
115
+        .sorted { lhs, rhs in
116
+            let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
117
+            if byName != .orderedSame {
118
+                return byName == .orderedAscending
119
+            }
120
+            return lhs.macAddress < rhs.macAddress
121
+        }
122
+    }
123
+
62 124
     private func scheduleObjectWillChange() {
63 125
         DispatchQueue.main.async { [weak self] in
64 126
             self?.objectWillChange.send()
+3 -0
USB Meter/Model/BluetoothManager.swift
@@ -61,6 +61,9 @@ class BluetoothManager : NSObject, ObservableObject {
61 61
         }
62 62
         
63 63
         let macAddress = MACAddress(from: manufacturerData.suffix(from: 2))
64
+        let macAddressString = macAddress.description
65
+        appData.registerKnownMeter(macAddress: macAddressString, modelName: model.canonicalName, advertisedName: peripheralName)
66
+        appData.noteMeterSeen(at: Date(), macAddress: macAddressString)
64 67
         
65 68
         if appData.meters[peripheral.identifier] == nil {
66 69
             track("adding new USB Meter named '\(peripheralName)' with MAC Address: '\(macAddress)'")
+13 -1
USB Meter/Model/Meter.swift
@@ -152,9 +152,11 @@ class Meter : NSObject, ObservableObject, Identifiable {
152 152
 
153 153
     private var wdTimer: Timer?
154 154
 
155
-    var lastSeen = Date() {
155
+    @Published var lastSeen: Date? {
156 156
         didSet {
157 157
             wdTimer?.invalidate()
158
+            guard lastSeen != nil else { return }
159
+            appData.noteMeterSeen(at: lastSeen!, macAddress: btSerial.macAddress.description)
158 160
             if operationalState == .peripheralNotConnected {
159 161
                 wdTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false, block: {_ in
160 162
                     track("\(self.name) - Lost advertisments...")
@@ -179,6 +181,8 @@ class Meter : NSObject, ObservableObject, Identifiable {
179 181
             appData.setMeterName(name, for: btSerial.macAddress.description)
180 182
         }
181 183
     }
184
+
185
+    @Published private(set) var lastConnectedAt: Date?
182 186
     
183 187
     var color : Color {
184 188
         get {
@@ -547,6 +551,8 @@ class Meter : NSObject, ObservableObject, Identifiable {
547 551
         self.model = model
548 552
         btSerial = serialPort
549 553
         name = appData.meterName(for: serialPort.macAddress.description) ?? serialPort.macAddress.description
554
+        lastSeen = appData.lastSeen(for: serialPort.macAddress.description)
555
+        lastConnectedAt = appData.lastConnected(for: serialPort.macAddress.description)
550 556
         super.init()
551 557
         btSerial.delegate = self
552 558
         reloadTemperatureUnitPreference()
@@ -571,6 +577,11 @@ class Meter : NSObject, ObservableObject, Identifiable {
571 577
         isSyncingNameFromStore = false
572 578
     }
573 579
 
580
+    private func noteConnectionEstablished(at date: Date) {
581
+        lastConnectedAt = date
582
+        appData.noteMeterConnected(at: date, macAddress: btSerial.macAddress.description)
583
+    }
584
+
574 585
     private func cancelPendingDataDumpRequest(reason: String) {
575 586
         guard let pendingDataDumpWorkItem else { return }
576 587
         track("\(name) - Cancel scheduled data request (\(reason))")
@@ -974,6 +985,7 @@ extension Meter : SerialPortDelegate {
974 985
             case .peripheralConnectionPending:
975 986
                 self.operationalState = .peripheralConnectionPending
976 987
             case .peripheralConnected:
988
+                self.noteConnectionEstablished(at: Date())
977 989
                 self.operationalState = .peripheralConnected
978 990
             case .peripheralReady:
979 991
                 self.operationalState = .peripheralReady
+110 -4
USB Meter/Model/MeterNameStore.swift
@@ -11,6 +11,10 @@ final class MeterNameStore {
11 11
         let macAddress: String
12 12
         let customName: String?
13 13
         let temperatureUnit: String?
14
+        let modelName: String?
15
+        let advertisedName: String?
16
+        let lastSeen: Date?
17
+        let lastConnected: Date?
14 18
 
15 19
         var id: String {
16 20
             macAddress
@@ -53,8 +57,13 @@ final class MeterNameStore {
53 57
     static let shared = MeterNameStore()
54 58
 
55 59
     private enum Keys {
60
+        static let knownMeters = "MeterNameStore.knownMeters"
56 61
         static let localMeterNames = "MeterNameStore.localMeterNames"
57 62
         static let localTemperatureUnits = "MeterNameStore.localTemperatureUnits"
63
+        static let localModelNames = "MeterNameStore.localModelNames"
64
+        static let localAdvertisedNames = "MeterNameStore.localAdvertisedNames"
65
+        static let localLastSeen = "MeterNameStore.localLastSeen"
66
+        static let localLastConnected = "MeterNameStore.localLastConnected"
58 67
         static let cloudMeterNames = "MeterNameStore.cloudMeterNames"
59 68
         static let cloudTemperatureUnits = "MeterNameStore.cloudTemperatureUnits"
60 69
     }
@@ -107,6 +116,53 @@ final class MeterNameStore {
107 116
         return mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits)[normalizedMAC]
108 117
     }
109 118
 
119
+    func lastSeen(for macAddress: String) -> Date? {
120
+        let normalizedMAC = normalizedMACAddress(macAddress)
121
+        guard !normalizedMAC.isEmpty else { return nil }
122
+        return dateDictionary(for: Keys.localLastSeen)[normalizedMAC]
123
+    }
124
+
125
+    func lastConnected(for macAddress: String) -> Date? {
126
+        let normalizedMAC = normalizedMACAddress(macAddress)
127
+        guard !normalizedMAC.isEmpty else { return nil }
128
+        return dateDictionary(for: Keys.localLastConnected)[normalizedMAC]
129
+    }
130
+
131
+    func registerKnownMeter(macAddress: String, modelName: String?, advertisedName: String?) {
132
+        let normalizedMAC = normalizedMACAddress(macAddress)
133
+        guard !normalizedMAC.isEmpty else {
134
+            track("MeterNameStore ignored known meter registration with invalid MAC '\(macAddress)'")
135
+            return
136
+        }
137
+
138
+        var didChange = false
139
+        didChange = updateKnownMeters(normalizedMAC) || didChange
140
+        didChange = updateDictionaryValue(
141
+            for: normalizedMAC,
142
+            value: normalizedName(modelName),
143
+            localKey: Keys.localModelNames,
144
+            cloudKey: nil
145
+        ) || didChange
146
+        didChange = updateDictionaryValue(
147
+            for: normalizedMAC,
148
+            value: normalizedName(advertisedName),
149
+            localKey: Keys.localAdvertisedNames,
150
+            cloudKey: nil
151
+        ) || didChange
152
+
153
+        if didChange {
154
+            notifyChange()
155
+        }
156
+    }
157
+
158
+    func noteLastSeen(_ date: Date, for macAddress: String) {
159
+        updateDate(date, for: macAddress, key: Keys.localLastSeen)
160
+    }
161
+
162
+    func noteLastConnected(_ date: Date, for macAddress: String) {
163
+        updateDate(date, for: macAddress, key: Keys.localLastConnected)
164
+    }
165
+
110 166
     func upsert(macAddress: String, name: String?, temperatureUnitRawValue: String?) {
111 167
         let normalizedMAC = normalizedMACAddress(macAddress)
112 168
         guard !normalizedMAC.isEmpty else {
@@ -115,6 +171,7 @@ final class MeterNameStore {
115 171
         }
116 172
 
117 173
         var didChange = false
174
+        didChange = updateKnownMeters(normalizedMAC) || didChange
118 175
 
119 176
         if let name {
120 177
             didChange = updateDictionaryValue(
@@ -142,13 +199,27 @@ final class MeterNameStore {
142 199
     func allRecords() -> [Record] {
143 200
         let names = mergedDictionary(localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames)
144 201
         let temperatureUnits = mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits)
145
-        let macAddresses = Set(names.keys).union(temperatureUnits.keys)
202
+        let modelNames = dictionary(for: Keys.localModelNames, store: defaults)
203
+        let advertisedNames = dictionary(for: Keys.localAdvertisedNames, store: defaults)
204
+        let lastSeenValues = dateDictionary(for: Keys.localLastSeen)
205
+        let lastConnectedValues = dateDictionary(for: Keys.localLastConnected)
206
+        let macAddresses = knownMeters()
207
+            .union(names.keys)
208
+            .union(temperatureUnits.keys)
209
+            .union(modelNames.keys)
210
+            .union(advertisedNames.keys)
211
+            .union(lastSeenValues.keys)
212
+            .union(lastConnectedValues.keys)
146 213
 
147 214
         return macAddresses.sorted().map { macAddress in
148 215
             Record(
149 216
                 macAddress: macAddress,
150 217
                 customName: names[macAddress],
151
-                temperatureUnit: temperatureUnits[macAddress]
218
+                temperatureUnit: temperatureUnits[macAddress],
219
+                modelName: modelNames[macAddress],
220
+                advertisedName: advertisedNames[macAddress],
221
+                lastSeen: lastSeenValues[macAddress],
222
+                lastConnected: lastConnectedValues[macAddress]
152 223
             )
153 224
         }
154 225
     }
@@ -177,6 +248,15 @@ final class MeterNameStore {
177 248
         (store.object(forKey: key) as? [String: String]) ?? [:]
178 249
     }
179 250
 
251
+    private func dateDictionary(for key: String) -> [String: Date] {
252
+        let rawValues = (defaults.object(forKey: key) as? [String: TimeInterval]) ?? [:]
253
+        return rawValues.mapValues(Date.init(timeIntervalSince1970:))
254
+    }
255
+
256
+    private func knownMeters() -> Set<String> {
257
+        Set((defaults.array(forKey: Keys.knownMeters) as? [String]) ?? [])
258
+    }
259
+
180 260
     private func mergedDictionary(localKey: String, cloudKey: String) -> [String: String] {
181 261
         let localValues = dictionary(for: localKey, store: defaults)
182 262
         let cloudValues = dictionary(for: cloudKey, store: ubiquitousStore)
@@ -185,12 +265,22 @@ final class MeterNameStore {
185 265
         }
186 266
     }
187 267
 
268
+    @discardableResult
269
+    private func updateKnownMeters(_ macAddress: String) -> Bool {
270
+        var known = knownMeters()
271
+        let initialCount = known.count
272
+        known.insert(macAddress)
273
+        guard known.count != initialCount else { return false }
274
+        defaults.set(Array(known).sorted(), forKey: Keys.knownMeters)
275
+        return true
276
+    }
277
+
188 278
     @discardableResult
189 279
     private func updateDictionaryValue(
190 280
         for macAddress: String,
191 281
         value: String?,
192 282
         localKey: String,
193
-        cloudKey: String
283
+        cloudKey: String?
194 284
     ) -> Bool {
195 285
         var localValues = dictionary(for: localKey, store: defaults)
196 286
         let didChangeLocal = setDictionaryValue(&localValues, for: macAddress, value: value)
@@ -199,7 +289,7 @@ final class MeterNameStore {
199 289
         }
200 290
 
201 291
         var didChangeCloud = false
202
-        if isICloudDriveAvailable {
292
+        if let cloudKey, isICloudDriveAvailable {
203 293
             var cloudValues = dictionary(for: cloudKey, store: ubiquitousStore)
204 294
             didChangeCloud = setDictionaryValue(&cloudValues, for: macAddress, value: value)
205 295
             if didChangeCloud {
@@ -227,6 +317,22 @@ final class MeterNameStore {
227 317
         return true
228 318
     }
229 319
 
320
+    private func updateDate(_ date: Date, for macAddress: String, key: String) {
321
+        let normalizedMAC = normalizedMACAddress(macAddress)
322
+        guard !normalizedMAC.isEmpty else {
323
+            track("MeterNameStore ignored date update with invalid MAC '\(macAddress)'")
324
+            return
325
+        }
326
+
327
+        var values = (defaults.object(forKey: key) as? [String: TimeInterval]) ?? [:]
328
+        let timeInterval = date.timeIntervalSince1970
329
+        guard values[normalizedMAC] != timeInterval else { return }
330
+        values[normalizedMAC] = timeInterval
331
+        defaults.set(values, forKey: key)
332
+        _ = updateKnownMeters(normalizedMAC)
333
+        notifyChange()
334
+    }
335
+
230 336
     private var isICloudDriveAvailable: Bool {
231 337
         FileManager.default.ubiquityIdentityToken != nil
232 338
     }
+110 -18
USB Meter/Views/ContentView.swift
@@ -64,8 +64,8 @@ struct ContentView: View {
64 64
                 VStack(alignment: .leading, spacing: 18) {
65 65
                     headerCard
66 66
                     helpSection
67
-                    debugLink
68 67
                     devicesSection
68
+                    debugSection
69 69
                 }
70 70
                 .padding()
71 71
             }
@@ -206,17 +206,17 @@ struct ContentView: View {
206 206
     private var devicesSection: some View {
207 207
         VStack(alignment: .leading, spacing: 12) {
208 208
             HStack {
209
-                Text("Discovered Devices")
209
+                Text("Known Meters")
210 210
                     .font(.headline)
211 211
                 Spacer()
212
-                Text("\(appData.meters.count)")
212
+                Text("\(appData.knownMeters.count)")
213 213
                     .font(.caption.weight(.bold))
214 214
                     .padding(.horizontal, 10)
215 215
                     .padding(.vertical, 6)
216 216
                     .meterCard(tint: .blue, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
217 217
             }
218 218
 
219
-            if appData.meters.isEmpty {
219
+            if appData.knownMeters.isEmpty {
220 220
                 Text(devicesEmptyStateText)
221 221
                     .font(.footnote)
222 222
                     .foregroundColor(.secondary)
@@ -228,22 +228,34 @@ struct ContentView: View {
228 228
                         strokeOpacity: 0.20
229 229
                     )
230 230
             } else {
231
-                ForEach(discoveredMeters, id: \.self) { meter in
232
-                    NavigationLink(destination: MeterView().environmentObject(meter)) {
233
-                        MeterRowView()
234
-                            .environmentObject(meter)
231
+                ForEach(appData.knownMeters) { knownMeter in
232
+                    if let meter = knownMeter.meter {
233
+                        NavigationLink(destination: MeterView().environmentObject(meter)) {
234
+                            MeterRowView()
235
+                                .environmentObject(meter)
236
+                        }
237
+                        .buttonStyle(.plain)
238
+                    } else {
239
+                        knownMeterCard(for: knownMeter)
235 240
                     }
236
-                    .buttonStyle(.plain)
237 241
                 }
238 242
             }
239 243
         }
240 244
     }
241 245
 
246
+    private var debugSection: some View {
247
+        VStack(alignment: .leading, spacing: 12) {
248
+            Text("Debug")
249
+                .font(.headline)
250
+            debugLink
251
+        }
252
+    }
253
+
242 254
     private var debugLink: some View {
243 255
         NavigationLink(destination: MeterMappingDebugView()) {
244 256
             sidebarLinkCard(
245
-                title: "Meter Mapping Debug",
246
-                subtitle: "Inspect the MAC ↔ name/TC66 table as seen by this device.",
257
+                title: "Meter Sync Debug",
258
+                subtitle: "Inspect meter name sync data and iCloud KVS visibility as seen by this device.",
247 259
                 symbol: "list.bullet.rectangle",
248 260
                 tint: .purple
249 261
             )
@@ -251,12 +263,6 @@ struct ContentView: View {
251 263
         .buttonStyle(.plain)
252 264
     }
253 265
 
254
-    private var discoveredMeters: [Meter] {
255
-        Array(appData.meters.values).sorted { lhs, rhs in
256
-            lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
257
-        }
258
-    }
259
-
260 266
     private var bluetoothStatusText: String {
261 267
         switch appData.bluetoothManager.managerState {
262 268
         case .poweredOff:
@@ -339,7 +345,7 @@ struct ContentView: View {
339 345
         if isWaitingForFirstDiscovery {
340 346
             return "Scanning for nearby supported meters..."
341 347
         }
342
-        return "No supported meters are visible right now."
348
+        return "No known meters yet. Nearby supported meters will appear here and remain available after they disappear."
343 349
     }
344 350
 
345 351
     private var helpSectionTint: Color {
@@ -446,4 +452,90 @@ struct ContentView: View {
446 452
         .padding(14)
447 453
         .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
448 454
     }
455
+
456
+    private func knownMeterCard(for knownMeter: AppData.KnownMeterSummary) -> some View {
457
+        HStack(spacing: 14) {
458
+            Image(systemName: "sensor.tag.radiowaves.forward.fill")
459
+                .font(.system(size: 18, weight: .semibold))
460
+                .foregroundColor(knownMeterTint(for: knownMeter))
461
+                .frame(width: 42, height: 42)
462
+                .background(
463
+                    Circle()
464
+                        .fill(knownMeterTint(for: knownMeter).opacity(0.18))
465
+                )
466
+                .overlay(alignment: .bottomTrailing) {
467
+                    Circle()
468
+                        .fill(Color.red)
469
+                        .frame(width: 12, height: 12)
470
+                        .overlay(
471
+                            Circle()
472
+                                .stroke(Color(uiColor: .systemBackground), lineWidth: 2)
473
+                        )
474
+                }
475
+
476
+            VStack(alignment: .leading, spacing: 4) {
477
+                Text(knownMeter.displayName)
478
+                    .font(.headline)
479
+                Text(knownMeter.modelSummary)
480
+                    .font(.caption)
481
+                    .foregroundColor(.secondary)
482
+                if let advertisedName = knownMeter.advertisedName, advertisedName != knownMeter.modelSummary {
483
+                    Text("Advertised as \(advertisedName)")
484
+                        .font(.caption2)
485
+                        .foregroundColor(.secondary)
486
+                }
487
+            }
488
+
489
+            Spacer()
490
+
491
+            VStack(alignment: .trailing, spacing: 4) {
492
+                HStack(spacing: 6) {
493
+                    Circle()
494
+                        .fill(Color.red)
495
+                        .frame(width: 8, height: 8)
496
+                    Text("Missing")
497
+                        .font(.caption.weight(.semibold))
498
+                        .foregroundColor(.secondary)
499
+                }
500
+                .padding(.horizontal, 10)
501
+                .padding(.vertical, 6)
502
+                .background(
503
+                    Capsule(style: .continuous)
504
+                        .fill(Color.red.opacity(0.12))
505
+                )
506
+                .overlay(
507
+                    Capsule(style: .continuous)
508
+                        .stroke(Color.red.opacity(0.22), lineWidth: 1)
509
+                )
510
+                Text(knownMeter.macAddress)
511
+                    .font(.caption2)
512
+                    .foregroundColor(.secondary)
513
+                if let lastSeen = knownMeter.lastSeen {
514
+                    Text("Seen \(lastSeen.format(as: "yyyy-MM-dd HH:mm"))")
515
+                        .font(.caption2)
516
+                        .foregroundColor(.secondary)
517
+                }
518
+            }
519
+        }
520
+        .padding(14)
521
+        .meterCard(
522
+            tint: knownMeterTint(for: knownMeter),
523
+            fillOpacity: 0.16,
524
+            strokeOpacity: 0.22,
525
+            cornerRadius: 18
526
+        )
527
+    }
528
+
529
+    private func knownMeterTint(for knownMeter: AppData.KnownMeterSummary) -> Color {
530
+        switch knownMeter.modelSummary {
531
+        case "UM25C":
532
+            return .blue
533
+        case "UM34C":
534
+            return .yellow
535
+        case "TC66C":
536
+            return Model.TC66C.color
537
+        default:
538
+            return .secondary
539
+        }
540
+    }
449 541
 }
+9 -0
USB Meter/Views/Meter/MeterView.swift
@@ -348,6 +348,8 @@ struct MeterView: View {
348 348
                 MeterInfoRow(label: "Advertised Model", value: meter.modelString)
349 349
                 MeterInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage)
350 350
                 MeterInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription)
351
+                MeterInfoRow(label: "Last Seen", value: meterHistoryText(for: meter.lastSeen))
352
+                MeterInfoRow(label: "Last Connected", value: meterHistoryText(for: meter.lastConnectedAt))
351 353
             }
352 354
 
353 355
             MeterInfoCard(title: "Identifiers", tint: .blue) {
@@ -415,6 +417,13 @@ struct MeterView: View {
415 417
         }
416 418
     }
417 419
 
420
+    private func meterHistoryText(for date: Date?) -> String {
421
+        guard let date else {
422
+            return "Never"
423
+        }
424
+        return date.format(as: "yyyy-MM-dd HH:mm")
425
+    }
426
+
418 427
     @ViewBuilder
419 428
     private func portraitSettingsPage(size: CGSize) -> some View {
420 429
         settingsTabContent
+37 -15
USB Meter/Views/MeterMappingDebugView.swift
@@ -12,25 +12,47 @@ struct MeterMappingDebugView: View {
12 12
     private let changePublisher = NotificationCenter.default.publisher(for: .meterNameStoreDidChange)
13 13
 
14 14
     var body: some View {
15
-        List(records) { record in
16
-            VStack(alignment: .leading, spacing: 6) {
17
-                Text(record.customName)
18
-                    .font(.headline)
19
-                Text(record.macAddress)
20
-                    .font(.caption.monospaced())
21
-                    .foregroundColor(.secondary)
22
-                HStack {
23
-                    Text("TC66 unit:")
24
-                        .font(.caption.weight(.semibold))
25
-                    Text(record.temperatureUnit)
26
-                        .font(.caption.monospaced())
27
-                        .foregroundColor(.blue)
15
+        List {
16
+            Section {
17
+                VStack(alignment: .leading, spacing: 8) {
18
+                    Text(store.currentCloudAvailability.helpTitle)
19
+                        .font(.headline)
20
+                    Text("This screen is limited to meter sync metadata visible on this device through the local store and iCloud KVS.")
21
+                        .font(.caption)
22
+                        .foregroundColor(.secondary)
23
+                    Text(store.currentCloudAvailability.helpMessage)
24
+                        .font(.caption)
25
+                        .foregroundColor(.secondary)
28 26
                 }
27
+                .padding(.vertical, 6)
28
+            } header: {
29
+                Text("Sync Status")
30
+            }
31
+
32
+            Section {
33
+                ForEach(records) { record in
34
+                    VStack(alignment: .leading, spacing: 6) {
35
+                        Text(record.customName)
36
+                            .font(.headline)
37
+                        Text(record.macAddress)
38
+                            .font(.caption.monospaced())
39
+                            .foregroundColor(.secondary)
40
+                        HStack {
41
+                            Text("TC66 unit:")
42
+                                .font(.caption.weight(.semibold))
43
+                            Text(record.temperatureUnit)
44
+                                .font(.caption.monospaced())
45
+                                .foregroundColor(.blue)
46
+                        }
47
+                    }
48
+                    .padding(.vertical, 8)
49
+                }
50
+            } header: {
51
+                Text("KVS Meter Mapping")
29 52
             }
30
-            .padding(.vertical, 8)
31 53
         }
32 54
         .listStyle(.insetGrouped)
33
-        .navigationTitle("Meter Name Mapping")
55
+        .navigationTitle("Meter Sync Debug")
34 56
         .onAppear(perform: reload)
35 57
         .onReceive(changePublisher) { _ in reload() }
36 58
         .toolbar {