@@ -123,6 +123,7 @@ |
||
| 123 | 123 |
43CBF661240BF3EB00255B8B /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
| 124 | 124 |
43CBF664240BF3EB00255B8B /* USB_Meter.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = USB_Meter.xcdatamodel; sourceTree = "<group>"; };
|
| 125 | 125 |
A1B2C3D4E5F6A7B8C9D0E100 /* USB_Meter 2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 2.xcdatamodel"; sourceTree = "<group>"; };
|
| 126 |
+ B2C3D4E5F6A7B8C9D0E1F201 /* USB_Meter 3.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 3.xcdatamodel"; sourceTree = "<group>"; };
|
|
| 126 | 127 |
43CBF666240BF3EB00255B8B /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
| 127 | 128 |
43CBF668240BF3ED00255B8B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
| 128 | 129 |
43CBF66B240BF3ED00255B8B /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
@@ -762,8 +763,11 @@ |
||
| 762 | 763 |
children = ( |
| 763 | 764 |
43CBF664240BF3EB00255B8B /* USB_Meter.xcdatamodel */, |
| 764 | 765 |
A1B2C3D4E5F6A7B8C9D0E100 /* USB_Meter 2.xcdatamodel */, |
| 766 |
+ B2C3D4E5F6A7B8C9D0E1F201 /* USB_Met |
|
| 767 |
+ |
|
| 768 |
+er 3.xcdatamodel */, |
|
| 765 | 769 |
); |
| 766 |
- currentVersion = A1B2C3D4E5F6A7B8C9D0E100 /* USB_Meter 2.xcdatamodel */; |
|
| 770 |
+ currentVersion = B2C3D4E5F6A7B8C9D0E1F201 /* USB_Meter 3.xcdatamodel */; |
|
| 767 | 771 |
path = CKModel.xcdatamodeld; |
| 768 | 772 |
sourceTree = "<group>"; |
| 769 | 773 |
versionGroupType = wrapper.xcdatamodel; |
@@ -27,12 +27,18 @@ final class AppData : ObservableObject {
|
||
| 27 | 27 |
private var icloudDefaultsNotification: AnyCancellable? |
| 28 | 28 |
private var bluetoothManagerNotification: AnyCancellable? |
| 29 | 29 |
private var coreDataSettingsChangeNotification: AnyCancellable? |
| 30 |
+ private var coreDataRemoteChangeNotification: AnyCancellable? |
|
| 30 | 31 |
private var cloudSettingsRefreshTimer: AnyCancellable? |
| 32 |
+ private var observerKeepaliveTimer: AnyCancellable? |
|
| 33 |
+ private var isRefreshingObservers = false |
|
| 31 | 34 |
private var cloudDeviceSettingsStore: CloudDeviceSettingsStore? |
| 35 |
+ private var observerStore: ObserverStore? |
|
| 32 | 36 |
private var hasMigratedLegacyDeviceSettings = false |
| 33 | 37 |
private var persistedMeterNames: [String: String] = [:] |
| 34 | 38 |
private var persistedTC66TemperatureUnits: [String: String] = [:] |
| 35 | 39 |
|
| 40 |
+ @Published private(set) var observers: [ObserverRecord] = [] |
|
| 41 |
+ |
|
| 36 | 42 |
init() {
|
| 37 | 43 |
persistedMeterNames = legacyMeterNames |
| 38 | 44 |
persistedTC66TemperatureUnits = legacyTC66TemperatureUnits |
@@ -66,10 +72,34 @@ final class AppData : ObservableObject {
|
||
| 66 | 72 |
cloudDeviceSettingsStore = CloudDeviceSettingsStore(context: context) |
| 67 | 73 |
cloudDeviceSettingsStore?.rebuildCanonicalStoreIfNeeded(version: Self.cloudStoreRebuildVersion) |
| 68 | 74 |
cloudDeviceSettingsStore?.compactDuplicateEntriesByMAC() |
| 75 |
+ |
|
| 76 |
+ // Observer registry setup |
|
| 77 |
+ observerStore = ObserverStore(context: context) |
|
| 78 |
+ sendObserverKeepalive() // Initial keepalive |
|
| 79 |
+ |
|
| 80 |
+ observerKeepaliveTimer = Timer.publish(every: 15, on: .main, in: .common) |
|
| 81 |
+ .autoconnect() |
|
| 82 |
+ .sink { [weak self] _ in
|
|
| 83 |
+ self?.sendObserverKeepalive() |
|
| 84 |
+ self?.refreshObservers() |
|
| 85 |
+ } |
|
| 86 |
+ |
|
| 87 |
+ // Observe local Core Data changes |
|
| 69 | 88 |
coreDataSettingsChangeNotification = NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context) |
| 70 | 89 |
.sink { [weak self] _ in
|
| 71 | 90 |
self?.reloadSettingsFromCloudStore(applyToMeters: true) |
| 72 | 91 |
} |
| 92 |
+ |
|
| 93 |
+ // Observe CloudKit remote sync changes (critical for observer registry) |
|
| 94 |
+ coreDataRemoteChangeNotification = NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange, object: nil) |
|
| 95 |
+ .sink { [weak self] notification in
|
|
| 96 |
+ track("CloudKit remote change detected: \(notification)")
|
|
| 97 |
+ DispatchQueue.main.async {
|
|
| 98 |
+ self?.reloadSettingsFromCloudStore(applyToMeters: true) |
|
| 99 |
+ self?.refreshObservers() |
|
| 100 |
+ } |
|
| 101 |
+ } |
|
| 102 |
+ |
|
| 73 | 103 |
cloudSettingsRefreshTimer = Timer.publish(every: 10, on: .main, in: .common) |
| 74 | 104 |
.autoconnect() |
| 75 | 105 |
.sink { [weak self] _ in
|
@@ -80,6 +110,29 @@ final class AppData : ObservableObject {
|
||
| 80 | 110 |
migrateLegacySettingsIntoCloudIfNeeded() |
| 81 | 111 |
cloudDeviceSettingsStore?.clearAllConnections(byDeviceID: Self.myDeviceID) |
| 82 | 112 |
reloadSettingsFromCloudStore(applyToMeters: true) |
| 113 |
+ refreshObservers() |
|
| 114 |
+ } |
|
| 115 |
+ |
|
| 116 |
+ private func sendObserverKeepalive() {
|
|
| 117 |
+ let bluetoothEnabled = bluetoothManager.managerState == .poweredOn |
|
| 118 |
+ observerStore?.upsertSelfAsObserver( |
|
| 119 |
+ deviceID: Self.myDeviceID, |
|
| 120 |
+ deviceName: Self.myDeviceName, |
|
| 121 |
+ bluetoothEnabled: bluetoothEnabled |
|
| 122 |
+ ) |
|
| 123 |
+ } |
|
| 124 |
+ |
|
| 125 |
+ private func refreshObservers() {
|
|
| 126 |
+ guard !isRefreshingObservers else {
|
|
| 127 |
+ return |
|
| 128 |
+ } |
|
| 129 |
+ |
|
| 130 |
+ isRefreshingObservers = true |
|
| 131 |
+ defer { isRefreshingObservers = false }
|
|
| 132 |
+ |
|
| 133 |
+ // Keep observer fetch in sync with pending context work without forcing a full object refresh. |
|
| 134 |
+ observerStore?.refreshContext() |
|
| 135 |
+ observers = observerStore?.fetchAllObservers() ?? [] |
|
| 83 | 136 |
} |
| 84 | 137 |
|
| 85 | 138 |
func persistedMeterName(for macAddress: String) -> String? {
|
@@ -257,6 +310,84 @@ final class AppData : ObservableObject {
|
||
| 257 | 310 |
} |
| 258 | 311 |
} |
| 259 | 312 |
|
| 313 |
+struct ObserverRecord: Identifiable, Hashable {
|
|
| 314 |
+ var id: String { deviceID }
|
|
| 315 |
+ let deviceID: String |
|
| 316 |
+ let deviceName: String |
|
| 317 |
+ let lastKeepalive: Date |
|
| 318 |
+ let bluetoothEnabled: Bool |
|
| 319 |
+ |
|
| 320 |
+ var isAlive: Bool {
|
|
| 321 |
+ Date().timeIntervalSince(lastKeepalive) < 60 // считается alive если keepalive < 60s |
|
| 322 |
+ } |
|
| 323 |
+} |
|
| 324 |
+ |
|
| 325 |
+private final class ObserverStore {
|
|
| 326 |
+ private let entityName = "Observer" |
|
| 327 |
+ private let context: NSManagedObjectContext |
|
| 328 |
+ |
|
| 329 |
+ init(context: NSManagedObjectContext) {
|
|
| 330 |
+ self.context = context |
|
| 331 |
+ } |
|
| 332 |
+ |
|
| 333 |
+ func refreshContext() {
|
|
| 334 |
+ context.performAndWait {
|
|
| 335 |
+ // Avoid refreshAllObjects() here; it emits ObjectsDidChange notifications and can recurse. |
|
| 336 |
+ context.processPendingChanges() |
|
| 337 |
+ } |
|
| 338 |
+ } |
|
| 339 |
+ |
|
| 340 |
+ func upsertSelfAsObserver(deviceID: String, deviceName: String, bluetoothEnabled: Bool) {
|
|
| 341 |
+ context.performAndWait {
|
|
| 342 |
+ do {
|
|
| 343 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: entityName) |
|
| 344 |
+ request.predicate = NSPredicate(format: "deviceID == %@", deviceID) |
|
| 345 |
+ |
|
| 346 |
+ let object = (try? context.fetch(request).first) ?? NSManagedObject(entity: NSEntityDescription.entity(forEntityName: entityName, in: context)!, insertInto: context) |
|
| 347 |
+ |
|
| 348 |
+ object.setValue(deviceID, forKey: "deviceID") |
|
| 349 |
+ object.setValue(deviceName, forKey: "deviceName") |
|
| 350 |
+ object.setValue(Date(), forKey: "lastKeepalive") |
|
| 351 |
+ object.setValue(bluetoothEnabled, forKey: "bluetoothEnabled") |
|
| 352 |
+ |
|
| 353 |
+ if context.hasChanges {
|
|
| 354 |
+ try context.save() |
|
| 355 |
+ track("Observer keepalive: \(deviceName)")
|
|
| 356 |
+ } |
|
| 357 |
+ } catch {
|
|
| 358 |
+ track("Failed to upsert observer: \(error)")
|
|
| 359 |
+ } |
|
| 360 |
+ } |
|
| 361 |
+ } |
|
| 362 |
+ |
|
| 363 |
+ func fetchAllObservers() -> [ObserverRecord] {
|
|
| 364 |
+ var records: [ObserverRecord] = [] |
|
| 365 |
+ context.performAndWait {
|
|
| 366 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: entityName) |
|
| 367 |
+ do {
|
|
| 368 |
+ let objects = try context.fetch(request) |
|
| 369 |
+ records = objects.compactMap { object in
|
|
| 370 |
+ guard let deviceID = object.value(forKey: "deviceID") as? String, |
|
| 371 |
+ let deviceName = object.value(forKey: "deviceName") as? String, |
|
| 372 |
+ let lastKeepalive = object.value(forKey: "lastKeepalive") as? Date else {
|
|
| 373 |
+ return nil |
|
| 374 |
+ } |
|
| 375 |
+ let bluetoothEnabled = (object.value(forKey: "bluetoothEnabled") as? Bool) ?? true |
|
| 376 |
+ return ObserverRecord( |
|
| 377 |
+ deviceID: deviceID, |
|
| 378 |
+ deviceName: deviceName, |
|
| 379 |
+ lastKeepalive: lastKeepalive, |
|
| 380 |
+ bluetoothEnabled: bluetoothEnabled |
|
| 381 |
+ ) |
|
| 382 |
+ } |
|
| 383 |
+ } catch {
|
|
| 384 |
+ track("Failed to fetch observers: \(error)")
|
|
| 385 |
+ } |
|
| 386 |
+ } |
|
| 387 |
+ return records |
|
| 388 |
+ } |
|
| 389 |
+} |
|
| 390 |
+ |
|
| 260 | 391 |
struct KnownMeterCatalogItem: Identifiable, Hashable {
|
| 261 | 392 |
var id: String { macAddress }
|
| 262 | 393 |
let macAddress: String |
@@ -3,6 +3,6 @@ |
||
| 3 | 3 |
<plist version="1.0"> |
| 4 | 4 |
<dict> |
| 5 | 5 |
<key>_XCCurrentVersionName</key> |
| 6 |
- <string>USB_Meter 2.xcdatamodel</string> |
|
| 6 |
+ <string>USB_Meter 3.xcdatamodel</string> |
|
| 7 | 7 |
</dict> |
| 8 | 8 |
</plist> |
@@ -0,0 +1,47 @@ |
||
| 1 |
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
|
| 2 |
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="16119" systemVersion="19E287" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier=""> |
|
| 3 |
+ <entity name="Entity" representedClassName="Entity" syncable="YES" codeGenerationType="class"/> |
|
| 4 |
+ |
|
| 5 |
+ <entity name="Observer" representedClassName="Observer" syncable="YES" codeGenerationType="class"> |
|
| 6 |
+ <attribute name="deviceID" optional="YES" attributeType="String"/> |
|
| 7 |
+ <attribute name="deviceName" optional="YES" attributeType="String"/> |
|
| 8 |
+ <attribute name="lastKeepalive" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 9 |
+ <attribute name="bluetoothEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> |
|
| 10 |
+ </entity> |
|
| 11 |
+ |
|
| 12 |
+ <entity name="MeterObservation" representedClassName="MeterObservation" syncable="YES" codeGenerationType="class"> |
|
| 13 |
+ <attribute name="observerDeviceID" optional="YES" attributeType="String"/> |
|
| 14 |
+ <attribute name="meterMAC" optional="YES" attributeType="String"/> |
|
| 15 |
+ <attribute name="lastSeen" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 16 |
+ <attribute name="isConnected" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 17 |
+ <attribute name="modelType" optional="YES" attributeType="String"/> |
|
| 18 |
+ <attribute name="peripheralName" optional="YES" attributeType="String"/> |
|
| 19 |
+ <attribute name="meterName" optional="YES" attributeType="String"/> |
|
| 20 |
+ <attribute name="tc66TemperatureUnit" optional="YES" attributeType="String"/> |
|
| 21 |
+ <attribute name="connectionEstablishedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 22 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 23 |
+ </entity> |
|
| 24 |
+ |
|
| 25 |
+ <entity name="DeviceSettings" representedClassName="DeviceSettings" syncable="YES" codeGenerationType="class"> |
|
| 26 |
+ <attribute name="macAddress" optional="YES" attributeType="String"/> |
|
| 27 |
+ <attribute name="meterName" optional="YES" attributeType="String"/> |
|
| 28 |
+ <attribute name="tc66TemperatureUnit" optional="YES" attributeType="String"/> |
|
| 29 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 30 |
+ <attribute name="modelType" optional="YES" attributeType="String"/> |
|
| 31 |
+ <attribute name="connectedByDeviceID" optional="YES" attributeType="String"/> |
|
| 32 |
+ <attribute name="connectedByDeviceName" optional="YES" attributeType="String"/> |
|
| 33 |
+ <attribute name="connectedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 34 |
+ <attribute name="connectedExpiryAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 35 |
+ <attribute name="lastSeenAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 36 |
+ <attribute name="lastSeenByDeviceID" optional="YES" attributeType="String"/> |
|
| 37 |
+ <attribute name="lastSeenByDeviceName" optional="YES" attributeType="String"/> |
|
| 38 |
+ <attribute name="lastSeenPeripheralName" optional="YES" attributeType="String"/> |
|
| 39 |
+ </entity> |
|
| 40 |
+ |
|
| 41 |
+ <elements> |
|
| 42 |
+ <element name="Entity" positionX="-63" positionY="-18" width="128" height="43"/> |
|
| 43 |
+ <element name="Observer" positionX="-200" positionY="100" width="128" height="103"/> |
|
| 44 |
+ <element name="MeterObservation" positionX="0" positionY="100" width="128" height="193"/> |
|
| 45 |
+ <element name="DeviceSettings" positionX="160" positionY="-18" width="128" height="103"/> |
|
| 46 |
+ </elements> |
|
| 47 |
+</model> |
|
@@ -569,6 +569,23 @@ struct ContentView: View {
|
||
| 569 | 569 |
"Live Meters: \(appData.meters.count)" |
| 570 | 570 |
]) |
| 571 | 571 |
|
| 572 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 573 |
+ Text("Observers (Keepalive: 15s)")
|
|
| 574 |
+ .font(.headline) |
|
| 575 |
+ if appData.observers.isEmpty {
|
|
| 576 |
+ Text("No observers registered yet")
|
|
| 577 |
+ .font(.caption) |
|
| 578 |
+ .foregroundColor(.secondary) |
|
| 579 |
+ } else {
|
|
| 580 |
+ ForEach(appData.observers) { observer in
|
|
| 581 |
+ observerDebugCard(for: observer) |
|
| 582 |
+ } |
|
| 583 |
+ } |
|
| 584 |
+ } |
|
| 585 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 586 |
+ .padding(18) |
|
| 587 |
+ .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.22) |
|
| 588 |
+ |
|
| 572 | 589 |
if !appData.knownMetersByMAC.isEmpty {
|
| 573 | 590 |
VStack(alignment: .leading, spacing: 10) {
|
| 574 | 591 |
Text("Known Meters")
|
@@ -612,6 +629,49 @@ struct ContentView: View {
|
||
| 612 | 629 |
.meterCard(tint: .purple, fillOpacity: 0.08, strokeOpacity: 0.16) |
| 613 | 630 |
} |
| 614 | 631 |
|
| 632 |
+ private func observerDebugCard(for observer: ObserverRecord) -> some View {
|
|
| 633 |
+ let statusColor: Color = observer.isAlive ? .green : .red |
|
| 634 |
+ let statusText = observer.isAlive ? "Alive" : "Dead" |
|
| 635 |
+ let timeSinceKeepalive = Date().timeIntervalSince(observer.lastKeepalive) |
|
| 636 |
+ |
|
| 637 |
+ return VStack(alignment: .leading, spacing: 6) {
|
|
| 638 |
+ HStack {
|
|
| 639 |
+ Circle() |
|
| 640 |
+ .fill(statusColor) |
|
| 641 |
+ .frame(width: 8, height: 8) |
|
| 642 |
+ Text(observer.deviceName) |
|
| 643 |
+ .font(.subheadline.weight(.semibold)) |
|
| 644 |
+ Spacer() |
|
| 645 |
+ Text(statusText) |
|
| 646 |
+ .font(.caption2) |
|
| 647 |
+ .foregroundColor(statusColor) |
|
| 648 |
+ } |
|
| 649 |
+ |
|
| 650 |
+ Text(observer.deviceID) |
|
| 651 |
+ .font(.system(.caption2, design: .monospaced)) |
|
| 652 |
+ .foregroundColor(.secondary) |
|
| 653 |
+ |
|
| 654 |
+ HStack {
|
|
| 655 |
+ Text("Last keepalive: \(Int(timeSinceKeepalive))s ago")
|
|
| 656 |
+ .font(.caption2) |
|
| 657 |
+ .foregroundColor(.secondary) |
|
| 658 |
+ Spacer() |
|
| 659 |
+ if observer.bluetoothEnabled {
|
|
| 660 |
+ Image(systemName: "antenna.radiowaves.left.and.right") |
|
| 661 |
+ .font(.caption2) |
|
| 662 |
+ .foregroundColor(.blue) |
|
| 663 |
+ } else {
|
|
| 664 |
+ Image(systemName: "antenna.radiowaves.left.and.right.slash") |
|
| 665 |
+ .font(.caption2) |
|
| 666 |
+ .foregroundColor(.secondary) |
|
| 667 |
+ } |
|
| 668 |
+ } |
|
| 669 |
+ } |
|
| 670 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 671 |
+ .padding(10) |
|
| 672 |
+ .meterCard(tint: statusColor, fillOpacity: 0.08, strokeOpacity: 0.14) |
|
| 673 |
+ } |
|
| 674 |
+ |
|
| 615 | 675 |
private func meterDebugCard(for known: KnownMeterCatalogItem) -> some View {
|
| 616 | 676 |
let isLive = appData.meters.values.contains { $0.btSerial.macAddress.description == known.macAddress }
|
| 617 | 677 |
let connectedElsewhere = isConnectedElsewhere(known) |