@@ -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() |
@@ -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)'")
|
@@ -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 |
@@ -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 |
} |
@@ -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 |
} |
@@ -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 |
@@ -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 {
|