Showing 9 changed files with 410 additions and 97 deletions
+0 -0
Documentation/Issues/2026-03-24_17-30-catalyst-tabview-freeze.md → Documentation/Issues/001_2026-03-24_17-30_catalyst-tabview-freeze.md
File renamed without changes.
+94 -0
Documentation/Issues/002_2026-03-24_18-45_designed-for-ipad-nav-feedback-loop.md
@@ -0,0 +1,94 @@
1
+# Issue: UIObservationTrackingFeedbackLoop on "Designed for iPad" macOS with NavigationBarContentView
2
+
3
+## Description
4
+- Aplicația USB Meter (SwiftUI "Designed for iPad") se bloaca instant la deschiderea `MeterView` pe macOS Apple Silicon, cu eroare repetata `UIObservationTrackingFeedbackLoopDetected` in `NavigationBarContentView.updateProperties`.
5
+- Freezeul apare **inainte** ca datele BLE să ajunga (instant la deschidere), diferit de problema Catalyst (care era blocata pe navigare).
6
+- Condiții: macOS Apple Silicon cu "Designed for iPad" mode (UIKit hosted SwiftUI). Pe Catalyst, NU apare (AppKit toolbar remplace NavigationBarContentView).
7
+
8
+## Impact
9
+- Imposibil de deschis meter view pe "Designed for iPad" macOS
10
+- Aplicatia devine complet neresponsiva, CPU la 100%
11
+- Error stack: `NavigationBarContentView` intra in ciclu infinit de observare SwiftUI (sa se schimbe ceva constant)
12
+
13
+## Root Cause Analysis
14
+
15
+### Ipoteza initiala (INCORECTA): Update frequency pe model
16
+- Prim diagnostic: "poate BLE packet-uri (~1Hz) cu 15+ @Published pe Meter triggerez body re-evaluate, care triggerez NavigationBar update, care cicleaza la infinit"
17
+- **CONCLUZIUNE incorecta**: problema nu este frecventa datelor; eroarea apare instant, inainte ca Bluetooth sa se conecteze
18
+
19
+### Adevarat root cause (GASIT): UIKit NavigationBarContentView bug
20
+- NavigationBarContentView (UIKit hosted component) are implement basata pe @Observable tracking
21
+- Cand SwiftUI seteaza `.navigationBarTitle()` + `.toolbar {}` pe o view push-uit in split NavigationView, UIKit intra in infinite feedback loop pe observation changes
22
+- Bug specific la "Designed for iPad" pe macOS; pe Catalyst NU apare (AppKit toolbar inlocuieste NavigationBarContentView)
23
+- Eroarea apare **inainte** ca bind pe meter data; doar configurarea vue modifiers triggerez loop-ul
24
+
25
+## Investigatie si attempts
26
+
27
+### 1. Model-layer deduplication (PARTIAL, nu era fix principal)
28
+Implemented:
29
+- `BluetoothSerial.swift`: RSSI averaging (sliding window 3 = ~9s), singura @Published ramasa
30
+- `Meter.swift`: Eliminat @Published de la ~20 proprietati per-packet (voltage, current, power, temps, etc.). Adaugat `setIfChanged()` helper cu dedup. Singur `objectWillChange.send()` la final de `parseData()` doar daca ceva s-a schimbat. Guard pe `operationalState` oscillation
31
+- `Measurements.swift`: `objectWillChange.send()` doar cand punct e adaugat (nu pe accumulation)
32
+- **Rezultat**: Zero improvement pe problema Designed-for-iPad. Insa sunt optimizari bune pentru defense-in-depth.
33
+
34
+### 2. Toolbar migration (DEPRECATED API FIX, nu era problema)
35
+- Inlocuit `.navigationBarItems(trailing:)` cu `.toolbar { ToolbarItemGroup }` in MeterView
36
+- Adaugat @State buffers (`navBarTitle`, `navBarShowRSSI`, `navBarRSSI`) ca shield
37
+- **Rezultat**: Zero improvement. Nu era problema API veche; problema era conceptuala (UIKit nav bar pe Mac).
38
+
39
+### 3. UIKit NavigationBarContentView bypass (SOLUTIE FINALA)
40
+- Runtime detection: `ProcessInfo.processInfo.isiOSAppOnMac` (true doar pe "Designed for iPad" pe Mac)
41
+- **MeterView.swift**:
42
+  - Adaugat `static let isMacIPadApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac`
43
+  - Body rewritten: VStack cu conditional `macNavigationHeader` (custom back button via `dismiss()`, title, RSSI, settings)
44
+  - `.navigationBarHidden(true)` cand `isMacIPadApp || landscape`
45
+  - Creat `IOSOnlyNavBar` ViewModifier (@ViewBuilder if/else): applies `.navigationBarTitle()` + `.toolbar {}` DOAR cand `!isMacIPadApp`, passeaza content nemodificat pe Mac
46
+- **MeterSettingsView.swift**: Acelasi pattern
47
+  - Custom `macSettingsHeader` cu back button, title, RSSI
48
+  - `IOSOnlySettingsNavBar` ViewModifier pentru conditional nav bar modifiers
49
+- Pe real iPad: toti modifiers se aplica normal (nav bar standard UIKit)
50
+
51
+## Solutie finala aplicata
52
+
53
+### Fișiere modificate
54
+1. **MeterView.swift** (728 → 765 lines)
55
+   - Adaugat `@Environment(\.dismiss)` si `isMacIPadApp` static const
56
+   - Rewritten body cu conditional `macNavigationHeader` (HStack cu back button, title, RSSI, settings NavigationLink)
57
+   - Adaugat `IOSOnlyNavBar` ViewModifier private struct cu @ViewBuilder if/else
58
+
59
+2. **MeterSettingsView.swift**
60
+   - Adaugat `@Environment(\.dismiss)` si `isMacIPadApp` static const
61
+   - Body rewritten cu VStack + conditional `macSettingsHeader`
62
+   - Adaugat `IOSOnlySettingsNavBar` ViewModifier private struct
63
+
64
+3. **Model layer** (executat anterior, nu direct pentru fix), dar relevant:
65
+   - BluetoothSerial.swift: RSSI averaging
66
+   - Meter.swift: @Published dedup
67
+   - Measurements.swift: objectWillChange fix
68
+
69
+## Test status
70
+- ✅ Compileaza fara erori
71
+- ✅ "Designed for iPad" mode pe macOS: MeterView si MeterSettingsView deschid fara blocaj
72
+- ⚠️ Real iPad: NU am iPad disponibil sa verific ca problema nu apare (nu am device pentru test)
73
+  - UIKit nav bar modifiers se aplica normal pe real iPad (path aplicarii e diferit din UIKit sandbox)
74
+  - Expect sa fie OK dar nu am certitudine
75
+
76
+## Commit-uri
77
+- [recent] - UIKit NavBar bypass pe "Designed for iPad", MeterView + MeterSettingsView custom headers
78
+
79
+## Verificare
80
+- pe macOS Apple Silicon + "Designed for iPad": deschide app, selecteaza meter, apasa sa o deschizi
81
+  - MeterView trebuie sa deschida fara freeze
82
+  - MeterSettingsView (pe gear icon) trebuie sa deschida fara freeze
83
+  - Back button sa revina la lista
84
+
85
+## Lessons learned
86
+- Nu toate feedback loop-urile ObservableObject sunt cauzate de frecventa datelor
87
+- UIKit pe macOS "Designed for iPad" are bugs nedocumentate in observation tracking cand SwiftUI configureaza nav bar
88
+- Catalyst (AppKit) nu are problema (toolbar separat)
89
+- Workaround: bypass-ul ca sa nu setam nav bar modifiers pe Mac
90
+
91
+## Notes pentru viitor
92
+- Aceasta solutie functioneaza pe "Designed for iPad" + Catalyst
93
+- Nu am testat pe real iPad (nu am device); expect sa fie OK din arhitectura (modifiers se aplica)
94
+- Daca problema apare pe real iPad in viitor: mai multi custom headers nu scala; s-ar putea reveni la NavigationView + sheet-based nav (cum am facut pe Catalyst in issue #001)
+1 -1
USB Meter/Model/BluetoothManager.swift
@@ -70,7 +70,7 @@ class BluetoothManager : NSObject, ObservableObject {
70 70
             appData.meters = m
71 71
         } else if let meter = appData.meters[peripheral.identifier] {
72 72
             meter.lastSeen = Date()
73
-            meter.btSerial.RSSI = RSSI.intValue
73
+            meter.btSerial.updateRSSI(RSSI.intValue)
74 74
             if peripheral.delegate == nil {
75 75
                 peripheral.delegate = meter.btSerial
76 76
             }
+32 -14
USB Meter/Model/BluetoothSerial.swift
@@ -35,14 +35,12 @@ final class BluetoothSerial : NSObject, ObservableObject {
35 35
     var macAddress: MACAddress
36 36
     private var manager: CBCentralManager
37 37
     private var radio: BluetoothRadio
38
-    @Published var RSSI: Int {
39
-        didSet {
40
-            minRSSI = Swift.min(minRSSI, RSSI)
41
-            maxRSSI = Swift.max(maxRSSI, RSSI)
42
-        }
43
-    }
44
-    @Published private(set) var minRSSI: Int
45
-    @Published private(set) var maxRSSI: Int
38
+    private(set) var rawRSSI: Int
39
+    private var rssiSamples: [Int] = []
40
+    private let rssiAveragingWindow = 3
41
+    @Published private(set) var averageRSSI: Int
42
+    private(set) var minRSSI: Int
43
+    private(set) var maxRSSI: Int
46 44
     
47 45
     private var expectedResponseLength = 0
48 46
     private var wdTimer: Timer?
@@ -64,7 +62,9 @@ final class BluetoothSerial : NSObject, ObservableObject {
64 62
         self.macAddress = macAddress
65 63
         self.radio = radio
66 64
         self.manager = manager
67
-        self.RSSI = RSSI
65
+        self.rawRSSI = RSSI
66
+        self.rssiSamples = [RSSI]
67
+        self.averageRSSI = RSSI
68 68
         self.minRSSI = RSSI
69 69
         self.maxRSSI = RSSI
70 70
         Timer.scheduledTimer(withTimeInterval: 3, repeats: true, block: {_ in
@@ -76,6 +76,20 @@ final class BluetoothSerial : NSObject, ObservableObject {
76 76
         peripheral.delegate = self
77 77
     }
78 78
 
79
+    func updateRSSI(_ value: Int) {
80
+        rawRSSI = value
81
+        rssiSamples.append(value)
82
+        if rssiSamples.count > rssiAveragingWindow {
83
+            rssiSamples.removeFirst()
84
+        }
85
+        let newAverage = rssiSamples.reduce(0, +) / rssiSamples.count
86
+        minRSSI = Swift.min(minRSSI, newAverage)
87
+        maxRSSI = Swift.max(maxRSSI, newAverage)
88
+        if newAverage != averageRSSI {
89
+            averageRSSI = newAverage
90
+        }
91
+    }
92
+
79 93
     private func resetCommunicationState(reason: String, clearCharacteristics: Bool) {
80 94
         if wdTimer != nil {
81 95
             track("Reset communication state (\(reason)) - invalidating watchdog")
@@ -154,8 +168,10 @@ final class BluetoothSerial : NSObject, ObservableObject {
154 168
     func connectionEstablished () {
155 169
         resetCommunicationState(reason: "connectionEstablished()", clearCharacteristics: true)
156 170
         track("Connection established for '\(peripheral.identifier)'")
157
-        minRSSI = RSSI
158
-        maxRSSI = RSSI
171
+        rssiSamples = [rawRSSI]
172
+        averageRSSI = rawRSSI
173
+        minRSSI = rawRSSI
174
+        maxRSSI = rawRSSI
159 175
         operationalState = .peripheralConnected
160 176
         peripheral.discoverServices(BluetoothRadioServicesUUIDS[radio])
161 177
     }
@@ -163,8 +179,10 @@ final class BluetoothSerial : NSObject, ObservableObject {
163 179
     func connectionClosed () {
164 180
         track("Connection closed for '\(peripheral.identifier)' while peripheral state is \(peripheral.state.rawValue)")
165 181
         resetCommunicationState(reason: "connectionClosed()", clearCharacteristics: true)
166
-        minRSSI = RSSI
167
-        maxRSSI = RSSI
182
+        rssiSamples = [rawRSSI]
183
+        averageRSSI = rawRSSI
184
+        minRSSI = rawRSSI
185
+        maxRSSI = rawRSSI
168 186
         operationalState = .peripheralNotConnected
169 187
     }
170 188
 
@@ -238,7 +256,7 @@ extension BluetoothSerial : CBPeripheralDelegate {
238 256
         if error != nil {
239 257
             track( "Error: \(error!)" )
240 258
         }
241
-        self.RSSI = RSSI.intValue
259
+        updateRSSI(RSSI.intValue)
242 260
     }
243 261
     
244 262
     //  MARK:   didDiscoverServices
+1 -1
USB Meter/Model/Measurements.swift
@@ -120,7 +120,7 @@ class Measurements : ObservableObject {
120 120
             powerSum = power
121 121
             voltageSum = voltage
122 122
             currentSum = current
123
+            self.objectWillChange.send()
123 124
         }
124
-        self.objectWillChange.send()
125 125
     }
126 126
 }
+90 -62
USB Meter/Model/Meter.swift
@@ -152,7 +152,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
152 152
 
153 153
     private var wdTimer: Timer?
154 154
 
155
-    @Published var lastSeen = Date() {
155
+    var lastSeen = Date() {
156 156
         didSet {
157 157
             wdTimer?.invalidate()
158 158
             if operationalState == .peripheralNotConnected {
@@ -415,27 +415,27 @@ class Meter : NSObject, ObservableObject, Identifiable {
415 415
         capabilities.recordingThresholdHint
416 416
     }
417 417
 
418
-    @Published var btSerial: BluetoothSerial
418
+    var btSerial: BluetoothSerial
419 419
     
420
-    @Published var measurements = Measurements()
420
+    var measurements = Measurements()
421 421
 
422 422
     private var commandQueue: [Data] = []
423 423
     private var dataDumpRequestTimestamp = Date()
424 424
     private var pendingDataDumpWorkItem: DispatchWorkItem?
425 425
     
426 426
     class DataGroupRecord {
427
-        @Published var ah: Double
428
-        @Published var wh: Double
427
+        var ah: Double
428
+        var wh: Double
429 429
         init(ah: Double, wh: Double) {
430 430
             self.ah = ah
431 431
             self.wh = wh
432 432
         }
433 433
     }
434
-    @Published var selectedDataGroup: UInt8 = 0
435
-    @Published var dataGroupRecords: [Int : DataGroupRecord] = [:]
436
-    @Published var chargeRecordAH: Double = 0
437
-    @Published var chargeRecordWH: Double = 0
438
-    @Published var chargeRecordDuration: TimeInterval = 0
434
+    private(set) var selectedDataGroup: UInt8 = 0
435
+    private(set) var dataGroupRecords: [Int : DataGroupRecord] = [:]
436
+    private(set) var chargeRecordAH: Double = 0
437
+    private(set) var chargeRecordWH: Double = 0
438
+    private(set) var chargeRecordDuration: TimeInterval = 0
439 439
     @Published var chargeRecordStopThreshold: Double = 0.05
440 440
     @Published var tc66TemperatureUnitPreference: TemperatureUnitPreference = .celsius {
441 441
         didSet {
@@ -471,16 +471,16 @@ class Meter : NSObject, ObservableObject, Identifiable {
471 471
     }
472 472
     private var screenTimeoutTimestamp = Date()
473 473
     
474
-    @Published var voltage: Double = 0
475
-    @Published var current: Double = 0
476
-    @Published var power: Double = 0
477
-    @Published var temperatureCelsius: Double = 0
478
-    @Published var temperatureFahrenheit: Double = 0
479
-    @Published var usbPlusVoltage: Double = 0
480
-    @Published var usbMinusVoltage: Double = 0
481
-    @Published var recordedAH: Double = 0
482
-    @Published var recordedWH: Double = 0
483
-    @Published var recording: Bool = false
474
+    private(set) var voltage: Double = 0
475
+    private(set) var current: Double = 0
476
+    private(set) var power: Double = 0
477
+    private(set) var temperatureCelsius: Double = 0
478
+    private(set) var temperatureFahrenheit: Double = 0
479
+    private(set) var usbPlusVoltage: Double = 0
480
+    private(set) var usbMinusVoltage: Double = 0
481
+    private(set) var recordedAH: Double = 0
482
+    private(set) var recordedWH: Double = 0
483
+    private(set) var recording: Bool = false
484 484
     @Published var recordingTreshold: Double = 0 {
485 485
         didSet {
486 486
             guard recordingTreshold != oldValue else { return }
@@ -492,20 +492,20 @@ class Meter : NSObject, ObservableObject, Identifiable {
492 492
             setrecordingTreshold(to: (recordingTreshold * 100).uInt8Value)
493 493
         }
494 494
     }
495
-    @Published var currentScreen: UInt16 = 0
496
-    @Published var recordingDuration: UInt32 = 0
497
-    @Published var loadResistance: Double = 0
498
-    @Published var modelNumber: UInt16 = 0
499
-    @Published var chargerTypeIndex: UInt16 = 0
500
-    @Published var reportedModelName: String = ""
501
-    @Published var firmwareVersion: String = ""
502
-    @Published var serialNumber: UInt32 = 0
503
-    @Published var bootCount: UInt32 = 0
495
+    private(set) var currentScreen: UInt16 = 0
496
+    private(set) var recordingDuration: UInt32 = 0
497
+    private(set) var loadResistance: Double = 0
498
+    private(set) var modelNumber: UInt16 = 0
499
+    private(set) var chargerTypeIndex: UInt16 = 0
500
+    private(set) var reportedModelName: String = ""
501
+    private(set) var firmwareVersion: String = ""
502
+    private(set) var serialNumber: UInt32 = 0
503
+    private(set) var bootCount: UInt32 = 0
504 504
     private var enableAutoConnect: Bool = false
505 505
     private var recordingThresholdTimestamp = Date()
506 506
     private var recordingThresholdLoadedFromDevice = false
507 507
     private var isApplyingRecordingThresholdFromDevice = false
508
-    @Published private(set) var chargeRecordState = ChargeRecordState.waitingForStart
508
+    private(set) var chargeRecordState = ChargeRecordState.waitingForStart
509 509
     private var chargeRecordStartTimestamp: Date?
510 510
     private var chargeRecordEndTimestamp: Date?
511 511
     private var chargeRecordLastTimestamp: Date?
@@ -518,7 +518,26 @@ class Meter : NSObject, ObservableObject, Identifiable {
518 518
     private var hasSeenTC66Snapshot = false
519 519
     private var pendingVolatileMemoryResetIgnoreCount = 0
520 520
     private var pendingVolatileMemoryResetDeadline: Date?
521
+    private var liveDataChanged = false
521 522
         
523
+    @discardableResult
524
+    private func setIfChanged<T: Equatable>(_ keyPath: ReferenceWritableKeyPath<Meter, T>, to value: T) -> Bool {
525
+        guard self[keyPath: keyPath] != value else { return false }
526
+        self[keyPath: keyPath] = value
527
+        liveDataChanged = true
528
+        return true
529
+    }
530
+
531
+    private func updateDataGroupRecord(index: Int, ah: Double, wh: Double) {
532
+        if let existing = dataGroupRecords[index] {
533
+            if existing.ah != ah { existing.ah = ah; liveDataChanged = true }
534
+            if existing.wh != wh { existing.wh = wh; liveDataChanged = true }
535
+        } else {
536
+            dataGroupRecords[index] = DataGroupRecord(ah: ah, wh: wh)
537
+            liveDataChanged = true
538
+        }
539
+    }
540
+
522 541
     init ( model: Model, with serialPort: BluetoothSerial ) {
523 542
         uuid = serialPort.peripheral.identifier
524 543
         //dataStore.meterUUIDS.append(serialPort.peripheral.identifier)
@@ -626,13 +645,13 @@ class Meter : NSObject, ObservableObject, Identifiable {
626 645
 
627 646
     private func updateChargerTypeLatch(observedIndex: UInt16, didDetectDeviceReset: Bool) {
628 647
         if didDetectDeviceReset, chargerTypeIndex != 0 {
629
-            chargerTypeIndex = 0
648
+            setIfChanged(\.chargerTypeIndex, to: 0)
630 649
         }
631 650
 
632 651
         guard supportsChargerDetection else { return }
633 652
 
634 653
         if chargerTypeIndex == 0 {
635
-            chargerTypeIndex = observedIndex
654
+            setIfChanged(\.chargerTypeIndex, to: observedIndex)
636 655
             return
637 656
         }
638 657
 
@@ -671,6 +690,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
671 690
      */
672 691
     func parseData ( from buffer: Data) {
673 692
         //track("\(name)")
693
+        liveDataChanged = false
674 694
         switch model {
675 695
         case .UM25C:
676 696
             do {
@@ -696,27 +716,31 @@ class Meter : NSObject, ObservableObject, Identifiable {
696 716
 //        DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
697 717
 //            //track("\(name) - Scheduled new request.")
698 718
 //        }
699
-        operationalState = .dataIsAvailable
719
+        if operationalState != .dataIsAvailable {
720
+            operationalState = .dataIsAvailable
721
+        } else if liveDataChanged {
722
+            objectWillChange.send()
723
+        }
700 724
         dataDumpRequest()
701 725
     }
702 726
 
703 727
     private func apply(umSnapshot snapshot: UMSnapshot) {
704 728
         let didDetectDeviceReset = didUMDeviceReboot(with: snapshot, at: dataDumpRequestTimestamp)
705
-        modelNumber = snapshot.modelNumber
706
-        voltage = snapshot.voltage
707
-        current = snapshot.current
708
-        power = snapshot.power
709
-        temperatureCelsius = snapshot.temperatureCelsius
710
-        temperatureFahrenheit = snapshot.temperatureFahrenheit
711
-        selectedDataGroup = snapshot.selectedDataGroup
729
+        setIfChanged(\.modelNumber, to: snapshot.modelNumber)
730
+        setIfChanged(\.voltage, to: snapshot.voltage)
731
+        setIfChanged(\.current, to: snapshot.current)
732
+        setIfChanged(\.power, to: snapshot.power)
733
+        setIfChanged(\.temperatureCelsius, to: snapshot.temperatureCelsius)
734
+        setIfChanged(\.temperatureFahrenheit, to: snapshot.temperatureFahrenheit)
735
+        setIfChanged(\.selectedDataGroup, to: snapshot.selectedDataGroup)
712 736
         for (index, record) in snapshot.dataGroupRecords {
713
-            dataGroupRecords[index] = DataGroupRecord(ah: record.ah, wh: record.wh)
737
+            updateDataGroupRecord(index: index, ah: record.ah, wh: record.wh)
714 738
         }
715
-        usbPlusVoltage = snapshot.usbPlusVoltage
716
-        usbMinusVoltage = snapshot.usbMinusVoltage
739
+        setIfChanged(\.usbPlusVoltage, to: snapshot.usbPlusVoltage)
740
+        setIfChanged(\.usbMinusVoltage, to: snapshot.usbMinusVoltage)
717 741
         updateChargerTypeLatch(observedIndex: snapshot.chargerTypeIndex, didDetectDeviceReset: didDetectDeviceReset)
718
-        recordedAH = snapshot.recordedAH
719
-        recordedWH = snapshot.recordedWH
742
+        setIfChanged(\.recordedAH, to: snapshot.recordedAH)
743
+        setIfChanged(\.recordedWH, to: snapshot.recordedWH)
720 744
 
721 745
         if recordingThresholdTimestamp < dataDumpRequestTimestamp || !recordingThresholdLoadedFromDevice {
722 746
             recordingThresholdLoadedFromDevice = true
@@ -728,8 +752,8 @@ class Meter : NSObject, ObservableObject, Identifiable {
728 752
         } else {
729 753
             track("\(name) - Skip updating recordingThreshold (changed after request).")
730 754
         }
731
-        recordingDuration = snapshot.recordingDuration
732
-        recording = snapshot.recording
755
+        setIfChanged(\.recordingDuration, to: snapshot.recordingDuration)
756
+        setIfChanged(\.recording, to: snapshot.recording)
733 757
 
734 758
         if screenTimeoutTimestamp < dataDumpRequestTimestamp {
735 759
             if screenTimeout != snapshot.screenTimeout {
@@ -747,8 +771,8 @@ class Meter : NSObject, ObservableObject, Identifiable {
747 771
             track("\(name) - Skip updating screenBrightness (changed after request).")
748 772
         }
749 773
 
750
-        currentScreen = snapshot.currentScreen
751
-        loadResistance = snapshot.loadResistance
774
+        setIfChanged(\.currentScreen, to: snapshot.currentScreen)
775
+        setIfChanged(\.loadResistance, to: snapshot.loadResistance)
752 776
     }
753 777
 
754 778
     private func apply(tc66Snapshot snapshot: TC66Snapshot) {
@@ -758,21 +782,21 @@ class Meter : NSObject, ObservableObject, Identifiable {
758 782
         } else {
759 783
             hasSeenTC66Snapshot = true
760 784
         }
761
-        reportedModelName = snapshot.modelName
762
-        firmwareVersion = snapshot.firmwareVersion
763
-        serialNumber = snapshot.serialNumber
764
-        bootCount = snapshot.bootCount
785
+        setIfChanged(\.reportedModelName, to: snapshot.modelName)
786
+        setIfChanged(\.firmwareVersion, to: snapshot.firmwareVersion)
787
+        setIfChanged(\.serialNumber, to: snapshot.serialNumber)
788
+        setIfChanged(\.bootCount, to: snapshot.bootCount)
765 789
         updateChargerTypeLatch(observedIndex: 0, didDetectDeviceReset: didDetectDeviceReset)
766
-        voltage = snapshot.voltage
767
-        current = snapshot.current
768
-        power = snapshot.power
769
-        loadResistance = snapshot.loadResistance
790
+        setIfChanged(\.voltage, to: snapshot.voltage)
791
+        setIfChanged(\.current, to: snapshot.current)
792
+        setIfChanged(\.power, to: snapshot.power)
793
+        setIfChanged(\.loadResistance, to: snapshot.loadResistance)
770 794
         for (index, record) in snapshot.dataGroupRecords {
771
-            dataGroupRecords[index] = DataGroupRecord(ah: record.ah, wh: record.wh)
795
+            updateDataGroupRecord(index: index, ah: record.ah, wh: record.wh)
772 796
         }
773
-        temperatureCelsius = snapshot.temperatureCelsius
774
-        usbPlusVoltage = snapshot.usbPlusVoltage
775
-        usbMinusVoltage = snapshot.usbMinusVoltage
797
+        setIfChanged(\.temperatureCelsius, to: snapshot.temperatureCelsius)
798
+        setIfChanged(\.usbPlusVoltage, to: snapshot.usbPlusVoltage)
799
+        setIfChanged(\.usbMinusVoltage, to: snapshot.usbMinusVoltage)
776 800
     }
777 801
 
778 802
     private func inferTC66ActiveDataGroup(from snapshot: TC66Snapshot) {
@@ -833,6 +857,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
833 857
         chargeRecordLastTimestamp = nil
834 858
         chargeRecordLastCurrent = 0
835 859
         chargeRecordLastPower = 0
860
+        objectWillChange.send()
836 861
     }
837 862
 
838 863
     func resetChargeRecordGraph() {
@@ -892,6 +917,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
892 917
         guard supportsDataGroupCommands else { return }
893 918
         track("\(name) - \(id)")
894 919
         selectedDataGroup = id
920
+        objectWillChange.send()
895 921
         commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
896 922
     }
897 923
     
@@ -956,7 +982,9 @@ extension Meter : SerialPortDelegate {
956 982
     func didReceiveData(_ data: Data) {
957 983
         let applyData = {
958 984
             self.lastSeen = Date()
959
-            self.operationalState = .comunicating
985
+            if self.operationalState < .comunicating {
986
+                self.operationalState = .comunicating
987
+            }
960 988
             self.parseData(from: data)
961 989
         }
962 990
 
+1 -1
USB Meter/Views/Meter/LiveView.swift
@@ -140,7 +140,7 @@ struct LiveView: View {
140 140
                     title: "RSSI",
141 141
                     symbol: "dot.radiowaves.left.and.right",
142 142
                     color: .mint,
143
-                    value: "\(meter.btSerial.RSSI) dBm",
143
+                    value: "\(meter.btSerial.averageRSSI) dBm",
144 144
                     range: MetricRange(
145 145
                         minLabel: "Min",
146 146
                         maxLabel: "Max",
+78 -4
USB Meter/Views/Meter/MeterSettingsView.swift
@@ -11,13 +11,20 @@ import SwiftUI
11 11
 struct MeterSettingsView: View {
12 12
     
13 13
     @EnvironmentObject private var meter: Meter
14
-    
14
+    @Environment(\.dismiss) private var dismiss
15
+
16
+    private static let isMacIPadApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac
17
+
15 18
     @State private var editingName = false
16 19
     @State private var editingScreenTimeout = false
17 20
     @State private var editingScreenBrightness = false
18 21
 
19 22
     var body: some View {
20
-        ScrollView {
23
+        VStack(spacing: 0) {
24
+            if Self.isMacIPadApp {
25
+                macSettingsHeader
26
+            }
27
+            ScrollView {
21 28
             VStack (spacing: 14) {
22 29
                 settingsCard(title: "Name", tint: meter.color) {
23 30
                     HStack {
@@ -106,8 +113,52 @@ struct MeterSettingsView: View {
106 113
             )
107 114
             .ignoresSafeArea()
108 115
         )
109
-        .navigationBarTitle("Meter Settings")
110
-        .navigationBarItems( trailing: RSSIView( RSSI: meter.btSerial.RSSI ).frame( width: 18, height: 18 ) )
116
+        }
117
+        .modifier(IOSOnlySettingsNavBar(
118
+            apply: !Self.isMacIPadApp,
119
+            rssi: meter.btSerial.averageRSSI
120
+        ))
121
+    }
122
+
123
+    // MARK: - Custom navigation header for Designed-for-iPad on Mac
124
+
125
+    private var macSettingsHeader: some View {
126
+        HStack(spacing: 12) {
127
+            Button {
128
+                dismiss()
129
+            } label: {
130
+                HStack(spacing: 4) {
131
+                    Image(systemName: "chevron.left")
132
+                        .font(.body.weight(.semibold))
133
+                    Text("Back")
134
+                }
135
+                .foregroundColor(.accentColor)
136
+            }
137
+            .buttonStyle(.plain)
138
+
139
+            Text("Meter Settings")
140
+                .font(.headline)
141
+                .lineLimit(1)
142
+
143
+            Spacer()
144
+
145
+            if meter.operationalState > .notPresent {
146
+                RSSIView(RSSI: meter.btSerial.averageRSSI)
147
+                    .frame(width: 18, height: 18)
148
+            }
149
+        }
150
+        .padding(.horizontal, 16)
151
+        .padding(.vertical, 10)
152
+        .background(
153
+            Rectangle()
154
+                .fill(.ultraThinMaterial)
155
+                .ignoresSafeArea(edges: .top)
156
+        )
157
+        .overlay(alignment: .bottom) {
158
+            Rectangle()
159
+                .fill(Color.secondary.opacity(0.12))
160
+                .frame(height: 1)
161
+        }
111 162
     }
112 163
 
113 164
     private func settingsCard<Content: View>(
@@ -181,3 +232,26 @@ struct EditScreenBrightnessView: View {
181 232
         .pickerStyle( SegmentedPickerStyle() )
182 233
     }
183 234
 }
235
+
236
+// MARK: - Conditional navigation bar modifier (skipped on Designed-for-iPad / Mac)
237
+
238
+private struct IOSOnlySettingsNavBar: ViewModifier {
239
+    let apply: Bool
240
+    let rssi: Int
241
+
242
+    @ViewBuilder
243
+    func body(content: Content) -> some View {
244
+        if apply {
245
+            content
246
+                .navigationBarTitle("Meter Settings")
247
+                .toolbar {
248
+                    ToolbarItem(placement: .navigationBarTrailing) {
249
+                        RSSIView(RSSI: rssi).frame(width: 18, height: 18)
250
+                    }
251
+                }
252
+        } else {
253
+            content
254
+                .navigationBarHidden(true)
255
+        }
256
+    }
257
+}
+113 -14
USB Meter/Views/Meter/MeterView.swift
@@ -34,11 +34,17 @@ struct MeterView: View {
34 34
     }
35 35
     
36 36
     @EnvironmentObject private var meter: Meter
37
+    @Environment(\.dismiss) private var dismiss
38
+
39
+    private static let isMacIPadApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac
37 40
     
38 41
     @State var dataGroupsViewVisibility: Bool = false
39 42
     @State var recordingViewVisibility: Bool = false
40 43
     @State var measurementsViewVisibility: Bool = false
41 44
     @State private var selectedMeterTab: MeterTab = .connection
45
+    @State private var navBarTitle: String = "Meter"
46
+    @State private var navBarShowRSSI: Bool = false
47
+    @State private var navBarRSSI: Int = 0
42 48
     private var myBounds: CGRect { UIScreen.main.bounds }
43 49
     private let actionStripPadding: CGFloat = 10
44 50
     private let actionDividerWidth: CGFloat = 1
@@ -53,32 +59,94 @@ struct MeterView: View {
53 59
         GeometryReader { proxy in
54 60
             let landscape = isLandscape(size: proxy.size)
55 61
 
56
-            Group {
57
-                if landscape {
58
-                    landscapeDeck(size: proxy.size)
59
-                } else {
60
-                    portraitContent(size: proxy.size)
62
+            VStack(spacing: 0) {
63
+                if Self.isMacIPadApp {
64
+                    macNavigationHeader
65
+                }
66
+                Group {
67
+                    if landscape {
68
+                        landscapeDeck(size: proxy.size)
69
+                    } else {
70
+                        portraitContent(size: proxy.size)
71
+                    }
61 72
                 }
73
+                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
62 74
             }
63
-            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
64 75
             #if !targetEnvironment(macCatalyst)
65
-            .navigationBarHidden(landscape)
76
+            .navigationBarHidden(Self.isMacIPadApp || landscape)
66 77
             #endif
67 78
         }
68 79
         .background(meterBackground)
69
-        .navigationBarTitle(meter.name.isEmpty ? "Meter" : meter.name)
70
-        .navigationBarItems(trailing: HStack (spacing: 6) {
80
+        .modifier(IOSOnlyNavBar(
81
+            apply: !Self.isMacIPadApp,
82
+            title: navBarTitle,
83
+            showRSSI: navBarShowRSSI,
84
+            rssi: navBarRSSI,
85
+            meter: meter
86
+        ))
87
+        .onAppear {
88
+            navBarTitle = meter.name.isEmpty ? "Meter" : meter.name
89
+            navBarShowRSSI = meter.operationalState > .notPresent
90
+            navBarRSSI = meter.btSerial.averageRSSI
91
+        }
92
+        .onChange(of: meter.name) { name in
93
+            navBarTitle = name.isEmpty ? "Meter" : name
94
+        }
95
+        .onChange(of: meter.operationalState) { state in
96
+            navBarShowRSSI = state > .notPresent
97
+        }
98
+        .onChange(of: meter.btSerial.averageRSSI) { newRSSI in
99
+            if abs(newRSSI - navBarRSSI) >= 5 {
100
+                navBarRSSI = newRSSI
101
+            }
102
+        }
103
+    }
104
+
105
+    // MARK: - Custom navigation header for Designed-for-iPad on Mac
106
+
107
+    private var macNavigationHeader: some View {
108
+        HStack(spacing: 12) {
109
+            Button {
110
+                dismiss()
111
+            } label: {
112
+                HStack(spacing: 4) {
113
+                    Image(systemName: "chevron.left")
114
+                        .font(.body.weight(.semibold))
115
+                    Text("USB Meters")
116
+                }
117
+                .foregroundColor(.accentColor)
118
+            }
119
+            .buttonStyle(.plain)
120
+
121
+            Text(meter.name.isEmpty ? "Meter" : meter.name)
122
+                .font(.headline)
123
+                .lineLimit(1)
124
+
125
+            Spacer()
126
+
71 127
             if meter.operationalState > .notPresent {
72
-                RSSIView(RSSI: meter.btSerial.RSSI)
128
+                RSSIView(RSSI: meter.btSerial.averageRSSI)
73 129
                     .frame(width: 18, height: 18)
74
-                    .padding(.leading, 6)
75
-                    .padding(.vertical)
76 130
             }
131
+
77 132
             NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
78 133
                 Image(systemName: "gearshape.fill")
79
-                    .padding(.vertical)
134
+                    .foregroundColor(.accentColor)
80 135
             }
81
-        })
136
+            .buttonStyle(.plain)
137
+        }
138
+        .padding(.horizontal, 16)
139
+        .padding(.vertical, 10)
140
+        .background(
141
+            Rectangle()
142
+                .fill(.ultraThinMaterial)
143
+                .ignoresSafeArea(edges: .top)
144
+        )
145
+        .overlay(alignment: .bottom) {
146
+            Rectangle()
147
+                .fill(Color.secondary.opacity(0.12))
148
+                .frame(height: 1)
149
+        }
82 150
     }
83 151
 
84 152
     private func portraitContent(size: CGSize) -> some View {
@@ -658,3 +726,34 @@ private struct MeterInfoRow: View {
658 726
         .font(.footnote)
659 727
     }
660 728
 }
729
+
730
+// MARK: - Conditional navigation bar modifier (skipped on Designed-for-iPad / Mac)
731
+
732
+private struct IOSOnlyNavBar: ViewModifier {
733
+    let apply: Bool
734
+    let title: String
735
+    let showRSSI: Bool
736
+    let rssi: Int
737
+    let meter: Meter
738
+
739
+    @ViewBuilder
740
+    func body(content: Content) -> some View {
741
+        if apply {
742
+            content
743
+                .navigationBarTitle(title)
744
+                .toolbar {
745
+                    ToolbarItemGroup(placement: .navigationBarTrailing) {
746
+                        if showRSSI {
747
+                            RSSIView(RSSI: rssi)
748
+                                .frame(width: 18, height: 18)
749
+                        }
750
+                        NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
751
+                            Image(systemName: "gearshape.fill")
752
+                        }
753
+                    }
754
+                }
755
+        } else {
756
+            content
757
+        }
758
+    }
759
+}