Showing 5 changed files with 244 additions and 2 deletions
+5 -1
USB Meter.xcodeproj/project.pbxproj
@@ -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;
+131 -0
USB Meter/Model/AppData.swift
@@ -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
+1 -1
USB Meter/Model/CKModel.xcdatamodeld/.xccurrentversion
@@ -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>
+47 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 3.xcdatamodel/contents
@@ -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>
+60 -0
USB Meter/Views/ContentView.swift
@@ -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)